detached 1724a11

Merge pull request #182 from bnema/fix/remote-unified-picker-e2e

fix(remote): harden unified picker handoff
commit
1724a1155ea878c9d6efadf6342ca6f9797c0b37
author
bnema · · 2026-08-06 11:06 UTC
committer
GitHub · · 2026-08-06 11:06 UTC
merge of
b9061f5 + 8b76008 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
10 files · +713 -31
M internal/usecase/daemon/picker.go +4 -7
@@ -589,13 +589,10 @@ func staticRemotePickerPreview(width, height uint16, message string) picker.Prev
589589 if width == 0 || height == 0 {
590590 return picker.Preview{}
591591 }
592- rows := make([][]renderer.Cell, int(height))
592+ rows := [][]renderer.Cell{make([]renderer.Cell, int(width))}
593593 style := renderer.DefaultStyle()
594- for y := range rows {
595- rows[y] = make([]renderer.Cell, int(width))
596- for x := range rows[y] {
597- rows[y][x] = renderer.Cell{Rune: ' ', Style: style}
598- }
594+ for x := range rows[0] {
595+ rows[0][x] = renderer.Cell{Rune: ' ', Style: style}
599596 }
600597 for x, r := range []rune(message) {
601598 if x >= int(width) {
@@ -603,7 +600,7 @@ func staticRemotePickerPreview(width, height uint16, message string) picker.Prev
603600 }
604601 rows[0][x] = renderer.Cell{Rune: r, Style: style}
605602 }
606- return picker.Preview{Rows: rows, Width: int(width), Height: int(height)}
603+ return picker.Preview{Rows: rows, Width: int(width), Height: len(rows)}
607604 }
608605
609606 func pickerPreviewCurrent(ac *attachedClient, targetSess *session, next *tab, generation uint64) bool {
@@ -589,13 +589,10 @@ func staticRemotePickerPreview(width, height uint16, message string) picker.Prev
589 if width == 0 || height == 0 {589 if width == 0 || height == 0 {
590 return picker.Preview{}590 return picker.Preview{}
591 }591 }
592- rows := make([][]renderer.Cell, int(height))592+ rows := [][]renderer.Cell{make([]renderer.Cell, int(width))}
593 style := renderer.DefaultStyle()593 style := renderer.DefaultStyle()
594- for y := range rows {594+ for x := range rows[0] {
595- rows[y] = make([]renderer.Cell, int(width))595+ rows[0][x] = renderer.Cell{Rune: ' ', Style: style}
596- for x := range rows[y] {
597- rows[y][x] = renderer.Cell{Rune: ' ', Style: style}
598- }
599 }596 }
600 for x, r := range []rune(message) {597 for x, r := range []rune(message) {
601 if x >= int(width) {598 if x >= int(width) {
@@ -603,7 +600,7 @@ func staticRemotePickerPreview(width, height uint16, message string) picker.Prev
603 }600 }
604 rows[0][x] = renderer.Cell{Rune: r, Style: style}601 rows[0][x] = renderer.Cell{Rune: r, Style: style}
605 }602 }
606- return picker.Preview{Rows: rows, Width: int(width), Height: int(height)}603+ return picker.Preview{Rows: rows, Width: int(width), Height: len(rows)}
607 }604 }
608 605
609 func pickerPreviewCurrent(ac *attachedClient, targetSess *session, next *tab, generation uint64) bool {606 func pickerPreviewCurrent(ac *attachedClient, targetSess *session, next *tab, generation uint64) bool {
M internal/usecase/daemon/remote_picker.go +7 -2
@@ -137,7 +137,11 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe
137137 broken := session.State == "broken"
138138 tabs := ports.CatalogTabs(session)
139139 viewTabs := make([]picker.TabEntry, 0, len(tabs))
140- for _, tab := range tabs {
140+ active := 0
141+ for i, tab := range tabs {
142+ if session.ActiveTabID != "" && tab.ID == session.ActiveTabID {
143+ active = i
144+ }
141145 name := tab.Name
142146 if name == "" {
143147 name = fmt.Sprintf("%d", int(tab.Index)+1)
@@ -169,7 +173,7 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe
169173 target.StoppedTab = domain.NewOrdinalTabSelector(0, first.Name, uint16(len(tabs)))
170174 }
171175 } else {
172- target.LiveTabID = domain.TabStableID(first.ID)
176+ target.LiveTabID = domain.TabStableID(tabs[active].ID)
173177 }
174178 }
175179 // Keep the structured session identity on rows even when one tab ID is
@@ -225,6 +229,7 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe
225229 RemoteDetail: detail,
226230 RemoteReason: reason,
227231 Tabs: viewTabs,
232+ Active: active,
228233 Stopped: stopped,
229234 ConnectOnly: legacy,
230235 RemoteAttachReady: ready,
@@ -137,7 +137,11 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe
137 broken := session.State == "broken"137 broken := session.State == "broken"
138 tabs := ports.CatalogTabs(session)138 tabs := ports.CatalogTabs(session)
139 viewTabs := make([]picker.TabEntry, 0, len(tabs))139 viewTabs := make([]picker.TabEntry, 0, len(tabs))
140- for _, tab := range tabs {140+ active := 0
141+ for i, tab := range tabs {
142+ if session.ActiveTabID != "" && tab.ID == session.ActiveTabID {
143+ active = i
144+ }
141 name := tab.Name145 name := tab.Name
142 if name == "" {146 if name == "" {
143 name = fmt.Sprintf("%d", int(tab.Index)+1)147 name = fmt.Sprintf("%d", int(tab.Index)+1)
@@ -169,7 +173,7 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe
169 target.StoppedTab = domain.NewOrdinalTabSelector(0, first.Name, uint16(len(tabs)))173 target.StoppedTab = domain.NewOrdinalTabSelector(0, first.Name, uint16(len(tabs)))
170 }174 }
171 } else {175 } else {
172- target.LiveTabID = domain.TabStableID(first.ID)176+ target.LiveTabID = domain.TabStableID(tabs[active].ID)
173 }177 }
174 }178 }
175 // Keep the structured session identity on rows even when one tab ID is179 // Keep the structured session identity on rows even when one tab ID is
@@ -225,6 +229,7 @@ func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSe
225 RemoteDetail: detail,229 RemoteDetail: detail,
226 RemoteReason: reason,230 RemoteReason: reason,
227 Tabs: viewTabs,231 Tabs: viewTabs,
232+ Active: active,
228 Stopped: stopped,233 Stopped: stopped,
229 ConnectOnly: legacy,234 ConnectOnly: legacy,
230 RemoteAttachReady: ready,235 RemoteAttachReady: ready,
M internal/usecase/daemon/remote_picker_test.go +71 -0
@@ -76,6 +76,77 @@ func TestRemotePickerCatalogExpiresAtAttachTTL(t *testing.T) {
7676 require.False(t, d.remotePickerTargetReadyTarget(target), "attachment must reject a catalogue exactly at the TTL boundary")
7777 }
7878
79+func TestRemotePickerSelectsCatalogActiveTab(t *testing.T) {
80+ tests := []struct {
81+ name string
82+ tabs []ports.RemoteCatalogTab
83+ activeTabID string
84+ wantActive int
85+ wantTabID domain.TabStableID
86+ }{
87+ {
88+ name: "active first",
89+ tabs: []ports.RemoteCatalogTab{
90+ {ID: "tab-active", Index: 0, Name: "active"},
91+ {ID: "tab-last", Index: 1, Name: "last"},
92+ },
93+ activeTabID: "tab-active",
94+ wantActive: 0,
95+ wantTabID: "tab-active",
96+ },
97+ {
98+ name: "active last",
99+ tabs: []ports.RemoteCatalogTab{
100+ {ID: "tab-first", Index: 0, Name: "first"},
101+ {ID: "tab-active", Index: 1, Name: "active"},
102+ },
103+ activeTabID: "tab-active",
104+ wantActive: 1,
105+ wantTabID: "tab-active",
106+ },
107+ {
108+ name: "empty active tab ID falls back to first stable tab",
109+ tabs: []ports.RemoteCatalogTab{
110+ {ID: "tab-first", Index: 0, Name: "first"},
111+ {ID: "", Index: 1, Name: "malformed"},
112+ },
113+ wantActive: 0,
114+ wantTabID: "tab-first",
115+ },
116+ }
117+
118+ for _, test := range tests {
119+ t.Run(test.name, func(t *testing.T) {
120+ d := newRemotePickerDaemon(nil)
121+ lifecycle := remoteLifecycleForTest()
122+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
123+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
124+ LifecycleID: lifecycle,
125+ Name: "vive",
126+ State: "running",
127+ Tabs: test.tabs,
128+ ActiveTabID: test.activeTabID,
129+ }},
130+ }})
131+ d.remoteCatalog.mu.Lock()
132+ d.remoteCatalog.status["arch"] = remoteHostFresh
133+ d.remoteCatalog.mu.Unlock()
134+
135+ views, _ := d.pickerViews(nil, nil)
136+ require.Len(t, views, 1)
137+ require.Equal(t, test.wantActive, views[0].Active)
138+ require.NotNil(t, views[0].RemoteTarget)
139+ require.Equal(t, test.wantTabID, views[0].RemoteTarget.LiveTabID)
140+
141+ model := d.newPickerModel(nil, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
142+ target, ok := model.Selected()
143+ require.True(t, ok)
144+ require.NotNil(t, target.RemoteTarget)
145+ require.Equal(t, test.wantTabID, target.RemoteTarget.LiveTabID)
146+ })
147+ }
148+}
149+
79150 func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) {
80151 tests := []struct {
81152 name string
@@ -76,6 +76,77 @@ func TestRemotePickerCatalogExpiresAtAttachTTL(t *testing.T) {
76 require.False(t, d.remotePickerTargetReadyTarget(target), "attachment must reject a catalogue exactly at the TTL boundary")76 require.False(t, d.remotePickerTargetReadyTarget(target), "attachment must reject a catalogue exactly at the TTL boundary")
77 }77 }
78 78
79+func TestRemotePickerSelectsCatalogActiveTab(t *testing.T) {
80+ tests := []struct {
81+ name string
82+ tabs []ports.RemoteCatalogTab
83+ activeTabID string
84+ wantActive int
85+ wantTabID domain.TabStableID
86+ }{
87+ {
88+ name: "active first",
89+ tabs: []ports.RemoteCatalogTab{
90+ {ID: "tab-active", Index: 0, Name: "active"},
91+ {ID: "tab-last", Index: 1, Name: "last"},
92+ },
93+ activeTabID: "tab-active",
94+ wantActive: 0,
95+ wantTabID: "tab-active",
96+ },
97+ {
98+ name: "active last",
99+ tabs: []ports.RemoteCatalogTab{
100+ {ID: "tab-first", Index: 0, Name: "first"},
101+ {ID: "tab-active", Index: 1, Name: "active"},
102+ },
103+ activeTabID: "tab-active",
104+ wantActive: 1,
105+ wantTabID: "tab-active",
106+ },
107+ {
108+ name: "empty active tab ID falls back to first stable tab",
109+ tabs: []ports.RemoteCatalogTab{
110+ {ID: "tab-first", Index: 0, Name: "first"},
111+ {ID: "", Index: 1, Name: "malformed"},
112+ },
113+ wantActive: 0,
114+ wantTabID: "tab-first",
115+ },
116+ }
117+
118+ for _, test := range tests {
119+ t.Run(test.name, func(t *testing.T) {
120+ d := newRemotePickerDaemon(nil)
121+ lifecycle := remoteLifecycleForTest()
122+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
123+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
124+ LifecycleID: lifecycle,
125+ Name: "vive",
126+ State: "running",
127+ Tabs: test.tabs,
128+ ActiveTabID: test.activeTabID,
129+ }},
130+ }})
131+ d.remoteCatalog.mu.Lock()
132+ d.remoteCatalog.status["arch"] = remoteHostFresh
133+ d.remoteCatalog.mu.Unlock()
134+
135+ views, _ := d.pickerViews(nil, nil)
136+ require.Len(t, views, 1)
137+ require.Equal(t, test.wantActive, views[0].Active)
138+ require.NotNil(t, views[0].RemoteTarget)
139+ require.Equal(t, test.wantTabID, views[0].RemoteTarget.LiveTabID)
140+
141+ model := d.newPickerModel(nil, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
142+ target, ok := model.Selected()
143+ require.True(t, ok)
144+ require.NotNil(t, target.RemoteTarget)
145+ require.Equal(t, test.wantTabID, target.RemoteTarget.LiveTabID)
146+ })
147+ }
148+}
149+
79 func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) {150 func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) {
80 tests := []struct {151 tests := []struct {
81 name string152 name string
M internal/usecase/daemon/remote_preview.go +40 -8
@@ -85,18 +85,19 @@ func (d *Daemon) captureRemotePreview(request ports.RemotePreviewRequest) (ports
8585 if width <= 0 || height <= 0 {
8686 return ports.RemotePreview{}, errRemotePreviewNoSuchTarget
8787 }
88- startY := source.Height - height
88+ startY, endY := source.Height-height, source.Height
89+ if last := remotePreviewLastContentRow(source, width, startY, endY); last >= startY {
90+ endY = last + 1
91+ } else if last := remotePreviewLastContentRow(source, width, 0, startY); last >= 0 {
92+ startY = max(0, last-height+1)
93+ endY = last + 1
94+ }
95+ height = endY - startY
8996 cells := make([]renderer.Cell, 0, width*height)
9097 for y := 0; y < height; y++ {
9198 row := source.Row(startY + y)
9299 for x := 0; x < width; x++ {
93- cell := row[x]
94- // A crop may end immediately after the left half of a wide
95- // rune. Replace that boundary cell with a styled blank rather
96- // than sending an invalid continuation sequence to the picker.
97- if x == width-1 && !cell.Continuation && renderer.RuneWidth(cell.Rune) == 2 {
98- cell = renderer.Cell{Rune: ' ', Style: cell.Style}
99- }
100+ cell, _ := cropRemotePreviewCell(row[x], x, width)
100101 cells = append(cells, cell)
101102 }
102103 }
@@ -117,3 +118,34 @@ func (d *Daemon) captureRemotePreview(request ports.RemotePreviewRequest) (ports
117118 }
118119 return preview, nil
119120 }
121+
122+// remotePreviewLastContentRow finds the final row with a visible glyph inside
123+// the bounded viewport crop. Remote terminal frames retain their full PTY
124+// height even when the only output is near the top; compacting the blank tail lets the
125+// picker bottom-anchor short output instead of clipping it away.
126+func remotePreviewLastContentRow(frame renderer.Frame, width, start, end int) int {
127+ for y := end - 1; y >= start; y-- {
128+ for x, cell := range frame.Row(y)[:width] {
129+ cell, splitWideRune := cropRemotePreviewCell(cell, x, width)
130+ if !splitWideRune && remotePreviewCellVisible(cell) {
131+ return y
132+ }
133+ }
134+ }
135+ return -1
136+}
137+
138+// cropRemotePreviewCell converts a left wide-rune half at the right crop edge
139+// to the styled blank that the picker can safely render. The flag distinguishes
140+// that replacement from an actual styled blank in the source frame.
141+func cropRemotePreviewCell(cell renderer.Cell, x, width int) (renderer.Cell, bool) {
142+ if x == width-1 && !cell.Continuation && renderer.RuneWidth(cell.Rune) == 2 {
143+ return renderer.Cell{Rune: ' ', Style: cell.Style}, true
144+ }
145+ return cell, false
146+}
147+
148+func remotePreviewCellVisible(cell renderer.Cell) bool {
149+ return !cell.Continuation &&
150+ (cell.Rune != 0 && cell.Rune != ' ' || !cell.Style.Equal(renderer.DefaultStyle()))
151+}
@@ -85,18 +85,19 @@ func (d *Daemon) captureRemotePreview(request ports.RemotePreviewRequest) (ports
85 if width <= 0 || height <= 0 {85 if width <= 0 || height <= 0 {
86 return ports.RemotePreview{}, errRemotePreviewNoSuchTarget86 return ports.RemotePreview{}, errRemotePreviewNoSuchTarget
87 }87 }
88- startY := source.Height - height88+ startY, endY := source.Height-height, source.Height
89+ if last := remotePreviewLastContentRow(source, width, startY, endY); last >= startY {
90+ endY = last + 1
91+ } else if last := remotePreviewLastContentRow(source, width, 0, startY); last >= 0 {
92+ startY = max(0, last-height+1)
93+ endY = last + 1
94+ }
95+ height = endY - startY
89 cells := make([]renderer.Cell, 0, width*height)96 cells := make([]renderer.Cell, 0, width*height)
90 for y := 0; y < height; y++ {97 for y := 0; y < height; y++ {
91 row := source.Row(startY + y)98 row := source.Row(startY + y)
92 for x := 0; x < width; x++ {99 for x := 0; x < width; x++ {
93- cell := row[x]100+ cell, _ := cropRemotePreviewCell(row[x], x, width)
94- // A crop may end immediately after the left half of a wide
95- // rune. Replace that boundary cell with a styled blank rather
96- // than sending an invalid continuation sequence to the picker.
97- if x == width-1 && !cell.Continuation && renderer.RuneWidth(cell.Rune) == 2 {
98- cell = renderer.Cell{Rune: ' ', Style: cell.Style}
99- }
100 cells = append(cells, cell)101 cells = append(cells, cell)
101 }102 }
102 }103 }
@@ -117,3 +118,34 @@ func (d *Daemon) captureRemotePreview(request ports.RemotePreviewRequest) (ports
117 }118 }
118 return preview, nil119 return preview, nil
119 }120 }
121+
122+// remotePreviewLastContentRow finds the final row with a visible glyph inside
123+// the bounded viewport crop. Remote terminal frames retain their full PTY
124+// height even when the only output is near the top; compacting the blank tail lets the
125+// picker bottom-anchor short output instead of clipping it away.
126+func remotePreviewLastContentRow(frame renderer.Frame, width, start, end int) int {
127+ for y := end - 1; y >= start; y-- {
128+ for x, cell := range frame.Row(y)[:width] {
129+ cell, splitWideRune := cropRemotePreviewCell(cell, x, width)
130+ if !splitWideRune && remotePreviewCellVisible(cell) {
131+ return y
132+ }
133+ }
134+ }
135+ return -1
136+}
137+
138+// cropRemotePreviewCell converts a left wide-rune half at the right crop edge
139+// to the styled blank that the picker can safely render. The flag distinguishes
140+// that replacement from an actual styled blank in the source frame.
141+func cropRemotePreviewCell(cell renderer.Cell, x, width int) (renderer.Cell, bool) {
142+ if x == width-1 && !cell.Continuation && renderer.RuneWidth(cell.Rune) == 2 {
143+ return renderer.Cell{Rune: ' ', Style: cell.Style}, true
144+ }
145+ return cell, false
146+}
147+
148+func remotePreviewCellVisible(cell renderer.Cell) bool {
149+ return !cell.Continuation &&
150+ (cell.Rune != 0 && cell.Rune != ' ' || !cell.Style.Equal(renderer.DefaultStyle()))
151+}
M internal/usecase/daemon/remote_preview_capture_test.go +103 -0
@@ -35,6 +35,109 @@ func TestCaptureRemotePreviewUsesBottomRowsForShorterPreview(t *testing.T) {
3535 require.Equal(t, []rune{'b', 'c'}, []rune{preview.Cells[0].Rune, preview.Cells[1].Rune})
3636 }
3737
38+func TestCaptureRemotePreviewCompactsBlankTail(t *testing.T) {
39+ type screenCell struct {
40+ x, y int
41+ cell renderer.Cell
42+ }
43+ type previewCell struct {
44+ index int
45+ rune rune
46+ style *renderer.Style
47+ }
48+ styledBlank := renderer.DefaultStyle()
49+ styledBlank.Background = 4
50+ tests := []struct {
51+ name string
52+ screenWidth, screenHeight int
53+ cells []screenCell
54+ width, height uint16
55+ wantHeight uint16
56+ wantCells []previewCell
57+ }{
58+ {
59+ name: "keeps short screen content visible",
60+ screenWidth: 2,
61+ screenHeight: 4,
62+ cells: []screenCell{{x: 0, y: 0, cell: renderer.Cell{Rune: 'v'}}},
63+ width: 2,
64+ height: 2,
65+ wantHeight: 1,
66+ wantCells: []previewCell{{index: 0, rune: 'v'}, {index: 1, rune: ' '}},
67+ },
68+ {
69+ name: "ignores boundary split wide rune",
70+ screenWidth: 3,
71+ screenHeight: 3,
72+ cells: []screenCell{
73+ {x: 0, y: 1, cell: renderer.Cell{Rune: 'a'}},
74+ {x: 1, y: 2, cell: renderer.Cell{Rune: '界'}},
75+ {x: 2, y: 2, cell: renderer.Cell{Continuation: true}},
76+ },
77+ width: 2,
78+ height: 2,
79+ wantHeight: 1,
80+ wantCells: []previewCell{{index: 0, rune: 'a'}, {index: 1, rune: ' '}},
81+ },
82+ {
83+ name: "preserves styled blank tail",
84+ screenWidth: 2,
85+ screenHeight: 4,
86+ cells: []screenCell{
87+ {x: 0, y: 2, cell: renderer.Cell{Rune: 'a'}},
88+ {x: 0, y: 3, cell: renderer.Cell{Rune: ' ', Style: styledBlank}},
89+ },
90+ width: 2,
91+ height: 2,
92+ wantHeight: 2,
93+ wantCells: []previewCell{{index: 0, rune: 'a'}, {index: 2, rune: ' ', style: &styledBlank}},
94+ },
95+ }
96+
97+ for _, test := range tests {
98+ t.Run(test.name, func(t *testing.T) {
99+ d := newTestDaemon(t, nil, stubClock{})
100+ sess := addControlSession(d, "work", "tab-1", "pane-1")
101+ sess.ephemeral = false
102+ sess.incarnation = remoteLifecycleForTest()
103+ pane := sess.tabs[0].focusedPane()
104+ pane.mu.Lock()
105+ pane.screen = vt.NewScreen(test.screenWidth, test.screenHeight)
106+ for _, cell := range test.cells {
107+ pane.screen.Frame.Set(cell.x, cell.y, cell.cell)
108+ }
109+ pane.mu.Unlock()
110+
111+ target := domain.RemoteSessionTarget{
112+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: sess.incarnation,
113+ SessionName: "work", LiveTabID: "tab-1",
114+ }
115+ preview, err := d.captureRemotePreview(ports.RemotePreviewRequest{
116+ Version: ports.RemotePreviewSchemaVersion, Target: target, Width: test.width, Height: test.height,
117+ })
118+ require.NoError(t, err)
119+ require.Equal(t, test.wantHeight, preview.Height)
120+ for _, want := range test.wantCells {
121+ require.Equal(t, want.rune, preview.Cells[want.index].Rune)
122+ if want.style != nil {
123+ require.True(t, preview.Cells[want.index].Style.Equal(*want.style))
124+ }
125+ }
126+ })
127+ }
128+}
129+
130+func TestStaticRemotePickerPreviewUsesOnlyItsTextRow(t *testing.T) {
131+ preview := staticRemotePickerPreview(8, 4, "loading")
132+
133+ require.Equal(t, 8, preview.Width)
134+ require.Equal(t, 1, preview.Height)
135+ require.Equal(t, []rune{'l', 'o', 'a', 'd', 'i', 'n', 'g', ' '}, []rune{
136+ preview.Rows[0][0].Rune, preview.Rows[0][1].Rune, preview.Rows[0][2].Rune, preview.Rows[0][3].Rune,
137+ preview.Rows[0][4].Rune, preview.Rows[0][5].Rune, preview.Rows[0][6].Rune, preview.Rows[0][7].Rune,
138+ })
139+}
140+
38141 func TestCaptureRemotePreviewDoesNotSplitWideRuneAtCropBoundary(t *testing.T) {
39142 d := newTestDaemon(t, nil, stubClock{})
40143 sess := addControlSession(d, "work", "tab-1", "pane-1")
@@ -35,6 +35,109 @@ func TestCaptureRemotePreviewUsesBottomRowsForShorterPreview(t *testing.T) {
35 require.Equal(t, []rune{'b', 'c'}, []rune{preview.Cells[0].Rune, preview.Cells[1].Rune})35 require.Equal(t, []rune{'b', 'c'}, []rune{preview.Cells[0].Rune, preview.Cells[1].Rune})
36 }36 }
37 37
38+func TestCaptureRemotePreviewCompactsBlankTail(t *testing.T) {
39+ type screenCell struct {
40+ x, y int
41+ cell renderer.Cell
42+ }
43+ type previewCell struct {
44+ index int
45+ rune rune
46+ style *renderer.Style
47+ }
48+ styledBlank := renderer.DefaultStyle()
49+ styledBlank.Background = 4
50+ tests := []struct {
51+ name string
52+ screenWidth, screenHeight int
53+ cells []screenCell
54+ width, height uint16
55+ wantHeight uint16
56+ wantCells []previewCell
57+ }{
58+ {
59+ name: "keeps short screen content visible",
60+ screenWidth: 2,
61+ screenHeight: 4,
62+ cells: []screenCell{{x: 0, y: 0, cell: renderer.Cell{Rune: 'v'}}},
63+ width: 2,
64+ height: 2,
65+ wantHeight: 1,
66+ wantCells: []previewCell{{index: 0, rune: 'v'}, {index: 1, rune: ' '}},
67+ },
68+ {
69+ name: "ignores boundary split wide rune",
70+ screenWidth: 3,
71+ screenHeight: 3,
72+ cells: []screenCell{
73+ {x: 0, y: 1, cell: renderer.Cell{Rune: 'a'}},
74+ {x: 1, y: 2, cell: renderer.Cell{Rune: '界'}},
75+ {x: 2, y: 2, cell: renderer.Cell{Continuation: true}},
76+ },
77+ width: 2,
78+ height: 2,
79+ wantHeight: 1,
80+ wantCells: []previewCell{{index: 0, rune: 'a'}, {index: 1, rune: ' '}},
81+ },
82+ {
83+ name: "preserves styled blank tail",
84+ screenWidth: 2,
85+ screenHeight: 4,
86+ cells: []screenCell{
87+ {x: 0, y: 2, cell: renderer.Cell{Rune: 'a'}},
88+ {x: 0, y: 3, cell: renderer.Cell{Rune: ' ', Style: styledBlank}},
89+ },
90+ width: 2,
91+ height: 2,
92+ wantHeight: 2,
93+ wantCells: []previewCell{{index: 0, rune: 'a'}, {index: 2, rune: ' ', style: &styledBlank}},
94+ },
95+ }
96+
97+ for _, test := range tests {
98+ t.Run(test.name, func(t *testing.T) {
99+ d := newTestDaemon(t, nil, stubClock{})
100+ sess := addControlSession(d, "work", "tab-1", "pane-1")
101+ sess.ephemeral = false
102+ sess.incarnation = remoteLifecycleForTest()
103+ pane := sess.tabs[0].focusedPane()
104+ pane.mu.Lock()
105+ pane.screen = vt.NewScreen(test.screenWidth, test.screenHeight)
106+ for _, cell := range test.cells {
107+ pane.screen.Frame.Set(cell.x, cell.y, cell.cell)
108+ }
109+ pane.mu.Unlock()
110+
111+ target := domain.RemoteSessionTarget{
112+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: sess.incarnation,
113+ SessionName: "work", LiveTabID: "tab-1",
114+ }
115+ preview, err := d.captureRemotePreview(ports.RemotePreviewRequest{
116+ Version: ports.RemotePreviewSchemaVersion, Target: target, Width: test.width, Height: test.height,
117+ })
118+ require.NoError(t, err)
119+ require.Equal(t, test.wantHeight, preview.Height)
120+ for _, want := range test.wantCells {
121+ require.Equal(t, want.rune, preview.Cells[want.index].Rune)
122+ if want.style != nil {
123+ require.True(t, preview.Cells[want.index].Style.Equal(*want.style))
124+ }
125+ }
126+ })
127+ }
128+}
129+
130+func TestStaticRemotePickerPreviewUsesOnlyItsTextRow(t *testing.T) {
131+ preview := staticRemotePickerPreview(8, 4, "loading")
132+
133+ require.Equal(t, 8, preview.Width)
134+ require.Equal(t, 1, preview.Height)
135+ require.Equal(t, []rune{'l', 'o', 'a', 'd', 'i', 'n', 'g', ' '}, []rune{
136+ preview.Rows[0][0].Rune, preview.Rows[0][1].Rune, preview.Rows[0][2].Rune, preview.Rows[0][3].Rune,
137+ preview.Rows[0][4].Rune, preview.Rows[0][5].Rune, preview.Rows[0][6].Rune, preview.Rows[0][7].Rune,
138+ })
139+}
140+
38 func TestCaptureRemotePreviewDoesNotSplitWideRuneAtCropBoundary(t *testing.T) {141 func TestCaptureRemotePreviewDoesNotSplitWideRuneAtCropBoundary(t *testing.T) {
39 d := newTestDaemon(t, nil, stubClock{})142 d := newTestDaemon(t, nil, stubClock{})
40 sess := addControlSession(d, "work", "tab-1", "pane-1")143 sess := addControlSession(d, "work", "tab-1", "pane-1")
M internal/usecase/daemon/status.go +3 -0
@@ -192,6 +192,9 @@ func (s *session) statusSegmentsFor(ac *attachedClient, includeTerminalTitle boo
192192 if s.ephemeral {
193193 name += "*"
194194 }
195+ if ac != nil && ac.remoteOrigin != "" {
196+ name += " at " + ac.remoteOrigin
197+ }
195198 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}
196199 activeIndex := 0
197200 if ac != nil {
@@ -192,6 +192,9 @@ func (s *session) statusSegmentsFor(ac *attachedClient, includeTerminalTitle boo
192 if s.ephemeral {192 if s.ephemeral {
193 name += "*"193 name += "*"
194 }194 }
195+ if ac != nil && ac.remoteOrigin != "" {
196+ name += " at " + ac.remoteOrigin
197+ }
195 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}198 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}
196 activeIndex := 0199 activeIndex := 0
197 if ac != nil {200 if ac != nil {
M internal/usecase/daemon/status_test.go +25 -0
@@ -109,6 +109,31 @@ func TestStatusCompositionGolden(t *testing.T) {
109109 }
110110 }
111111
112+func TestStatusSegmentsIncludesAttachmentRemoteOrigin(t *testing.T) {
113+ tests := []struct {
114+ name string
115+ remoteOrigin string
116+ wantAttachmentBar string
117+ }{
118+ {name: "remote origin", remoteOrigin: "arch", wantAttachmentBar: "vive at arch"},
119+ {name: "local attachment", wantAttachmentBar: "vive"},
120+ }
121+
122+ for _, test := range tests {
123+ t.Run(test.name, func(t *testing.T) {
124+ p, releasePTY := newBlockingPTY(t)
125+ _, sess, ac, _ := newManualSessionWithPTYs(t, p)
126+ defer releasePTY()
127+
128+ sess.name = "vive"
129+ ac.remoteOrigin = test.remoteOrigin
130+
131+ require.Equal(t, test.wantAttachmentBar, sess.statusSegmentsFor(ac, true).session)
132+ require.Equal(t, "vive", sess.statusSegments(true).session, "remote provenance belongs only to the selected attachment")
133+ })
134+ }
135+}
136+
112137 func TestStatusSegmentsIncludesFocusedPaneTitle(t *testing.T) {
113138 p1, releasePTY1 := newBlockingPTY(t)
114139 p2, releasePTY2 := newBlockingPTY(t)
@@ -109,6 +109,31 @@ func TestStatusCompositionGolden(t *testing.T) {
109 }109 }
110 }110 }
111 111
112+func TestStatusSegmentsIncludesAttachmentRemoteOrigin(t *testing.T) {
113+ tests := []struct {
114+ name string
115+ remoteOrigin string
116+ wantAttachmentBar string
117+ }{
118+ {name: "remote origin", remoteOrigin: "arch", wantAttachmentBar: "vive at arch"},
119+ {name: "local attachment", wantAttachmentBar: "vive"},
120+ }
121+
122+ for _, test := range tests {
123+ t.Run(test.name, func(t *testing.T) {
124+ p, releasePTY := newBlockingPTY(t)
125+ _, sess, ac, _ := newManualSessionWithPTYs(t, p)
126+ defer releasePTY()
127+
128+ sess.name = "vive"
129+ ac.remoteOrigin = test.remoteOrigin
130+
131+ require.Equal(t, test.wantAttachmentBar, sess.statusSegmentsFor(ac, true).session)
132+ require.Equal(t, "vive", sess.statusSegments(true).session, "remote provenance belongs only to the selected attachment")
133+ })
134+ }
135+}
136+
112 func TestStatusSegmentsIncludesFocusedPaneTitle(t *testing.T) {137 func TestStatusSegmentsIncludesFocusedPaneTitle(t *testing.T) {
113 p1, releasePTY1 := newBlockingPTY(t)138 p1, releasePTY1 := newBlockingPTY(t)
114 p2, releasePTY2 := newBlockingPTY(t)139 p2, releasePTY2 := newBlockingPTY(t)
M scripts/remote-picker-harness/main.go +413 -12
@@ -1,6 +1,7 @@
11 package main
22
33 import (
4+ "bytes"
45 "context"
56 "errors"
67 "fmt"
@@ -14,10 +15,12 @@ import (
1415 "time"
1516
1617 "github.com/bnema/vev/internal/adapters/dgram"
18+ "github.com/bnema/vev/internal/adapters/ipc"
1719 remoteadapter "github.com/bnema/vev/internal/adapters/remote"
1820 "github.com/bnema/vev/internal/adapters/sshstdio"
1921 "github.com/bnema/vev/internal/domain"
2022 "github.com/bnema/vev/internal/ports"
23+ "github.com/bnema/vev/pkg/vt"
2124 )
2225
2326 const (
@@ -45,6 +48,9 @@ func run() error {
4548
4649 catalog := remoteadapter.NewCatalogClient()
4750 preview := remoteadapter.NewPreviewClient()
51+ if err := runLocalPickerUnitedPhase(ctx, target, catalog, preview); err != nil {
52+ return err
53+ }
4854 if err := runLiveStdioPhase(ctx, target, catalog, preview); err != nil {
4955 return err
5056 }
@@ -131,6 +137,169 @@ func runLiveStdioPhase(ctx context.Context, target string, catalog ports.RemoteC
131137 return nil
132138 }
133139
140+// runLocalPickerUnitedPhase drives the actual local picker against the SSH
141+// daemon container. It validates the seam that individual catalog, preview,
142+// and attach tests cannot: catalog active-tab selection, live remote preview,
143+// emitted rich handoff, and the remote attachment's visible provenance.
144+func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports.RemoteCatalogClient, preview ports.RemotePreviewClient) (err error) {
145+ const (
146+ localSession = "local-picker"
147+ remoteSession = "picker"
148+ previewMarker = "VEV_REMOTE_PICKER_ACTIVE"
149+ )
150+
151+ remote, err := openTransport(ctx, ports.RemoteTransportStdio, target)
152+ if err != nil {
153+ return fmt.Errorf("open remote picker source: %w", err)
154+ }
155+ ownsRemoteSession := false
156+ defer func() {
157+ _ = remote.Close()
158+ if !ownsRemoteSession {
159+ return
160+ }
161+ cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second)
162+ defer cancel()
163+ if cleanupErr := runRemoteCommand(cleanup, target, "vev", "kill", remoteSession); cleanupErr != nil {
164+ err = errors.Join(err, fmt.Errorf("destroy remote picker session: %w", cleanupErr))
165+ }
166+ }()
167+ if err := attachAndCheck(ctx, remote, ports.Hello{
168+ Intent: ports.IntentNew, Name: remoteSession, Env: localEnvironment(), Cwd: "/tmp/remote-cwd",
169+ TermEnv: "xterm-256color", TrueColor: true,
170+ }); err != nil {
171+ return fmt.Errorf("attach remote picker source: %w", err)
172+ }
173+ ownsRemoteSession = true
174+ if err := sendCommand(ctx, remote, "new-tab"); err != nil {
175+ return fmt.Errorf("create remote picker tab: %w", err)
176+ }
177+ if err := sendCommand(ctx, remote, "next-tab"); err != nil {
178+ return fmt.Errorf("focus remote picker tab: %w", err)
179+ }
180+ if err := sendInputAndAwait(ctx, remote, "printf '"+previewMarker+"\\n'", previewMarker); err != nil {
181+ return fmt.Errorf("seed remote picker preview: %w", err)
182+ }
183+ remoteCatalog, err := waitForSessionWithTabCount(ctx, catalog, target, remoteSession, "running", 2)
184+ if err != nil {
185+ return fmt.Errorf("remote picker catalog: %w", err)
186+ }
187+ if remoteCatalog.ActiveTabID == "" {
188+ return errors.New("remote picker catalog omitted active tab")
189+ }
190+ tabs := ports.CatalogTabs(remoteCatalog)
191+ if len(tabs) != 2 {
192+ return errors.New("remote picker catalog did not retain both tabs")
193+ }
194+ selectedTarget, err := targetForSession(target, remoteCatalog, false)
195+ if err != nil {
196+ return fmt.Errorf("active remote picker target: %w", err)
197+ }
198+ if selectedTarget.LiveTabID != domain.TabStableID(remoteCatalog.ActiveTabID) {
199+ return errors.New("remote picker catalog active tab did not resolve to the selected target")
200+ }
201+ activeTabIndex := -1
202+ for i, tab := range tabs {
203+ if domain.TabStableID(tab.ID) == selectedTarget.LiveTabID {
204+ activeTabIndex = i
205+ break
206+ }
207+ }
208+ if activeTabIndex < 0 {
209+ return errors.New("remote picker catalog active tab was absent from tab list")
210+ }
211+ selectedPreview, err := preview.Preview(ctx, selectedTarget, 55, 22)
212+ if err != nil {
213+ return fmt.Errorf("explicit remote tab preview: %w", err)
214+ }
215+ if !remotePreviewContains(selectedPreview, previewMarker) {
216+ return errors.New("explicit remote tab preview omitted marker")
217+ }
218+
219+ if err := runLocalCommand(ctx, "vev", "host", "add", target); err != nil {
220+ return fmt.Errorf("register remote picker host: %w", err)
221+ }
222+ localDaemon, err := startLocalDaemon(ctx)
223+ if err != nil {
224+ return fmt.Errorf("start local picker daemon: %w", err)
225+ }
226+ defer stopLocalDaemon(localDaemon)
227+
228+ local, err := openLocalTransport(ctx)
229+ if err != nil {
230+ return fmt.Errorf("open local picker transport: %w", err)
231+ }
232+ defer func() { _ = local.Close() }()
233+ if err := attachAndCheck(ctx, local, ports.Hello{
234+ Intent: ports.IntentNew, Name: localSession, Env: localEnvironment(), Cwd: "/tmp/local-cwd",
235+ TermEnv: "xterm-256color", TrueColor: true,
236+ }); err != nil {
237+ return fmt.Errorf("attach local picker session: %w", err)
238+ }
239+ if err := sendRawInput(local, "\x1b "); err != nil {
240+ return fmt.Errorf("open command palette: %w", err)
241+ }
242+ if err := awaitOutputContains(ctx, local, "Commands"); err != nil {
243+ return fmt.Errorf("await command palette: %w", err)
244+ }
245+ if err := sendRawInput(local, "SSP\r"); err != nil {
246+ return fmt.Errorf("open session picker: %w", err)
247+ }
248+ if err := awaitOutputContains(ctx, local, remoteSession+"@remote"); err != nil {
249+ return fmt.Errorf("await remote picker catalog row: %w", err)
250+ }
251+ // The local selected tab is first and the remote session header is not
252+ // selectable, so one down reaches the first remote tab. Derive the rest
253+ // from the catalog's stable active tab identity instead of assuming an
254+ // ordinal target.
255+ if err := sendRawInput(local, strings.Repeat("j", activeTabIndex+1)); err != nil {
256+ return fmt.Errorf("select catalog active remote tab: %w", err)
257+ }
258+ if err := awaitRemotePickerPreview(ctx, local, previewMarker); err != nil {
259+ return fmt.Errorf("remote picker preview: %w", err)
260+ }
261+ if err := sendRawInput(local, "\r"); err != nil {
262+ return fmt.Errorf("commit remote picker target: %w", err)
263+ }
264+ handoffFrame, err := receiveFrame(ctx, local, ports.MsgAttachTarget)
265+ if err != nil {
266+ return fmt.Errorf("await remote picker handoff: %w", err)
267+ }
268+ handoff, err := ports.UnmarshalAttachTarget(handoffFrame.Payload)
269+ if err != nil {
270+ return fmt.Errorf("decode remote picker handoff: %w", err)
271+ }
272+ if handoff.RemoteTarget == nil {
273+ return errors.New("remote picker handoff omitted the remote target")
274+ }
275+ if handoff.RemoteTarget.LiveTabID != selectedTarget.LiveTabID {
276+ return fmt.Errorf("remote picker handoff tab = %q, want %q", handoff.RemoteTarget.LiveTabID, selectedTarget.LiveTabID)
277+ }
278+ if handoff.RemoteTarget.DisplayOrigin != "remote" {
279+ return fmt.Errorf("remote picker handoff origin = %q, want remote", handoff.RemoteTarget.DisplayOrigin)
280+ }
281+ if handoff.EnvironmentPolicy != ports.EnvironmentPolicyDaemonOwned {
282+ return fmt.Errorf("remote picker handoff policy = %d, want daemon-owned", handoff.EnvironmentPolicy)
283+ }
284+
285+ selected, err := openTransport(ctx, ports.RemoteTransportStdio, target)
286+ if err != nil {
287+ return fmt.Errorf("open selected remote transport: %w", err)
288+ }
289+ defer func() { _ = selected.Close() }()
290+ if err := attachAndCheck(ctx, selected, ports.Hello{
291+ Intent: ports.IntentAttach, Name: handoff.Session, Env: localEnvironment(), Cwd: "/tmp/local-cwd",
292+ TermEnv: "xterm-256color", TrueColor: true,
293+ RemoteTarget: handoff.RemoteTarget, EnvironmentPolicy: handoff.EnvironmentPolicy,
294+ }); err != nil {
295+ return fmt.Errorf("attach selected remote target: %w", err)
296+ }
297+ if err := awaitOutputContains(ctx, selected, remoteSession+" at remote"); err != nil {
298+ return fmt.Errorf("remote provenance status bar: %w", err)
299+ }
300+ return nil
301+}
302+
134303 func runRestartResumePhase(ctx context.Context, target string, catalog ports.RemoteCatalogClient, preview ports.RemotePreviewClient) (domain.RemoteSessionTarget, error) {
135304 if err := runRemoteCommand(ctx, target, "vev", "kill", "--daemon"); err != nil {
136305 return domain.RemoteSessionTarget{}, fmt.Errorf("stop remote daemon: %w", err)
@@ -318,6 +487,107 @@ func openTransport(ctx context.Context, mode ports.RemoteTransportMode, target s
318487 return newHarnessTransport(transport), nil
319488 }
320489
490+const commandStderrLimit = 4 << 10
491+
492+type boundedStderr struct {
493+ bytes.Buffer
494+ truncated bool
495+}
496+
497+func (b *boundedStderr) Write(p []byte) (int, error) {
498+ written := len(p)
499+ remaining := commandStderrLimit - b.Len()
500+ if remaining <= 0 {
501+ b.truncated = b.truncated || written != 0
502+ return written, nil
503+ }
504+ if len(p) > remaining {
505+ p = p[:remaining]
506+ b.truncated = true
507+ }
508+ _, _ = b.Buffer.Write(p)
509+ return written, nil
510+}
511+
512+func (b *boundedStderr) String() string {
513+ stderr := b.Buffer.String()
514+ if b.truncated {
515+ stderr += "\n[stderr truncated]"
516+ }
517+ return stderr
518+}
519+
520+func commandErrorWithStderr(err error, stderr string) error {
521+ if stderr = strings.TrimSpace(stderr); stderr == "" {
522+ return err
523+ }
524+ return fmt.Errorf("%w: stderr: %s", err, stderr)
525+}
526+
527+func startLocalDaemon(ctx context.Context) (*exec.Cmd, error) {
528+ cmd := exec.CommandContext(ctx, "vev", "--daemon")
529+ stderr := &boundedStderr{}
530+ cmd.Stdout = io.Discard
531+ cmd.Stderr = stderr
532+ if err := cmd.Start(); err != nil {
533+ return nil, err
534+ }
535+
536+ deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
537+ defer cancel()
538+ for {
539+ transport, err := ipc.DialContext(deadline, ipc.SocketDir())
540+ if err == nil {
541+ _ = transport.Close()
542+ return cmd, nil
543+ }
544+ select {
545+ case <-deadline.Done():
546+ stopLocalProcess(cmd)
547+ return nil, commandErrorWithStderr(deadline.Err(), stderr.String())
548+ case <-time.After(25 * time.Millisecond):
549+ }
550+ }
551+}
552+
553+func stopLocalDaemon(cmd *exec.Cmd) {
554+ cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second)
555+ defer cancel()
556+ _ = runLocalCommand(cleanup, "vev", "kill", "--daemon")
557+ stopLocalProcess(cmd)
558+}
559+
560+func stopLocalProcess(cmd *exec.Cmd) {
561+ if cmd == nil || cmd.Process == nil {
562+ return
563+ }
564+ _ = cmd.Process.Kill()
565+ _ = cmd.Wait()
566+}
567+
568+func runLocalCommand(ctx context.Context, name string, args ...string) error {
569+ return runCapturedCommand(ctx, name, args...)
570+}
571+
572+func runCapturedCommand(ctx context.Context, name string, args ...string) error {
573+ cmd := exec.CommandContext(ctx, name, args...)
574+ stderr := &boundedStderr{}
575+ cmd.Stdout = io.Discard
576+ cmd.Stderr = stderr
577+ if err := cmd.Run(); err != nil {
578+ return commandErrorWithStderr(err, stderr.String())
579+ }
580+ return nil
581+}
582+
583+func openLocalTransport(ctx context.Context) (*harnessTransport, error) {
584+ transport, err := ipc.DialContext(ctx, ipc.SocketDir())
585+ if err != nil {
586+ return nil, err
587+ }
588+ return newHarnessTransport(transport), nil
589+}
590+
321591 func attachAndCheck(ctx context.Context, tr *harnessTransport, hello ports.Hello) error {
322592 hello.Version = ports.ProtocolVersion
323593 hello.Size = domain.Size{Cols: 80, Rows: 24}
@@ -348,6 +618,136 @@ func attachAndCheck(ctx context.Context, tr *harnessTransport, hello ports.Hello
348618 return nil
349619 }
350620
621+func sendRawInput(tr *harnessTransport, data string) error {
622+ return tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte(data)})})
623+}
624+
625+func acknowledgeOutput(tr *harnessTransport, output ports.Output) error {
626+ if output.Epoch == 0 || output.New == 0 {
627+ return nil
628+ }
629+ payload, err := ports.MarshalAck(ports.Ack{Epoch: output.Epoch, State: output.New})
630+ if err != nil {
631+ return err
632+ }
633+ return tr.Send(ports.Frame{Type: ports.MsgAck, Payload: payload})
634+}
635+
636+func awaitOutputContains(ctx context.Context, tr *harnessTransport, want string) error {
637+ deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
638+ defer cancel()
639+ var received strings.Builder
640+ screen := vt.NewScreen(80, 24)
641+ for {
642+ frame, err := receiveWithTimeout(deadline, tr)
643+ if err != nil {
644+ return err
645+ }
646+ if frame.Type != ports.MsgOutput {
647+ continue
648+ }
649+ output, err := ports.UnmarshalOutput(frame.Payload)
650+ if err != nil {
651+ return err
652+ }
653+ if err := acknowledgeOutput(tr, output); err != nil {
654+ return err
655+ }
656+ received.Write(output.Data)
657+ screen.Write(output.Data)
658+ if strings.Contains(received.String(), want) || strings.Contains(harnessScreenText(screen), want) {
659+ return nil
660+ }
661+ if received.Len() > 64<<10 {
662+ text := received.String()
663+ received.Reset()
664+ received.WriteString(text[len(text)-16<<10:])
665+ }
666+ }
667+}
668+
669+func awaitRemotePickerPreview(ctx context.Context, tr *harnessTransport, marker string) error {
670+ deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
671+ defer cancel()
672+ screen := vt.NewScreen(80, 24)
673+ outputFrames := 0
674+ sawLoading := false
675+ for {
676+ frame, err := receiveWithTimeout(deadline, tr)
677+ if err != nil {
678+ return fmt.Errorf("no rendered remote preview after %d output frames (loading=%t): %w", outputFrames, sawLoading, err)
679+ }
680+ if frame.Type != ports.MsgOutput {
681+ continue
682+ }
683+ output, err := ports.UnmarshalOutput(frame.Payload)
684+ if err != nil {
685+ return err
686+ }
687+ if err := acknowledgeOutput(tr, output); err != nil {
688+ return err
689+ }
690+ outputFrames++
691+ screen.Write(output.Data)
692+ text := harnessScreenText(screen)
693+ sawLoading = sawLoading || strings.Contains(text, "loading remote preview") || strings.Contains(string(output.Data), "loading remote preview")
694+ if strings.Contains(text, marker) || strings.Contains(string(output.Data), marker) {
695+ return nil
696+ }
697+ if strings.Contains(text, "remote preview unavailable") {
698+ return errors.New("remote preview unavailable")
699+ }
700+ }
701+}
702+
703+func harnessScreenText(screen *vt.Screen) string {
704+ var text strings.Builder
705+ for y := range screen.Frame.Height {
706+ for x := range screen.Frame.Width {
707+ r := screen.Frame.At(x, y).Rune
708+ if r == 0 {
709+ r = ' '
710+ }
711+ text.WriteRune(r)
712+ }
713+ text.WriteByte('\n')
714+ }
715+ return text.String()
716+}
717+
718+func remotePreviewContains(preview ports.RemotePreview, want string) bool {
719+ var text strings.Builder
720+ for _, cell := range preview.Cells {
721+ if !cell.Continuation {
722+ text.WriteRune(cell.Rune)
723+ }
724+ }
725+ return strings.Contains(text.String(), want)
726+}
727+
728+func receiveFrame(ctx context.Context, tr *harnessTransport, typ ports.MsgType) (ports.Frame, error) {
729+ deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
730+ defer cancel()
731+ for {
732+ frame, err := receiveWithTimeout(deadline, tr)
733+ if err != nil {
734+ return ports.Frame{}, err
735+ }
736+ if frame.Type == typ {
737+ return frame, nil
738+ }
739+ if frame.Type == ports.MsgOutput {
740+ output, decodeErr := ports.UnmarshalOutput(frame.Payload)
741+ if decodeErr != nil {
742+ return ports.Frame{}, decodeErr
743+ }
744+ if ackErr := acknowledgeOutput(tr, output); ackErr != nil {
745+ return ports.Frame{}, ackErr
746+ }
747+ }
748+ }
749+}
750+
351751 func sendInputAndAwait(ctx context.Context, tr *harnessTransport, command, want string) error {
352752 if err := tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte(command + "\n")})}); err != nil {
353753 return err
@@ -371,14 +771,8 @@ func sendInputAndAwait(ctx context.Context, tr *harnessTransport, command, want
371771 }
372772 outputFrames++
373773 outputBytes += len(output.Data)
374- if output.Epoch != 0 && output.New != 0 {
375- ackPayload, err := ports.MarshalAck(ports.Ack{Epoch: output.Epoch, State: output.New})
376- if err != nil {
377- return fmt.Errorf("encode output ack: %w", err)
378- }
379- if err := tr.Send(ports.Frame{Type: ports.MsgAck, Payload: ackPayload}); err != nil {
380- return fmt.Errorf("ack output: %w", err)
381- }
774+ if err := acknowledgeOutput(tr, output); err != nil {
775+ return fmt.Errorf("ack output: %w", err)
382776 }
383777 received.Write(output.Data)
384778 if strings.Contains(received.String(), want) {
@@ -414,6 +808,16 @@ func sendCommand(ctx context.Context, tr *harnessTransport, slug string) error {
414808 if err != nil {
415809 return err
416810 }
811+ if frame.Type == ports.MsgOutput {
812+ output, decodeErr := ports.UnmarshalOutput(frame.Payload)
813+ if decodeErr != nil {
814+ return decodeErr
815+ }
816+ if ackErr := acknowledgeOutput(tr, output); ackErr != nil {
817+ return ackErr
818+ }
819+ continue
820+ }
417821 if frame.Type != ports.MsgCommandResult {
418822 continue
419823 }
@@ -583,10 +987,7 @@ func touchRemoteFlag(ctx context.Context, target, path string) error {
583987
584988 func runRemoteCommand(ctx context.Context, target string, command ...string) error {
585989 spec := sshstdio.BuildCommandForRemoteCommand(target, command...)
586- cmd := exec.CommandContext(ctx, spec.Path, spec.Args...)
587- cmd.Stdout = io.Discard
588- cmd.Stderr = io.Discard
589- return cmd.Run()
990+ return runCapturedCommand(ctx, spec.Path, spec.Args...)
590991 }
591992
592993 func receiveWithTimeout(ctx context.Context, tr *harnessTransport) (ports.Frame, error) {
@@ -1,6 +1,7 @@
1 package main1 package main
2 2
3 import (3 import (
4+ "bytes"
4 "context"5 "context"
5 "errors"6 "errors"
6 "fmt"7 "fmt"
@@ -14,10 +15,12 @@ import (
14 "time"15 "time"
15 16
16 "github.com/bnema/vev/internal/adapters/dgram"17 "github.com/bnema/vev/internal/adapters/dgram"
18+ "github.com/bnema/vev/internal/adapters/ipc"
17 remoteadapter "github.com/bnema/vev/internal/adapters/remote"19 remoteadapter "github.com/bnema/vev/internal/adapters/remote"
18 "github.com/bnema/vev/internal/adapters/sshstdio"20 "github.com/bnema/vev/internal/adapters/sshstdio"
19 "github.com/bnema/vev/internal/domain"21 "github.com/bnema/vev/internal/domain"
20 "github.com/bnema/vev/internal/ports"22 "github.com/bnema/vev/internal/ports"
23+ "github.com/bnema/vev/pkg/vt"
21 )24 )
22 25
23 const (26 const (
@@ -45,6 +48,9 @@ func run() error {
45 48
46 catalog := remoteadapter.NewCatalogClient()49 catalog := remoteadapter.NewCatalogClient()
47 preview := remoteadapter.NewPreviewClient()50 preview := remoteadapter.NewPreviewClient()
51+ if err := runLocalPickerUnitedPhase(ctx, target, catalog, preview); err != nil {
52+ return err
53+ }
48 if err := runLiveStdioPhase(ctx, target, catalog, preview); err != nil {54 if err := runLiveStdioPhase(ctx, target, catalog, preview); err != nil {
49 return err55 return err
50 }56 }
@@ -131,6 +137,169 @@ func runLiveStdioPhase(ctx context.Context, target string, catalog ports.RemoteC
131 return nil137 return nil
132 }138 }
133 139
140+// runLocalPickerUnitedPhase drives the actual local picker against the SSH
141+// daemon container. It validates the seam that individual catalog, preview,
142+// and attach tests cannot: catalog active-tab selection, live remote preview,
143+// emitted rich handoff, and the remote attachment's visible provenance.
144+func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports.RemoteCatalogClient, preview ports.RemotePreviewClient) (err error) {
145+ const (
146+ localSession = "local-picker"
147+ remoteSession = "picker"
148+ previewMarker = "VEV_REMOTE_PICKER_ACTIVE"
149+ )
150+
151+ remote, err := openTransport(ctx, ports.RemoteTransportStdio, target)
152+ if err != nil {
153+ return fmt.Errorf("open remote picker source: %w", err)
154+ }
155+ ownsRemoteSession := false
156+ defer func() {
157+ _ = remote.Close()
158+ if !ownsRemoteSession {
159+ return
160+ }
161+ cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second)
162+ defer cancel()
163+ if cleanupErr := runRemoteCommand(cleanup, target, "vev", "kill", remoteSession); cleanupErr != nil {
164+ err = errors.Join(err, fmt.Errorf("destroy remote picker session: %w", cleanupErr))
165+ }
166+ }()
167+ if err := attachAndCheck(ctx, remote, ports.Hello{
168+ Intent: ports.IntentNew, Name: remoteSession, Env: localEnvironment(), Cwd: "/tmp/remote-cwd",
169+ TermEnv: "xterm-256color", TrueColor: true,
170+ }); err != nil {
171+ return fmt.Errorf("attach remote picker source: %w", err)
172+ }
173+ ownsRemoteSession = true
174+ if err := sendCommand(ctx, remote, "new-tab"); err != nil {
175+ return fmt.Errorf("create remote picker tab: %w", err)
176+ }
177+ if err := sendCommand(ctx, remote, "next-tab"); err != nil {
178+ return fmt.Errorf("focus remote picker tab: %w", err)
179+ }
180+ if err := sendInputAndAwait(ctx, remote, "printf '"+previewMarker+"\\n'", previewMarker); err != nil {
181+ return fmt.Errorf("seed remote picker preview: %w", err)
182+ }
183+ remoteCatalog, err := waitForSessionWithTabCount(ctx, catalog, target, remoteSession, "running", 2)
184+ if err != nil {
185+ return fmt.Errorf("remote picker catalog: %w", err)
186+ }
187+ if remoteCatalog.ActiveTabID == "" {
188+ return errors.New("remote picker catalog omitted active tab")
189+ }
190+ tabs := ports.CatalogTabs(remoteCatalog)
191+ if len(tabs) != 2 {
192+ return errors.New("remote picker catalog did not retain both tabs")
193+ }
194+ selectedTarget, err := targetForSession(target, remoteCatalog, false)
195+ if err != nil {
196+ return fmt.Errorf("active remote picker target: %w", err)
197+ }
198+ if selectedTarget.LiveTabID != domain.TabStableID(remoteCatalog.ActiveTabID) {
199+ return errors.New("remote picker catalog active tab did not resolve to the selected target")
200+ }
201+ activeTabIndex := -1
202+ for i, tab := range tabs {
203+ if domain.TabStableID(tab.ID) == selectedTarget.LiveTabID {
204+ activeTabIndex = i
205+ break
206+ }
207+ }
208+ if activeTabIndex < 0 {
209+ return errors.New("remote picker catalog active tab was absent from tab list")
210+ }
211+ selectedPreview, err := preview.Preview(ctx, selectedTarget, 55, 22)
212+ if err != nil {
213+ return fmt.Errorf("explicit remote tab preview: %w", err)
214+ }
215+ if !remotePreviewContains(selectedPreview, previewMarker) {
216+ return errors.New("explicit remote tab preview omitted marker")
217+ }
218+
219+ if err := runLocalCommand(ctx, "vev", "host", "add", target); err != nil {
220+ return fmt.Errorf("register remote picker host: %w", err)
221+ }
222+ localDaemon, err := startLocalDaemon(ctx)
223+ if err != nil {
224+ return fmt.Errorf("start local picker daemon: %w", err)
225+ }
226+ defer stopLocalDaemon(localDaemon)
227+
228+ local, err := openLocalTransport(ctx)
229+ if err != nil {
230+ return fmt.Errorf("open local picker transport: %w", err)
231+ }
232+ defer func() { _ = local.Close() }()
233+ if err := attachAndCheck(ctx, local, ports.Hello{
234+ Intent: ports.IntentNew, Name: localSession, Env: localEnvironment(), Cwd: "/tmp/local-cwd",
235+ TermEnv: "xterm-256color", TrueColor: true,
236+ }); err != nil {
237+ return fmt.Errorf("attach local picker session: %w", err)
238+ }
239+ if err := sendRawInput(local, "\x1b "); err != nil {
240+ return fmt.Errorf("open command palette: %w", err)
241+ }
242+ if err := awaitOutputContains(ctx, local, "Commands"); err != nil {
243+ return fmt.Errorf("await command palette: %w", err)
244+ }
245+ if err := sendRawInput(local, "SSP\r"); err != nil {
246+ return fmt.Errorf("open session picker: %w", err)
247+ }
248+ if err := awaitOutputContains(ctx, local, remoteSession+"@remote"); err != nil {
249+ return fmt.Errorf("await remote picker catalog row: %w", err)
250+ }
251+ // The local selected tab is first and the remote session header is not
252+ // selectable, so one down reaches the first remote tab. Derive the rest
253+ // from the catalog's stable active tab identity instead of assuming an
254+ // ordinal target.
255+ if err := sendRawInput(local, strings.Repeat("j", activeTabIndex+1)); err != nil {
256+ return fmt.Errorf("select catalog active remote tab: %w", err)
257+ }
258+ if err := awaitRemotePickerPreview(ctx, local, previewMarker); err != nil {
259+ return fmt.Errorf("remote picker preview: %w", err)
260+ }
261+ if err := sendRawInput(local, "\r"); err != nil {
262+ return fmt.Errorf("commit remote picker target: %w", err)
263+ }
264+ handoffFrame, err := receiveFrame(ctx, local, ports.MsgAttachTarget)
265+ if err != nil {
266+ return fmt.Errorf("await remote picker handoff: %w", err)
267+ }
268+ handoff, err := ports.UnmarshalAttachTarget(handoffFrame.Payload)
269+ if err != nil {
270+ return fmt.Errorf("decode remote picker handoff: %w", err)
271+ }
272+ if handoff.RemoteTarget == nil {
273+ return errors.New("remote picker handoff omitted the remote target")
274+ }
275+ if handoff.RemoteTarget.LiveTabID != selectedTarget.LiveTabID {
276+ return fmt.Errorf("remote picker handoff tab = %q, want %q", handoff.RemoteTarget.LiveTabID, selectedTarget.LiveTabID)
277+ }
278+ if handoff.RemoteTarget.DisplayOrigin != "remote" {
279+ return fmt.Errorf("remote picker handoff origin = %q, want remote", handoff.RemoteTarget.DisplayOrigin)
280+ }
281+ if handoff.EnvironmentPolicy != ports.EnvironmentPolicyDaemonOwned {
282+ return fmt.Errorf("remote picker handoff policy = %d, want daemon-owned", handoff.EnvironmentPolicy)
283+ }
284+
285+ selected, err := openTransport(ctx, ports.RemoteTransportStdio, target)
286+ if err != nil {
287+ return fmt.Errorf("open selected remote transport: %w", err)
288+ }
289+ defer func() { _ = selected.Close() }()
290+ if err := attachAndCheck(ctx, selected, ports.Hello{
291+ Intent: ports.IntentAttach, Name: handoff.Session, Env: localEnvironment(), Cwd: "/tmp/local-cwd",
292+ TermEnv: "xterm-256color", TrueColor: true,
293+ RemoteTarget: handoff.RemoteTarget, EnvironmentPolicy: handoff.EnvironmentPolicy,
294+ }); err != nil {
295+ return fmt.Errorf("attach selected remote target: %w", err)
296+ }
297+ if err := awaitOutputContains(ctx, selected, remoteSession+" at remote"); err != nil {
298+ return fmt.Errorf("remote provenance status bar: %w", err)
299+ }
300+ return nil
301+}
302+
134 func runRestartResumePhase(ctx context.Context, target string, catalog ports.RemoteCatalogClient, preview ports.RemotePreviewClient) (domain.RemoteSessionTarget, error) {303 func runRestartResumePhase(ctx context.Context, target string, catalog ports.RemoteCatalogClient, preview ports.RemotePreviewClient) (domain.RemoteSessionTarget, error) {
135 if err := runRemoteCommand(ctx, target, "vev", "kill", "--daemon"); err != nil {304 if err := runRemoteCommand(ctx, target, "vev", "kill", "--daemon"); err != nil {
136 return domain.RemoteSessionTarget{}, fmt.Errorf("stop remote daemon: %w", err)305 return domain.RemoteSessionTarget{}, fmt.Errorf("stop remote daemon: %w", err)
@@ -318,6 +487,107 @@ func openTransport(ctx context.Context, mode ports.RemoteTransportMode, target s
318 return newHarnessTransport(transport), nil487 return newHarnessTransport(transport), nil
319 }488 }
320 489
490+const commandStderrLimit = 4 << 10
491+
492+type boundedStderr struct {
493+ bytes.Buffer
494+ truncated bool
495+}
496+
497+func (b *boundedStderr) Write(p []byte) (int, error) {
498+ written := len(p)
499+ remaining := commandStderrLimit - b.Len()
500+ if remaining <= 0 {
501+ b.truncated = b.truncated || written != 0
502+ return written, nil
503+ }
504+ if len(p) > remaining {
505+ p = p[:remaining]
506+ b.truncated = true
507+ }
508+ _, _ = b.Buffer.Write(p)
509+ return written, nil
510+}
511+
512+func (b *boundedStderr) String() string {
513+ stderr := b.Buffer.String()
514+ if b.truncated {
515+ stderr += "\n[stderr truncated]"
516+ }
517+ return stderr
518+}
519+
520+func commandErrorWithStderr(err error, stderr string) error {
521+ if stderr = strings.TrimSpace(stderr); stderr == "" {
522+ return err
523+ }
524+ return fmt.Errorf("%w: stderr: %s", err, stderr)
525+}
526+
527+func startLocalDaemon(ctx context.Context) (*exec.Cmd, error) {
528+ cmd := exec.CommandContext(ctx, "vev", "--daemon")
529+ stderr := &boundedStderr{}
530+ cmd.Stdout = io.Discard
531+ cmd.Stderr = stderr
532+ if err := cmd.Start(); err != nil {
533+ return nil, err
534+ }
535+
536+ deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
537+ defer cancel()
538+ for {
539+ transport, err := ipc.DialContext(deadline, ipc.SocketDir())
540+ if err == nil {
541+ _ = transport.Close()
542+ return cmd, nil
543+ }
544+ select {
545+ case <-deadline.Done():
546+ stopLocalProcess(cmd)
547+ return nil, commandErrorWithStderr(deadline.Err(), stderr.String())
548+ case <-time.After(25 * time.Millisecond):
549+ }
550+ }
551+}
552+
553+func stopLocalDaemon(cmd *exec.Cmd) {
554+ cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second)
555+ defer cancel()
556+ _ = runLocalCommand(cleanup, "vev", "kill", "--daemon")
557+ stopLocalProcess(cmd)
558+}
559+
560+func stopLocalProcess(cmd *exec.Cmd) {
561+ if cmd == nil || cmd.Process == nil {
562+ return
563+ }
564+ _ = cmd.Process.Kill()
565+ _ = cmd.Wait()
566+}
567+
568+func runLocalCommand(ctx context.Context, name string, args ...string) error {
569+ return runCapturedCommand(ctx, name, args...)
570+}
571+
572+func runCapturedCommand(ctx context.Context, name string, args ...string) error {
573+ cmd := exec.CommandContext(ctx, name, args...)
574+ stderr := &boundedStderr{}
575+ cmd.Stdout = io.Discard
576+ cmd.Stderr = stderr
577+ if err := cmd.Run(); err != nil {
578+ return commandErrorWithStderr(err, stderr.String())
579+ }
580+ return nil
581+}
582+
583+func openLocalTransport(ctx context.Context) (*harnessTransport, error) {
584+ transport, err := ipc.DialContext(ctx, ipc.SocketDir())
585+ if err != nil {
586+ return nil, err
587+ }
588+ return newHarnessTransport(transport), nil
589+}
590+
321 func attachAndCheck(ctx context.Context, tr *harnessTransport, hello ports.Hello) error {591 func attachAndCheck(ctx context.Context, tr *harnessTransport, hello ports.Hello) error {
322 hello.Version = ports.ProtocolVersion592 hello.Version = ports.ProtocolVersion
323 hello.Size = domain.Size{Cols: 80, Rows: 24}593 hello.Size = domain.Size{Cols: 80, Rows: 24}
@@ -348,6 +618,136 @@ func attachAndCheck(ctx context.Context, tr *harnessTransport, hello ports.Hello
348 return nil618 return nil
349 }619 }
350 620
621+func sendRawInput(tr *harnessTransport, data string) error {
622+ return tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte(data)})})
623+}
624+
625+func acknowledgeOutput(tr *harnessTransport, output ports.Output) error {
626+ if output.Epoch == 0 || output.New == 0 {
627+ return nil
628+ }
629+ payload, err := ports.MarshalAck(ports.Ack{Epoch: output.Epoch, State: output.New})
630+ if err != nil {
631+ return err
632+ }
633+ return tr.Send(ports.Frame{Type: ports.MsgAck, Payload: payload})
634+}
635+
636+func awaitOutputContains(ctx context.Context, tr *harnessTransport, want string) error {
637+ deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
638+ defer cancel()
639+ var received strings.Builder
640+ screen := vt.NewScreen(80, 24)
641+ for {
642+ frame, err := receiveWithTimeout(deadline, tr)
643+ if err != nil {
644+ return err
645+ }
646+ if frame.Type != ports.MsgOutput {
647+ continue
648+ }
649+ output, err := ports.UnmarshalOutput(frame.Payload)
650+ if err != nil {
651+ return err
652+ }
653+ if err := acknowledgeOutput(tr, output); err != nil {
654+ return err
655+ }
656+ received.Write(output.Data)
657+ screen.Write(output.Data)
658+ if strings.Contains(received.String(), want) || strings.Contains(harnessScreenText(screen), want) {
659+ return nil
660+ }
661+ if received.Len() > 64<<10 {
662+ text := received.String()
663+ received.Reset()
664+ received.WriteString(text[len(text)-16<<10:])
665+ }
666+ }
667+}
668+
669+func awaitRemotePickerPreview(ctx context.Context, tr *harnessTransport, marker string) error {
670+ deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
671+ defer cancel()
672+ screen := vt.NewScreen(80, 24)
673+ outputFrames := 0
674+ sawLoading := false
675+ for {
676+ frame, err := receiveWithTimeout(deadline, tr)
677+ if err != nil {
678+ return fmt.Errorf("no rendered remote preview after %d output frames (loading=%t): %w", outputFrames, sawLoading, err)
679+ }
680+ if frame.Type != ports.MsgOutput {
681+ continue
682+ }
683+ output, err := ports.UnmarshalOutput(frame.Payload)
684+ if err != nil {
685+ return err
686+ }
687+ if err := acknowledgeOutput(tr, output); err != nil {
688+ return err
689+ }
690+ outputFrames++
691+ screen.Write(output.Data)
692+ text := harnessScreenText(screen)
693+ sawLoading = sawLoading || strings.Contains(text, "loading remote preview") || strings.Contains(string(output.Data), "loading remote preview")
694+ if strings.Contains(text, marker) || strings.Contains(string(output.Data), marker) {
695+ return nil
696+ }
697+ if strings.Contains(text, "remote preview unavailable") {
698+ return errors.New("remote preview unavailable")
699+ }
700+ }
701+}
702+
703+func harnessScreenText(screen *vt.Screen) string {
704+ var text strings.Builder
705+ for y := range screen.Frame.Height {
706+ for x := range screen.Frame.Width {
707+ r := screen.Frame.At(x, y).Rune
708+ if r == 0 {
709+ r = ' '
710+ }
711+ text.WriteRune(r)
712+ }
713+ text.WriteByte('\n')
714+ }
715+ return text.String()
716+}
717+
718+func remotePreviewContains(preview ports.RemotePreview, want string) bool {
719+ var text strings.Builder
720+ for _, cell := range preview.Cells {
721+ if !cell.Continuation {
722+ text.WriteRune(cell.Rune)
723+ }
724+ }
725+ return strings.Contains(text.String(), want)
726+}
727+
728+func receiveFrame(ctx context.Context, tr *harnessTransport, typ ports.MsgType) (ports.Frame, error) {
729+ deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
730+ defer cancel()
731+ for {
732+ frame, err := receiveWithTimeout(deadline, tr)
733+ if err != nil {
734+ return ports.Frame{}, err
735+ }
736+ if frame.Type == typ {
737+ return frame, nil
738+ }
739+ if frame.Type == ports.MsgOutput {
740+ output, decodeErr := ports.UnmarshalOutput(frame.Payload)
741+ if decodeErr != nil {
742+ return ports.Frame{}, decodeErr
743+ }
744+ if ackErr := acknowledgeOutput(tr, output); ackErr != nil {
745+ return ports.Frame{}, ackErr
746+ }
747+ }
748+ }
749+}
750+
351 func sendInputAndAwait(ctx context.Context, tr *harnessTransport, command, want string) error {751 func sendInputAndAwait(ctx context.Context, tr *harnessTransport, command, want string) error {
352 if err := tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte(command + "\n")})}); err != nil {752 if err := tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte(command + "\n")})}); err != nil {
353 return err753 return err
@@ -371,14 +771,8 @@ func sendInputAndAwait(ctx context.Context, tr *harnessTransport, command, want
371 }771 }
372 outputFrames++772 outputFrames++
373 outputBytes += len(output.Data)773 outputBytes += len(output.Data)
374- if output.Epoch != 0 && output.New != 0 {774+ if err := acknowledgeOutput(tr, output); err != nil {
375- ackPayload, err := ports.MarshalAck(ports.Ack{Epoch: output.Epoch, State: output.New})775+ return fmt.Errorf("ack output: %w", err)
376- if err != nil {
377- return fmt.Errorf("encode output ack: %w", err)
378- }
379- if err := tr.Send(ports.Frame{Type: ports.MsgAck, Payload: ackPayload}); err != nil {
380- return fmt.Errorf("ack output: %w", err)
381- }
382 }776 }
383 received.Write(output.Data)777 received.Write(output.Data)
384 if strings.Contains(received.String(), want) {778 if strings.Contains(received.String(), want) {
@@ -414,6 +808,16 @@ func sendCommand(ctx context.Context, tr *harnessTransport, slug string) error {
414 if err != nil {808 if err != nil {
415 return err809 return err
416 }810 }
811+ if frame.Type == ports.MsgOutput {
812+ output, decodeErr := ports.UnmarshalOutput(frame.Payload)
813+ if decodeErr != nil {
814+ return decodeErr
815+ }
816+ if ackErr := acknowledgeOutput(tr, output); ackErr != nil {
817+ return ackErr
818+ }
819+ continue
820+ }
417 if frame.Type != ports.MsgCommandResult {821 if frame.Type != ports.MsgCommandResult {
418 continue822 continue
419 }823 }
@@ -583,10 +987,7 @@ func touchRemoteFlag(ctx context.Context, target, path string) error {
583 987
584 func runRemoteCommand(ctx context.Context, target string, command ...string) error {988 func runRemoteCommand(ctx context.Context, target string, command ...string) error {
585 spec := sshstdio.BuildCommandForRemoteCommand(target, command...)989 spec := sshstdio.BuildCommandForRemoteCommand(target, command...)
586- cmd := exec.CommandContext(ctx, spec.Path, spec.Args...)990+ return runCapturedCommand(ctx, spec.Path, spec.Args...)
587- cmd.Stdout = io.Discard
588- cmd.Stderr = io.Discard
589- return cmd.Run()
590 }991 }
591 992
592 func receiveWithTimeout(ctx context.Context, tr *harnessTransport) (ports.Frame, error) {993 func receiveWithTimeout(ctx context.Context, tr *harnessTransport) (ports.Frame, error) {
A scripts/remote-picker-harness/main_test.go +44 -0
@@ -0,0 +1,44 @@
1+package main
2+
3+import (
4+ "errors"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestBoundedStderrRetainsAtMostDiagnosticLimit(t *testing.T) {
10+ stderr := &boundedStderr{}
11+ input := strings.Repeat("x", commandStderrLimit+1)
12+
13+ written, err := stderr.Write([]byte(input))
14+
15+ if err != nil {
16+ t.Fatal(err)
17+ }
18+ if written != len(input) {
19+ t.Fatalf("written = %d, want %d", written, len(input))
20+ }
21+ if stderr.Len() != commandStderrLimit {
22+ t.Fatalf("stderr length = %d, want %d", stderr.Len(), commandStderrLimit)
23+ }
24+ if !strings.Contains(stderr.String(), "[stderr truncated]") {
25+ t.Fatalf("stderr = %q, want truncation marker", stderr.String())
26+ }
27+}
28+
29+func TestCommandErrorWithStderr(t *testing.T) {
30+ cause := errors.New("command failed")
31+ if got := commandErrorWithStderr(cause, " daemon could not start\n"); !errors.Is(got, cause) || !strings.Contains(got.Error(), "stderr: daemon could not start") {
32+ t.Fatalf("command error = %v", got)
33+ }
34+ if got := commandErrorWithStderr(cause, "\n"); got != cause {
35+ t.Fatalf("empty stderr error = %v, want original error", got)
36+ }
37+}
38+
39+func TestRunCapturedCommandIncludesStderr(t *testing.T) {
40+ err := runCapturedCommand(t.Context(), "sh", "-c", "printf 'command diagnostics\\n' >&2; exit 1")
41+ if err == nil || !strings.Contains(err.Error(), "stderr: command diagnostics") {
42+ t.Fatalf("command error = %v", err)
43+ }
44+}
@@ -0,0 +1,44 @@
1+package main
2+
3+import (
4+ "errors"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestBoundedStderrRetainsAtMostDiagnosticLimit(t *testing.T) {
10+ stderr := &boundedStderr{}
11+ input := strings.Repeat("x", commandStderrLimit+1)
12+
13+ written, err := stderr.Write([]byte(input))
14+
15+ if err != nil {
16+ t.Fatal(err)
17+ }
18+ if written != len(input) {
19+ t.Fatalf("written = %d, want %d", written, len(input))
20+ }
21+ if stderr.Len() != commandStderrLimit {
22+ t.Fatalf("stderr length = %d, want %d", stderr.Len(), commandStderrLimit)
23+ }
24+ if !strings.Contains(stderr.String(), "[stderr truncated]") {
25+ t.Fatalf("stderr = %q, want truncation marker", stderr.String())
26+ }
27+}
28+
29+func TestCommandErrorWithStderr(t *testing.T) {
30+ cause := errors.New("command failed")
31+ if got := commandErrorWithStderr(cause, " daemon could not start\n"); !errors.Is(got, cause) || !strings.Contains(got.Error(), "stderr: daemon could not start") {
32+ t.Fatalf("command error = %v", got)
33+ }
34+ if got := commandErrorWithStderr(cause, "\n"); got != cause {
35+ t.Fatalf("empty stderr error = %v, want original error", got)
36+ }
37+}
38+
39+func TestRunCapturedCommandIncludesStderr(t *testing.T) {
40+ err := runCapturedCommand(t.Context(), "sh", "-c", "printf 'command diagnostics\\n' >&2; exit 1")
41+ if err == nil || !strings.Contains(err.Error(), "stderr: command diagnostics") {
42+ t.Fatalf("command error = %v", err)
43+ }
44+}
M scripts/remote-picker-harness/run.sh +3 -2
@@ -6,8 +6,9 @@ usage() {
66 Usage: scripts/remote-picker-harness/run.sh
77
88 Builds the current vev binary into a disposable two-container environment and
9-runs the SSH-stdio, UDP, catalog, preview, lifecycle-fence, and environment
10-acceptance checks. Docker is invoked normally, so DOCKER_HOST and the active
9+runs local-picker rich-handoff plus SSH-stdio, UDP, catalog, preview,
10+lifecycle-fence, and environment acceptance checks. Docker is invoked normally,
11+so DOCKER_HOST and the active
1112 Docker context select the daemon; no socket path is assumed.
1213
1314 Environment:
@@ -6,8 +6,9 @@ usage() {
6 Usage: scripts/remote-picker-harness/run.sh6 Usage: scripts/remote-picker-harness/run.sh
7 7
8 Builds the current vev binary into a disposable two-container environment and8 Builds the current vev binary into a disposable two-container environment and
9-runs the SSH-stdio, UDP, catalog, preview, lifecycle-fence, and environment9+runs local-picker rich-handoff plus SSH-stdio, UDP, catalog, preview,
10-acceptance checks. Docker is invoked normally, so DOCKER_HOST and the active10+lifecycle-fence, and environment acceptance checks. Docker is invoked normally,
11+so DOCKER_HOST and the active
11 Docker context select the daemon; no socket path is assumed.12 Docker context select the daemon; no socket path is assumed.
12 13
13 Environment:14 Environment:

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 2

Tags 13

↑↓ moveenter openesc close