Improve delete UX and wrap the helpbar on narrow terminals.

Confirm with x, force-delete with X, and wrap keybinding hints so all shortcuts stay visible.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Skruecken
2026-10-19 10:32:40 +02:00
co-authored by Cursor
parent 73216ec4ae
commit 28cd2628e4
4 changed files with 143 additions and 50 deletions
+37 -1
View File
@@ -36,6 +36,7 @@ const (
ModeProjects ModeProjects
ModeStatus ModeStatus
ModeBoard ModeBoard
ModeConfirmDelete
) )
// Model is the root Bubble Tea model. // Model is the root Bubble Tea model.
@@ -62,6 +63,7 @@ type Model struct {
version string version string
allTickets []ticket.Ticket allTickets []ticket.Ticket
creatingProject bool creatingProject bool
pendingDeleteTitle string
} }
// New creates the root UI model. // New creates the root UI model.
@@ -124,6 +126,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateStatus(msg) return m.updateStatus(msg)
case ModeBoard: case ModeBoard:
return m.updateBoard(msg) return m.updateBoard(msg)
case ModeConfirmDelete:
return m.updateConfirmDelete(msg)
default: default:
return m.updateList(msg) return m.updateList(msg)
} }
@@ -212,7 +216,7 @@ func (m Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.mode = ModeBoard m.mode = ModeBoard
return m, nil return m, nil
case key.Matches(msg, m.keys.Delete): case key.Matches(msg, m.keys.DeleteForce):
m.clearStatus() m.clearStatus()
if err := m.deleteSelected(); err != nil { if err := m.deleteSelected(); err != nil {
m.setError(err) m.setError(err)
@@ -221,6 +225,17 @@ func (m Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
return m, nil return m, nil
case key.Matches(msg, m.keys.Delete):
m.clearStatus()
t, ok := m.tickets.Selected()
if !ok {
m.setError(fmt.Errorf("no ticket selected"))
return m, nil
}
m.pendingDeleteTitle = strings.TrimSpace(t.Title)
m.mode = ModeConfirmDelete
return m, nil
case key.Matches(msg, m.keys.Undo): case key.Matches(msg, m.keys.Undo):
m.clearStatus() m.clearStatus()
if err := m.undo(); err != nil { if err := m.undo(); err != nil {
@@ -324,6 +339,27 @@ func (m Model) updateBoard(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
func (m Model) updateConfirmDelete(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, m.keys.Escape):
m.pendingDeleteTitle = ""
m.mode = ModeList
m.clearStatus()
return m, nil
case key.Matches(msg, m.keys.Enter), key.Matches(msg, m.keys.ConfirmYes):
m.pendingDeleteTitle = ""
m.mode = ModeList
if err := m.deleteSelected(); err != nil {
m.setError(err)
} else {
m.setStatus("deleted")
}
return m, nil
}
return m, nil
}
func (m Model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch { switch {
case key.Matches(msg, m.keys.Escape): case key.Matches(msg, m.keys.Escape):
+75 -38
View File
@@ -42,6 +42,11 @@ func (m Model) helpEntries() []helpEntry {
{"enter", "apply"}, {"enter", "apply"},
{"esc", "cancel"}, {"esc", "cancel"},
} }
case ModeConfirmDelete:
return []helpEntry{
{"y/enter", "confirm"},
{"esc", "cancel"},
}
default: default:
return []helpEntry{ return []helpEntry{
{"j/k", "navigate"}, {"j/k", "navigate"},
@@ -52,64 +57,96 @@ func (m Model) helpEntries() []helpEntry {
{"p", "project"}, {"p", "project"},
{"l", "list"}, {"l", "list"},
{"s", "status"}, {"s", "status"},
{"X", "delete"}, {"x", "delete"},
{"X", "force delete"},
{"q", "quit"}, {"q", "quit"},
} }
} }
} }
func (m Model) renderHelpbar() string { func (m Model) helpParts() []string {
var parts []string parts := make([]string, 0, len(m.helpEntries()))
for _, e := range m.helpEntries() { for _, e := range m.helpEntries() {
parts = append(parts, m.styles.HelpKey.Render(e.key)+" "+m.styles.HelpDesc.Render(e.desc)) parts = append(parts, m.styles.HelpKey.Render(e.key)+" "+m.styles.HelpDesc.Render(e.desc))
} }
return parts
}
func (m Model) helpSep() string {
sep := m.styles.HelpSep.String() sep := m.styles.HelpSep.String()
if sep == "" { if sep == "" {
sep = " " return " "
} }
return strings.Join(parts, sep) return sep
}
// wrapHelpParts packs styled help snippets into lines that fit within width.
func wrapHelpParts(parts []string, width int, sep string) []string {
if width < 1 {
width = 1
}
sepW := lipgloss.Width(sep)
var lines []string
var cur string
for _, p := range parts {
pw := lipgloss.Width(p)
if cur == "" {
cur = p
// Oversized single entry still gets its own line so nothing is dropped.
if pw > width {
lines = append(lines, cur)
cur = ""
}
continue
}
if lipgloss.Width(cur)+sepW+pw <= width {
cur = cur + sep + p
continue
}
lines = append(lines, cur)
cur = p
if pw > width {
lines = append(lines, cur)
cur = ""
}
}
if cur != "" {
lines = append(lines, cur)
}
if len(lines) == 0 {
return []string{""}
}
return lines
} }
func (m Model) renderFooter() string { func (m Model) renderFooter() string {
help := m.renderHelpbar()
ver := m.styles.Version.Render(m.version)
width := m.width width := m.width
if width <= 0 { if width <= 0 {
width = lipgloss.Width(help) + lipgloss.Width(ver) + 1 width = 80
} }
helpW := lipgloss.Width(help) ver := m.styles.Version.Render(m.version)
verW := lipgloss.Width(ver) verW := lipgloss.Width(ver)
gap := width - helpW - verW sep := m.helpSep()
if gap < 1 { parts := m.helpParts()
maxHelp := width - verW - 1
if maxHelp < 0 {
maxHelp = 0
}
help = truncateWidth(help, maxHelp)
helpW = lipgloss.Width(help)
gap = width - helpW - verW
if gap < 1 {
gap = 1
}
}
return help + strings.Repeat(" ", gap) + ver
}
func truncateWidth(s string, max int) string { // Wrap help across the full width so all bindings stay visible.
if max <= 0 { lines := wrapHelpParts(parts, width, sep)
return ""
// Place version on the last line to the right when it fits; otherwise
// put it on an extra right-aligned line.
last := lines[len(lines)-1]
gap := width - lipgloss.Width(last) - verW
if gap >= 1 {
lines[len(lines)-1] = last + strings.Repeat(" ", gap) + ver
} else {
pad := width - verW
if pad < 0 {
pad = 0
} }
if lipgloss.Width(s) <= max { lines = append(lines, strings.Repeat(" ", pad)+ver)
return s
} }
runes := []rune(s)
for len(runes) > 0 { return strings.Join(lines, "\n")
runes = runes[:len(runes)-1]
if lipgloss.Width(string(runes)) <= max-1 {
return string(runes) + "…"
}
}
return ""
} }
+13 -3
View File
@@ -12,7 +12,9 @@ type KeyMap struct {
Board key.Binding Board key.Binding
Status key.Binding Status key.Binding
Edit key.Binding Edit key.Binding
Delete key.Binding Delete key.Binding // x — confirm first
DeleteForce key.Binding // X — immediate
ConfirmYes key.Binding
Undo key.Binding Undo key.Binding
Redo key.Binding Redo key.Binding
Quit key.Binding Quit key.Binding
@@ -56,8 +58,16 @@ func DefaultKeyMap() KeyMap {
key.WithHelp("e", "edit"), key.WithHelp("e", "edit"),
), ),
Delete: key.NewBinding( Delete: key.NewBinding(
key.WithKeys("x"),
key.WithHelp("x", "delete"),
),
DeleteForce: key.NewBinding(
key.WithKeys("X"), key.WithKeys("X"),
key.WithHelp("X", "delete"), key.WithHelp("X", "force delete"),
),
ConfirmYes: key.NewBinding(
key.WithKeys("y", "Y"),
key.WithHelp("y", "confirm"),
), ),
Undo: key.NewBinding( Undo: key.NewBinding(
key.WithKeys("u", "ctrl+z"), key.WithKeys("u", "ctrl+z"),
@@ -84,7 +94,7 @@ func DefaultKeyMap() KeyMap {
// ShortHelp implements help.KeyMap. // ShortHelp implements help.KeyMap.
func (k KeyMap) ShortHelp() []key.Binding { func (k KeyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Up, k.New, k.Search, k.Project, k.Board, k.Status, k.Edit, k.Delete, k.Undo, k.Redo, k.Quit} return []key.Binding{k.Up, k.New, k.Search, k.Project, k.Board, k.Status, k.Edit, k.Delete, k.DeleteForce, k.Undo, k.Redo, k.Quit}
} }
// FullHelp implements help.KeyMap. // FullHelp implements help.KeyMap.
+10
View File
@@ -63,6 +63,16 @@ func (m Model) renderBody(height int) string {
menu := m.boardMenu.View() menu := m.boardMenu.View()
return lipgloss.NewStyle().Height(height).Width(m.width).Render(menu) return lipgloss.NewStyle().Height(height).Width(m.width).Render(menu)
case ModeConfirmDelete:
title := m.pendingDeleteTitle
if title == "" {
title = "this ticket"
}
prompt := m.styles.ErrorMsg.Render(fmt.Sprintf("Delete %q?", title))
hint := m.styles.StatusMsg.Render("y/enter confirm · esc cancel")
content := prompt + "\n" + hint
return lipgloss.NewStyle().Height(height).Width(m.width).Render(content)
default: default:
m.tickets.SetSize(m.width, height) m.tickets.SetSize(m.width, height)
return m.tickets.View() return m.tickets.View()