detached 8ecb88d

fix(remote): harden unified picker handoff

commit
8ecb88dff25e75cfc1b24f21927eb44ae5fd7cd6
author
brice · · 2026-08-06 07:32 UTC
committer
brice · · 2026-08-06 08:01 UTC
parent
b9061f5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +541 -27
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 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 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 +32 -0
@@ -76,6 +76,38 @@ 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+ d := newRemotePickerDaemon(nil)
81+ lifecycle := remoteLifecycleForTest()
82+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
83+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
84+ LifecycleID: lifecycle,
85+ Name: "vive",
86+ State: "running",
87+ Tabs: []ports.RemoteCatalogTab{
88+ {ID: "tab-first", Index: 0, Name: "first"},
89+ {ID: "tab-active", Index: 1, Name: "active"},
90+ },
91+ ActiveTabID: "tab-active",
92+ }},
93+ }})
94+ d.remoteCatalog.mu.Lock()
95+ d.remoteCatalog.status["arch"] = remoteHostFresh
96+ d.remoteCatalog.mu.Unlock()
97+
98+ views, _ := d.pickerViews(nil, nil)
99+ require.Len(t, views, 1)
100+ require.Equal(t, 1, views[0].Active)
101+ require.NotNil(t, views[0].RemoteTarget)
102+ require.Equal(t, domain.TabStableID("tab-active"), views[0].RemoteTarget.LiveTabID)
103+
104+ model := d.newPickerModel(nil, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
105+ target, ok := model.Selected()
106+ require.True(t, ok)
107+ require.NotNil(t, target.RemoteTarget)
108+ require.Equal(t, domain.TabStableID("tab-active"), target.RemoteTarget.LiveTabID)
109+}
110+
79111 func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) {
80112 tests := []struct {
81113 name string
@@ -76,6 +76,38 @@ 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+ d := newRemotePickerDaemon(nil)
81+ lifecycle := remoteLifecycleForTest()
82+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
83+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
84+ LifecycleID: lifecycle,
85+ Name: "vive",
86+ State: "running",
87+ Tabs: []ports.RemoteCatalogTab{
88+ {ID: "tab-first", Index: 0, Name: "first"},
89+ {ID: "tab-active", Index: 1, Name: "active"},
90+ },
91+ ActiveTabID: "tab-active",
92+ }},
93+ }})
94+ d.remoteCatalog.mu.Lock()
95+ d.remoteCatalog.status["arch"] = remoteHostFresh
96+ d.remoteCatalog.mu.Unlock()
97+
98+ views, _ := d.pickerViews(nil, nil)
99+ require.Len(t, views, 1)
100+ require.Equal(t, 1, views[0].Active)
101+ require.NotNil(t, views[0].RemoteTarget)
102+ require.Equal(t, domain.TabStableID("tab-active"), views[0].RemoteTarget.LiveTabID)
103+
104+ model := d.newPickerModel(nil, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
105+ target, ok := model.Selected()
106+ require.True(t, ok)
107+ require.NotNil(t, target.RemoteTarget)
108+ require.Equal(t, domain.TabStableID("tab-active"), target.RemoteTarget.LiveTabID)
109+}
110+
79 func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) {111 func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) {
80 tests := []struct {112 tests := []struct {
81 name string113 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 +87 -0
@@ -35,6 +35,93 @@ 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 TestCaptureRemotePreviewCompactsBlankTailToKeepShortScreenContentVisible(t *testing.T) {
39+ d := newTestDaemon(t, nil, stubClock{})
40+ sess := addControlSession(d, "work", "tab-1", "pane-1")
41+ sess.ephemeral = false
42+ sess.incarnation = remoteLifecycleForTest()
43+ pane := sess.tabs[0].focusedPane()
44+ pane.mu.Lock()
45+ pane.screen = vt.NewScreen(2, 4)
46+ pane.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'v'})
47+ pane.mu.Unlock()
48+
49+ target := domain.RemoteSessionTarget{
50+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: sess.incarnation,
51+ SessionName: "work", LiveTabID: "tab-1",
52+ }
53+ preview, err := d.captureRemotePreview(ports.RemotePreviewRequest{
54+ Version: ports.RemotePreviewSchemaVersion, Target: target, Width: 2, Height: 2,
55+ })
56+ require.NoError(t, err)
57+ require.Equal(t, uint16(1), preview.Height)
58+ require.Equal(t, []rune{'v', ' '}, []rune{preview.Cells[0].Rune, preview.Cells[1].Rune})
59+}
60+
61+func TestCaptureRemotePreviewIgnoresBoundarySplitWideRuneWhenCompacting(t *testing.T) {
62+ d := newTestDaemon(t, nil, stubClock{})
63+ sess := addControlSession(d, "work", "tab-1", "pane-1")
64+ sess.ephemeral = false
65+ sess.incarnation = remoteLifecycleForTest()
66+ pane := sess.tabs[0].focusedPane()
67+ pane.mu.Lock()
68+ pane.screen = vt.NewScreen(3, 3)
69+ pane.screen.Frame.Set(0, 1, renderer.Cell{Rune: 'a'})
70+ pane.screen.Frame.Set(1, 2, renderer.Cell{Rune: '界'})
71+ pane.screen.Frame.Set(2, 2, renderer.Cell{Continuation: true})
72+ pane.mu.Unlock()
73+
74+ target := domain.RemoteSessionTarget{
75+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: sess.incarnation,
76+ SessionName: "work", LiveTabID: "tab-1",
77+ }
78+ preview, err := d.captureRemotePreview(ports.RemotePreviewRequest{
79+ Version: ports.RemotePreviewSchemaVersion, Target: target, Width: 2, Height: 2,
80+ })
81+ require.NoError(t, err)
82+ require.Equal(t, uint16(1), preview.Height)
83+ require.Equal(t, []rune{'a', ' '}, []rune{preview.Cells[0].Rune, preview.Cells[1].Rune})
84+}
85+
86+func TestCaptureRemotePreviewPreservesStyledBlankTail(t *testing.T) {
87+ d := newTestDaemon(t, nil, stubClock{})
88+ sess := addControlSession(d, "work", "tab-1", "pane-1")
89+ sess.ephemeral = false
90+ sess.incarnation = remoteLifecycleForTest()
91+ pane := sess.tabs[0].focusedPane()
92+ style := renderer.DefaultStyle()
93+ style.Background = 4
94+ pane.mu.Lock()
95+ pane.screen = vt.NewScreen(2, 4)
96+ pane.screen.Frame.Set(0, 2, renderer.Cell{Rune: 'a'})
97+ pane.screen.Frame.Set(0, 3, renderer.Cell{Rune: ' ', Style: style})
98+ pane.mu.Unlock()
99+
100+ target := domain.RemoteSessionTarget{
101+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: sess.incarnation,
102+ SessionName: "work", LiveTabID: "tab-1",
103+ }
104+ preview, err := d.captureRemotePreview(ports.RemotePreviewRequest{
105+ Version: ports.RemotePreviewSchemaVersion, Target: target, Width: 2, Height: 2,
106+ })
107+ require.NoError(t, err)
108+ require.Equal(t, uint16(2), preview.Height)
109+ require.Equal(t, rune('a'), preview.Cells[0].Rune)
110+ require.Equal(t, rune(' '), preview.Cells[2].Rune)
111+ require.True(t, preview.Cells[2].Style.Equal(style))
112+}
113+
114+func TestStaticRemotePickerPreviewUsesOnlyItsTextRow(t *testing.T) {
115+ preview := staticRemotePickerPreview(8, 4, "loading")
116+
117+ require.Equal(t, 8, preview.Width)
118+ require.Equal(t, 1, preview.Height)
119+ require.Equal(t, []rune{'l', 'o', 'a', 'd', 'i', 'n', 'g', ' '}, []rune{
120+ preview.Rows[0][0].Rune, preview.Rows[0][1].Rune, preview.Rows[0][2].Rune, preview.Rows[0][3].Rune,
121+ preview.Rows[0][4].Rune, preview.Rows[0][5].Rune, preview.Rows[0][6].Rune, preview.Rows[0][7].Rune,
122+ })
123+}
124+
38125 func TestCaptureRemotePreviewDoesNotSplitWideRuneAtCropBoundary(t *testing.T) {
39126 d := newTestDaemon(t, nil, stubClock{})
40127 sess := addControlSession(d, "work", "tab-1", "pane-1")
@@ -35,6 +35,93 @@ 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 TestCaptureRemotePreviewCompactsBlankTailToKeepShortScreenContentVisible(t *testing.T) {
39+ d := newTestDaemon(t, nil, stubClock{})
40+ sess := addControlSession(d, "work", "tab-1", "pane-1")
41+ sess.ephemeral = false
42+ sess.incarnation = remoteLifecycleForTest()
43+ pane := sess.tabs[0].focusedPane()
44+ pane.mu.Lock()
45+ pane.screen = vt.NewScreen(2, 4)
46+ pane.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'v'})
47+ pane.mu.Unlock()
48+
49+ target := domain.RemoteSessionTarget{
50+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: sess.incarnation,
51+ SessionName: "work", LiveTabID: "tab-1",
52+ }
53+ preview, err := d.captureRemotePreview(ports.RemotePreviewRequest{
54+ Version: ports.RemotePreviewSchemaVersion, Target: target, Width: 2, Height: 2,
55+ })
56+ require.NoError(t, err)
57+ require.Equal(t, uint16(1), preview.Height)
58+ require.Equal(t, []rune{'v', ' '}, []rune{preview.Cells[0].Rune, preview.Cells[1].Rune})
59+}
60+
61+func TestCaptureRemotePreviewIgnoresBoundarySplitWideRuneWhenCompacting(t *testing.T) {
62+ d := newTestDaemon(t, nil, stubClock{})
63+ sess := addControlSession(d, "work", "tab-1", "pane-1")
64+ sess.ephemeral = false
65+ sess.incarnation = remoteLifecycleForTest()
66+ pane := sess.tabs[0].focusedPane()
67+ pane.mu.Lock()
68+ pane.screen = vt.NewScreen(3, 3)
69+ pane.screen.Frame.Set(0, 1, renderer.Cell{Rune: 'a'})
70+ pane.screen.Frame.Set(1, 2, renderer.Cell{Rune: '界'})
71+ pane.screen.Frame.Set(2, 2, renderer.Cell{Continuation: true})
72+ pane.mu.Unlock()
73+
74+ target := domain.RemoteSessionTarget{
75+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: sess.incarnation,
76+ SessionName: "work", LiveTabID: "tab-1",
77+ }
78+ preview, err := d.captureRemotePreview(ports.RemotePreviewRequest{
79+ Version: ports.RemotePreviewSchemaVersion, Target: target, Width: 2, Height: 2,
80+ })
81+ require.NoError(t, err)
82+ require.Equal(t, uint16(1), preview.Height)
83+ require.Equal(t, []rune{'a', ' '}, []rune{preview.Cells[0].Rune, preview.Cells[1].Rune})
84+}
85+
86+func TestCaptureRemotePreviewPreservesStyledBlankTail(t *testing.T) {
87+ d := newTestDaemon(t, nil, stubClock{})
88+ sess := addControlSession(d, "work", "tab-1", "pane-1")
89+ sess.ephemeral = false
90+ sess.incarnation = remoteLifecycleForTest()
91+ pane := sess.tabs[0].focusedPane()
92+ style := renderer.DefaultStyle()
93+ style.Background = 4
94+ pane.mu.Lock()
95+ pane.screen = vt.NewScreen(2, 4)
96+ pane.screen.Frame.Set(0, 2, renderer.Cell{Rune: 'a'})
97+ pane.screen.Frame.Set(0, 3, renderer.Cell{Rune: ' ', Style: style})
98+ pane.mu.Unlock()
99+
100+ target := domain.RemoteSessionTarget{
101+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: sess.incarnation,
102+ SessionName: "work", LiveTabID: "tab-1",
103+ }
104+ preview, err := d.captureRemotePreview(ports.RemotePreviewRequest{
105+ Version: ports.RemotePreviewSchemaVersion, Target: target, Width: 2, Height: 2,
106+ })
107+ require.NoError(t, err)
108+ require.Equal(t, uint16(2), preview.Height)
109+ require.Equal(t, rune('a'), preview.Cells[0].Rune)
110+ require.Equal(t, rune(' '), preview.Cells[2].Rune)
111+ require.True(t, preview.Cells[2].Style.Equal(style))
112+}
113+
114+func TestStaticRemotePickerPreviewUsesOnlyItsTextRow(t *testing.T) {
115+ preview := staticRemotePickerPreview(8, 4, "loading")
116+
117+ require.Equal(t, 8, preview.Width)
118+ require.Equal(t, 1, preview.Height)
119+ require.Equal(t, []rune{'l', 'o', 'a', 'd', 'i', 'n', 'g', ' '}, []rune{
120+ preview.Rows[0][0].Rune, preview.Rows[0][1].Rune, preview.Rows[0][2].Rune, preview.Rows[0][3].Rune,
121+ preview.Rows[0][4].Rune, preview.Rows[0][5].Rune, preview.Rows[0][6].Rune, preview.Rows[0][7].Rune,
122+ })
123+}
124+
38 func TestCaptureRemotePreviewDoesNotSplitWideRuneAtCropBoundary(t *testing.T) {125 func TestCaptureRemotePreviewDoesNotSplitWideRuneAtCropBoundary(t *testing.T) {
39 d := newTestDaemon(t, nil, stubClock{})126 d := newTestDaemon(t, nil, stubClock{})
40 sess := addControlSession(d, "work", "tab-1", "pane-1")127 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 +12 -0
@@ -109,6 +109,18 @@ func TestStatusCompositionGolden(t *testing.T) {
109109 }
110110 }
111111
112+func TestStatusSegmentsIncludesAttachmentRemoteOrigin(t *testing.T) {
113+ p, releasePTY := newBlockingPTY(t)
114+ _, sess, ac, _ := newManualSessionWithPTYs(t, p)
115+ defer releasePTY()
116+
117+ sess.name = "vive"
118+ ac.remoteOrigin = "arch"
119+
120+ require.Equal(t, "vive at arch", sess.statusSegmentsFor(ac, true).session)
121+ require.Equal(t, "vive", sess.statusSegments(true).session, "remote provenance belongs only to the selected attachment")
122+}
123+
112124 func TestStatusSegmentsIncludesFocusedPaneTitle(t *testing.T) {
113125 p1, releasePTY1 := newBlockingPTY(t)
114126 p2, releasePTY2 := newBlockingPTY(t)
@@ -109,6 +109,18 @@ func TestStatusCompositionGolden(t *testing.T) {
109 }109 }
110 }110 }
111 111
112+func TestStatusSegmentsIncludesAttachmentRemoteOrigin(t *testing.T) {
113+ p, releasePTY := newBlockingPTY(t)
114+ _, sess, ac, _ := newManualSessionWithPTYs(t, p)
115+ defer releasePTY()
116+
117+ sess.name = "vive"
118+ ac.remoteOrigin = "arch"
119+
120+ require.Equal(t, "vive at arch", sess.statusSegmentsFor(ac, true).session)
121+ require.Equal(t, "vive", sess.statusSegments(true).session, "remote provenance belongs only to the selected attachment")
122+}
123+
112 func TestStatusSegmentsIncludesFocusedPaneTitle(t *testing.T) {124 func TestStatusSegmentsIncludesFocusedPaneTitle(t *testing.T) {
113 p1, releasePTY1 := newBlockingPTY(t)125 p1, releasePTY1 := newBlockingPTY(t)
114 p2, releasePTY2 := newBlockingPTY(t)126 p2, releasePTY2 := newBlockingPTY(t)
M scripts/remote-picker-harness/main.go +353 -8
@@ -14,10 +14,12 @@ import (
1414 "time"
1515
1616 "github.com/bnema/vev/internal/adapters/dgram"
17+ "github.com/bnema/vev/internal/adapters/ipc"
1718 remoteadapter "github.com/bnema/vev/internal/adapters/remote"
1819 "github.com/bnema/vev/internal/adapters/sshstdio"
1920 "github.com/bnema/vev/internal/domain"
2021 "github.com/bnema/vev/internal/ports"
22+ "github.com/bnema/vev/pkg/vt"
2123 )
2224
2325 const (
@@ -45,6 +47,9 @@ func run() error {
4547
4648 catalog := remoteadapter.NewCatalogClient()
4749 preview := remoteadapter.NewPreviewClient()
50+ if err := runLocalPickerUnitedPhase(ctx, target, catalog, preview); err != nil {
51+ return err
52+ }
4853 if err := runLiveStdioPhase(ctx, target, catalog, preview); err != nil {
4954 return err
5055 }
@@ -131,6 +136,157 @@ func runLiveStdioPhase(ctx context.Context, target string, catalog ports.RemoteC
131136 return nil
132137 }
133138
139+// runLocalPickerUnitedPhase drives the actual local picker against the SSH
140+// daemon container. It validates the seam that individual catalog, preview,
141+// and attach tests cannot: catalog active-tab selection, live remote preview,
142+// emitted rich handoff, and the remote attachment's visible provenance.
143+func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports.RemoteCatalogClient, preview ports.RemotePreviewClient) error {
144+ const (
145+ localSession = "local-picker"
146+ remoteSession = "picker"
147+ previewMarker = "VEV_REMOTE_PICKER_ACTIVE"
148+ )
149+
150+ remote, err := openTransport(ctx, ports.RemoteTransportStdio, target)
151+ if err != nil {
152+ return fmt.Errorf("open remote picker source: %w", err)
153+ }
154+ defer func() { _ = remote.Close() }()
155+ if err := attachAndCheck(ctx, remote, ports.Hello{
156+ Intent: ports.IntentNew, Name: remoteSession, Env: localEnvironment(), Cwd: "/tmp/remote-cwd",
157+ TermEnv: "xterm-256color", TrueColor: true,
158+ }); err != nil {
159+ return fmt.Errorf("attach remote picker source: %w", err)
160+ }
161+ if err := sendCommand(ctx, remote, "new-tab"); err != nil {
162+ return fmt.Errorf("create remote picker tab: %w", err)
163+ }
164+ if err := sendCommand(ctx, remote, "next-tab"); err != nil {
165+ return fmt.Errorf("focus remote picker tab: %w", err)
166+ }
167+ if err := sendInputAndAwait(ctx, remote, "printf '"+previewMarker+"\\n'", previewMarker); err != nil {
168+ return fmt.Errorf("seed remote picker preview: %w", err)
169+ }
170+ remoteCatalog, err := waitForSessionWithTabCount(ctx, catalog, target, remoteSession, "running", 2)
171+ if err != nil {
172+ return fmt.Errorf("remote picker catalog: %w", err)
173+ }
174+ if remoteCatalog.ActiveTabID == "" {
175+ return errors.New("remote picker catalog omitted active tab")
176+ }
177+ tabs := ports.CatalogTabs(remoteCatalog)
178+ if len(tabs) != 2 {
179+ return errors.New("remote picker catalog did not retain both tabs")
180+ }
181+ selectedTarget, err := targetForSession(target, remoteCatalog, false)
182+ if err != nil {
183+ return fmt.Errorf("active remote picker target: %w", err)
184+ }
185+ if selectedTarget.LiveTabID != domain.TabStableID(remoteCatalog.ActiveTabID) {
186+ return errors.New("remote picker catalog active tab did not resolve to the selected target")
187+ }
188+ activeTabIndex := -1
189+ for i, tab := range tabs {
190+ if domain.TabStableID(tab.ID) == selectedTarget.LiveTabID {
191+ activeTabIndex = i
192+ break
193+ }
194+ }
195+ if activeTabIndex < 0 {
196+ return errors.New("remote picker catalog active tab was absent from tab list")
197+ }
198+ selectedPreview, err := preview.Preview(ctx, selectedTarget, 55, 22)
199+ if err != nil {
200+ return fmt.Errorf("explicit remote tab preview: %w", err)
201+ }
202+ if !remotePreviewContains(selectedPreview, previewMarker) {
203+ return errors.New("explicit remote tab preview omitted marker")
204+ }
205+
206+ if err := runLocalCommand(ctx, "vev", "host", "add", target); err != nil {
207+ return fmt.Errorf("register remote picker host: %w", err)
208+ }
209+ localDaemon, err := startLocalDaemon(ctx)
210+ if err != nil {
211+ return fmt.Errorf("start local picker daemon: %w", err)
212+ }
213+ defer stopLocalDaemon(localDaemon)
214+
215+ local, err := openLocalTransport(ctx)
216+ if err != nil {
217+ return fmt.Errorf("open local picker transport: %w", err)
218+ }
219+ defer func() { _ = local.Close() }()
220+ if err := attachAndCheck(ctx, local, ports.Hello{
221+ Intent: ports.IntentNew, Name: localSession, Env: localEnvironment(), Cwd: "/tmp/local-cwd",
222+ TermEnv: "xterm-256color", TrueColor: true,
223+ }); err != nil {
224+ return fmt.Errorf("attach local picker session: %w", err)
225+ }
226+ if err := sendRawInput(local, "\x1b "); err != nil {
227+ return fmt.Errorf("open command palette: %w", err)
228+ }
229+ if err := awaitOutputContains(ctx, local, "Commands"); err != nil {
230+ return fmt.Errorf("await command palette: %w", err)
231+ }
232+ if err := sendRawInput(local, "SSP\r"); err != nil {
233+ return fmt.Errorf("open session picker: %w", err)
234+ }
235+ if err := awaitOutputContains(ctx, local, remoteSession+"@remote"); err != nil {
236+ return fmt.Errorf("await remote picker catalog row: %w", err)
237+ }
238+ // The local selected tab is first and the remote session header is not
239+ // selectable, so one down reaches the first remote tab. Derive the rest
240+ // from the catalog's stable active tab identity instead of assuming an
241+ // ordinal target.
242+ if err := sendRawInput(local, strings.Repeat("j", activeTabIndex+1)); err != nil {
243+ return fmt.Errorf("select catalog active remote tab: %w", err)
244+ }
245+ if err := awaitRemotePickerPreview(ctx, local, previewMarker); err != nil {
246+ return fmt.Errorf("remote picker preview: %w", err)
247+ }
248+ if err := sendRawInput(local, "\r"); err != nil {
249+ return fmt.Errorf("commit remote picker target: %w", err)
250+ }
251+ handoffFrame, err := receiveFrame(ctx, local, ports.MsgAttachTarget)
252+ if err != nil {
253+ return fmt.Errorf("await remote picker handoff: %w", err)
254+ }
255+ handoff, err := ports.UnmarshalAttachTarget(handoffFrame.Payload)
256+ if err != nil {
257+ return fmt.Errorf("decode remote picker handoff: %w", err)
258+ }
259+ if handoff.RemoteTarget == nil {
260+ return errors.New("remote picker handoff omitted the remote target")
261+ }
262+ if handoff.RemoteTarget.LiveTabID != selectedTarget.LiveTabID {
263+ return fmt.Errorf("remote picker handoff tab = %q, want %q", handoff.RemoteTarget.LiveTabID, selectedTarget.LiveTabID)
264+ }
265+ if handoff.RemoteTarget.DisplayOrigin != "remote" {
266+ return fmt.Errorf("remote picker handoff origin = %q, want remote", handoff.RemoteTarget.DisplayOrigin)
267+ }
268+ if handoff.EnvironmentPolicy != ports.EnvironmentPolicyDaemonOwned {
269+ return fmt.Errorf("remote picker handoff policy = %d, want daemon-owned", handoff.EnvironmentPolicy)
270+ }
271+
272+ selected, err := openTransport(ctx, ports.RemoteTransportStdio, target)
273+ if err != nil {
274+ return fmt.Errorf("open selected remote transport: %w", err)
275+ }
276+ defer func() { _ = selected.Close() }()
277+ if err := attachAndCheck(ctx, selected, ports.Hello{
278+ Intent: ports.IntentAttach, Name: handoff.Session, Env: localEnvironment(), Cwd: "/tmp/local-cwd",
279+ TermEnv: "xterm-256color", TrueColor: true,
280+ RemoteTarget: handoff.RemoteTarget, EnvironmentPolicy: handoff.EnvironmentPolicy,
281+ }); err != nil {
282+ return fmt.Errorf("attach selected remote target: %w", err)
283+ }
284+ if err := awaitOutputContains(ctx, selected, remoteSession+" at remote"); err != nil {
285+ return fmt.Errorf("remote provenance status bar: %w", err)
286+ }
287+ return nil
288+}
289+
134290 func runRestartResumePhase(ctx context.Context, target string, catalog ports.RemoteCatalogClient, preview ports.RemotePreviewClient) (domain.RemoteSessionTarget, error) {
135291 if err := runRemoteCommand(ctx, target, "vev", "kill", "--daemon"); err != nil {
136292 return domain.RemoteSessionTarget{}, fmt.Errorf("stop remote daemon: %w", err)
@@ -318,6 +474,61 @@ func openTransport(ctx context.Context, mode ports.RemoteTransportMode, target s
318474 return newHarnessTransport(transport), nil
319475 }
320476
477+func startLocalDaemon(ctx context.Context) (*exec.Cmd, error) {
478+ cmd := exec.CommandContext(ctx, "vev", "--daemon")
479+ cmd.Stdout = io.Discard
480+ cmd.Stderr = io.Discard
481+ if err := cmd.Start(); err != nil {
482+ return nil, err
483+ }
484+
485+ deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
486+ defer cancel()
487+ for {
488+ transport, err := ipc.DialContext(deadline, ipc.SocketDir())
489+ if err == nil {
490+ _ = transport.Close()
491+ return cmd, nil
492+ }
493+ select {
494+ case <-deadline.Done():
495+ stopLocalProcess(cmd)
496+ return nil, deadline.Err()
497+ case <-time.After(25 * time.Millisecond):
498+ }
499+ }
500+}
501+
502+func stopLocalDaemon(cmd *exec.Cmd) {
503+ cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second)
504+ defer cancel()
505+ _ = runLocalCommand(cleanup, "vev", "kill", "--daemon")
506+ stopLocalProcess(cmd)
507+}
508+
509+func stopLocalProcess(cmd *exec.Cmd) {
510+ if cmd == nil || cmd.Process == nil {
511+ return
512+ }
513+ _ = cmd.Process.Kill()
514+ _ = cmd.Wait()
515+}
516+
517+func runLocalCommand(ctx context.Context, name string, args ...string) error {
518+ cmd := exec.CommandContext(ctx, name, args...)
519+ cmd.Stdout = io.Discard
520+ cmd.Stderr = io.Discard
521+ return cmd.Run()
522+}
523+
524+func openLocalTransport(ctx context.Context) (*harnessTransport, error) {
525+ transport, err := ipc.DialContext(ctx, ipc.SocketDir())
526+ if err != nil {
527+ return nil, err
528+ }
529+ return newHarnessTransport(transport), nil
530+}
531+
321532 func attachAndCheck(ctx context.Context, tr *harnessTransport, hello ports.Hello) error {
322533 hello.Version = ports.ProtocolVersion
323534 hello.Size = domain.Size{Cols: 80, Rows: 24}
@@ -348,6 +559,136 @@ func attachAndCheck(ctx context.Context, tr *harnessTransport, hello ports.Hello
348559 return nil
349560 }
350561
562+func sendRawInput(tr *harnessTransport, data string) error {
563+ return tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte(data)})})
564+}
565+
566+func acknowledgeOutput(tr *harnessTransport, output ports.Output) error {
567+ if output.Epoch == 0 || output.New == 0 {
568+ return nil
569+ }
570+ payload, err := ports.MarshalAck(ports.Ack{Epoch: output.Epoch, State: output.New})
571+ if err != nil {
572+ return err
573+ }
574+ return tr.Send(ports.Frame{Type: ports.MsgAck, Payload: payload})
575+}
576+
577+func awaitOutputContains(ctx context.Context, tr *harnessTransport, want string) error {
578+ deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
579+ defer cancel()
580+ var received strings.Builder
581+ screen := vt.NewScreen(80, 24)
582+ for {
583+ frame, err := receiveWithTimeout(deadline, tr)
584+ if err != nil {
585+ return err
586+ }
587+ if frame.Type != ports.MsgOutput {
588+ continue
589+ }
590+ output, err := ports.UnmarshalOutput(frame.Payload)
591+ if err != nil {
592+ return err
593+ }
594+ if err := acknowledgeOutput(tr, output); err != nil {
595+ return err
596+ }
597+ received.Write(output.Data)
598+ screen.Write(output.Data)
599+ if strings.Contains(received.String(), want) || strings.Contains(harnessScreenText(screen), want) {
600+ return nil
601+ }
602+ if received.Len() > 64<<10 {
603+ text := received.String()
604+ received.Reset()
605+ received.WriteString(text[len(text)-16<<10:])
606+ }
607+ }
608+}
609+
610+func awaitRemotePickerPreview(ctx context.Context, tr *harnessTransport, marker string) error {
611+ deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
612+ defer cancel()
613+ screen := vt.NewScreen(80, 24)
614+ outputFrames := 0
615+ sawLoading := false
616+ for {
617+ frame, err := receiveWithTimeout(deadline, tr)
618+ if err != nil {
619+ return fmt.Errorf("no rendered remote preview after %d output frames (loading=%t): %w", outputFrames, sawLoading, err)
620+ }
621+ if frame.Type != ports.MsgOutput {
622+ continue
623+ }
624+ output, err := ports.UnmarshalOutput(frame.Payload)
625+ if err != nil {
626+ return err
627+ }
628+ if err := acknowledgeOutput(tr, output); err != nil {
629+ return err
630+ }
631+ outputFrames++
632+ screen.Write(output.Data)
633+ text := harnessScreenText(screen)
634+ sawLoading = sawLoading || strings.Contains(text, "loading remote preview") || strings.Contains(string(output.Data), "loading remote preview")
635+ if strings.Contains(text, marker) || strings.Contains(string(output.Data), marker) {
636+ return nil
637+ }
638+ if strings.Contains(text, "remote preview unavailable") {
639+ return errors.New("remote preview unavailable")
640+ }
641+ }
642+}
643+
644+func harnessScreenText(screen *vt.Screen) string {
645+ var text strings.Builder
646+ for y := range screen.Frame.Height {
647+ for x := range screen.Frame.Width {
648+ r := screen.Frame.At(x, y).Rune
649+ if r == 0 {
650+ r = ' '
651+ }
652+ text.WriteRune(r)
653+ }
654+ text.WriteByte('\n')
655+ }
656+ return text.String()
657+}
658+
659+func remotePreviewContains(preview ports.RemotePreview, want string) bool {
660+ var text strings.Builder
661+ for _, cell := range preview.Cells {
662+ if !cell.Continuation {
663+ text.WriteRune(cell.Rune)
664+ }
665+ }
666+ return strings.Contains(text.String(), want)
667+}
668+
669+func receiveFrame(ctx context.Context, tr *harnessTransport, typ ports.MsgType) (ports.Frame, error) {
670+ deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
671+ defer cancel()
672+ for {
673+ frame, err := receiveWithTimeout(deadline, tr)
674+ if err != nil {
675+ return ports.Frame{}, err
676+ }
677+ if frame.Type == typ {
678+ return frame, nil
679+ }
680+ if frame.Type == ports.MsgOutput {
681+ output, decodeErr := ports.UnmarshalOutput(frame.Payload)
682+ if decodeErr != nil {
683+ return ports.Frame{}, decodeErr
684+ }
685+ if ackErr := acknowledgeOutput(tr, output); ackErr != nil {
686+ return ports.Frame{}, ackErr
687+ }
688+ }
689+ }
690+}
691+
351692 func sendInputAndAwait(ctx context.Context, tr *harnessTransport, command, want string) error {
352693 if err := tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte(command + "\n")})}); err != nil {
353694 return err
@@ -371,14 +712,8 @@ func sendInputAndAwait(ctx context.Context, tr *harnessTransport, command, want
371712 }
372713 outputFrames++
373714 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- }
715+ if err := acknowledgeOutput(tr, output); err != nil {
716+ return fmt.Errorf("ack output: %w", err)
382717 }
383718 received.Write(output.Data)
384719 if strings.Contains(received.String(), want) {
@@ -414,6 +749,16 @@ func sendCommand(ctx context.Context, tr *harnessTransport, slug string) error {
414749 if err != nil {
415750 return err
416751 }
752+ if frame.Type == ports.MsgOutput {
753+ output, decodeErr := ports.UnmarshalOutput(frame.Payload)
754+ if decodeErr != nil {
755+ return decodeErr
756+ }
757+ if ackErr := acknowledgeOutput(tr, output); ackErr != nil {
758+ return ackErr
759+ }
760+ continue
761+ }
417762 if frame.Type != ports.MsgCommandResult {
418763 continue
419764 }
@@ -14,10 +14,12 @@ import (
14 "time"14 "time"
15 15
16 "github.com/bnema/vev/internal/adapters/dgram"16 "github.com/bnema/vev/internal/adapters/dgram"
17+ "github.com/bnema/vev/internal/adapters/ipc"
17 remoteadapter "github.com/bnema/vev/internal/adapters/remote"18 remoteadapter "github.com/bnema/vev/internal/adapters/remote"
18 "github.com/bnema/vev/internal/adapters/sshstdio"19 "github.com/bnema/vev/internal/adapters/sshstdio"
19 "github.com/bnema/vev/internal/domain"20 "github.com/bnema/vev/internal/domain"
20 "github.com/bnema/vev/internal/ports"21 "github.com/bnema/vev/internal/ports"
22+ "github.com/bnema/vev/pkg/vt"
21 )23 )
22 24
23 const (25 const (
@@ -45,6 +47,9 @@ func run() error {
45 47
46 catalog := remoteadapter.NewCatalogClient()48 catalog := remoteadapter.NewCatalogClient()
47 preview := remoteadapter.NewPreviewClient()49 preview := remoteadapter.NewPreviewClient()
50+ if err := runLocalPickerUnitedPhase(ctx, target, catalog, preview); err != nil {
51+ return err
52+ }
48 if err := runLiveStdioPhase(ctx, target, catalog, preview); err != nil {53 if err := runLiveStdioPhase(ctx, target, catalog, preview); err != nil {
49 return err54 return err
50 }55 }
@@ -131,6 +136,157 @@ func runLiveStdioPhase(ctx context.Context, target string, catalog ports.RemoteC
131 return nil136 return nil
132 }137 }
133 138
139+// runLocalPickerUnitedPhase drives the actual local picker against the SSH
140+// daemon container. It validates the seam that individual catalog, preview,
141+// and attach tests cannot: catalog active-tab selection, live remote preview,
142+// emitted rich handoff, and the remote attachment's visible provenance.
143+func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports.RemoteCatalogClient, preview ports.RemotePreviewClient) error {
144+ const (
145+ localSession = "local-picker"
146+ remoteSession = "picker"
147+ previewMarker = "VEV_REMOTE_PICKER_ACTIVE"
148+ )
149+
150+ remote, err := openTransport(ctx, ports.RemoteTransportStdio, target)
151+ if err != nil {
152+ return fmt.Errorf("open remote picker source: %w", err)
153+ }
154+ defer func() { _ = remote.Close() }()
155+ if err := attachAndCheck(ctx, remote, ports.Hello{
156+ Intent: ports.IntentNew, Name: remoteSession, Env: localEnvironment(), Cwd: "/tmp/remote-cwd",
157+ TermEnv: "xterm-256color", TrueColor: true,
158+ }); err != nil {
159+ return fmt.Errorf("attach remote picker source: %w", err)
160+ }
161+ if err := sendCommand(ctx, remote, "new-tab"); err != nil {
162+ return fmt.Errorf("create remote picker tab: %w", err)
163+ }
164+ if err := sendCommand(ctx, remote, "next-tab"); err != nil {
165+ return fmt.Errorf("focus remote picker tab: %w", err)
166+ }
167+ if err := sendInputAndAwait(ctx, remote, "printf '"+previewMarker+"\\n'", previewMarker); err != nil {
168+ return fmt.Errorf("seed remote picker preview: %w", err)
169+ }
170+ remoteCatalog, err := waitForSessionWithTabCount(ctx, catalog, target, remoteSession, "running", 2)
171+ if err != nil {
172+ return fmt.Errorf("remote picker catalog: %w", err)
173+ }
174+ if remoteCatalog.ActiveTabID == "" {
175+ return errors.New("remote picker catalog omitted active tab")
176+ }
177+ tabs := ports.CatalogTabs(remoteCatalog)
178+ if len(tabs) != 2 {
179+ return errors.New("remote picker catalog did not retain both tabs")
180+ }
181+ selectedTarget, err := targetForSession(target, remoteCatalog, false)
182+ if err != nil {
183+ return fmt.Errorf("active remote picker target: %w", err)
184+ }
185+ if selectedTarget.LiveTabID != domain.TabStableID(remoteCatalog.ActiveTabID) {
186+ return errors.New("remote picker catalog active tab did not resolve to the selected target")
187+ }
188+ activeTabIndex := -1
189+ for i, tab := range tabs {
190+ if domain.TabStableID(tab.ID) == selectedTarget.LiveTabID {
191+ activeTabIndex = i
192+ break
193+ }
194+ }
195+ if activeTabIndex < 0 {
196+ return errors.New("remote picker catalog active tab was absent from tab list")
197+ }
198+ selectedPreview, err := preview.Preview(ctx, selectedTarget, 55, 22)
199+ if err != nil {
200+ return fmt.Errorf("explicit remote tab preview: %w", err)
201+ }
202+ if !remotePreviewContains(selectedPreview, previewMarker) {
203+ return errors.New("explicit remote tab preview omitted marker")
204+ }
205+
206+ if err := runLocalCommand(ctx, "vev", "host", "add", target); err != nil {
207+ return fmt.Errorf("register remote picker host: %w", err)
208+ }
209+ localDaemon, err := startLocalDaemon(ctx)
210+ if err != nil {
211+ return fmt.Errorf("start local picker daemon: %w", err)
212+ }
213+ defer stopLocalDaemon(localDaemon)
214+
215+ local, err := openLocalTransport(ctx)
216+ if err != nil {
217+ return fmt.Errorf("open local picker transport: %w", err)
218+ }
219+ defer func() { _ = local.Close() }()
220+ if err := attachAndCheck(ctx, local, ports.Hello{
221+ Intent: ports.IntentNew, Name: localSession, Env: localEnvironment(), Cwd: "/tmp/local-cwd",
222+ TermEnv: "xterm-256color", TrueColor: true,
223+ }); err != nil {
224+ return fmt.Errorf("attach local picker session: %w", err)
225+ }
226+ if err := sendRawInput(local, "\x1b "); err != nil {
227+ return fmt.Errorf("open command palette: %w", err)
228+ }
229+ if err := awaitOutputContains(ctx, local, "Commands"); err != nil {
230+ return fmt.Errorf("await command palette: %w", err)
231+ }
232+ if err := sendRawInput(local, "SSP\r"); err != nil {
233+ return fmt.Errorf("open session picker: %w", err)
234+ }
235+ if err := awaitOutputContains(ctx, local, remoteSession+"@remote"); err != nil {
236+ return fmt.Errorf("await remote picker catalog row: %w", err)
237+ }
238+ // The local selected tab is first and the remote session header is not
239+ // selectable, so one down reaches the first remote tab. Derive the rest
240+ // from the catalog's stable active tab identity instead of assuming an
241+ // ordinal target.
242+ if err := sendRawInput(local, strings.Repeat("j", activeTabIndex+1)); err != nil {
243+ return fmt.Errorf("select catalog active remote tab: %w", err)
244+ }
245+ if err := awaitRemotePickerPreview(ctx, local, previewMarker); err != nil {
246+ return fmt.Errorf("remote picker preview: %w", err)
247+ }
248+ if err := sendRawInput(local, "\r"); err != nil {
249+ return fmt.Errorf("commit remote picker target: %w", err)
250+ }
251+ handoffFrame, err := receiveFrame(ctx, local, ports.MsgAttachTarget)
252+ if err != nil {
253+ return fmt.Errorf("await remote picker handoff: %w", err)
254+ }
255+ handoff, err := ports.UnmarshalAttachTarget(handoffFrame.Payload)
256+ if err != nil {
257+ return fmt.Errorf("decode remote picker handoff: %w", err)
258+ }
259+ if handoff.RemoteTarget == nil {
260+ return errors.New("remote picker handoff omitted the remote target")
261+ }
262+ if handoff.RemoteTarget.LiveTabID != selectedTarget.LiveTabID {
263+ return fmt.Errorf("remote picker handoff tab = %q, want %q", handoff.RemoteTarget.LiveTabID, selectedTarget.LiveTabID)
264+ }
265+ if handoff.RemoteTarget.DisplayOrigin != "remote" {
266+ return fmt.Errorf("remote picker handoff origin = %q, want remote", handoff.RemoteTarget.DisplayOrigin)
267+ }
268+ if handoff.EnvironmentPolicy != ports.EnvironmentPolicyDaemonOwned {
269+ return fmt.Errorf("remote picker handoff policy = %d, want daemon-owned", handoff.EnvironmentPolicy)
270+ }
271+
272+ selected, err := openTransport(ctx, ports.RemoteTransportStdio, target)
273+ if err != nil {
274+ return fmt.Errorf("open selected remote transport: %w", err)
275+ }
276+ defer func() { _ = selected.Close() }()
277+ if err := attachAndCheck(ctx, selected, ports.Hello{
278+ Intent: ports.IntentAttach, Name: handoff.Session, Env: localEnvironment(), Cwd: "/tmp/local-cwd",
279+ TermEnv: "xterm-256color", TrueColor: true,
280+ RemoteTarget: handoff.RemoteTarget, EnvironmentPolicy: handoff.EnvironmentPolicy,
281+ }); err != nil {
282+ return fmt.Errorf("attach selected remote target: %w", err)
283+ }
284+ if err := awaitOutputContains(ctx, selected, remoteSession+" at remote"); err != nil {
285+ return fmt.Errorf("remote provenance status bar: %w", err)
286+ }
287+ return nil
288+}
289+
134 func runRestartResumePhase(ctx context.Context, target string, catalog ports.RemoteCatalogClient, preview ports.RemotePreviewClient) (domain.RemoteSessionTarget, error) {290 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 {291 if err := runRemoteCommand(ctx, target, "vev", "kill", "--daemon"); err != nil {
136 return domain.RemoteSessionTarget{}, fmt.Errorf("stop remote daemon: %w", err)292 return domain.RemoteSessionTarget{}, fmt.Errorf("stop remote daemon: %w", err)
@@ -318,6 +474,61 @@ func openTransport(ctx context.Context, mode ports.RemoteTransportMode, target s
318 return newHarnessTransport(transport), nil474 return newHarnessTransport(transport), nil
319 }475 }
320 476
477+func startLocalDaemon(ctx context.Context) (*exec.Cmd, error) {
478+ cmd := exec.CommandContext(ctx, "vev", "--daemon")
479+ cmd.Stdout = io.Discard
480+ cmd.Stderr = io.Discard
481+ if err := cmd.Start(); err != nil {
482+ return nil, err
483+ }
484+
485+ deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
486+ defer cancel()
487+ for {
488+ transport, err := ipc.DialContext(deadline, ipc.SocketDir())
489+ if err == nil {
490+ _ = transport.Close()
491+ return cmd, nil
492+ }
493+ select {
494+ case <-deadline.Done():
495+ stopLocalProcess(cmd)
496+ return nil, deadline.Err()
497+ case <-time.After(25 * time.Millisecond):
498+ }
499+ }
500+}
501+
502+func stopLocalDaemon(cmd *exec.Cmd) {
503+ cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second)
504+ defer cancel()
505+ _ = runLocalCommand(cleanup, "vev", "kill", "--daemon")
506+ stopLocalProcess(cmd)
507+}
508+
509+func stopLocalProcess(cmd *exec.Cmd) {
510+ if cmd == nil || cmd.Process == nil {
511+ return
512+ }
513+ _ = cmd.Process.Kill()
514+ _ = cmd.Wait()
515+}
516+
517+func runLocalCommand(ctx context.Context, name string, args ...string) error {
518+ cmd := exec.CommandContext(ctx, name, args...)
519+ cmd.Stdout = io.Discard
520+ cmd.Stderr = io.Discard
521+ return cmd.Run()
522+}
523+
524+func openLocalTransport(ctx context.Context) (*harnessTransport, error) {
525+ transport, err := ipc.DialContext(ctx, ipc.SocketDir())
526+ if err != nil {
527+ return nil, err
528+ }
529+ return newHarnessTransport(transport), nil
530+}
531+
321 func attachAndCheck(ctx context.Context, tr *harnessTransport, hello ports.Hello) error {532 func attachAndCheck(ctx context.Context, tr *harnessTransport, hello ports.Hello) error {
322 hello.Version = ports.ProtocolVersion533 hello.Version = ports.ProtocolVersion
323 hello.Size = domain.Size{Cols: 80, Rows: 24}534 hello.Size = domain.Size{Cols: 80, Rows: 24}
@@ -348,6 +559,136 @@ func attachAndCheck(ctx context.Context, tr *harnessTransport, hello ports.Hello
348 return nil559 return nil
349 }560 }
350 561
562+func sendRawInput(tr *harnessTransport, data string) error {
563+ return tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte(data)})})
564+}
565+
566+func acknowledgeOutput(tr *harnessTransport, output ports.Output) error {
567+ if output.Epoch == 0 || output.New == 0 {
568+ return nil
569+ }
570+ payload, err := ports.MarshalAck(ports.Ack{Epoch: output.Epoch, State: output.New})
571+ if err != nil {
572+ return err
573+ }
574+ return tr.Send(ports.Frame{Type: ports.MsgAck, Payload: payload})
575+}
576+
577+func awaitOutputContains(ctx context.Context, tr *harnessTransport, want string) error {
578+ deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
579+ defer cancel()
580+ var received strings.Builder
581+ screen := vt.NewScreen(80, 24)
582+ for {
583+ frame, err := receiveWithTimeout(deadline, tr)
584+ if err != nil {
585+ return err
586+ }
587+ if frame.Type != ports.MsgOutput {
588+ continue
589+ }
590+ output, err := ports.UnmarshalOutput(frame.Payload)
591+ if err != nil {
592+ return err
593+ }
594+ if err := acknowledgeOutput(tr, output); err != nil {
595+ return err
596+ }
597+ received.Write(output.Data)
598+ screen.Write(output.Data)
599+ if strings.Contains(received.String(), want) || strings.Contains(harnessScreenText(screen), want) {
600+ return nil
601+ }
602+ if received.Len() > 64<<10 {
603+ text := received.String()
604+ received.Reset()
605+ received.WriteString(text[len(text)-16<<10:])
606+ }
607+ }
608+}
609+
610+func awaitRemotePickerPreview(ctx context.Context, tr *harnessTransport, marker string) error {
611+ deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
612+ defer cancel()
613+ screen := vt.NewScreen(80, 24)
614+ outputFrames := 0
615+ sawLoading := false
616+ for {
617+ frame, err := receiveWithTimeout(deadline, tr)
618+ if err != nil {
619+ return fmt.Errorf("no rendered remote preview after %d output frames (loading=%t): %w", outputFrames, sawLoading, err)
620+ }
621+ if frame.Type != ports.MsgOutput {
622+ continue
623+ }
624+ output, err := ports.UnmarshalOutput(frame.Payload)
625+ if err != nil {
626+ return err
627+ }
628+ if err := acknowledgeOutput(tr, output); err != nil {
629+ return err
630+ }
631+ outputFrames++
632+ screen.Write(output.Data)
633+ text := harnessScreenText(screen)
634+ sawLoading = sawLoading || strings.Contains(text, "loading remote preview") || strings.Contains(string(output.Data), "loading remote preview")
635+ if strings.Contains(text, marker) || strings.Contains(string(output.Data), marker) {
636+ return nil
637+ }
638+ if strings.Contains(text, "remote preview unavailable") {
639+ return errors.New("remote preview unavailable")
640+ }
641+ }
642+}
643+
644+func harnessScreenText(screen *vt.Screen) string {
645+ var text strings.Builder
646+ for y := range screen.Frame.Height {
647+ for x := range screen.Frame.Width {
648+ r := screen.Frame.At(x, y).Rune
649+ if r == 0 {
650+ r = ' '
651+ }
652+ text.WriteRune(r)
653+ }
654+ text.WriteByte('\n')
655+ }
656+ return text.String()
657+}
658+
659+func remotePreviewContains(preview ports.RemotePreview, want string) bool {
660+ var text strings.Builder
661+ for _, cell := range preview.Cells {
662+ if !cell.Continuation {
663+ text.WriteRune(cell.Rune)
664+ }
665+ }
666+ return strings.Contains(text.String(), want)
667+}
668+
669+func receiveFrame(ctx context.Context, tr *harnessTransport, typ ports.MsgType) (ports.Frame, error) {
670+ deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
671+ defer cancel()
672+ for {
673+ frame, err := receiveWithTimeout(deadline, tr)
674+ if err != nil {
675+ return ports.Frame{}, err
676+ }
677+ if frame.Type == typ {
678+ return frame, nil
679+ }
680+ if frame.Type == ports.MsgOutput {
681+ output, decodeErr := ports.UnmarshalOutput(frame.Payload)
682+ if decodeErr != nil {
683+ return ports.Frame{}, decodeErr
684+ }
685+ if ackErr := acknowledgeOutput(tr, output); ackErr != nil {
686+ return ports.Frame{}, ackErr
687+ }
688+ }
689+ }
690+}
691+
351 func sendInputAndAwait(ctx context.Context, tr *harnessTransport, command, want string) error {692 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 {693 if err := tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte(command + "\n")})}); err != nil {
353 return err694 return err
@@ -371,14 +712,8 @@ func sendInputAndAwait(ctx context.Context, tr *harnessTransport, command, want
371 }712 }
372 outputFrames++713 outputFrames++
373 outputBytes += len(output.Data)714 outputBytes += len(output.Data)
374- if output.Epoch != 0 && output.New != 0 {715+ if err := acknowledgeOutput(tr, output); err != nil {
375- ackPayload, err := ports.MarshalAck(ports.Ack{Epoch: output.Epoch, State: output.New})716+ 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 }717 }
383 received.Write(output.Data)718 received.Write(output.Data)
384 if strings.Contains(received.String(), want) {719 if strings.Contains(received.String(), want) {
@@ -414,6 +749,16 @@ func sendCommand(ctx context.Context, tr *harnessTransport, slug string) error {
414 if err != nil {749 if err != nil {
415 return err750 return err
416 }751 }
752+ if frame.Type == ports.MsgOutput {
753+ output, decodeErr := ports.UnmarshalOutput(frame.Payload)
754+ if decodeErr != nil {
755+ return decodeErr
756+ }
757+ if ackErr := acknowledgeOutput(tr, output); ackErr != nil {
758+ return ackErr
759+ }
760+ continue
761+ }
417 if frame.Type != ports.MsgCommandResult {762 if frame.Type != ports.MsgCommandResult {
418 continue763 continue
419 }764 }
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