detached d6a30a5

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) {
196196 popupRows = len(d.fileSuggest.Render(d.newTaskEd.Value(), tui.Theme{}, width))
197197 }
198198 _, eRow, eCol := d.newTaskEd.Render(width - 2)
199+ const headerRows = 1
199200 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
201210 }
202211 // Prompt editor cursor placement. Two flavours:
203212 // 1. List-view modal (`p` from the dashboard list): renderPromptEditor
@@ -220,12 +229,14 @@ func (d *swarmDialog) CursorPos(width int) (row, col int) {
220229 return d.transcriptEditorCursorRow(width, popupRows, eRow), eCol + 2
221230 }
222231
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.
225235 const headerRows = 1
236+ const padAfterHeaderRows = 1
226237 const sendToHintRow = 1
227238 const editorBlankRow = 1
228- return headerRows + sendToHintRow + popupRows + editorBlankRow + eRow, eCol + 2
239+ return headerRows + padAfterHeaderRows + sendToHintRow + popupRows + editorBlankRow + eRow, eCol + 2
229240 }
230241 return -1, -1
231242 }
@@ -240,6 +251,7 @@ func (d *swarmDialog) transcriptEditorCursorRow(width, popupRows, editorRowOffse
240251 return -1
241252 }
242253 row := 1 // frame header
254+ row++ // padDialogFrame blank row after header (next row is task metadata)
243255 row += 3 // task / dir / status (mirrors renderTranscript's fixed header rows)
244256 if a.Model != "" {
245257 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 = 1200 const editorBlankRow = 1
200- return 1 + modelBannerRows + popupRows + editorBlankRow + eRow, eCol + 2201+ 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): renderPromptEditor212 // 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 + 2229 return d.transcriptEditorCursorRow(width, popupRows, eRow), eCol + 2
221 }230 }
222 231
223- // List-view modal layout: frame header + "send to <id>:" hint232+ // 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 = 1235 const headerRows = 1
236+ const padAfterHeaderRows = 1
226 const sendToHintRow = 1237 const sendToHintRow = 1
227 const editorBlankRow = 1238 const editorBlankRow = 1
228- return headerRows + sendToHintRow + popupRows + editorBlankRow + eRow, eCol + 2239+ return headerRows + padAfterHeaderRows + sendToHintRow + popupRows + editorBlankRow + eRow, eCol + 2
229 }240 }
230 return -1, -1241 return -1, -1
231 }242 }
@@ -240,6 +251,7 @@ func (d *swarmDialog) transcriptEditorCursorRow(width, popupRows, editorRowOffse
240 return -1251 return -1
241 }252 }
242 row := 1 // frame header253 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) {
650650 }
651651 }
652652
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+
653673 // TestSwarmDialogPOnNonRunningAgentShowsHint covers the regression
654674 // caught by the user's screenshot: pressing 'p' on a detached agent
655675 // 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 regression673 // TestSwarmDialogPOnNonRunningAgentShowsHint covers the regression
654 // caught by the user's screenshot: pressing 'p' on a detached agent674 // 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.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close