Skip to content

Commit ca7ede1

Browse files
Merge remote-tracking branch 'origin/dev' into sync/workspace-relay-dev-final
2 parents b1d3caf + e88e49a commit ca7ede1

10 files changed

Lines changed: 362 additions & 26 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
kind: fixed
3+
title: Make Add another device clickable with an editable link field
4+
pr: 1949
5+
surface: [chat, docs]
6+
invalidates:
7+
- The add-device heading was keyboard-only; clicking it now opens pairing instructions.
8+
- The pairing prompt was static text; it now accepts clicks, typing, and editable pastes before Enter opens approval.
9+
---
10+
11+
The pairing field keeps its own draft and caret. Existing chat drafts are preserved,
12+
and short windows keep the opened form visible whenever it fits. A field hidden
13+
by resizing releases the keyboard rather than accepting invisible input.

‎docs/testing-anywhere.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ already has your work, B is the new one. This is what each step should look like
224224
"Add another device", with the pitch "pick up your work anywhere".
225225
2. **Pair B.** On B run `codeaf pair`. B prints a link ending `codeaf.agentfield.ai/p/<code>`
226226
(staging prints its own host), a "Check number", and "Waiting for approval".
227-
3. **Approve on A.** Open the card, choose to add a machine, and paste the link. A shows
227+
3. **Approve on A.** Click **+ Add another device**, click its link field, paste the link, and press Enter. A shows
228228
"wants to pair" with the device's name, its platform, how long ago it asked and
229229
the same Check number B shows. If the numbers differ, deny. Press `a` to approve.
230230
4. **Both sides confirm.** B prints "Paired - N workspaces available". A shows a news line,

‎internal/e2e/pairjourney_steps_e2e_test.go‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,21 @@ func journeyRun(j *journey, env []string, homeA, homeB, wsA, wsB string, online
5454
})
5555

5656
j.step("3a A: paste the link into the card", true, func() {
57-
a.keys("M-d")
57+
clickText := func(text string) {
58+
for y, line := range strings.Split(plain(a), "\n") {
59+
if x := strings.Index(line, text); x >= 0 {
60+
a.mouseClick(x+1, y+1)
61+
return
62+
}
63+
}
64+
j.require(false, "no visible target: %s", text)
65+
}
66+
clickText("+ Add another device")
5867
_, ok := waitPlain(a, 5*time.Second, "Paste it here")
5968
j.check(ok, "the card did not open its steps")
69+
clickText("Paste pairing link")
6070
a.paste(link)
71+
a.keys("Enter")
6172
screen, ok := waitPlain(a, 6*time.Second, "wants to pair")
6273
j.check(ok, "pasting the link into the card showed no approve screen")
6374
j.see(a)

‎internal/manual/chat/add-or-remove-a-computer.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,16 @@ Add another device — pick up your work anywhere, exactly where you left it.
1616
alt+d how
1717
```
1818

19-
Press `alt+d` to open the steps, and `alt+d` again to close them:
19+
Click **+ Add another device** to open the steps, or press `alt+d`. Click the heading or press `alt+d` again to close them:
2020

2121
```
2222
1. On the new device, install and run: codeaf pair
2323
2. It shows a link. Paste it here:
24-
>
24+
> Paste pairing link
2525
```
2626

2727
1. On the new device, install codeaf and run `codeaf pair`. It makes a link and waits.
28-
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.
28+
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).
2929

3030
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`.
3131

‎internal/tui3/app.go‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4735,6 +4735,9 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) {
47354735
// the box whichever room somebody is standing in. It is the ordinary
47364736
// text-field gesture the conversation already answers
47374737
// (placemouse.go's [app.placeBoxPress], draftclick.go).
4738+
if a.at(pageHome) {
4739+
a.addMachine.focused = false
4740+
}
47384741
if a.placeBoxPress(msg.Mouse().X, msg.Mouse().Y) {
47394742
return a, nil
47404743
}
@@ -9492,10 +9495,6 @@ func (a *app) paste(text string) tea.Cmd {
94929495
if a.copy.on {
94939496
return nil
94949497
}
9495-
// A LINK PASTED ON THE OPEN ADD-MACHINE CARD is the card's, not the draft's.
9496-
if cmd, took := a.pasteLink(text); took {
9497-
return cmd
9498-
}
94999498
// A PASTE IS SOMEBODY STARTING WORK, so it dismisses the welcome box on the
95009499
// same terms every other input does (welcome.go): everything puts the box
95019500
// away except the two keys that walk its list, and a paste is not one of
@@ -9634,6 +9633,15 @@ func (a *app) paste(text string) tea.Cmd {
96349633
a.touch()
96359634
return nil
96369635
}
9636+
if a.at(pageHome) && a.addMachineWanted() && a.addMachine.open && a.addMachine.focused && !a.pair.open {
9637+
a.addMachine.box.insert(strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(text, "\r", ""), "\n", " ")))
9638+
a.touch()
9639+
return nil
9640+
}
9641+
// A LINK PASTED ON THE OPEN ADD-MACHINE CARD is the card's, not the draft's.
9642+
if cmd, took := a.pasteLink(text); took {
9643+
return cmd
9644+
}
96379645
// THE HOME SCREEN IS FULLSCREEN, so while it is up the chat's own draft is
96389646
// not on the page at all — and this is where a paste used to go anyway,
96399647
// which read as the paste doing nothing: the text sat in a box nobody could

‎internal/tui3/home.go‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3988,6 +3988,9 @@ func (a *app) homePress(x, y int) tea.Cmd {
39883988
}
39893989
width, height := a.size()
39903990
lines, hits, _, _ := a.homeFrame(width, height)
3991+
if cmd, took := a.addMachinePress(x, y, width); took {
3992+
return cmd
3993+
}
39913994
if y < 0 || y >= len(hits) {
39923995
return nil
39933996
}
@@ -4277,6 +4280,8 @@ func (a *app) homeFrame(width, height int) ([]string, []int, int, int) {
42774280
a.home.build()
42784281
}
42794282
if a.home.phone {
4283+
a.addMachine.targets = nil
4284+
a.addMachine.focused = false
42804285
// THE PHONE FRAME IS HOME'S OWN SHAPE AND NOT THE ROUTER'S, so the box
42814286
// span the router keeps for the pointer is not written by it — and a
42824287
// 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) {
43094314
// cuts rows cuts both of them the same way.
43104315
marks := placeHitsOf(hits, homeMark{line: -1, pane: -1})
43114316
rows, panes := make([]int, len(marks)), make([]int, len(marks))
4317+
a.addMachine.targets = make([]addMachineTarget, len(marks))
4318+
inputVisible := false
43124319
for i, mark := range marks {
43134320
rows[i], panes[i] = mark.line, mark.pane
4321+
a.addMachine.targets[i] = mark.addMachine
4322+
inputVisible = inputVisible || mark.addMachine == addMachineInput
4323+
}
4324+
if !inputVisible {
4325+
a.addMachine.focused = false
43144326
}
43154327
a.home.pane = panes
43164328
a.home.gridMarks = nil
@@ -4343,8 +4355,9 @@ type homePainted struct {
43434355
// It is named apart from [homeHit], which is a PROJECT that survived the box and
43444356
// has nothing to do with the pointer.
43454357
type homeMark struct {
4346-
line int
4347-
pane int
4358+
addMachine addMachineTarget // 1 opens the instructions; 2 focuses the link input.
4359+
line int
4360+
pane int
43484361
// grid says the row is the resting grid's, where one screen row holds a line
43494362
// of EVERY column and the x of the press says which (homecell.go's
43504363
// [homeGridZip]); cells is those lines, -1 where a column drew nothing.

‎internal/tui3/homeband_addmachine.go‎

Lines changed: 122 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ package tui3
1717
//
1818
// THIS MACHINE MAKES NO LINK. The NEW machine makes it (`codeaf pair`), so a
1919
// machine that is already paired has none to hand out. The opened card is two
20-
// numbered steps and a paste target: the link pasted here goes to the approve
20+
// numbered steps and an editable link field: Enter opens the approve
2121
// screen, and its toast ends the card's job.
2222
//
23-
// ONE CHORD OPENS IT. Every word is the vocabulary law's: devices, and never
23+
// A CLICK OR THE CHORD OPENS IT. Every word is the vocabulary law's: devices, and never
2424
// the names of the machinery behind them.
2525

2626
import (
@@ -29,6 +29,7 @@ import (
2929
"time"
3030

3131
tea "charm.land/bubbletea/v2"
32+
"github.com/charmbracelet/x/ansi"
3233
)
3334

3435
// Fleet counts the devices this person has paired. Nil in [Options] is a
@@ -61,14 +62,27 @@ func init() {
6162
standing: true, draw: drawAddMachineBand})
6263
}
6364

65+
// addMachineTarget distinguishes the two actions drawn in the card.
66+
type addMachineTarget uint8
67+
68+
const (
69+
addMachineNone addMachineTarget = iota
70+
addMachineToggle
71+
addMachineInput
72+
)
73+
6474
// addMachine is the card's whole state.
6575
type addMachine struct {
6676
// size is the fleet's size once known, and asking says an ask is out.
6777
size int
6878
known bool
6979
asking bool
7080
// open says the steps are showing.
71-
open bool
81+
open bool
82+
targets []addMachineTarget
83+
focused bool
84+
box editor
85+
error string
7286
}
7387

7488
// 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 {
120134
return nil
121135
}
122136
m.open = !m.open
137+
m.focused = m.open
123138
a.touch()
124139
return nil
125140
}
@@ -132,6 +147,7 @@ func (a *app) pasteLink(text string) (tea.Cmd, bool) {
132147
return nil, false
133148
}
134149
a.addMachine.open = false
150+
a.addMachine.focused = false
135151
return a.openApprove(strings.TrimSpace(text)), true
136152
}
137153

@@ -152,7 +168,10 @@ func drawAddMachineBand(a *app, ctx bandContext) []string {
152168
}
153169
key := addMachineShow
154170
if m.open {
155-
key = addMachineHide
171+
key = addMachineHide + " · enter continue · esc message box"
172+
if m.error != "" {
173+
key = m.error + " · " + key
174+
}
156175
}
157176
keys := func(s string) string { return paintHint(s, ctx.pal, ctx.pal.dim) }
158177
return append(rows, bandClauses(ctx.width, 0, keys, a.chords.say(key))...)
@@ -166,5 +185,103 @@ func (a *app) addMachineHow(ctx bandContext) []string {
166185
rows = append(rows, ctx.pal.ink(fit(wrapped, ctx.width)))
167186
}
168187
}
169-
return append(rows, ctx.pal.accent(fit(addMachineField, ctx.width)))
188+
line, _ := a.addMachineLine(ctx.width, ctx.pal)
189+
return append(rows, line)
190+
}
191+
192+
// addMachinePress reads the targets kept by the rendered frame, including its clamp.
193+
func (a *app) addMachinePress(x, y, width int) (tea.Cmd, bool) {
194+
m := &a.addMachine
195+
if x < homeGridMargin || x >= width || y < 0 || y >= len(m.targets) || m.targets[y] == addMachineNone {
196+
return nil, false
197+
}
198+
if m.targets[y] == addMachineInput {
199+
m.focused = true
200+
_, start := a.addMachineWindow(width - homeGridMargin)
201+
m.box.cursor = start
202+
column := homeGridMargin + ansi.StringWidth(addMachineField)
203+
for m.box.cursor < len(m.box.value) && column+cells(m.box.value[m.box.cursor]) <= x {
204+
column += cells(m.box.value[m.box.cursor])
205+
m.box.cursor++
206+
}
207+
m.box.dropPick()
208+
a.touch()
209+
return nil, true
210+
}
211+
return a.toggleAddMachine(), true
212+
}
213+
214+
// The single-line window keeps the caret visible while a long pairing link is edited.
215+
func (a *app) addMachineWindow(width int) (string, int) {
216+
b := &a.addMachine.box
217+
room := max(1, width-ansi.StringWidth(addMachineField))
218+
start := 0
219+
before := ansi.StringWidth(string(b.value[:b.cursor]))
220+
for start < b.cursor && before >= room {
221+
before -= cells(b.value[start])
222+
start++
223+
}
224+
return fit(string(b.value[start:]), room), start
225+
}
226+
227+
func (a *app) addMachineLine(width int, pal palette) (string, int) {
228+
shown, start := a.addMachineWindow(width)
229+
b := &a.addMachine.box
230+
column := ansi.StringWidth(addMachineField) + ansi.StringWidth(string(b.value[start:b.cursor]))
231+
if b.empty() {
232+
shown = "Paste pairing link"
233+
}
234+
line := pal.accent(addMachineField) + pal.ink(fit(shown, max(0, width-ansi.StringWidth(addMachineField))))
235+
236+
return line, column
237+
}
238+
239+
func (a *app) addMachineInputKey(msg tea.KeyPressMsg) (tea.Cmd, bool) {
240+
m := &a.addMachine
241+
if !a.addMachineWanted() || !m.open || !m.focused {
242+
return nil, false
243+
}
244+
a.touch()
245+
m.error = ""
246+
b := &m.box
247+
k := msg.String()
248+
if editorMotion(b, k) || editorWordKill(b, k) || editorUndo(b, k) || editorPick(b, k) {
249+
return nil, true
250+
}
251+
switch k {
252+
case "esc", "tab", "shift+tab":
253+
m.focused = false
254+
case "enter":
255+
link := strings.TrimSpace(b.String())
256+
if !isPairLink(link) {
257+
m.error = "Enter a pairing link"
258+
return nil, true
259+
}
260+
m.open, m.focused = false, false
261+
b.reset()
262+
return a.openApprove(link), true
263+
case "backspace":
264+
b.deleteBackward()
265+
case "delete", "ctrl+d":
266+
b.deleteForward()
267+
case "left":
268+
b.left()
269+
case "right":
270+
b.right()
271+
case "home", "ctrl+a":
272+
b.home()
273+
case "end", "ctrl+e":
274+
b.end()
275+
case "ctrl+w":
276+
b.deleteWord()
277+
case "ctrl+u":
278+
b.killToStart()
279+
case "ctrl+k":
280+
b.killToEnd()
281+
default:
282+
if msg.Text != "" {
283+
b.insert(strings.ReplaceAll(msg.Text, "\n", " "))
284+
}
285+
}
286+
return nil, true
170287
}

0 commit comments

Comments
 (0)