Skip to content

Commit f6d09af

Browse files
cursoragentadmin
andcommitted
fix(tui): keep retry off cancel queue and sanitize lists
/retry and alt+r queued during cancelling, bypassing the enter guard. Management drawer lists and the model catalog painted raw wire fields. Co-authored-by: admin <admin@21no.de>
1 parent 801a165 commit f6d09af

5 files changed

Lines changed: 121 additions & 16 deletions

File tree

‎internal/tui/input.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,9 @@ func (m *Model) retryLast() tea.Cmd {
347347
return m.transientNoteCmd("nothing to retry yet — send a prompt first")
348348
}
349349
if m.busy {
350+
if m.status == "cancelling" {
351+
return m.transientNoteCmd("cancelling — retry stays off the queue")
352+
}
350353
m.queue = append(m.queue, m.lastPrompt)
351354
return m.transientNoteCmd("retry queued — it sends when the turn ends")
352355
}

‎internal/tui/mgmt.go‎

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -452,7 +452,7 @@ func (m *Model) memRowsRender(w int) []string {
452452
th := m.th
453453
rows := make([]string, 0, len(m.memRows))
454454
for i, r := range m.memRows {
455-
label := r.text
455+
label := collapse(r.text)
456456
detail := " fact · " + r.kind
457457
if r.kind == "episode" {
458458
detail = " ⏳ pending episode · " + shortID(r.sessionID)
@@ -482,11 +482,12 @@ func (m *Model) skillRowsRender(w int) []string {
482482
if s.Untrusted {
483483
badges += " ⚠ untrusted"
484484
}
485-
detail := fmt.Sprintf(" ×%d · %s", s.UsageCount, s.Source) + badges
485+
detail := fmt.Sprintf(" ×%d · %s", s.UsageCount, collapse(s.Source)) + badges
486486
budget := w - 2 - lipgloss.Width(detail)
487-
prefix, lab := " ", th.acItem.Render(truncate(s.Name, budget))
487+
name := collapse(s.Name)
488+
prefix, lab := " ", th.acItem.Render(truncate(name, budget))
488489
if i == m.panelSel {
489-
prefix, lab = th.acSel.Render("› "), th.acSel.Render(truncate(s.Name, budget))
490+
prefix, lab = th.acSel.Render("› "), th.acSel.Render(truncate(name, budget))
490491
}
491492
rows = append(rows, prefix+lab+th.acDetail.Render(detail))
492493
// A dim description line under each name: the list stays scannable
@@ -502,14 +503,16 @@ func (m *Model) toolRowsRender(w int) []string {
502503
th := m.th
503504
rows := make([]string, 0, len(m.toolRows))
504505
for i, r := range m.toolRows {
505-
detail := " " + r.dim
506+
dim := collapse(r.dim)
507+
text := collapse(r.text)
508+
detail := " " + dim
506509
if r.kind == "mcp" {
507-
detail = " mcp · " + r.dim
510+
detail = " mcp · " + dim
508511
}
509512
budget := w - 2 - lipgloss.Width(detail)
510-
prefix, lab := " ", th.acItem.Render(truncate(r.text, budget))
513+
prefix, lab := " ", th.acItem.Render(truncate(text, budget))
511514
if i == m.panelSel {
512-
prefix, lab = th.acSel.Render("› "), th.acSel.Render(truncate(r.text, budget))
515+
prefix, lab = th.acSel.Render("› "), th.acSel.Render(truncate(text, budget))
513516
}
514517
rows = append(rows, prefix+lab+th.acDetail.Render(detail))
515518
}
@@ -520,14 +523,15 @@ func (m *Model) cfgRowsRender(w int) []string {
520523
th := m.th
521524
rows := make([]string, 0, len(m.cfgRows))
522525
for i, r := range m.cfgRows {
523-
detail := " " + r.v
526+
k := collapse(r.k)
527+
detail := " " + collapse(r.v)
524528
if r.kind == "conn" {
525529
detail += " · d kicks"
526530
}
527531
budget := w - 2 - lipgloss.Width(detail)
528-
prefix, lab := " ", th.acItem.Render(truncate(r.k, budget))
532+
prefix, lab := " ", th.acItem.Render(truncate(k, budget))
529533
if i == m.panelSel {
530-
prefix, lab = th.acSel.Render("› "), th.acSel.Render(truncate(r.k, budget))
534+
prefix, lab = th.acSel.Render("› "), th.acSel.Render(truncate(k, budget))
531535
}
532536
rows = append(rows, prefix+lab+th.acDetail.Render(detail))
533537
}
@@ -597,7 +601,7 @@ func (m *Model) agentRowsRender(w int) []string {
597601
th := m.th
598602
rows := make([]string, 0, len(m.agentsReg))
599603
for i, e := range m.agentsReg {
600-
goal := e.Goal
604+
goal := collapse(e.Goal)
601605
if goal == "" {
602606
goal = "(no goal recorded)"
603607
}
@@ -609,9 +613,9 @@ func (m *Model) agentRowsRender(w int) []string {
609613
}
610614
var detail string
611615
if e.Phase == "finished" {
612-
detail = fmt.Sprintf(" %s · %d it · %s tok", e.Status, e.Iterations, human(e.TokensUsed))
616+
detail = fmt.Sprintf(" %s · %d it · %s tok", collapse(e.Status), e.Iterations, human(e.TokensUsed))
613617
} else {
614-
tool := e.LastTool
618+
tool := collapse(e.LastTool)
615619
if tool == "" && e.Step > 0 {
616620
tool = fmt.Sprintf("step %d", e.Step)
617621
}

‎internal/tui/panels.go‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -622,12 +622,13 @@ type modelEntry struct {
622622
func (m *Model) modelEntries() []modelEntry {
623623
out := make([]modelEntry, 0, len(m.models))
624624
for _, md := range m.models {
625-
e := modelEntry{id: md.ID, label: md.ID, current: md.Current}
625+
id := collapse(md.ID)
626+
e := modelEntry{id: id, label: id, current: md.Current}
626627
if md.MaxContext > 0 {
627628
e.detail = fmt.Sprintf("%s ctx", humanCtx(md.MaxContext))
628629
}
629630
if md.Description != "" {
630-
e.detail = strings.TrimSpace(md.Description + " " + e.detail)
631+
e.detail = strings.TrimSpace(collapse(md.Description) + " " + e.detail)
631632
}
632633
out = append(out, e)
633634
}

‎internal/tui/promptflow_test.go‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -701,3 +701,33 @@ func TestCancelApprovalDoesNotClobberCancelling(t *testing.T) {
701701
}
702702
})
703703
}
704+
705+
// /retry and alt+r still queue while cancelling — they skip submit()'s
706+
// enter guard and retryLast only checks busy.
707+
func TestRetryDuringCancelDoesNotQueue(t *testing.T) {
708+
m := newTestModel()
709+
busyTurn(m)
710+
m.sessionID = "s1"
711+
m.lastPrompt = "first"
712+
m.queue = []string{"held"}
713+
m.cancelRun()
714+
if m.status != "cancelling" {
715+
t.Fatalf("precondition: status = %q", m.status)
716+
}
717+
718+
m.retryLast()
719+
if len(m.queue) != 0 {
720+
t.Fatalf("retryLast while cancelling queued: %v", m.queue)
721+
}
722+
723+
m.Update(key("alt+r"))
724+
if len(m.queue) != 0 {
725+
t.Fatalf("alt+r while cancelling queued: %v", m.queue)
726+
}
727+
728+
m.ta.SetValue("/retry")
729+
m.submit()
730+
if len(m.queue) != 0 {
731+
t.Fatalf("/retry while cancelling queued: %v", m.queue)
732+
}
733+
}

‎internal/tui/w1_hardening_test.go‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,73 @@ func TestEngineNoticesCollapseNewlines(t *testing.T) {
264264
}
265265
}
266266

267+
// Management drawer list rows paint API/wire strings. CSI in a skill name,
268+
// fact, MCP command, or agent goal must not reach the terminal.
269+
func TestMgmtListRowsSanitizeWireFields(t *testing.T) {
270+
const payload = "\x1b[2J"
271+
hasCSI := func(rows []string) bool {
272+
return strings.Contains(strings.Join(rows, "\n"), payload)
273+
}
274+
275+
t.Run("skills", func(t *testing.T) {
276+
m := newTestModel()
277+
m.skills = []client.Skill{{Name: "x" + payload + "evil", Source: "y" + payload}}
278+
if hasCSI(m.skillRowsRender(80)) {
279+
t.Error("skill list leaked CSI")
280+
}
281+
})
282+
t.Run("memory", func(t *testing.T) {
283+
m := newTestModel()
284+
m.memRows = []memRow{{kind: "user", text: "x" + payload + "evil"}}
285+
if hasCSI(m.memRowsRender(80)) {
286+
t.Error("memory list leaked CSI")
287+
}
288+
})
289+
t.Run("tools", func(t *testing.T) {
290+
m := newTestModel()
291+
m.toolRows = []toolRow{{kind: "mcp", text: "x" + payload, dim: "y" + payload}}
292+
if hasCSI(m.toolRowsRender(80)) {
293+
t.Error("tools list leaked CSI")
294+
}
295+
})
296+
t.Run("config", func(t *testing.T) {
297+
m := newTestModel()
298+
m.cfgRows = []cfgRow{{kind: "cfg", k: "x" + payload, v: "y" + payload}}
299+
if hasCSI(m.cfgRowsRender(80)) {
300+
t.Error("config list leaked CSI")
301+
}
302+
})
303+
t.Run("agents", func(t *testing.T) {
304+
m := newTestModel()
305+
m.agentsReg = []client.SubagentEntry{{Goal: "x" + payload, LastTool: "y" + payload, Phase: "running"}}
306+
if hasCSI(m.agentRowsRender(80)) {
307+
t.Error("agents list leaked CSI")
308+
}
309+
})
310+
}
311+
312+
// The models picker and palette still paint catalog ID/description raw
313+
// (v1.4.5 only collapsed the header / applyModelChoice store).
314+
func TestModelCatalogSanitizesPickerAndPalette(t *testing.T) {
315+
const payload = "\x1b[2J"
316+
m := newTestModel()
317+
m.models = []client.ModelInfo{{ID: "x" + payload + "evil", Description: "y" + payload}}
318+
319+
if strings.Contains(strings.Join(m.modelRows(80), "\n"), payload) {
320+
t.Error("model picker leaked CSI")
321+
}
322+
323+
m.togglePalette()
324+
for _, e := range m.pal.all {
325+
if e.kind != "model" {
326+
continue
327+
}
328+
if strings.Contains(e.title, payload) {
329+
t.Errorf("palette model title leaked CSI: %q", e.title)
330+
}
331+
}
332+
}
333+
267334
// The conversation is the click/scroll hit-test source of truth: a line that
268335
// physically wraps desyncs every index below it. Every emitted line must fit
269336
// the viewport.

0 commit comments

Comments
 (0)