fix swarm editor cursor placement
- commit
- d6a30a59677accd0bd3cf6fd1c862da9527ee072
- author
- patriceckhart · · 2026-05-26 15:30 UTC
- parent
- 80c0ac9
- signature
- unsigned
2 files · +36 -4
M internal/agent/modes/swarm_dialog.go +16 -4
| @@ -196,8 +196,17 @@ func (d *swarmDialog) CursorPos(width int) (row, col int) { | |||
| 196 | 196 | popupRows = len(d.fileSuggest.Render(d.newTaskEd.Value(), tui.Theme{}, width)) | |
| 197 | 197 | } | |
| 198 | 198 | _, eRow, eCol := d.newTaskEd.Render(width - 2) | |
| 199 | + | const headerRows = 1 | |
| 199 | 200 | const editorBlankRow = 1 | |
| 200 | - | return 1 + modelBannerRows + popupRows + editorBlankRow + eRow, eCol + 2 | |
| 201 | + | padAfterHeaderRows := 0 | |
| 202 | + | if modelBannerRows > 0 { | |
| 203 | + | // interactive.Render wraps dialog output with padDialogFrame, | |
| 204 | + | // which inserts a blank row after the frame header when the | |
| 205 | + | // first body row is non-empty. The model banner is that first | |
| 206 | + | // body row in the spawn editor, so mirror the extra row here. | |
| 207 | + | padAfterHeaderRows = 1 | |
| 208 | + | } | |
| 209 | + | return headerRows + padAfterHeaderRows + modelBannerRows + popupRows + editorBlankRow + eRow, eCol + 2 | |
| 201 | 210 | } | |
| 202 | 211 | // Prompt editor cursor placement. Two flavours: | |
| 203 | 212 | // 1. List-view modal (`p` from the dashboard list): renderPromptEditor | |
| @@ -220,12 +229,14 @@ func (d *swarmDialog) CursorPos(width int) (row, col int) { | |||
| 220 | 229 | return d.transcriptEditorCursorRow(width, popupRows, eRow), eCol + 2 | |
| 221 | 230 | } | |
| 222 | 231 | ||
| 223 | - | // List-view modal layout: frame header + "send to <id>:" hint | |
| 224 | - | // + popup + blank + editor. | |
| 232 | + | // List-view modal layout after interactive.Render applies | |
| 233 | + | // padDialogFrame: frame header + inserted pad row + "send to | |
| 234 | + | // <id>:" hint + popup + blank + editor. | |
| 225 | 235 | const headerRows = 1 | |
| 236 | + | const padAfterHeaderRows = 1 | |
| 226 | 237 | const sendToHintRow = 1 | |
| 227 | 238 | const editorBlankRow = 1 | |
| 228 | - | return headerRows + sendToHintRow + popupRows + editorBlankRow + eRow, eCol + 2 | |
| 239 | + | return headerRows + padAfterHeaderRows + sendToHintRow + popupRows + editorBlankRow + eRow, eCol + 2 | |
| 229 | 240 | } | |
| 230 | 241 | return -1, -1 | |
| 231 | 242 | } | |
| @@ -240,6 +251,7 @@ func (d *swarmDialog) transcriptEditorCursorRow(width, popupRows, editorRowOffse | |||
| 240 | 251 | return -1 | |
| 241 | 252 | } | |
| 242 | 253 | row := 1 // frame header | |
| 254 | + | row++ // padDialogFrame blank row after header (next row is task metadata) | |
| 243 | 255 | row += 3 // task / dir / status (mirrors renderTranscript's fixed header rows) | |
| 244 | 256 | if a.Model != "" { | |
| 245 | 257 | row++ | |
| @@ -196,8 +196,17 @@ func (d *swarmDialog) CursorPos(width int) (row, col int) { | |||||
| 196 | popupRows = len(d.fileSuggest.Render(d.newTaskEd.Value(), tui.Theme{}, width)) | 196 | popupRows = len(d.fileSuggest.Render(d.newTaskEd.Value(), tui.Theme{}, width)) | ||
| 197 | } | 197 | } | ||
| 198 | _, eRow, eCol := d.newTaskEd.Render(width - 2) | 198 | _, eRow, eCol := d.newTaskEd.Render(width - 2) | ||
| 199 | + | const headerRows = 1 | |||
| 199 | const editorBlankRow = 1 | 200 | const editorBlankRow = 1 | ||
| 200 | - | return 1 + modelBannerRows + popupRows + editorBlankRow + eRow, eCol + 2 | 201 | + | padAfterHeaderRows := 0 |
| 202 | + | if modelBannerRows > 0 { | |||
| 203 | + | // interactive.Render wraps dialog output with padDialogFrame, | |||
| 204 | + | // which inserts a blank row after the frame header when the | |||
| 205 | + | // first body row is non-empty. The model banner is that first | |||
| 206 | + | // body row in the spawn editor, so mirror the extra row here. | |||
| 207 | + | padAfterHeaderRows = 1 | |||
| 208 | + | } | |||
| 209 | + | return headerRows + padAfterHeaderRows + modelBannerRows + popupRows + editorBlankRow + eRow, eCol + 2 | |||
| 201 | } | 210 | } | ||
| 202 | // Prompt editor cursor placement. Two flavours: | 211 | // Prompt editor cursor placement. Two flavours: | ||
| 203 | // 1. List-view modal (`p` from the dashboard list): renderPromptEditor | 212 | // 1. List-view modal (`p` from the dashboard list): renderPromptEditor | ||
| @@ -220,12 +229,14 @@ func (d *swarmDialog) CursorPos(width int) (row, col int) { | |||||
| 220 | return d.transcriptEditorCursorRow(width, popupRows, eRow), eCol + 2 | 229 | return d.transcriptEditorCursorRow(width, popupRows, eRow), eCol + 2 | ||
| 221 | } | 230 | } | ||
| 222 | 231 | ||||
| 223 | - | // List-view modal layout: frame header + "send to <id>:" hint | 232 | + | // List-view modal layout after interactive.Render applies |
| 224 | - | // + popup + blank + editor. | 233 | + | // padDialogFrame: frame header + inserted pad row + "send to |
| 234 | + | // <id>:" hint + popup + blank + editor. | |||
| 225 | const headerRows = 1 | 235 | const headerRows = 1 | ||
| 236 | + | const padAfterHeaderRows = 1 | |||
| 226 | const sendToHintRow = 1 | 237 | const sendToHintRow = 1 | ||
| 227 | const editorBlankRow = 1 | 238 | const editorBlankRow = 1 | ||
| 228 | - | return headerRows + sendToHintRow + popupRows + editorBlankRow + eRow, eCol + 2 | 239 | + | return headerRows + padAfterHeaderRows + sendToHintRow + popupRows + editorBlankRow + eRow, eCol + 2 |
| 229 | } | 240 | } | ||
| 230 | return -1, -1 | 241 | return -1, -1 | ||
| 231 | } | 242 | } | ||
| @@ -240,6 +251,7 @@ func (d *swarmDialog) transcriptEditorCursorRow(width, popupRows, editorRowOffse | |||||
| 240 | return -1 | 251 | return -1 | ||
| 241 | } | 252 | } | ||
| 242 | row := 1 // frame header | 253 | row := 1 // frame header | ||
| 254 | + | row++ // padDialogFrame blank row after header (next row is task metadata) | |||
| 243 | row += 3 // task / dir / status (mirrors renderTranscript's fixed header rows) | 255 | row += 3 // task / dir / status (mirrors renderTranscript's fixed header rows) | ||
| 244 | if a.Model != "" { | 256 | if a.Model != "" { | ||
| 245 | row++ | 257 | row++ | ||
M internal/agent/modes/swarm_dialog_test.go +20 -0
| @@ -650,6 +650,26 @@ func TestCursorPosPromptEditor(t *testing.T) { | |||
| 650 | 650 | } | |
| 651 | 651 | } | |
| 652 | 652 | ||
| 653 | + | func TestCursorPosSpawnEditorWithInheritedModelMatchesPaddedRender(t *testing.T) { | |
| 654 | + | d := newSwarmDialog() | |
| 655 | + | d.SetCurrentModel("claude-sonnet-4-5", "anthropic") | |
| 656 | + | d.Open(staticSnapshots(), nil, nil, func(task, model, provider string) error { return nil }, nil, nil, "") | |
| 657 | + | _ = d.Render(tui.Theme{}, 80) | |
| 658 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'}) | |
| 659 | + | if !d.spawning { | |
| 660 | + | t.Fatal("setup: n did not enter spawning mode") | |
| 661 | + | } | |
| 662 | + | ||
| 663 | + | row, _ := d.CursorPos(80) | |
| 664 | + | lines := padDialogFrame(d.Render(tui.Theme{}, 80)) | |
| 665 | + | if row < 0 || row >= len(lines) { | |
| 666 | + | t.Fatalf("spawn CursorPos row = %d outside rendered lines %d", row, len(lines)) | |
| 667 | + | } | |
| 668 | + | if got := stripANSIBytes(lines[row]); !strings.Contains(got, "▌") { | |
| 669 | + | t.Fatalf("spawn CursorPos row %d = %q; want editor prompt row", row, got) | |
| 670 | + | } | |
| 671 | + | } | |
| 672 | + | ||
| 653 | 673 | // TestSwarmDialogPOnNonRunningAgentShowsHint covers the regression | |
| 654 | 674 | // caught by the user's screenshot: pressing 'p' on a detached agent | |
| 655 | 675 | // dialled a dead unix socket and printed the raw path as an error. | |
| @@ -650,6 +650,26 @@ func TestCursorPosPromptEditor(t *testing.T) { | |||||
| 650 | } | 650 | } | ||
| 651 | } | 651 | } | ||
| 652 | 652 | ||||
| 653 | + | func TestCursorPosSpawnEditorWithInheritedModelMatchesPaddedRender(t *testing.T) { | |||
| 654 | + | d := newSwarmDialog() | |||
| 655 | + | d.SetCurrentModel("claude-sonnet-4-5", "anthropic") | |||
| 656 | + | d.Open(staticSnapshots(), nil, nil, func(task, model, provider string) error { return nil }, nil, nil, "") | |||
| 657 | + | _ = d.Render(tui.Theme{}, 80) | |||
| 658 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'}) | |||
| 659 | + | if !d.spawning { | |||
| 660 | + | t.Fatal("setup: n did not enter spawning mode") | |||
| 661 | + | } | |||
| 662 | + | ||||
| 663 | + | row, _ := d.CursorPos(80) | |||
| 664 | + | lines := padDialogFrame(d.Render(tui.Theme{}, 80)) | |||
| 665 | + | if row < 0 || row >= len(lines) { | |||
| 666 | + | t.Fatalf("spawn CursorPos row = %d outside rendered lines %d", row, len(lines)) | |||
| 667 | + | } | |||
| 668 | + | if got := stripANSIBytes(lines[row]); !strings.Contains(got, "▌") { | |||
| 669 | + | t.Fatalf("spawn CursorPos row %d = %q; want editor prompt row", row, got) | |||
| 670 | + | } | |||
| 671 | + | } | |||
| 672 | + | ||||
| 653 | // TestSwarmDialogPOnNonRunningAgentShowsHint covers the regression | 673 | // TestSwarmDialogPOnNonRunningAgentShowsHint covers the regression | ||
| 654 | // caught by the user's screenshot: pressing 'p' on a detached agent | 674 | // caught by the user's screenshot: pressing 'p' on a detached agent | ||
| 655 | // dialled a dead unix socket and printed the raw path as an error. | 675 | // dialled a dead unix socket and printed the raw path as an error. | ||