Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/changes/unreleased/1949-click-add-device.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion docs/testing-anywhere.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<code>`
(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,
Expand Down
13 changes: 12 additions & 1 deletion internal/e2e/pairjourney_steps_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions internal/manual/chat/add-or-remove-a-computer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
16 changes: 12 additions & 4 deletions internal/tui3/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
17 changes: 15 additions & 2 deletions internal/tui3/home.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
127 changes: 122 additions & 5 deletions internal/tui3/homeband_addmachine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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
Expand Down Expand Up @@ -61,14 +62,27 @@ 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.
size int
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.
Expand Down Expand Up @@ -120,6 +134,7 @@ func (a *app) toggleAddMachine() tea.Cmd {
return nil
}
m.open = !m.open
m.focused = m.open
a.touch()
return nil
}
Expand All @@ -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
}

Expand All @@ -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))...)
Expand All @@ -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
}
Loading
Loading