From c25f32dc12ac5ac16254a1c254939020642c0447 Mon Sep 17 00:00:00 2001 From: Chris Hendrickson Date: Sat, 25 Apr 2026 03:02:39 -0400 Subject: [PATCH] feat(kanban): add `dew kanban tui` interactive board TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an interactive Trello-style kanban TUI powered by dart_console. Features: - Side-by-side column layout, adapts to terminal width - Keyboard navigation: j/k (up/down), h/l or ←/→ (switch column) - Move selected ticket between columns with < and > - Ticket cards show ID, type badge (colour-coded), title, labels/milestone - Scroll indicators (↑ N above / ↓ N below) when a column overflows - Column headers highlighted in their configured colour; active column uses filled background + double-line border - Live filter with / (fuzzy search across id, title, type, labels, body) - Detail view (Enter): full ticket info, body and comments with word-wrap, scrollable with j/k - r to reload tickets from disk without leaving the TUI - Graceful terminal restoration on exit (Esc / q) - Requires an interactive terminal; prints a clear error otherwise Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../kanban/lib/src/commands/tui_command.dart | 770 ++++++++++++++++++ packages/kanban/lib/src/dew_kanban_base.dart | 2 + packages/kanban/pubspec.yaml | 1 + pubspec.lock | 48 ++ 4 files changed, 821 insertions(+) create mode 100644 packages/kanban/lib/src/commands/tui_command.dart diff --git a/packages/kanban/lib/src/commands/tui_command.dart b/packages/kanban/lib/src/commands/tui_command.dart new file mode 100644 index 0000000..a6b131b --- /dev/null +++ b/packages/kanban/lib/src/commands/tui_command.dart @@ -0,0 +1,770 @@ +import 'dart:math'; + +import 'package:dart_console/dart_console.dart'; +import 'package:dew_core/dew_core.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; + +import '../kanban_config.dart'; +import '../ticket.dart'; +import '../ticket_store.dart'; + +enum _Mode { board, detail } + +/// A single rendered cell: text + optional styling. +class _Cell { + final String text; + final ConsoleColor? fg; + final ConsoleColor? bg; + final bool bold; + + const _Cell(this.text, {this.fg, this.bg, this.bold = false}); +} + +/// Interactive Trello-style kanban TUI. +class TuiCommand extends DewCommand { + final FileSystem _fs; + + TuiCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs; + + @override + final String name = 'tui'; + + @override + final String description = 'Launch the interactive kanban board TUI.'; + + // Layout constants + static const _colSep = 1; // gap between columns (chars) + static const _minColW = 26; // minimum column width + static const _colHeaderH = 3; // rows used by column header box + static const _ticketH = 3; // rows per ticket card + static const _indicatorRows = 2; // rows reserved for scroll indicators + + @override + Future run() async { + final context = await ProjectContext.find(fs: _fs); + final config = context.config.kanban; + final store = TicketStore( + kanbanDir: context.dirs.kanban, + prefix: config.prefix, + fs: context.fs, + ); + + final console = Console(); + if (!console.hasTerminal) { + print('dew kanban tui requires an interactive terminal.'); + return; + } + + var tickets = await store.list(); + var byColumn = _groupByColumn(tickets, config); + + var colIdx = 0; + var ticketIdx = 0; + var mode = _Mode.board; + var detailScroll = 0; + var statusMsg = ''; + var searchQuery = ''; + var searchMode = false; + + console.hideCursor(); + console.rawMode = true; + + void redraw() { + final w = max(console.windowWidth, 40); + final h = max(console.windowHeight, 12); + console.clearScreen(); + console.resetCursorPosition(); + + if (mode == _Mode.board) { + _renderBoard( + console: console, + config: config, + byColumn: byColumn, + colIdx: colIdx, + ticketIdx: ticketIdx, + statusMsg: statusMsg, + searchQuery: searchQuery, + searchMode: searchMode, + w: w, + h: h, + ); + } else { + final col = config.columns[colIdx]; + final colTickets = _filtered(byColumn[col.id] ?? [], searchQuery); + if (colTickets.isEmpty) { + mode = _Mode.board; + } else { + _renderDetail( + console: console, + ticket: colTickets[ticketIdx.clamp(0, colTickets.length - 1)], + config: config, + scroll: detailScroll, + w: w, + h: h, + ); + } + } + } + + try { + redraw(); + + loop: + while (true) { + final key = console.readKey(); + + // ── Search mode ──────────────────────────────────────────────────── + if (searchMode) { + if (key.isControl) { + switch (key.controlChar) { + case ControlCharacter.enter: + searchMode = false; + ticketIdx = 0; + case ControlCharacter.escape: + searchMode = false; + searchQuery = ''; + ticketIdx = 0; + case ControlCharacter.backspace: + if (searchQuery.isNotEmpty) { + searchQuery = searchQuery.substring(0, searchQuery.length - 1); + } + default: + {} + } + } else { + searchQuery += key.char; + } + redraw(); + continue; + } + + // ── Board mode ───────────────────────────────────────────────────── + if (mode == _Mode.board) { + final col = config.columns[colIdx]; + final colTickets = _filtered(byColumn[col.id] ?? [], searchQuery); + + if (!key.isControl) { + 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]; + final newColId = config.columns[colIdx - 1].id; + try { + await store.update(t.id, column: newColId); + tickets = await store.list(); + byColumn = _groupByColumn(tickets, config); + colIdx--; + final nt = _filtered(byColumn[newColId] ?? [], searchQuery); + ticketIdx = max(0, nt.length - 1); + statusMsg = 'Moved ${t.id} → ${config.columns[colIdx].name}'; + } on ArgumentError catch (e) { + statusMsg = 'Error: ${e.message ?? e}'; + } + } + case '>': + if (colIdx < config.columns.length - 1 && colTickets.isNotEmpty) { + final t = colTickets[ticketIdx]; + final newColId = config.columns[colIdx + 1].id; + try { + await store.update(t.id, column: newColId); + tickets = await store.list(); + byColumn = _groupByColumn(tickets, config); + colIdx++; + final nt = _filtered(byColumn[newColId] ?? [], searchQuery); + ticketIdx = max(0, nt.length - 1); + statusMsg = 'Moved ${t.id} → ${config.columns[colIdx].name}'; + } on ArgumentError catch (e) { + statusMsg = 'Error: ${e.message ?? e}'; + } + } + case 'r': + tickets = await store.list(); + byColumn = _groupByColumn(tickets, config); + statusMsg = 'Reloaded.'; + case '/': + searchMode = true; + searchQuery = ''; + ticketIdx = 0; + default: + continue loop; // skip redraw + } + } else { + switch (key.controlChar) { + case ControlCharacter.arrowUp: + if (ticketIdx > 0) ticketIdx--; + case ControlCharacter.arrowDown: + if (ticketIdx < colTickets.length - 1) ticketIdx++; + case ControlCharacter.arrowLeft: + if (colIdx > 0) { + colIdx--; + ticketIdx = 0; + } + case ControlCharacter.arrowRight: + if (colIdx < config.columns.length - 1) { + colIdx++; + ticketIdx = 0; + } + case ControlCharacter.enter: + if (colTickets.isNotEmpty) { + mode = _Mode.detail; + detailScroll = 0; + } + case ControlCharacter.escape: + searchQuery = ''; + statusMsg = ''; + default: + continue loop; // skip redraw + } + } + } + // ── Detail mode ──────────────────────────────────────────────────── + else { + if (!key.isControl) { + switch (key.char) { + case 'q': + break loop; + case 'b': + mode = _Mode.board; + detailScroll = 0; + case 'j': + detailScroll++; + case 'k': + if (detailScroll > 0) detailScroll--; + default: + continue loop; + } + } else { + switch (key.controlChar) { + case ControlCharacter.escape: + mode = _Mode.board; + detailScroll = 0; + case ControlCharacter.arrowUp: + if (detailScroll > 0) detailScroll--; + case ControlCharacter.arrowDown: + detailScroll++; + default: + continue loop; + } + } + } + + redraw(); + } + } finally { + console.rawMode = false; + console.showCursor(); + console.clearScreen(); + console.resetCursorPosition(); + } + } + + // ══════════════════════════════════════════════════════════════════════════ + // Board rendering + // ══════════════════════════════════════════════════════════════════════════ + + static void _renderBoard({ + required Console console, + required KanbanConfig config, + required Map> byColumn, + required int colIdx, + required int ticketIdx, + required String statusMsg, + required String searchQuery, + required bool searchMode, + required int w, + required int h, + }) { + _writeHeaderBar(console, config, searchQuery, searchMode, w); + console.writeLine(); // blank line after header + + final numCols = config.columns.length; + // How many columns fit side-by-side? + final numVisible = max(1, min(numCols, (w + _colSep) ~/ (_minColW + _colSep))); + final colW = (w - (numVisible - 1) * _colSep) ~/ numVisible; + + // Keep selected column in viewport + var viewStart = colIdx - numVisible ~/ 2; + viewStart = viewStart.clamp(0, max(0, numCols - numVisible)); + if (colIdx < viewStart) viewStart = colIdx; + if (colIdx >= viewStart + numVisible) viewStart = colIdx - numVisible + 1; + viewStart = viewStart.clamp(0, max(0, numCols - numVisible)); + + final viewCols = config.columns.sublist(viewStart, min(viewStart + numVisible, numCols)); + + // h - 2 (header+blank) - 2 (footer separator+help) = content height + final colAreaH = h - 4; + + // Build column cell buffers (one list per column, each list has colAreaH cells) + final colCells = >[]; + for (int i = 0; i < viewCols.length; i++) { + final col = viewCols[i]; + final isSelected = (viewStart + i) == colIdx; + final tickets = _filtered(byColumn[col.id] ?? [], searchQuery); + colCells.add( + _buildColumnCells( + col: col, + tickets: tickets, + colW: colW, + areaH: colAreaH, + isSelected: isSelected, + selectedIdx: isSelected ? ticketIdx : -1, + ), + ); + } + + // Render side-by-side, row by row + for (int row = 0; row < colAreaH; row++) { + for (int ci = 0; ci < colCells.length; ci++) { + if (ci > 0) console.write(' ' * _colSep); + final cell = colCells[ci][row]; + _applyCell(console, cell); + console.resetColorAttributes(); + } + if (row < colAreaH - 1) { + console.writeLine(); + } + } + + _writeFooterBar( + console: console, + statusMsg: statusMsg, + searchMode: searchMode, + searchQuery: searchQuery, + w: w, + colIdx: colIdx, + numCols: numCols, + numVisible: numVisible, + columns: config.columns, + ); + } + + static List<_Cell> _buildColumnCells({ + required ColumnConfig col, + required List tickets, + required int colW, + required int areaH, + required bool isSelected, + required int selectedIdx, + }) { + final cells = <_Cell>[]; + final color = _colColor(col.color); + final innerW = colW - 2; + + // ── Column header (3 rows) ───────────────────────────────────────────── + + // Top border + cells.add(_Cell( + '┌${'─' * innerW}┐', + fg: isSelected ? color : ConsoleColor.brightBlack, + )); + + // Name + count + final count = tickets.length; + final label = _trunc(' ${col.name.toUpperCase()} ($count) ', innerW); + cells.add(_Cell( + '│${label.padRight(innerW)}│', + fg: isSelected ? ConsoleColor.black : color, + bg: isSelected ? color : null, + bold: isSelected, + )); + + // Separator (double line for selected, single for others) + cells.add(_Cell( + isSelected ? '╞${'═' * innerW}╡' : '└${'─' * innerW}┘', + fg: isSelected ? color : ConsoleColor.brightBlack, + )); + + // ── Ticket area ──────────────────────────────────────────────────────── + + final ticketAreaH = areaH - _colHeaderH - 1; // reserve 1 for bottom border + final maxVisible = max(1, (ticketAreaH - _indicatorRows) ~/ _ticketH); + + // Compute scroll to keep selectedIdx in view + var scroll = 0; + if (isSelected && selectedIdx >= 0 && tickets.isNotEmpty) { + scroll = (selectedIdx - maxVisible + 1).clamp(0, max(0, tickets.length - maxVisible)); + if (selectedIdx < scroll) scroll = selectedIdx; + } + + final showAbove = scroll > 0; + final showBelow = tickets.isNotEmpty && (scroll + maxVisible) < tickets.length; + + // "More above" indicator + if (showAbove) { + final msg = _trunc(' ↑ $scroll above', innerW); + cells.add(_Cell('│${msg.padRight(innerW)}│', fg: ConsoleColor.brightYellow)); + } else { + cells.add(_Cell('│${' ' * innerW}│', fg: ConsoleColor.brightBlack)); + } + + // Visible tickets + final visEnd = min(scroll + maxVisible, tickets.length); + for (int ti = scroll; ti < visEnd; ti++) { + _addTicketCells(cells, tickets[ti], innerW, ti == selectedIdx && isSelected); + } + + // Empty state + if (tickets.isEmpty) { + cells.add(_Cell('│${'·' * (innerW ~/ 2)}${' ' * (innerW - innerW ~/ 2)}│', fg: ConsoleColor.brightBlack)); + cells.add(_Cell('│ (empty)${' ' * (innerW - 9)}│', fg: ConsoleColor.brightBlack)); + cells.add(_Cell('│${'·' * (innerW ~/ 2)}${' ' * (innerW - innerW ~/ 2)}│', fg: ConsoleColor.brightBlack)); + } + + // "More below" indicator + if (showBelow) { + final remaining = tickets.length - scroll - maxVisible; + final msg = _trunc(' ↓ $remaining below', innerW); + cells.add(_Cell('│${msg.padRight(innerW)}│', fg: ConsoleColor.brightYellow)); + } else { + cells.add(_Cell('│${' ' * innerW}│', fg: ConsoleColor.brightBlack)); + } + + // Fill remaining space before bottom border + while (cells.length < areaH - 1) { + cells.add(_Cell('│${' ' * innerW}│', fg: ConsoleColor.brightBlack)); + } + + // Bottom border (always at areaH - 1) + if (cells.length > areaH - 1) cells.length = areaH - 1; + cells.add(_Cell( + isSelected ? '╘${'═' * innerW}╛' : '└${'─' * innerW}┘', + fg: isSelected ? color : ConsoleColor.brightBlack, + )); + + // Pad to exact height + while (cells.length < areaH) { + cells.add(_Cell(' ' * colW)); + } + + return cells; + } + + static void _addTicketCells(List<_Cell> cells, Ticket ticket, int innerW, bool isSel) { + final bg = isSel ? ConsoleColor.blue : null; + final titleFg = isSel ? ConsoleColor.brightWhite : null; + final bullet = isSel ? '▶' : ' '; + final typeColor = _typeColor(ticket.type); + + // Row 1: bullet + ID + type badge + final badge = ' [${_trunc(ticket.type, 7)}]'; + final idLine = '$bullet ${ticket.id}$badge'; + cells.add(_Cell( + '│${_trunc(idLine, innerW).padRight(innerW)}│', + fg: isSel ? ConsoleColor.brightWhite : typeColor, + bg: bg, + bold: isSel, + )); + + // Row 2: title + final titleLine = ' ${_trunc(ticket.title, innerW - 2)}'; + cells.add(_Cell( + '│${titleLine.padRight(innerW)}│', + fg: titleFg, + bg: bg, + )); + + // Row 3: labels / milestone / blank + final String tagLine; + if (ticket.labels.isNotEmpty) { + tagLine = ' ${ticket.labels.take(3).map((l) => '#$l').join(' ')}'; + } else if (ticket.milestones.isNotEmpty) { + tagLine = ' @ ${ticket.milestones.first}'; + } else { + tagLine = ''; + } + cells.add(_Cell( + '│${_trunc(tagLine, innerW).padRight(innerW)}│', + fg: isSel ? ConsoleColor.brightCyan : ConsoleColor.brightBlack, + bg: bg, + )); + } + + static void _writeHeaderBar( + Console console, + KanbanConfig config, + String searchQuery, + bool searchMode, + int w, + ) { + console.setBackgroundColor(ConsoleColor.blue); + console.setForegroundColor(ConsoleColor.brightWhite); + console.setTextStyle(bold: true); + + final left = ' DEW Kanban [${config.prefix}]'; + final right = searchMode + ? ' / ${searchQuery}_ ' + : (searchQuery.isNotEmpty ? ' filter: "$searchQuery" ' : ''); + final line = '$left${right.padLeft(max(0, w - left.length))}'; + console.write(_trunc(line, w).padRight(w)); + console.resetColorAttributes(); + } + + static void _writeFooterBar({ + required Console console, + required String statusMsg, + required bool searchMode, + required String searchQuery, + required int w, + required int colIdx, + required int numCols, + required int numVisible, + required List columns, + }) { + console.writeLine(); + + // Separator + console.setForegroundColor(ConsoleColor.brightBlack); + console.write('─' * w); + console.resetColorAttributes(); + console.writeLine(); + + if (statusMsg.isNotEmpty) { + console.setForegroundColor(ConsoleColor.brightYellow); + console.write(' $statusMsg'); + console.resetColorAttributes(); + } else if (searchMode) { + console.setForegroundColor(ConsoleColor.brightCyan); + console.write(' Search: ${searchQuery}_ (Enter to apply, Esc to clear)'); + console.resetColorAttributes(); + } else { + // Column position indicator + help + console.setForegroundColor(ConsoleColor.brightBlack); + final pos = numCols > numVisible ? ' [${colIdx + 1}/$numCols cols]' : ''; + const help = ' [j/k] nav [h/l] col [] move [enter] detail [/] filter [r] reload [q] quit'; + console.write(_trunc('$pos$help', w).padRight(w)); + console.resetColorAttributes(); + } + } + + // ══════════════════════════════════════════════════════════════════════════ + // Detail rendering + // ══════════════════════════════════════════════════════════════════════════ + + static void _renderDetail({ + required Console console, + required Ticket ticket, + required KanbanConfig config, + required int scroll, + required int w, + required int h, + }) { + _writeHeaderBar(console, config, '', false, w); + console.writeLine(); + + // Ticket title bar + console.setBackgroundColor(ConsoleColor.blue); + console.setForegroundColor(ConsoleColor.brightWhite); + console.setTextStyle(bold: true); + final titleBar = ' ${ticket.id} ${_trunc(ticket.title, w - ticket.id.length - 6)}'; + console.write(titleBar.padRight(w)); + console.resetColorAttributes(); + console.writeLine(); + + // Build scrollable content lines + final lines = _buildDetailLines(ticket, w - 4); + final contentH = h - 5; // header + blank + title + separator + footer + final maxScroll = max(0, lines.length - contentH); + final s = scroll.clamp(0, maxScroll); + + final visible = lines.sublist(s, min(s + contentH, lines.length)); + + for (final line in visible) { + _applyCell(console, line); + console.resetColorAttributes(); + console.writeLine(); + } + for (int i = visible.length; i < contentH; i++) { + console.writeLine(); + } + + // Footer + console.setForegroundColor(ConsoleColor.brightBlack); + console.write('─' * w); + console.resetColorAttributes(); + console.writeLine(); + console.setForegroundColor(ConsoleColor.brightBlack); + final scrollInfo = lines.isNotEmpty + ? ' [${s + 1}-${min(s + contentH, lines.length)}/${lines.length}]' + : ''; + console.write(' [j/k↑↓] scroll$scrollInfo [b/Esc] back [q] quit'.padRight(w)); + console.resetColorAttributes(); + } + + static List<_Cell> _buildDetailLines(Ticket ticket, int w) { + final lines = <_Cell>[]; + + void hr([String? label]) { + if (label != null) { + final tag = ' $label '; + final dashes = max(0, w - tag.length + 2); + lines.add(_Cell(' ─$tag${'─' * dashes}', fg: ConsoleColor.brightBlue, bold: true)); + } else { + lines.add(_Cell(' ${'─' * w}', fg: ConsoleColor.brightBlack)); + } + } + + void kv(String key, String value) { + lines.add( + _Cell( + ' ${key.padRight(12)}: ${_trunc(value, w - 16)}', + fg: ConsoleColor.white, + ), + ); + } + + void blank() => lines.add(const _Cell('')); + + // Metadata section + hr('INFO'); + kv('Type', ticket.type); + kv('Column', ticket.column); + kv('Created', _fmtDate(ticket.created)); + if (ticket.milestones.isNotEmpty) kv('Milestones', ticket.milestones.join(', ')); + if (ticket.labels.isNotEmpty) kv('Labels', ticket.labels.map((l) => '#$l').join(' ')); + if (ticket.links.isNotEmpty) { + for (final link in ticket.links) { + kv(link.type, link.targetId); + } + } + + // Body + if (ticket.body.isNotEmpty) { + blank(); + hr('BODY'); + blank(); + for (final para in ticket.body.split('\n')) { + for (final wl in _wordWrap(para, w)) { + lines.add(_Cell(' $wl', fg: ConsoleColor.white)); + } + } + } + + // Comments + for (int ci = 0; ci < ticket.comments.length; ci++) { + blank(); + hr('COMMENT ${ci + 1}'); + blank(); + for (final para in ticket.comments[ci].split('\n')) { + for (final wl in _wordWrap(para, w)) { + lines.add(_Cell(' $wl', fg: ConsoleColor.white)); + } + } + } + + blank(); + return lines; + } + + // ══════════════════════════════════════════════════════════════════════════ + // Utilities + // ══════════════════════════════════════════════════════════════════════════ + + static void _applyCell(Console console, _Cell cell) { + if (cell.bg != null) console.setBackgroundColor(cell.bg!); + if (cell.fg != null) console.setForegroundColor(cell.fg!); + if (cell.bold) console.setTextStyle(bold: true); + console.write(cell.text); + } + + static Map> _groupByColumn( + List tickets, + KanbanConfig config, + ) { + final map = >{}; + for (final col in config.columns) { + map[col.id] = []; + } + for (final t in tickets) { + (map[t.column] ??= []).add(t); + } + return map; + } + + static List _filtered(List tickets, String query) { + if (query.isEmpty) return tickets; + final q = query.toLowerCase(); + return tickets + .where( + (t) => + t.id.toLowerCase().contains(q) || + t.title.toLowerCase().contains(q) || + t.type.toLowerCase().contains(q) || + t.labels.any((l) => l.toLowerCase().contains(q)) || + t.body.toLowerCase().contains(q), + ) + .toList(); + } + + static ConsoleColor _colColor(String color) => switch (color.toLowerCase()) { + 'red' => ConsoleColor.red, + 'green' => ConsoleColor.green, + 'yellow' => ConsoleColor.yellow, + 'blue' => ConsoleColor.blue, + 'magenta' || 'purple' => ConsoleColor.magenta, + 'cyan' || 'teal' => ConsoleColor.cyan, + 'white' => ConsoleColor.white, + 'brightred' || 'bright_red' => ConsoleColor.brightRed, + 'brightgreen' || 'bright_green' => ConsoleColor.brightGreen, + 'brightyellow' || 'bright_yellow' => ConsoleColor.brightYellow, + 'brightblue' || 'bright_blue' => ConsoleColor.brightBlue, + 'brightmagenta' || 'bright_magenta' => ConsoleColor.brightMagenta, + 'brightcyan' || 'bright_cyan' => ConsoleColor.brightCyan, + 'brightwhite' || 'bright_white' => ConsoleColor.brightWhite, + _ => ConsoleColor.cyan, + }; + + static ConsoleColor _typeColor(String type) => switch (type.toLowerCase()) { + 'bug' => ConsoleColor.red, + 'task' => ConsoleColor.blue, + 'feature' || 'feat' => ConsoleColor.green, + 'chore' || 'spike' => ConsoleColor.yellow, + 'epic' => ConsoleColor.magenta, + 'story' => ConsoleColor.cyan, + _ => ConsoleColor.white, + }; + + static String _trunc(String s, int maxLen) { + if (maxLen <= 0) return ''; + if (s.length <= maxLen) return s; + if (maxLen == 1) return s[0]; + return '${s.substring(0, maxLen - 1)}…'; + } + + static String _fmtDate(DateTime dt) => + '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}'; + + static List _wordWrap(String text, int width) { + if (text.isEmpty) return ['']; + if (text.length <= width) return [text]; + final words = text.split(' '); + final result = []; + final buf = StringBuffer(); + for (final word in words) { + if (buf.isNotEmpty && buf.length + 1 + word.length > width) { + result.add(buf.toString()); + buf.clear(); + } + if (buf.isNotEmpty) buf.write(' '); + buf.write(word); + } + if (buf.isNotEmpty) result.add(buf.toString()); + return result; + } +} diff --git a/packages/kanban/lib/src/dew_kanban_base.dart b/packages/kanban/lib/src/dew_kanban_base.dart index 0fb0b93..53ba587 100644 --- a/packages/kanban/lib/src/dew_kanban_base.dart +++ b/packages/kanban/lib/src/dew_kanban_base.dart @@ -14,6 +14,7 @@ import 'commands/list_command.dart'; import 'commands/move_command.dart'; import 'commands/search_command.dart'; import 'commands/stats_command.dart'; +import 'commands/tui_command.dart'; import 'commands/unarchive_command.dart'; import 'commands/unlink_command.dart'; import 'commands/update_command.dart'; @@ -34,6 +35,7 @@ class KanbanCommand extends DewCommand { addSubcommand(AddCommentCommand(fs: fs)); addSubcommand(GetConfigCommand(fs: fs)); addSubcommand(StatsCommand(fs: fs)); + addSubcommand(TuiCommand(fs: fs)); addSubcommand(LinkCommand(fs: fs)); addSubcommand(UnlinkCommand(fs: fs)); } diff --git a/packages/kanban/pubspec.yaml b/packages/kanban/pubspec.yaml index dae1a49..aec098b 100644 --- a/packages/kanban/pubspec.yaml +++ b/packages/kanban/pubspec.yaml @@ -12,6 +12,7 @@ environment: dependencies: dew_core: path: ../core + dart_console: ^4.1.2 file: ^7.0.1 path: ^1.9.0 yaml: ^3.1.0 diff --git a/pubspec.lock b/pubspec.lock index 7129dd0..c1bba20 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -49,6 +49,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" charcode: dependency: transitive description: @@ -89,6 +97,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" collection: dependency: transitive description: @@ -129,6 +145,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + dart_console: + dependency: transitive + description: + name: dart_console + sha256: bf62b8016530fef83557c1f01867c281d0937dceb84204128819e6e925ddf73f + url: "https://pub.dev" + source: hosted + version: "4.1.4" dart_mcp: dependency: transitive description: @@ -137,6 +161,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.0" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" file: dependency: "direct dev" description: @@ -193,6 +225,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + intl: + dependency: transitive + description: + name: intl + sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" + url: "https://pub.dev" + source: hosted + version: "0.18.1" io: dependency: transitive description: @@ -537,6 +577,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" xml: dependency: transitive description: