feat(tui): pill/tab column headers and inline kanban actions
Column header redesign (3 rows → 2 rows): - Active column: ▌ NAME (n) ▐ full-width bold in column accent color - Inactive column: lowercase dimmed name, thin ─ separator - ▔▔▔▔▔ underline bar replaces the old ╞════╡ box separator - Saves one terminal row per column, exposes more ticket content Inline action prompts (bottom bar, Esc to cancel): - [n] new ticket — prompts for title, creates in current column using the first configured ticket type as default - [e] edit title — prefills current title for in-place editing - [c] add comment — single-line comment appended to selected ticket - [a] archive — shows 'Archive TICKET-ID? [y/N]' confirm prompt After each mutation the board reloads and ticketIdx is clamped so the cursor stays valid. Errors surface as yellow status messages. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
e8a703e6ca
commit
7f7dd25d76
1 changed files with 140 additions and 16 deletions
|
|
@ -28,6 +28,17 @@ final class _TuiRefresh extends _TuiEvent {
|
|||
const _TuiRefresh();
|
||||
}
|
||||
|
||||
// ── Inline prompt ────────────────────────────────────────────────────────────
|
||||
|
||||
enum _PromptKind { newTitle, editTitle, addComment, archiveConfirm }
|
||||
|
||||
class _Prompt {
|
||||
final _PromptKind kind;
|
||||
final String? ticketId;
|
||||
String input;
|
||||
_Prompt(this.kind, {this.input = '', this.ticketId});
|
||||
}
|
||||
|
||||
/// Parses raw terminal bytes into [Key] values without blocking.
|
||||
///
|
||||
/// Mirrors [Console.readKey] but works on a pre-read byte batch so the event
|
||||
|
|
@ -147,7 +158,7 @@ class TuiCommand extends DewCommand {
|
|||
// 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 _colHeaderH = 2; // rows used by column header (pill + underline)
|
||||
static const _ticketH = 3; // rows per ticket card
|
||||
static const _indicatorRows = 2; // rows reserved for scroll indicators
|
||||
|
||||
|
|
@ -178,6 +189,7 @@ class TuiCommand extends DewCommand {
|
|||
var statusMsg = '';
|
||||
var searchQuery = '';
|
||||
var searchMode = false;
|
||||
_Prompt? prompt;
|
||||
|
||||
console.hideCursor();
|
||||
console.rawMode = true;
|
||||
|
|
@ -198,6 +210,7 @@ class TuiCommand extends DewCommand {
|
|||
statusMsg: statusMsg,
|
||||
searchQuery: searchQuery,
|
||||
searchMode: searchMode,
|
||||
prompt: prompt,
|
||||
w: w,
|
||||
h: h,
|
||||
);
|
||||
|
|
@ -261,6 +274,91 @@ class TuiCommand extends DewCommand {
|
|||
|
||||
final key = (event as _TuiKey).key;
|
||||
|
||||
// ── Prompt mode (inline action input) ─────────────────────────────
|
||||
if (prompt != null) {
|
||||
final p = prompt;
|
||||
if (p.kind == _PromptKind.archiveConfirm) {
|
||||
if (!key.isControl) {
|
||||
if (key.char == 'y' || key.char == 'Y') {
|
||||
try {
|
||||
await store.update(p.ticketId!, column: 'archive');
|
||||
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}';
|
||||
}
|
||||
prompt = null;
|
||||
} else {
|
||||
prompt = null;
|
||||
statusMsg = '';
|
||||
}
|
||||
} else if (key.controlChar == ControlCharacter.escape) {
|
||||
prompt = null;
|
||||
statusMsg = '';
|
||||
} else {
|
||||
continue loop;
|
||||
}
|
||||
} else {
|
||||
if (key.isControl) {
|
||||
switch (key.controlChar) {
|
||||
case ControlCharacter.escape:
|
||||
prompt = null;
|
||||
statusMsg = '';
|
||||
case ControlCharacter.backspace:
|
||||
if (p.input.isNotEmpty) {
|
||||
p.input = p.input.substring(0, p.input.length - 1);
|
||||
}
|
||||
case ControlCharacter.enter:
|
||||
final trimmed = p.input.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
prompt = null;
|
||||
} else {
|
||||
try {
|
||||
switch (p.kind) {
|
||||
case _PromptKind.newTitle:
|
||||
final col = config.columns[colIdx];
|
||||
final type = config.ticketTypes.isNotEmpty
|
||||
? config.ticketTypes.first.id
|
||||
: 'task';
|
||||
await store.create(title: trimmed, type: type, column: col.id);
|
||||
tickets = await store.list();
|
||||
byColumn = _groupByColumn(tickets, config);
|
||||
final created = _filtered(byColumn[col.id] ?? [], searchQuery);
|
||||
ticketIdx = max(0, created.length - 1);
|
||||
statusMsg = 'Created in ${col.name}.';
|
||||
case _PromptKind.editTitle:
|
||||
await store.update(p.ticketId!, title: trimmed);
|
||||
tickets = await store.list();
|
||||
byColumn = _groupByColumn(tickets, config);
|
||||
statusMsg = 'Title updated.';
|
||||
case _PromptKind.addComment:
|
||||
await store.addComment(p.ticketId!, trimmed);
|
||||
tickets = await store.list();
|
||||
byColumn = _groupByColumn(tickets, config);
|
||||
statusMsg = 'Comment added.';
|
||||
case _PromptKind.archiveConfirm:
|
||||
break; // handled above
|
||||
}
|
||||
} on ArgumentError catch (e) {
|
||||
statusMsg = 'Error: ${e.message ?? e}';
|
||||
}
|
||||
prompt = null;
|
||||
}
|
||||
default:
|
||||
continue loop;
|
||||
}
|
||||
} else {
|
||||
p.input += key.char;
|
||||
}
|
||||
}
|
||||
redraw();
|
||||
continue loop;
|
||||
}
|
||||
|
||||
// ── Search mode ────────────────────────────────────────────────────
|
||||
if (searchMode) {
|
||||
if (key.isControl) {
|
||||
|
|
@ -345,6 +443,24 @@ class TuiCommand extends DewCommand {
|
|||
searchMode = true;
|
||||
searchQuery = '';
|
||||
ticketIdx = 0;
|
||||
case 'n':
|
||||
searchMode = false;
|
||||
prompt = _Prompt(_PromptKind.newTitle);
|
||||
case 'e':
|
||||
if (colTickets.isNotEmpty) {
|
||||
final t = colTickets[ticketIdx];
|
||||
prompt = _Prompt(_PromptKind.editTitle, input: t.title, ticketId: t.id);
|
||||
}
|
||||
case 'c':
|
||||
if (colTickets.isNotEmpty) {
|
||||
final t = colTickets[ticketIdx];
|
||||
prompt = _Prompt(_PromptKind.addComment, ticketId: t.id);
|
||||
}
|
||||
case 'a':
|
||||
if (colTickets.isNotEmpty) {
|
||||
final t = colTickets[ticketIdx];
|
||||
prompt = _Prompt(_PromptKind.archiveConfirm, ticketId: t.id);
|
||||
}
|
||||
default:
|
||||
continue loop; // skip redraw
|
||||
}
|
||||
|
|
@ -443,6 +559,7 @@ class TuiCommand extends DewCommand {
|
|||
required String statusMsg,
|
||||
required String searchQuery,
|
||||
required bool searchMode,
|
||||
required _Prompt? prompt,
|
||||
required int w,
|
||||
required int h,
|
||||
}) {
|
||||
|
|
@ -502,6 +619,7 @@ class TuiCommand extends DewCommand {
|
|||
statusMsg: statusMsg,
|
||||
searchMode: searchMode,
|
||||
searchQuery: searchQuery,
|
||||
prompt: prompt,
|
||||
w: w,
|
||||
colIdx: colIdx,
|
||||
numCols: numCols,
|
||||
|
|
@ -522,27 +640,22 @@ class TuiCommand extends DewCommand {
|
|||
final color = _colColor(col.color);
|
||||
final innerW = colW - 2;
|
||||
|
||||
// ── Column header (3 rows) ─────────────────────────────────────────────
|
||||
// ── Column header (2 rows: pill name + underline bar) ─────────────────
|
||||
|
||||
// Top border
|
||||
cells.add(_Cell(
|
||||
'┌${'─' * innerW}┐',
|
||||
fg: isSelected ? color : ConsoleColor.brightBlack,
|
||||
));
|
||||
|
||||
// Name + count
|
||||
// Name pill — full width, no side borders
|
||||
final count = tickets.length;
|
||||
final label = _trunc(' ${col.name.toUpperCase()} ($count) ', innerW);
|
||||
final nameRaw = isSelected
|
||||
? ' ▌ ${col.name.toUpperCase()} ($count) ▐'
|
||||
: ' ${col.name} ($count) ';
|
||||
cells.add(_Cell(
|
||||
'│${label.padRight(innerW)}│',
|
||||
fg: isSelected ? ConsoleColor.black : color,
|
||||
bg: isSelected ? color : null,
|
||||
_trunc(nameRaw, colW).padRight(colW),
|
||||
fg: isSelected ? color : ConsoleColor.brightBlack,
|
||||
bold: isSelected,
|
||||
));
|
||||
|
||||
// Separator (double line for selected, single for others)
|
||||
// Underline bar — ▔ (upper-eighth-block) for selected, thin ─ for inactive
|
||||
cells.add(_Cell(
|
||||
isSelected ? '╞${'═' * innerW}╡' : '└${'─' * innerW}┘',
|
||||
isSelected ? '▔' * colW : '─' * colW,
|
||||
fg: isSelected ? color : ConsoleColor.brightBlack,
|
||||
));
|
||||
|
||||
|
|
@ -682,6 +795,7 @@ class TuiCommand extends DewCommand {
|
|||
required String statusMsg,
|
||||
required bool searchMode,
|
||||
required String searchQuery,
|
||||
required _Prompt? prompt,
|
||||
required int w,
|
||||
required int colIdx,
|
||||
required int numCols,
|
||||
|
|
@ -700,6 +814,16 @@ class TuiCommand extends DewCommand {
|
|||
console.setForegroundColor(ConsoleColor.brightYellow);
|
||||
console.write(' $statusMsg');
|
||||
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]',
|
||||
};
|
||||
console.write(_trunc(line, w).padRight(w));
|
||||
console.resetColorAttributes();
|
||||
} else if (searchMode) {
|
||||
console.setForegroundColor(ConsoleColor.brightCyan);
|
||||
console.write(' Search: ${searchQuery}_ (Enter to apply, Esc to clear)');
|
||||
|
|
@ -708,7 +832,7 @@ class TuiCommand extends DewCommand {
|
|||
// 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 [q] quit';
|
||||
const help = ' [j/k] nav [h/l] col [</>] move [↵] detail [n] new [e] edit [a] archive [c] comment [?] filter [q] quit';
|
||||
console.write(_trunc('$pos$help', w).padRight(w));
|
||||
console.resetColorAttributes();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue