TUI: remove hjkl nav, add delete/link/type-picker/resize/help

- Remove all h/j/k/l navigation bindings; arrow keys are the sole nav
- Update all footer hint text to reflect arrow-key navigation
- Delete ticket: D key → confirm prompt [y/N] → store.delete()
- Link tickets: L key → enter target ID → ←/→ cycle relation types → Enter commits via store.linkTickets()
- Type picker: ←/→ during new-title prompt cycles ticket types when multiple exist
- SIGWINCH: ProcessSignal.sigwinch triggers redraw on terminal resize (Unix only, wrapped in try/catch)
- Help overlay: F1 opens centered modal listing all keybindings by mode, any key closes
- prevMode tracked so F1 returns to board or detail correctly

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Chris Hendrickson 2026-04-25 14:49:30 -04:00
parent 5d9d9ded3f
commit 0a00c4f744

View file

@ -11,7 +11,7 @@ import '../kanban_config.dart';
import '../ticket.dart';
import '../ticket_store.dart';
enum _Mode { board, detail, editor }
enum _Mode { board, detail, editor, help }
// TUI event bus
@ -30,13 +30,21 @@ final class _TuiRefresh extends _TuiEvent {
// Inline prompt
enum _PromptKind { newTitle, editTitle, addComment, archiveConfirm }
enum _PromptKind { newTitle, editTitle, addComment, archiveConfirm, deleteConfirm, linkId, linkType }
const _linkRelations = [
'blocks', 'is_blocked_by', 'relates_to',
'parent_of', 'child_of', 'duplicates', 'is_duplicated_by',
];
class _Prompt {
final _PromptKind kind;
final String? ticketId;
String input;
_Prompt(this.kind, {this.input = '', this.ticketId});
int typeIdx; // newTitle: selected ticket type index
int relationIdx; // linkType: selected relation index
String? linkTargetId; // linkType: resolved target ticket id
_Prompt(this.kind, {this.input = '', this.ticketId, this.typeIdx = 0, this.relationIdx = 0, this.linkTargetId});
}
// Ticket editor
@ -231,6 +239,7 @@ class TuiCommand extends DewCommand {
var colIdx = 0;
var ticketIdx = 0;
var mode = _Mode.board;
var prevMode = _Mode.board;
var detailScroll = 0;
var statusMsg = '';
var searchQuery = '';
@ -247,7 +256,9 @@ class TuiCommand extends DewCommand {
console.clearScreen();
console.resetCursorPosition();
if (mode == _Mode.editor && editorState != null) {
if (mode == _Mode.help) {
_renderHelp(console: console, w: w, h: h);
} else if (mode == _Mode.editor && editorState != null) {
_renderEditor(console: console, config: config, es: editorState, w: w, h: h);
} else if (mode == _Mode.board) {
_renderBoard(
@ -308,6 +319,16 @@ class TuiCommand extends DewCommand {
}
});
// Terminal resize (SIGWINCH) trigger a redraw on window size change.
StreamSubscription<io.ProcessSignal>? sigwinchSub;
try {
sigwinchSub = io.ProcessSignal.sigwinch.watch().listen((_) {
if (!events.isClosed) events.add(const _TuiRefresh());
});
} catch (_) {
// SIGWINCH not supported on all platforms (e.g. Windows)
}
try {
redraw();
@ -326,17 +347,22 @@ class TuiCommand extends DewCommand {
// Prompt mode (inline action input)
if (prompt != null) {
final p = prompt;
if (p.kind == _PromptKind.archiveConfirm) {
if (p.kind == _PromptKind.archiveConfirm || p.kind == _PromptKind.deleteConfirm) {
if (!key.isControl) {
if (key.char == 'y' || key.char == 'Y') {
try {
if (p.kind == _PromptKind.archiveConfirm) {
await store.update(p.ticketId!, column: 'archive');
statusMsg = 'Archived ${p.ticketId}.';
} else {
await store.delete(p.ticketId!);
statusMsg = 'Deleted ${p.ticketId}.';
}
tickets = await store.list();
byColumn = _groupByColumn(tickets, config);
final col = config.columns[colIdx];
final remaining = _filtered(byColumn[col.id] ?? [], searchQuery);
ticketIdx = ticketIdx.clamp(0, max(0, remaining.length - 1));
statusMsg = 'Archived ${p.ticketId}.';
} on ArgumentError catch (e) {
statusMsg = 'Error: ${e.message ?? e}';
}
@ -351,6 +377,33 @@ class TuiCommand extends DewCommand {
} else {
continue loop;
}
} else if (p.kind == _PromptKind.linkType) {
// / cycle relations; Enter commits; Esc cancels
if (key.isControl) {
switch (key.controlChar) {
case ControlCharacter.escape:
prompt = null;
statusMsg = '';
case ControlCharacter.arrowLeft:
if (p.relationIdx > 0) p.relationIdx--;
case ControlCharacter.arrowRight:
if (p.relationIdx < _linkRelations.length - 1) p.relationIdx++;
case ControlCharacter.enter:
try {
await store.linkTickets(p.ticketId!, p.linkTargetId!, _linkRelations[p.relationIdx]);
tickets = await store.list();
byColumn = _groupByColumn(tickets, config);
statusMsg = 'Linked ${p.ticketId}${p.linkTargetId} (${_linkRelations[p.relationIdx]}).';
} on ArgumentError catch (e) {
statusMsg = 'Error: ${e.message ?? e}';
}
prompt = null;
default:
continue loop;
}
} else {
continue loop;
}
} else {
if (key.isControl) {
switch (key.controlChar) {
@ -361,9 +414,21 @@ class TuiCommand extends DewCommand {
if (p.input.isNotEmpty) {
p.input = p.input.substring(0, p.input.length - 1);
}
case ControlCharacter.arrowLeft:
if (p.kind == _PromptKind.newTitle && p.typeIdx > 0) p.typeIdx--;
case ControlCharacter.arrowRight:
if (p.kind == _PromptKind.newTitle && p.typeIdx < config.ticketTypes.length - 1) p.typeIdx++;
case ControlCharacter.enter:
final trimmed = p.input.trim();
if (trimmed.isEmpty) {
if (p.kind == _PromptKind.linkId) {
// Validate target ticket exists, then move to relation selector
final exists = tickets.any((t) => t.id == trimmed);
if (!exists || trimmed.isEmpty) {
statusMsg = 'Ticket "$trimmed" not found.';
} else {
prompt = _Prompt(_PromptKind.linkType, ticketId: p.ticketId, linkTargetId: trimmed);
}
} else if (trimmed.isEmpty) {
prompt = null;
} else {
try {
@ -371,7 +436,7 @@ class TuiCommand extends DewCommand {
case _PromptKind.newTitle:
final col = config.columns[colIdx];
final type = config.ticketTypes.isNotEmpty
? config.ticketTypes.first.id
? config.ticketTypes[p.typeIdx].id
: 'task';
await store.create(title: trimmed, type: type, column: col.id);
tickets = await store.list();
@ -390,7 +455,10 @@ class TuiCommand extends DewCommand {
byColumn = _groupByColumn(tickets, config);
statusMsg = 'Comment added.';
case _PromptKind.archiveConfirm:
break; // handled above
case _PromptKind.deleteConfirm:
case _PromptKind.linkId:
case _PromptKind.linkType:
break; // handled in other branches
}
} on ArgumentError catch (e) {
statusMsg = 'Error: ${e.message ?? e}';
@ -492,50 +560,6 @@ class TuiCommand extends DewCommand {
if (!key.isControl) {
switch (key.char) {
case 'j':
es.focus = _EditorField.values[
(es.focus.index + 1) % _EditorField.values.length
];
es.itemCursor = 0;
case 'k':
es.focus = _EditorField.values[
(es.focus.index - 1 + _EditorField.values.length) % _EditorField.values.length
];
es.itemCursor = 0;
case 'h':
switch (es.focus) {
case _EditorField.type:
final i = allTypes.indexOf(es.type);
if (i > 0) es.type = allTypes[i - 1];
case _EditorField.column:
final i = allCols.indexOf(es.column);
if (i > 0) es.column = allCols[i - 1];
case _EditorField.labels:
if (es.labels.isNotEmpty && es.itemCursor > 0) es.itemCursor--;
case _EditorField.milestones:
if (es.milestones.isNotEmpty && es.itemCursor > 0) es.itemCursor--;
default:
break;
}
case 'l':
switch (es.focus) {
case _EditorField.type:
final i = allTypes.indexOf(es.type);
if (i < allTypes.length - 1) es.type = allTypes[i + 1];
case _EditorField.column:
final i = allCols.indexOf(es.column);
if (i < allCols.length - 1) es.column = allCols[i + 1];
case _EditorField.labels:
if (es.labels.isNotEmpty && es.itemCursor < es.labels.length - 1) {
es.itemCursor++;
}
case _EditorField.milestones:
if (es.milestones.isNotEmpty && es.itemCursor < es.milestones.length - 1) {
es.itemCursor++;
}
default:
break;
}
case 'd':
switch (es.focus) {
case _EditorField.labels:
@ -673,6 +697,14 @@ class TuiCommand extends DewCommand {
continue loop;
}
// Help mode
if (mode == _Mode.help) {
// Any key closes help
mode = prevMode;
redraw();
continue loop;
}
// Board mode
if (mode == _Mode.board) {
final col = config.columns[colIdx];
@ -682,20 +714,6 @@ class TuiCommand extends DewCommand {
switch (key.char) {
case 'q':
break loop;
case 'j':
if (ticketIdx < colTickets.length - 1) ticketIdx++;
case 'k':
if (ticketIdx > 0) ticketIdx--;
case 'h':
if (colIdx > 0) {
colIdx--;
ticketIdx = 0;
}
case 'l':
if (colIdx < config.columns.length - 1) {
colIdx++;
ticketIdx = 0;
}
case '<':
if (colIdx > 0 && colTickets.isNotEmpty) {
final t = colTickets[ticketIdx];
@ -750,6 +768,16 @@ class TuiCommand extends DewCommand {
final t = colTickets[ticketIdx];
prompt = _Prompt(_PromptKind.archiveConfirm, ticketId: t.id);
}
case 'D':
if (colTickets.isNotEmpty) {
final t = colTickets[ticketIdx];
prompt = _Prompt(_PromptKind.deleteConfirm, ticketId: t.id);
}
case 'L':
if (colTickets.isNotEmpty) {
final t = colTickets[ticketIdx];
prompt = _Prompt(_PromptKind.linkId, ticketId: t.id);
}
default:
continue loop; // skip redraw
}
@ -783,6 +811,9 @@ class TuiCommand extends DewCommand {
} else {
break loop;
}
case ControlCharacter.F1:
prevMode = mode;
mode = _Mode.help;
default:
continue loop; // skip redraw
}
@ -798,18 +829,14 @@ class TuiCommand extends DewCommand {
mode = _Mode.board;
detailScroll = 0;
case 'e':
final col = config.columns[colIdx];
final colTickets2 = _filtered(byColumn[col.id] ?? [], searchQuery);
final col2 = config.columns[colIdx];
final colTickets2 = _filtered(byColumn[col2.id] ?? [], searchQuery);
if (colTickets2.isNotEmpty) {
editorState = _EditorState.from(
colTickets2[ticketIdx.clamp(0, colTickets2.length - 1)],
);
mode = _Mode.editor;
}
case 'j':
detailScroll++;
case 'k':
if (detailScroll > 0) detailScroll--;
default:
continue loop; // skip redraw
}
@ -824,6 +851,9 @@ class TuiCommand extends DewCommand {
if (detailScroll > 0) detailScroll--;
case ControlCharacter.arrowDown:
detailScroll++;
case ControlCharacter.F1:
prevMode = mode;
mode = _Mode.help;
default:
continue loop; // skip redraw
}
@ -835,6 +865,7 @@ class TuiCommand extends DewCommand {
} finally {
await keySub.cancel();
await watchSub?.cancel();
await sigwinchSub?.cancel();
debounce?.cancel();
await events.close();
console.rawMode = false;
@ -923,6 +954,7 @@ class TuiCommand extends DewCommand {
numCols: numCols,
numVisible: numVisible,
columns: config.columns,
ticketTypes: config.ticketTypes,
);
}
@ -1099,6 +1131,7 @@ class TuiCommand extends DewCommand {
required int numCols,
required int numVisible,
required List<ColumnConfig> columns,
required List<TicketTypeConfig> ticketTypes,
}) {
console.writeLine();
@ -1114,12 +1147,29 @@ class TuiCommand extends DewCommand {
console.resetColorAttributes();
} else if (prompt != null) {
console.setForegroundColor(ConsoleColor.brightCyan);
final line = switch (prompt.kind) {
_PromptKind.newTitle => ' New ticket title: ${prompt.input}',
_PromptKind.editTitle => ' Edit title: ${prompt.input}',
_PromptKind.addComment => ' Add comment: ${prompt.input}',
_PromptKind.archiveConfirm => ' Archive ${prompt.ticketId}? [y/N]',
};
final String line;
switch (prompt.kind) {
case _PromptKind.newTitle:
if (ticketTypes.length > 1) {
final typeName = ticketTypes[prompt.typeIdx].name;
line = ' New [◀ $typeName ▶] title: ${prompt.input}';
} else {
line = ' New ticket title: ${prompt.input}';
}
case _PromptKind.editTitle:
line = ' Edit title: ${prompt.input}';
case _PromptKind.addComment:
line = ' Add comment: ${prompt.input}';
case _PromptKind.archiveConfirm:
line = ' Archive ${prompt.ticketId}? [y/N]';
case _PromptKind.deleteConfirm:
line = ' ⚠ Delete ${prompt.ticketId} permanently? [y/N]';
case _PromptKind.linkId:
line = ' Link ${prompt.ticketId} to ticket ID: ${prompt.input}';
case _PromptKind.linkType:
final rel = _linkRelations[prompt.relationIdx];
line = ' ${prompt.ticketId} [◀ $rel ▶] ${prompt.linkTargetId} (Enter to confirm)';
}
console.write(_trunc(line, w).padRight(w));
console.resetColorAttributes();
} else if (searchMode) {
@ -1130,7 +1180,7 @@ class TuiCommand extends DewCommand {
// Column position indicator + help
console.setForegroundColor(ConsoleColor.white);
final pos = numCols > numVisible ? ' [${colIdx + 1}/$numCols cols]' : '';
const help = ' [j/k] nav [h/l] col [</>] move [↵] detail [n] new [e] edit [a] archive [c] comment [?] filter [q] quit';
const help = ' [↑↓] nav [←→] col [</>] move [↵] detail [n] new [e] edit [a] archive [c] comment [?] filter [q] quit';
console.write(_trunc('$pos$help', w).padRight(w));
console.resetColorAttributes();
}
@ -1186,7 +1236,7 @@ class TuiCommand extends DewCommand {
final scrollInfo = lines.isNotEmpty
? ' [${s + 1}-${min(s + contentH, lines.length)}/${lines.length}]'
: '';
console.write(' [j/k↑↓] scroll$scrollInfo [e] edit [b/Esc] back [q] quit'.padRight(w));
console.write(' [↑↓] scroll$scrollInfo [e] edit [b/Esc] back [q] quit'.padRight(w));
console.resetColorAttributes();
}
@ -1330,6 +1380,125 @@ class TuiCommand extends DewCommand {
return '${s.substring(0, maxLen - 1)}';
}
//
// Help overlay modal
//
static void _renderHelp({
required Console console,
required int w,
required int h,
}) {
// Draw dim background
console.setForegroundColor(ConsoleColor.white);
for (var r = 0; r < h; r++) {
console.cursorPosition = Coordinate(r, 0);
console.write('' * w);
}
const sections = [
('Board', [
('↑ / ↓', 'Navigate tickets'),
('← / →', 'Switch columns'),
('< / >', 'Move ticket left / right'),
('Enter', 'Open ticket detail'),
('n', 'New ticket'),
('e', 'Edit ticket'),
('a', 'Archive ticket'),
('D', 'Delete ticket'),
('c', 'Add comment'),
('L', 'Link ticket'),
('?', 'Filter / search'),
('q / Esc', 'Quit'),
('F1', 'This help'),
]),
('Detail', [
('↑ / ↓', 'Scroll'),
('e', 'Edit ticket'),
('b / Esc', 'Back to board'),
('q', 'Quit'),
('F1', 'This help'),
]),
('Editor', [
('↑ / ↓', 'Navigate fields'),
('← / →', 'Cycle selector values'),
('Enter', 'Edit text / open body editor'),
('d', 'Delete selected item'),
('s', 'Save changes'),
('Esc', 'Discard & close'),
]),
];
// Compute modal size
const labelW = 12;
const descW = 36;
const innerW = labelW + 3 + descW; // "key desc"
final modalW = min(w - 4, innerW + 4);
final totalRows = sections.fold(0, (s, sec) => s + sec.$2.length + 2); // +2 per section: header + blank
final modalH = min(h - 4, totalRows + 4);
final modalLeft = max(0, (w - modalW) ~/ 2);
final modalTop = max(0, (h - modalH) ~/ 2);
final innerWActual = modalW - 2;
// Draw box
console.setForegroundColor(ConsoleColor.brightCyan);
console.cursorPosition = Coordinate(modalTop, modalLeft);
console.write('${'' * innerWActual}');
// Title row
const title = ' Keyboard Shortcuts ';
console.cursorPosition = Coordinate(modalTop + 1, modalLeft);
console.setBackgroundColor(ConsoleColor.cyan);
console.setForegroundColor(ConsoleColor.black);
console.write('${title.padRight(innerWActual).substring(0, innerWActual)}');
console.resetColorAttributes();
// Second header row
console.setForegroundColor(ConsoleColor.brightCyan);
console.cursorPosition = Coordinate(modalTop + 2, modalLeft);
console.write('${'' * innerWActual}');
var row = modalTop + 3;
for (final (secName, bindings) in sections) {
if (row >= modalTop + modalH - 1) break;
// Section header
console.cursorPosition = Coordinate(row, modalLeft);
console.setForegroundColor(ConsoleColor.brightCyan);
final secLine = '$secName';
console.write('${secLine.padRight(innerWActual).substring(0, innerWActual)}');
row++;
for (final (key, desc) in bindings) {
if (row >= modalTop + modalH - 1) break;
console.cursorPosition = Coordinate(row, modalLeft);
final keyPart = key.padLeft(labelW);
final line = ' $keyPart $desc';
console.setForegroundColor(ConsoleColor.white);
console.write('${line.padRight(innerWActual).substring(0, innerWActual)}');
row++;
}
// Blank separator line
if (row < modalTop + modalH - 1) {
console.cursorPosition = Coordinate(row, modalLeft);
console.setForegroundColor(ConsoleColor.brightCyan);
console.write('${' ' * innerWActual}');
row++;
}
}
// Bottom border
console.cursorPosition = Coordinate(modalTop + modalH - 1, modalLeft);
console.setForegroundColor(ConsoleColor.brightCyan);
console.write('${'' * innerWActual}');
// Footer hint
const hint = ' Press any key to close ';
console.cursorPosition = Coordinate(modalTop + modalH - 1, modalLeft + (modalW - hint.length) ~/ 2);
console.setForegroundColor(ConsoleColor.white);
console.write(hint);
console.resetColorAttributes();
}
//
// Ticket editor modal
//
@ -1500,7 +1669,7 @@ class TuiCommand extends DewCommand {
// Footer hints
final dirtyMarker = es.isDirty ? ' ● unsaved' : '';
final footerHints = '[j/k↑↓] field [h/l←→] value [Enter] edit [d] del [s] save [Esc] discard$dirtyMarker';
final footerHints = '[↑↓] field [←→] value [Enter] edit [d] del [s] save [Esc] discard$dirtyMarker';
at(modalTop + modalH - 2, modalLeft + 1, () {
console.setForegroundColor(ConsoleColor.white);
console.write(_trunc(footerHints, innerW));