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
+39 -3
View File
@@ -36,6 +36,7 @@ const (
ModeProjects
ModeStatus
ModeBoard
ModeConfirmDelete
)
// Model is the root Bubble Tea model.
@@ -60,8 +61,9 @@ type Model struct {
keys KeyMap
styles Styles
version string
allTickets []ticket.Ticket
creatingProject bool
allTickets []ticket.Ticket
creatingProject bool
pendingDeleteTitle string
}
// 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)
case ModeBoard:
return m.updateBoard(msg)
case ModeConfirmDelete:
return m.updateConfirmDelete(msg)
default:
return m.updateList(msg)
}
@@ -212,7 +216,7 @@ func (m Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.mode = ModeBoard
return m, nil
case key.Matches(msg, m.keys.Delete):
case key.Matches(msg, m.keys.DeleteForce):
m.clearStatus()
if err := m.deleteSelected(); err != nil {
m.setError(err)
@@ -221,6 +225,17 @@ func (m Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
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):
m.clearStatus()
if err := m.undo(); err != nil {
@@ -324,6 +339,27 @@ func (m Model) updateBoard(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
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) {
switch {
case key.Matches(msg, m.keys.Escape):
+75 -38
View File
@@ -42,6 +42,11 @@ func (m Model) helpEntries() []helpEntry {
{"enter", "apply"},
{"esc", "cancel"},
}
case ModeConfirmDelete:
return []helpEntry{
{"y/enter", "confirm"},
{"esc", "cancel"},
}
default:
return []helpEntry{
{"j/k", "navigate"},
@@ -52,64 +57,96 @@ func (m Model) helpEntries() []helpEntry {
{"p", "project"},
{"l", "list"},
{"s", "status"},
{"X", "delete"},
{"x", "delete"},
{"X", "force delete"},
{"q", "quit"},
}
}
}
func (m Model) renderHelpbar() string {
var parts []string
func (m Model) helpParts() []string {
parts := make([]string, 0, len(m.helpEntries()))
for _, e := range m.helpEntries() {
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()
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 {
help := m.renderHelpbar()
ver := m.styles.Version.Render(m.version)
width := m.width
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)
gap := width - helpW - verW
if gap < 1 {
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
}
sep := m.helpSep()
parts := m.helpParts()
func truncateWidth(s string, max int) string {
if max <= 0 {
return ""
}
if lipgloss.Width(s) <= max {
return s
}
runes := []rune(s)
for len(runes) > 0 {
runes = runes[:len(runes)-1]
if lipgloss.Width(string(runes)) <= max-1 {
return string(runes) + "…"
// Wrap help across the full width so all bindings stay visible.
lines := wrapHelpParts(parts, width, sep)
// 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
}
lines = append(lines, strings.Repeat(" ", pad)+ver)
}
return ""
return strings.Join(lines, "\n")
}
+19 -9
View File
@@ -11,13 +11,15 @@ type KeyMap struct {
Project key.Binding
Board key.Binding
Status key.Binding
Edit key.Binding
Delete key.Binding
Undo key.Binding
Redo key.Binding
Quit key.Binding
Enter key.Binding
Escape key.Binding
Edit key.Binding
Delete key.Binding // x — confirm first
DeleteForce key.Binding // X — immediate
ConfirmYes key.Binding
Undo key.Binding
Redo key.Binding
Quit key.Binding
Enter key.Binding
Escape key.Binding
}
// DefaultKeyMap returns the default key bindings.
@@ -56,8 +58,16 @@ func DefaultKeyMap() KeyMap {
key.WithHelp("e", "edit"),
),
Delete: key.NewBinding(
key.WithKeys("x"),
key.WithHelp("x", "delete"),
),
DeleteForce: key.NewBinding(
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(
key.WithKeys("u", "ctrl+z"),
@@ -84,7 +94,7 @@ func DefaultKeyMap() KeyMap {
// ShortHelp implements help.KeyMap.
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.
+10
View File
@@ -63,6 +63,16 @@ func (m Model) renderBody(height int) string {
menu := m.boardMenu.View()
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:
m.tickets.SetSize(m.width, height)
return m.tickets.View()