diff --git a/docs/changes/unreleased/1949-click-add-device.md b/docs/changes/unreleased/1949-click-add-device.md new file mode 100644 index 0000000000..d28ed8db17 --- /dev/null +++ b/docs/changes/unreleased/1949-click-add-device.md @@ -0,0 +1,13 @@ +--- +kind: fixed +title: Make Add another device clickable with an editable link field +pr: 1949 +surface: [chat, docs] +invalidates: + - The add-device heading was keyboard-only; clicking it now opens pairing instructions. + - The pairing prompt was static text; it now accepts clicks, typing, and editable pastes before Enter opens approval. +--- + +The pairing field keeps its own draft and caret. Existing chat drafts are preserved, +and short windows keep the opened form visible whenever it fits. A field hidden +by resizing releases the keyboard rather than accepting invisible input. diff --git a/docs/testing-anywhere.md b/docs/testing-anywhere.md index f46135f47d..52834043d6 100644 --- a/docs/testing-anywhere.md +++ b/docs/testing-anywhere.md @@ -224,7 +224,7 @@ already has your work, B is the new one. This is what each step should look like "Add another device", with the pitch "pick up your work anywhere". 2. **Pair B.** On B run `codeaf pair`. B prints a link ending `codeaf.agentfield.ai/p/` (staging prints its own host), a "Check number", and "Waiting for approval". -3. **Approve on A.** Open the card, choose to add a machine, and paste the link. A shows +3. **Approve on A.** Click **+ Add another device**, click its link field, paste the link, and press Enter. A shows "wants to pair" with the device's name, its platform, how long ago it asked and the same Check number B shows. If the numbers differ, deny. Press `a` to approve. 4. **Both sides confirm.** B prints "Paired - N workspaces available". A shows a news line, diff --git a/internal/e2e/pairjourney_steps_e2e_test.go b/internal/e2e/pairjourney_steps_e2e_test.go index b35d9022f7..92f0b11e7d 100644 --- a/internal/e2e/pairjourney_steps_e2e_test.go +++ b/internal/e2e/pairjourney_steps_e2e_test.go @@ -54,10 +54,21 @@ func journeyRun(j *journey, env []string, homeA, homeB, wsA, wsB string, online }) j.step("3a A: paste the link into the card", true, func() { - a.keys("M-d") + clickText := func(text string) { + for y, line := range strings.Split(plain(a), "\n") { + if x := strings.Index(line, text); x >= 0 { + a.mouseClick(x+1, y+1) + return + } + } + j.require(false, "no visible target: %s", text) + } + clickText("+ Add another device") _, ok := waitPlain(a, 5*time.Second, "Paste it here") j.check(ok, "the card did not open its steps") + clickText("Paste pairing link") a.paste(link) + a.keys("Enter") screen, ok := waitPlain(a, 6*time.Second, "wants to pair") j.check(ok, "pasting the link into the card showed no approve screen") j.see(a) diff --git a/internal/manual/chat/add-or-remove-a-computer.md b/internal/manual/chat/add-or-remove-a-computer.md index a1da3031dd..492145ca7b 100644 --- a/internal/manual/chat/add-or-remove-a-computer.md +++ b/internal/manual/chat/add-or-remove-a-computer.md @@ -16,16 +16,16 @@ Add another device — pick up your work anywhere, exactly where you left it. alt+d how ``` -Press `alt+d` to open the steps, and `alt+d` again to close them: +Click **+ Add another device** to open the steps, or press `alt+d`. Click the heading or press `alt+d` again to close them: ``` 1. On the new device, install and run: codeaf pair 2. It shows a link. Paste it here: -> +> Paste pairing link ``` 1. On the new device, install codeaf and run `codeaf pair`. It makes a link and waits. -2. On this device, paste that link into the card, or paste it anywhere on home, or type it as your message (on home, on the start page of a new chat, or in any chat) and press enter. The link is never sent to the model. The approve screen opens (see "Approve a new device" below). Text that is not a pair link stays in the box as before. +2. On this device, click the card’s link field, type or paste the link, and press Enter. Opening the card also focuses its field. You can edit the link before submitting it; clicking inside the field places the caret there. Esc or Tab returns typing to the normal message box, and clicking the message box does the same. Your chat draft stays separate. You can also paste it anywhere on home when the card’s field is not focused, or type it as your message and press Enter. The link opens the approve screen and is never sent to the model (see "Approve a new device" below). The card goes away once you have two devices. It is not drawn when this chat cannot pair at all, for example a chat that runs over `--host` or `--at`. diff --git a/internal/tui3/app.go b/internal/tui3/app.go index a4cf12aa70..ceb04c7897 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -4735,6 +4735,9 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { // the box whichever room somebody is standing in. It is the ordinary // text-field gesture the conversation already answers // (placemouse.go's [app.placeBoxPress], draftclick.go). + if a.at(pageHome) { + a.addMachine.focused = false + } if a.placeBoxPress(msg.Mouse().X, msg.Mouse().Y) { return a, nil } @@ -9492,10 +9495,6 @@ func (a *app) paste(text string) tea.Cmd { if a.copy.on { return nil } - // A LINK PASTED ON THE OPEN ADD-MACHINE CARD is the card's, not the draft's. - if cmd, took := a.pasteLink(text); took { - return cmd - } // A PASTE IS SOMEBODY STARTING WORK, so it dismisses the welcome box on the // same terms every other input does (welcome.go): everything puts the box // away except the two keys that walk its list, and a paste is not one of @@ -9624,6 +9623,15 @@ func (a *app) paste(text string) tea.Cmd { a.touch() return nil } + if a.at(pageHome) && a.addMachineWanted() && a.addMachine.open && a.addMachine.focused && !a.pair.open { + a.addMachine.box.insert(strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(text, "\r", ""), "\n", " "))) + a.touch() + return nil + } + // A LINK PASTED ON THE OPEN ADD-MACHINE CARD is the card's, not the draft's. + if cmd, took := a.pasteLink(text); took { + return cmd + } // THE HOME SCREEN IS FULLSCREEN, so while it is up the chat's own draft is // not on the page at all — and this is where a paste used to go anyway, // which read as the paste doing nothing: the text sat in a box nobody could diff --git a/internal/tui3/home.go b/internal/tui3/home.go index ba239e8b12..acf7fcb938 100644 --- a/internal/tui3/home.go +++ b/internal/tui3/home.go @@ -3988,6 +3988,9 @@ func (a *app) homePress(x, y int) tea.Cmd { } width, height := a.size() lines, hits, _, _ := a.homeFrame(width, height) + if cmd, took := a.addMachinePress(x, y, width); took { + return cmd + } if y < 0 || y >= len(hits) { return nil } @@ -4277,6 +4280,8 @@ func (a *app) homeFrame(width, height int) ([]string, []int, int, int) { a.home.build() } if a.home.phone { + a.addMachine.targets = nil + a.addMachine.focused = false // THE PHONE FRAME IS HOME'S OWN SHAPE AND NOT THE ROUTER'S, so the box // span the router keeps for the pointer is not written by it — and a // span left standing from the wide frame would be a press resolved @@ -4309,8 +4314,15 @@ func (a *app) homeFrame(width, height int) ([]string, []int, int, int) { // cuts rows cuts both of them the same way. marks := placeHitsOf(hits, homeMark{line: -1, pane: -1}) rows, panes := make([]int, len(marks)), make([]int, len(marks)) + a.addMachine.targets = make([]addMachineTarget, len(marks)) + inputVisible := false for i, mark := range marks { rows[i], panes[i] = mark.line, mark.pane + a.addMachine.targets[i] = mark.addMachine + inputVisible = inputVisible || mark.addMachine == addMachineInput + } + if !inputVisible { + a.addMachine.focused = false } a.home.pane = panes a.home.gridMarks = nil @@ -4343,8 +4355,9 @@ type homePainted struct { // It is named apart from [homeHit], which is a PROJECT that survived the box and // has nothing to do with the pointer. type homeMark struct { - line int - pane int + addMachine addMachineTarget // 1 opens the instructions; 2 focuses the link input. + line int + pane int // grid says the row is the resting grid's, where one screen row holds a line // of EVERY column and the x of the press says which (homecell.go's // [homeGridZip]); cells is those lines, -1 where a column drew nothing. diff --git a/internal/tui3/homeband_addmachine.go b/internal/tui3/homeband_addmachine.go index 74e89ac271..fc6f0d64f8 100644 --- a/internal/tui3/homeband_addmachine.go +++ b/internal/tui3/homeband_addmachine.go @@ -17,10 +17,10 @@ package tui3 // // THIS MACHINE MAKES NO LINK. The NEW machine makes it (`codeaf pair`), so a // machine that is already paired has none to hand out. The opened card is two -// numbered steps and a paste target: the link pasted here goes to the approve +// numbered steps and an editable link field: Enter opens the approve // screen, and its toast ends the card's job. // -// ONE CHORD OPENS IT. Every word is the vocabulary law's: devices, and never +// A CLICK OR THE CHORD OPENS IT. Every word is the vocabulary law's: devices, and never // the names of the machinery behind them. import ( @@ -29,6 +29,7 @@ import ( "time" tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" ) // Fleet counts the devices this person has paired. Nil in [Options] is a @@ -61,6 +62,15 @@ func init() { standing: true, draw: drawAddMachineBand}) } +// addMachineTarget distinguishes the two actions drawn in the card. +type addMachineTarget uint8 + +const ( + addMachineNone addMachineTarget = iota + addMachineToggle + addMachineInput +) + // addMachine is the card's whole state. type addMachine struct { // size is the fleet's size once known, and asking says an ask is out. @@ -68,7 +78,11 @@ type addMachine struct { known bool asking bool // open says the steps are showing. - open bool + open bool + targets []addMachineTarget + focused bool + box editor + error string } // wanted says the card is on: a door that can approve, and a fleet known to be one. @@ -120,6 +134,7 @@ func (a *app) toggleAddMachine() tea.Cmd { return nil } m.open = !m.open + m.focused = m.open a.touch() return nil } @@ -132,6 +147,7 @@ func (a *app) pasteLink(text string) (tea.Cmd, bool) { return nil, false } a.addMachine.open = false + a.addMachine.focused = false return a.openApprove(strings.TrimSpace(text)), true } @@ -152,7 +168,10 @@ func drawAddMachineBand(a *app, ctx bandContext) []string { } key := addMachineShow if m.open { - key = addMachineHide + key = addMachineHide + " · enter continue · esc message box" + if m.error != "" { + key = m.error + " · " + key + } } keys := func(s string) string { return paintHint(s, ctx.pal, ctx.pal.dim) } return append(rows, bandClauses(ctx.width, 0, keys, a.chords.say(key))...) @@ -166,5 +185,103 @@ func (a *app) addMachineHow(ctx bandContext) []string { rows = append(rows, ctx.pal.ink(fit(wrapped, ctx.width))) } } - return append(rows, ctx.pal.accent(fit(addMachineField, ctx.width))) + line, _ := a.addMachineLine(ctx.width, ctx.pal) + return append(rows, line) +} + +// addMachinePress reads the targets kept by the rendered frame, including its clamp. +func (a *app) addMachinePress(x, y, width int) (tea.Cmd, bool) { + m := &a.addMachine + if x < homeGridMargin || x >= width || y < 0 || y >= len(m.targets) || m.targets[y] == addMachineNone { + return nil, false + } + if m.targets[y] == addMachineInput { + m.focused = true + _, start := a.addMachineWindow(width - homeGridMargin) + m.box.cursor = start + column := homeGridMargin + ansi.StringWidth(addMachineField) + for m.box.cursor < len(m.box.value) && column+cells(m.box.value[m.box.cursor]) <= x { + column += cells(m.box.value[m.box.cursor]) + m.box.cursor++ + } + m.box.dropPick() + a.touch() + return nil, true + } + return a.toggleAddMachine(), true +} + +// The single-line window keeps the caret visible while a long pairing link is edited. +func (a *app) addMachineWindow(width int) (string, int) { + b := &a.addMachine.box + room := max(1, width-ansi.StringWidth(addMachineField)) + start := 0 + before := ansi.StringWidth(string(b.value[:b.cursor])) + for start < b.cursor && before >= room { + before -= cells(b.value[start]) + start++ + } + return fit(string(b.value[start:]), room), start +} + +func (a *app) addMachineLine(width int, pal palette) (string, int) { + shown, start := a.addMachineWindow(width) + b := &a.addMachine.box + column := ansi.StringWidth(addMachineField) + ansi.StringWidth(string(b.value[start:b.cursor])) + if b.empty() { + shown = "Paste pairing link" + } + line := pal.accent(addMachineField) + pal.ink(fit(shown, max(0, width-ansi.StringWidth(addMachineField)))) + + return line, column +} + +func (a *app) addMachineInputKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { + m := &a.addMachine + if !a.addMachineWanted() || !m.open || !m.focused { + return nil, false + } + a.touch() + m.error = "" + b := &m.box + k := msg.String() + if editorMotion(b, k) || editorWordKill(b, k) || editorUndo(b, k) || editorPick(b, k) { + return nil, true + } + switch k { + case "esc", "tab", "shift+tab": + m.focused = false + case "enter": + link := strings.TrimSpace(b.String()) + if !isPairLink(link) { + m.error = "Enter a pairing link" + return nil, true + } + m.open, m.focused = false, false + b.reset() + return a.openApprove(link), true + case "backspace": + b.deleteBackward() + case "delete", "ctrl+d": + b.deleteForward() + case "left": + b.left() + case "right": + b.right() + case "home", "ctrl+a": + b.home() + case "end", "ctrl+e": + b.end() + case "ctrl+w": + b.deleteWord() + case "ctrl+u": + b.killToStart() + case "ctrl+k": + b.killToEnd() + default: + if msg.Text != "" { + b.insert(strings.ReplaceAll(msg.Text, "\n", " ")) + } + } + return nil, true } diff --git a/internal/tui3/homeband_addmachine_test.go b/internal/tui3/homeband_addmachine_test.go index b76c2a7180..f46a2ec9cc 100644 --- a/internal/tui3/homeband_addmachine_test.go +++ b/internal/tui3/homeband_addmachine_test.go @@ -86,14 +86,11 @@ func TestAddMachinePasteGoesToTheApproveScreenAndApproveEndsTheCard(t *testing.T a.addMachine = addMachine{size: 1, known: true} a.openHome() a.toggleAddMachine() - cmd := a.paste(testLink) - if cmd == nil { - t.Fatal("a pasted link did not open the approve screen") - } + drive(t, a, tea.PasteMsg{Content: testLink}) + r.press("enter") if a.addMachine.open { t.Fatal("the card stayed open under the approve screen") } - drive(t, a, cmd()) r.until("the card", func() bool { c, ok := a.pair.card.(*approveCard); return ok && c.req != nil }) if !strings.Contains(door.did(), "pending:"+testLink) { t.Fatalf("door was not asked for the link: %s", door.did()) @@ -276,8 +273,148 @@ func TestAddMachinePasteOnHomeShowsTheApproveScreen(t *testing.T) { a.openHome() drive(t, a, altD) drive(t, a, tea.PasteStartMsg{}, tea.PasteMsg{Content: testLink}, tea.PasteEndMsg{}) + r.press("enter") r.until("the card", func() bool { c, ok := a.pair.card.(*approveCard); return ok && c.req != nil }) if got := plain(frame(a)); !strings.Contains(got, "wants to pair") { t.Fatalf("no approve screen on the frame:\n%s", got) } } + +// Click the visible text through the same event boundary as the terminal. +func clickAddMachineText(t *testing.T, a *app, text string) { + t.Helper() + for y, line := range strings.Split(plain(frame(a)), "\n") { + if x := strings.Index(line, text); x >= 0 { + drive(t, a, tea.MouseClickMsg{X: ansi.StringWidth(line[:x]), Y: y, Button: tea.MouseLeft}) + return + } + } + t.Fatalf("no clickable %q in frame:\n%s", text, plain(frame(a))) +} + +func TestAddMachineClickOpensInstructions(t *testing.T) { + a := addMachineRig(&fakeApprovals{}, 1, true) + a.openHome() + drive(t, a, tea.WindowSizeMsg{Width: 100, Height: 40}) + clickAddMachineText(t, a, addMachineHeading) + if !strings.Contains(plain(frame(a)), addMachineStepRun) { + t.Fatalf("click did not open pairing instructions:\n%s", plain(frame(a))) + } +} + +func TestAddMachineClickFieldTypesAndSubmitsLink(t *testing.T) { + door := &fakeApprovals{pending: tower(time.Now())} + a, r := approveApp(t, door) + a.addMachine = addMachine{size: 1, known: true} + a.openHome() + drive(t, a, tea.WindowSizeMsg{Width: 100, Height: 40}) + clickAddMachineText(t, a, addMachineHeading) + clickAddMachineText(t, a, addMachineField) + for _, ch := range testLink { + r.press(string(ch)) + } + if !strings.Contains(plain(frame(a)), testLink) { + t.Fatalf("typed link is not visible in the field:\n%s", plain(frame(a))) + } + if a.home.box.String() != "" { + t.Fatalf("pairing input leaked into chat draft: %q", a.home.box.String()) + } + r.press("enter") + r.until("pairing approval", func() bool { c, ok := a.pair.card.(*approveCard); return ok && c.req != nil }) + if !strings.Contains(door.did(), "pending:"+testLink) { + t.Fatalf("typed link did not reach approval: %s", door.did()) + } +} + +func TestAddMachineClickFieldPasteAndReturnToDraft(t *testing.T) { + a := addMachineRig(&fakeApprovals{}, 1, true) + a.openHome() + drive(t, a, tea.WindowSizeMsg{Width: 100, Height: 40}) + clickAddMachineText(t, a, addMachineHeading) + clickAddMachineText(t, a, "Paste pairing link") + drive(t, a, tea.PasteMsg{Content: testLink}) + if a.pair.open || !strings.Contains(plain(frame(a)), testLink) { + t.Fatalf("paste should remain editable until Enter:\n%s", plain(frame(a))) + } + clickAddMachineText(t, a, "type to search or start something new") + drive(t, a, key("h"), key("i")) + if a.home.box.String() != "hi" { + t.Fatalf("clicking the composer did not restore typing: %q", a.home.box.String()) + } + drive(t, a, key("backspace"), key("backspace")) + if !strings.Contains(plain(frame(a)), testLink) { + t.Fatal("returning to the draft lost the pairing link") + } +} + +func TestAddMachineClickRemainsUsableOnShortTerminal(t *testing.T) { + a := addMachineRig(&fakeApprovals{}, 1, true) + a.openHome() + drive(t, a, tea.WindowSizeMsg{Width: 80, Height: 20}) + clickAddMachineText(t, a, addMachineHeading) + clickAddMachineText(t, a, "Paste pairing link") + drive(t, a, key("x")) + if !strings.Contains(plain(frame(a)), "> x") || a.home.box.String() != "" { + t.Fatalf("short terminal lost the pairing input:\n%s", plain(frame(a))) + } +} + +func TestAddMachineInvalidLinkStaysEditable(t *testing.T) { + a := addMachineRig(&fakeApprovals{}, 1, true) + a.openHome() + drive(t, a, tea.WindowSizeMsg{Width: 100, Height: 40}) + clickAddMachineText(t, a, addMachineHeading) + drive(t, a, tea.PasteMsg{Content: "not a pairing link"}, key("enter")) + got := plain(frame(a)) + if a.pair.open || !strings.Contains(got, "not a pairing link") || !strings.Contains(got, "Enter a pairing link") { + t.Fatalf("invalid input needs an editable value and explanation:\n%s", got) + } + if a.home.box.String() != "" { + t.Fatal("invalid pairing link was sent to the chat draft") + } +} + +func TestAddMachineFieldDoesNotTakeOverlayPaste(t *testing.T) { + a := addMachineRig(&fakeApprovals{}, 1, true) + a.openHome() + drive(t, a, tea.WindowSizeMsg{Width: 100, Height: 40}) + clickAddMachineText(t, a, addMachineHeading) + a.openPicker() + drive(t, a, tea.PasteMsg{Content: "needle"}) + if got := a.pick.filter.String(); got != "needle" { + t.Fatalf("paste did not reach the model filter: %q", got) + } + if a.addMachine.box.String() != "" { + t.Fatal("the hidden pairing field took the overlay's paste") + } +} + +func TestAddMachineShortcutPreservesExistingDraft(t *testing.T) { + a := addMachineRig(&fakeApprovals{}, 1, true) + a.openHome() + drive(t, a, tea.WindowSizeMsg{Width: 100, Height: 40}) + for _, ch := range "draft" { + drive(t, a, key(string(ch))) + } + drive(t, a, altD) + if !strings.Contains(plain(frame(a)), "Paste pairing link") { + t.Fatalf("shortcut focused an invisible pairing field:\n%s", plain(frame(a))) + } + drive(t, a, key("x")) + if !strings.Contains(plain(frame(a)), "> x") || a.home.box.String() != "draft" { + t.Fatalf("pairing edits changed the existing draft:\n%s", plain(frame(a))) + } +} + +func TestAddMachineHiddenOnResizeReleasesInput(t *testing.T) { + a := addMachineRig(&fakeApprovals{}, 1, true) + a.openHome() + drive(t, a, tea.WindowSizeMsg{Width: 100, Height: 40}) + clickAddMachineText(t, a, addMachineHeading) + drive(t, a, tea.WindowSizeMsg{Width: 80, Height: 10}) + _ = frame(a) + drive(t, a, key("x")) + if a.home.box.String() != "x" { + t.Fatalf("hidden pairing field kept the keyboard: draft=%q", a.home.box.String()) + } +} diff --git a/internal/tui3/homeband_standing.go b/internal/tui3/homeband_standing.go index 9c17976870..88e983d6ac 100644 --- a/internal/tui3/homeband_standing.go +++ b/internal/tui3/homeband_standing.go @@ -21,17 +21,39 @@ func (a *app) standingStrip(width, room int) []placeRow { } ctx := bandContext{width: inner, pal: a.pal, now: a.now()} var lines []string + var targets []addMachineTarget for _, band := range standingBands() { card := band.draw(a, ctx) if len(card) == 0 { continue } - if len(lines)+len(card)+2 > room/2 { + limit := room / 2 + // An opened form needs its input on screen even in a short terminal. + if band.name == "addmachine" && a.addMachine.open { + limit = room + } + if len(lines)+len(card)+2 > limit { break } + for i := range card { + target := addMachineNone + if band.name == "addmachine" { + target = addMachineToggle + if a.addMachine.open && i == len(card)-2 { + target = addMachineInput + } + } + targets = append(targets, target) + } lines = append(lines, card...) } - return standingRows(lines) + rows := standingRows(lines) + for i, target := range targets { + mark := rows[i+1].hit.(homeMark) + mark.addMachine = target + rows[i+1].hit = mark + } + return rows } func standingRows(lines []string) []placeRow { diff --git a/internal/tui3/place_home.go b/internal/tui3/place_home.go index 4ac0fa8767..da113f2e93 100644 --- a/internal/tui3/place_home.go +++ b/internal/tui3/place_home.go @@ -382,7 +382,7 @@ func (placeHome) body(a *app, width, room int) []placeRow { // moving is a rebuild, and the lines are made again only when one moved. _, stacked := a.homeStacked() var strip []placeRow - if !a.home.searching() && !a.home.phone && !stacked { + if (!a.home.searching() || a.addMachine.open) && !a.home.phone && !stacked { strip = a.standingStrip(width, room) } room -= len(strip) @@ -396,7 +396,6 @@ func (placeHome) body(a *app, width, room int) []placeRow { if a.home.gridOn() && !stacked { return append(a.homeGridRows(width, room, a.pal), strip...) } - room += len(strip) left, right := homeColumns(width) // THE HEIGHT REACHES THE READING HERE AND NOWHERE ELSE. It is the same law // the width is settled under one layer up (home.go's [app.homeFrame]: the @@ -417,7 +416,7 @@ func (placeHome) body(a *app, width, room int) []placeRow { hit: homeMark{line: drawn.hit, pane: drawn.pane}, }) } - return rows + return append(rows, strip...) } // ownFrame is home's own, for two reasons and not one. Below sixty columns this @@ -599,6 +598,9 @@ func (placeHome) owns(a *app, msg tea.KeyPressMsg) (tea.Cmd, bool) { if msg.String() == addMachineKey && (a.addMachineWanted() || a.cellsUnavailable != "") { return a.toggleAddMachine(), true } + if cmd, took := a.addMachineInputKey(msg); took { + return cmd, true + } if cmd, took := a.resumeKeyHandler(msg.String()); took { return cmd, true } @@ -621,3 +623,16 @@ func (placeHome) owns(a *app, msg tea.KeyPressMsg) (tea.Cmd, bool) { a.touch() return cmd, true } + +func (placeHome) caretRow(a *app, width int, rows []placeRow) (int, int, bool) { + if !a.addMachineWanted() || !a.addMachine.open || !a.addMachine.focused { + return -1, 0, false + } + for i, row := range rows { + if mark, ok := row.hit.(homeMark); ok && mark.addMachine == addMachineInput { + _, column := a.addMachineLine(width-homeGridMargin, a.pal) + return i, homeGridMargin + column, true + } + } + return -1, 0, true +}