detached 097f96e

feat(remote): expose versioned session catalog

Signed-off-by: brice <b@bnema.dev>
Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: brice <b@bnema.dev>
commit
097f96e216b8df47ec488996ae6fe766f8eb11b9
author
brice · · 2026-07-29 21:36 UTC
committer
brice · · 2026-07-30 09:15 UTC
parent
d94ca5a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +198 -1
M internal/ports/remote.go +16 -0
@@ -28,3 +28,19 @@ type RemoteHostStore interface {
2828 // Remove deletes target from both pinned and learned lists atomically.
2929 Remove(target string) error
3030 }
31+
32+// RemoteCatalogSession is one live session in the remote discovery catalog.
33+// State is an explicit string contract (running|stopped|broken), not SessionState.
34+type RemoteCatalogSession struct {
35+ Name string `json:"name"`
36+ State string `json:"state"`
37+ Ephemeral bool `json:"ephemeral"`
38+ Tabs uint16 `json:"tabs"`
39+ Attached bool `json:"attached"`
40+}
41+
42+// RemoteCatalog is the versioned JSON envelope returned by remote-catalog --json.
43+type RemoteCatalog struct {
44+ ProtocolVersion uint16 `json:"protocol_version"`
45+ Sessions []RemoteCatalogSession `json:"sessions"`
46+}
@@ -28,3 +28,19 @@ type RemoteHostStore interface {
28 // Remove deletes target from both pinned and learned lists atomically.28 // Remove deletes target from both pinned and learned lists atomically.
29 Remove(target string) error29 Remove(target string) error
30 }30 }
31+
32+// RemoteCatalogSession is one live session in the remote discovery catalog.
33+// State is an explicit string contract (running|stopped|broken), not SessionState.
34+type RemoteCatalogSession struct {
35+ Name string `json:"name"`
36+ State string `json:"state"`
37+ Ephemeral bool `json:"ephemeral"`
38+ Tabs uint16 `json:"tabs"`
39+ Attached bool `json:"attached"`
40+}
41+
42+// RemoteCatalog is the versioned JSON envelope returned by remote-catalog --json.
43+type RemoteCatalog struct {
44+ ProtocolVersion uint16 `json:"protocol_version"`
45+ Sessions []RemoteCatalogSession `json:"sessions"`
46+}
M internal/usecase/command/command.go +1 -0
@@ -155,6 +155,7 @@ type ControlContext interface {
155155 ListSessions(json bool) (string, error)
156156 ListTabs(json bool) (string, error)
157157 ListPanes(json bool) (string, error)
158+ RemoteCatalog(json bool) (string, error)
158159 }
159160
160161 // Command describes an executable command.
@@ -155,6 +155,7 @@ type ControlContext interface {
155 ListSessions(json bool) (string, error)155 ListSessions(json bool) (string, error)
156 ListTabs(json bool) (string, error)156 ListTabs(json bool) (string, error)
157 ListPanes(json bool) (string, error)157 ListPanes(json bool) (string, error)
158+ RemoteCatalog(json bool) (string, error)
158 }159 }
159 160
160 // Command describes an executable command.161 // Command describes an executable command.
M internal/usecase/command/registry.go +16 -0
@@ -51,6 +51,7 @@ func Registry() []Command {
5151 listCommand("list-sessions", "List sessions", "List sessions with active markers", TargetNone, func(ctx ControlContext, json bool) (string, error) { return ctx.ListSessions(json) }),
5252 listCommand("list-tabs", "List tabs", "List tabs in the target session", TargetSession, func(ctx ControlContext, json bool) (string, error) { return ctx.ListTabs(json) }),
5353 listCommand("list-panes", "List panes", "List panes in the target tab", TargetTab, func(ctx ControlContext, json bool) (string, error) { return ctx.ListPanes(json) }),
54+ remoteCatalogCommand(),
5455 sessionRecoveryCommand(),
5556 }
5657 return commands
@@ -139,6 +140,21 @@ func sessionRecoveryCommand() Command {
139140 }
140141 }
141142
143+func remoteCatalogCommand() Command {
144+ return Command{
145+ Slug: "remote-catalog", Name: "Remote catalog",
146+ Desc: "Return the versioned session catalog for remote discovery",
147+ Usage: "remote-catalog --json", Scriptable: true, Target: TargetNone,
148+ Control: func(ctx ControlContext, args []string, opts ControlOptions) (ControlResult, error) {
149+ if len(args) != 0 || !opts.JSON {
150+ return ControlResult{}, ErrInvalidArguments
151+ }
152+ output, err := ctx.RemoteCatalog(true)
153+ return ControlResult{Output: output}, err
154+ },
155+ }
156+}
157+
142158 func listCommand(slug, name, desc string, target TargetKind, list func(ControlContext, bool) (string, error)) Command {
143159 return Command{Slug: slug, Name: name, Desc: desc, Usage: slug + " [--json]", Scriptable: true, Target: target, Control: func(ctx ControlContext, args []string, opts ControlOptions) (ControlResult, error) {
144160 if len(args) != 0 {
@@ -51,6 +51,7 @@ func Registry() []Command {
51 listCommand("list-sessions", "List sessions", "List sessions with active markers", TargetNone, func(ctx ControlContext, json bool) (string, error) { return ctx.ListSessions(json) }),51 listCommand("list-sessions", "List sessions", "List sessions with active markers", TargetNone, func(ctx ControlContext, json bool) (string, error) { return ctx.ListSessions(json) }),
52 listCommand("list-tabs", "List tabs", "List tabs in the target session", TargetSession, func(ctx ControlContext, json bool) (string, error) { return ctx.ListTabs(json) }),52 listCommand("list-tabs", "List tabs", "List tabs in the target session", TargetSession, func(ctx ControlContext, json bool) (string, error) { return ctx.ListTabs(json) }),
53 listCommand("list-panes", "List panes", "List panes in the target tab", TargetTab, func(ctx ControlContext, json bool) (string, error) { return ctx.ListPanes(json) }),53 listCommand("list-panes", "List panes", "List panes in the target tab", TargetTab, func(ctx ControlContext, json bool) (string, error) { return ctx.ListPanes(json) }),
54+ remoteCatalogCommand(),
54 sessionRecoveryCommand(),55 sessionRecoveryCommand(),
55 }56 }
56 return commands57 return commands
@@ -139,6 +140,21 @@ func sessionRecoveryCommand() Command {
139 }140 }
140 }141 }
141 142
143+func remoteCatalogCommand() Command {
144+ return Command{
145+ Slug: "remote-catalog", Name: "Remote catalog",
146+ Desc: "Return the versioned session catalog for remote discovery",
147+ Usage: "remote-catalog --json", Scriptable: true, Target: TargetNone,
148+ Control: func(ctx ControlContext, args []string, opts ControlOptions) (ControlResult, error) {
149+ if len(args) != 0 || !opts.JSON {
150+ return ControlResult{}, ErrInvalidArguments
151+ }
152+ output, err := ctx.RemoteCatalog(true)
153+ return ControlResult{Output: output}, err
154+ },
155+ }
156+}
157+
142 func listCommand(slug, name, desc string, target TargetKind, list func(ControlContext, bool) (string, error)) Command {158 func listCommand(slug, name, desc string, target TargetKind, list func(ControlContext, bool) (string, error)) Command {
143 return Command{Slug: slug, Name: name, Desc: desc, Usage: slug + " [--json]", Scriptable: true, Target: target, Control: func(ctx ControlContext, args []string, opts ControlOptions) (ControlResult, error) {159 return Command{Slug: slug, Name: name, Desc: desc, Usage: slug + " [--json]", Scriptable: true, Target: target, Control: func(ctx ControlContext, args []string, opts ControlOptions) (ControlResult, error) {
144 if len(args) != 0 {160 if len(args) != 0 {
M internal/usecase/command/registry_test.go +46 -1
@@ -145,6 +145,7 @@ func TestRegistryControlMetadata(t *testing.T) {
145145 "grow-pane-height": TargetPane, "shrink-pane-height": TargetPane, "equalize-panes": TargetTab,
146146 "move-pane": TargetPane, "move-tab": TargetTab,
147147 "list-sessions": TargetNone, "list-tabs": TargetSession, "list-panes": TargetTab,
148+ "remote-catalog": TargetNone,
148149 }
149150 notScriptable := []string{
150151 "session-picker", "notifications", "visual-mode", "yank-last-notification",
@@ -173,7 +174,7 @@ func TestPaletteRegistryHidesAPIOnly(t *testing.T) {
173174 if !cmd.PaletteVisible || cmd.Run == nil {
174175 t.Errorf("palette command is not executable and visible: %#v", cmd)
175176 }
176- if cmd.Slug == "toast" || cmd.Slug == "list-sessions" || cmd.Slug == "list-tabs" || cmd.Slug == "list-panes" {
177+ if cmd.Slug == "toast" || cmd.Slug == "list-sessions" || cmd.Slug == "list-tabs" || cmd.Slug == "list-panes" || cmd.Slug == "remote-catalog" {
177178 t.Errorf("API-only command %q is visible", cmd.Slug)
178179 }
179180 }
@@ -208,6 +209,45 @@ func TestSessionRecoveryCommand(t *testing.T) {
208209 }
209210 }
210211
212+func TestRemoteCatalogCommand(t *testing.T) {
213+ cmd, ok := BySlug("remote-catalog")
214+ if !ok {
215+ t.Fatal("BySlug(\"remote-catalog\") missing")
216+ }
217+ if cmd.Name != "Remote catalog" || cmd.Desc == "" || cmd.Usage != "remote-catalog --json" {
218+ t.Fatalf("remote-catalog metadata = %#v", cmd)
219+ }
220+ if !cmd.Scriptable || cmd.PaletteVisible || cmd.Target != TargetNone || cmd.Run != nil || cmd.Control == nil {
221+ t.Fatalf("remote-catalog visibility/target = %#v", cmd)
222+ }
223+
224+ tests := []struct {
225+ name string
226+ args []string
227+ opts ControlOptions
228+ wantCall string
229+ wantErr error
230+ }{
231+ {name: "json delegates", opts: ControlOptions{JSON: true}, wantCall: "remote-catalog:true"},
232+ {name: "rejects missing json", wantErr: ErrInvalidArguments},
233+ {name: "rejects args", args: []string{"extra"}, opts: ControlOptions{JSON: true}, wantErr: ErrInvalidArguments},
234+ {name: "rejects args without json", args: []string{"extra"}, wantErr: ErrInvalidArguments},
235+ }
236+ for _, tt := range tests {
237+ t.Run(tt.name, func(t *testing.T) {
238+ ctx := &controlSpy{catalogOutput: `{"protocol_version":19,"sessions":[]}` + "\n"}
239+ got, err := cmd.Control(ctx, tt.args, tt.opts)
240+ wantOutput := ""
241+ if tt.wantCall != "" {
242+ wantOutput = ctx.catalogOutput
243+ }
244+ if !errors.Is(err, tt.wantErr) || ctx.call != tt.wantCall || got.Output != wantOutput {
245+ t.Fatalf("Control() call/output/error = %q/%q/%v, want %q/%q/%v", ctx.call, got.Output, err, tt.wantCall, wantOutput, tt.wantErr)
246+ }
247+ })
248+ }
249+}
250+
211251 func TestControlHandlersValidateAndDelegate(t *testing.T) {
212252 tests := []struct {
213253 name string
@@ -257,6 +297,7 @@ func TestControlHandlersValidateAndDelegate(t *testing.T) {
257297 type controlSpy struct {
258298 call string
259299 recoveryOutput string
300+ catalogOutput string
260301 }
261302
262303 func (s *controlSpy) record(call string) error { s.call = call; return nil }
@@ -307,6 +348,10 @@ func (s *controlSpy) ListPanes(json bool) (string, error) {
307348 _ = s.record("list-panes:" + strconv.FormatBool(json))
308349 return "panes", nil
309350 }
351+func (s *controlSpy) RemoteCatalog(json bool) (string, error) {
352+ _ = s.record("remote-catalog:" + strconv.FormatBool(json))
353+ return s.catalogOutput, nil
354+}
310355
311356 func TestRegistryIncludesFloatingPaneToggle(t *testing.T) {
312357 cmd, ok := ByCode("FLT")
@@ -145,6 +145,7 @@ func TestRegistryControlMetadata(t *testing.T) {
145 "grow-pane-height": TargetPane, "shrink-pane-height": TargetPane, "equalize-panes": TargetTab,145 "grow-pane-height": TargetPane, "shrink-pane-height": TargetPane, "equalize-panes": TargetTab,
146 "move-pane": TargetPane, "move-tab": TargetTab,146 "move-pane": TargetPane, "move-tab": TargetTab,
147 "list-sessions": TargetNone, "list-tabs": TargetSession, "list-panes": TargetTab,147 "list-sessions": TargetNone, "list-tabs": TargetSession, "list-panes": TargetTab,
148+ "remote-catalog": TargetNone,
148 }149 }
149 notScriptable := []string{150 notScriptable := []string{
150 "session-picker", "notifications", "visual-mode", "yank-last-notification",151 "session-picker", "notifications", "visual-mode", "yank-last-notification",
@@ -173,7 +174,7 @@ func TestPaletteRegistryHidesAPIOnly(t *testing.T) {
173 if !cmd.PaletteVisible || cmd.Run == nil {174 if !cmd.PaletteVisible || cmd.Run == nil {
174 t.Errorf("palette command is not executable and visible: %#v", cmd)175 t.Errorf("palette command is not executable and visible: %#v", cmd)
175 }176 }
176- if cmd.Slug == "toast" || cmd.Slug == "list-sessions" || cmd.Slug == "list-tabs" || cmd.Slug == "list-panes" {177+ if cmd.Slug == "toast" || cmd.Slug == "list-sessions" || cmd.Slug == "list-tabs" || cmd.Slug == "list-panes" || cmd.Slug == "remote-catalog" {
177 t.Errorf("API-only command %q is visible", cmd.Slug)178 t.Errorf("API-only command %q is visible", cmd.Slug)
178 }179 }
179 }180 }
@@ -208,6 +209,45 @@ func TestSessionRecoveryCommand(t *testing.T) {
208 }209 }
209 }210 }
210 211
212+func TestRemoteCatalogCommand(t *testing.T) {
213+ cmd, ok := BySlug("remote-catalog")
214+ if !ok {
215+ t.Fatal("BySlug(\"remote-catalog\") missing")
216+ }
217+ if cmd.Name != "Remote catalog" || cmd.Desc == "" || cmd.Usage != "remote-catalog --json" {
218+ t.Fatalf("remote-catalog metadata = %#v", cmd)
219+ }
220+ if !cmd.Scriptable || cmd.PaletteVisible || cmd.Target != TargetNone || cmd.Run != nil || cmd.Control == nil {
221+ t.Fatalf("remote-catalog visibility/target = %#v", cmd)
222+ }
223+
224+ tests := []struct {
225+ name string
226+ args []string
227+ opts ControlOptions
228+ wantCall string
229+ wantErr error
230+ }{
231+ {name: "json delegates", opts: ControlOptions{JSON: true}, wantCall: "remote-catalog:true"},
232+ {name: "rejects missing json", wantErr: ErrInvalidArguments},
233+ {name: "rejects args", args: []string{"extra"}, opts: ControlOptions{JSON: true}, wantErr: ErrInvalidArguments},
234+ {name: "rejects args without json", args: []string{"extra"}, wantErr: ErrInvalidArguments},
235+ }
236+ for _, tt := range tests {
237+ t.Run(tt.name, func(t *testing.T) {
238+ ctx := &controlSpy{catalogOutput: `{"protocol_version":19,"sessions":[]}` + "\n"}
239+ got, err := cmd.Control(ctx, tt.args, tt.opts)
240+ wantOutput := ""
241+ if tt.wantCall != "" {
242+ wantOutput = ctx.catalogOutput
243+ }
244+ if !errors.Is(err, tt.wantErr) || ctx.call != tt.wantCall || got.Output != wantOutput {
245+ t.Fatalf("Control() call/output/error = %q/%q/%v, want %q/%q/%v", ctx.call, got.Output, err, tt.wantCall, wantOutput, tt.wantErr)
246+ }
247+ })
248+ }
249+}
250+
211 func TestControlHandlersValidateAndDelegate(t *testing.T) {251 func TestControlHandlersValidateAndDelegate(t *testing.T) {
212 tests := []struct {252 tests := []struct {
213 name string253 name string
@@ -257,6 +297,7 @@ func TestControlHandlersValidateAndDelegate(t *testing.T) {
257 type controlSpy struct {297 type controlSpy struct {
258 call string298 call string
259 recoveryOutput string299 recoveryOutput string
300+ catalogOutput string
260 }301 }
261 302
262 func (s *controlSpy) record(call string) error { s.call = call; return nil }303 func (s *controlSpy) record(call string) error { s.call = call; return nil }
@@ -307,6 +348,10 @@ func (s *controlSpy) ListPanes(json bool) (string, error) {
307 _ = s.record("list-panes:" + strconv.FormatBool(json))348 _ = s.record("list-panes:" + strconv.FormatBool(json))
308 return "panes", nil349 return "panes", nil
309 }350 }
351+func (s *controlSpy) RemoteCatalog(json bool) (string, error) {
352+ _ = s.record("remote-catalog:" + strconv.FormatBool(json))
353+ return s.catalogOutput, nil
354+}
310 355
311 func TestRegistryIncludesFloatingPaneToggle(t *testing.T) {356 func TestRegistryIncludesFloatingPaneToggle(t *testing.T) {
312 cmd, ok := ByCode("FLT")357 cmd, ok := ByCode("FLT")
M internal/usecase/daemon/control.go +39 -0
@@ -605,6 +605,45 @@ func (e controlExec) ListSessions(asJSON bool) (string, error) {
605605 return out.String(), nil
606606 }
607607
608+func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
609+ if !asJSON {
610+ return "", command.ErrInvalidArguments
611+ }
612+ e.d.mu.Lock()
613+ sessions := make([]*session, 0, len(e.d.sessions))
614+ for _, sess := range e.d.sessions {
615+ sessions = append(sessions, sess)
616+ }
617+ e.d.mu.Unlock()
618+ rows := make([]ports.RemoteCatalogSession, 0, len(sessions))
619+ for _, sess := range sessions {
620+ snap := sess.snapshotView(viewOptions{})
621+ rows = append(rows, ports.RemoteCatalogSession{
622+ Name: snap.name,
623+ State: remoteCatalogState(ports.SessionRunning),
624+ Ephemeral: snap.ephemeral,
625+ Tabs: uint16(snap.tabCount),
626+ Attached: snap.attached,
627+ })
628+ }
629+ sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name })
630+ return marshalListing(ports.RemoteCatalog{
631+ ProtocolVersion: ports.ProtocolVersion,
632+ Sessions: rows,
633+ })
634+}
635+
636+func remoteCatalogState(state ports.SessionState) string {
637+ switch state {
638+ case ports.SessionStopped:
639+ return "stopped"
640+ case ports.SessionBroken:
641+ return "broken"
642+ default:
643+ return "running"
644+ }
645+}
646+
608647 func (e controlExec) ListTabs(asJSON bool) (string, error) {
609648 type row struct {
610649 Index int `json:"index"`
@@ -605,6 +605,45 @@ func (e controlExec) ListSessions(asJSON bool) (string, error) {
605 return out.String(), nil605 return out.String(), nil
606 }606 }
607 607
608+func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
609+ if !asJSON {
610+ return "", command.ErrInvalidArguments
611+ }
612+ e.d.mu.Lock()
613+ sessions := make([]*session, 0, len(e.d.sessions))
614+ for _, sess := range e.d.sessions {
615+ sessions = append(sessions, sess)
616+ }
617+ e.d.mu.Unlock()
618+ rows := make([]ports.RemoteCatalogSession, 0, len(sessions))
619+ for _, sess := range sessions {
620+ snap := sess.snapshotView(viewOptions{})
621+ rows = append(rows, ports.RemoteCatalogSession{
622+ Name: snap.name,
623+ State: remoteCatalogState(ports.SessionRunning),
624+ Ephemeral: snap.ephemeral,
625+ Tabs: uint16(snap.tabCount),
626+ Attached: snap.attached,
627+ })
628+ }
629+ sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name })
630+ return marshalListing(ports.RemoteCatalog{
631+ ProtocolVersion: ports.ProtocolVersion,
632+ Sessions: rows,
633+ })
634+}
635+
636+func remoteCatalogState(state ports.SessionState) string {
637+ switch state {
638+ case ports.SessionStopped:
639+ return "stopped"
640+ case ports.SessionBroken:
641+ return "broken"
642+ default:
643+ return "running"
644+ }
645+}
646+
608 func (e controlExec) ListTabs(asJSON bool) (string, error) {647 func (e controlExec) ListTabs(asJSON bool) (string, error) {
609 type row struct {648 type row struct {
610 Index int `json:"index"`649 Index int `json:"index"`
M internal/usecase/daemon/control_test.go +80 -0
@@ -3,6 +3,7 @@ package daemon
33 import (
44 "context"
55 "encoding/json"
6+ "strings"
67 "sync"
78 "testing"
89 "time"
@@ -648,6 +649,85 @@ func TestHandleCommandListingsContainStableIDsMarkersAndCWD(t *testing.T) {
648649 require.Equal(t, true, decoded[0]["focused"])
649650 }
650651
652+func TestRemoteCatalogJSONOutput(t *testing.T) {
653+ d := newTestDaemon(t, nil, stubClock{})
654+ work := addControlSession(d, "work", "t_work", "p_work")
655+ work.ephemeral = false
656+ work.client = &attachedClient{}
657+ work.mruAt.Store(2)
658+ tb := newTabWithStableID("t_work_2", "p_work_2", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
659+ tb.ctx, tb.cancel = context.WithCancel(d.serveCtx)
660+ work.mu.Lock()
661+ work.tabs = append(work.tabs, tb)
662+ work.mu.Unlock()
663+
664+ build := addControlSession(d, "build", "t_build", "p_build")
665+ build.ephemeral = true
666+ build.mruAt.Store(1)
667+
668+ d.mu.Lock()
669+ d.stopped["old"] = stoppedSession{name: "old", cwd: "/tmp/old", createdAt: 1, state: ports.SessionStopped}
670+ d.mu.Unlock()
671+
672+ listBefore := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"})
673+ require.True(t, listBefore.OK, listBefore.Text)
674+
675+ missingJSON := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog"})
676+ require.False(t, missingJSON.OK)
677+ require.Equal(t, ports.ErrInvalidCommandArgs, missingJSON.Code)
678+
679+ withArgs := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog", Args: []string{"extra"}, JSON: true})
680+ require.False(t, withArgs.OK)
681+ require.Equal(t, ports.ErrInvalidCommandArgs, withArgs.Code)
682+
683+ result := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog", JSON: true})
684+ require.True(t, result.OK, result.Text)
685+ require.True(t, strings.HasSuffix(result.Output, "\n"), "catalog output must be newline-terminated")
686+
687+ var catalog ports.RemoteCatalog
688+ require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))
689+ require.Equal(t, ports.ProtocolVersion, catalog.ProtocolVersion)
690+
691+ raw := map[string]json.RawMessage{}
692+ require.NoError(t, json.Unmarshal([]byte(result.Output), &raw))
693+ require.Len(t, raw, 2)
694+ _, hasProtocol := raw["protocol_version"]
695+ _, hasSessions := raw["sessions"]
696+ require.True(t, hasProtocol && hasSessions)
697+
698+ require.Equal(t, []ports.RemoteCatalogSession{
699+ {Name: "build", State: "running", Ephemeral: true, Tabs: 1, Attached: false},
700+ {Name: "work", State: "running", Ephemeral: false, Tabs: 2, Attached: true},
701+ }, catalog.Sessions)
702+
703+ for _, session := range catalog.Sessions {
704+ require.Equal(t, "running", session.State)
705+ require.NotEqual(t, "old", session.Name)
706+ }
707+
708+ listAfter := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"})
709+ require.True(t, listAfter.OK, listAfter.Text)
710+ require.Equal(t, listBefore.Output, listAfter.Output)
711+ require.Contains(t, listAfter.Output, "work\tnamed\t2\ttrue\t")
712+ require.NotContains(t, listAfter.Output, "old")
713+}
714+
715+func TestRemoteCatalogStateMapping(t *testing.T) {
716+ tests := []struct {
717+ state ports.SessionState
718+ want string
719+ }{
720+ {state: ports.SessionRunning, want: "running"},
721+ {state: ports.SessionStopped, want: "stopped"},
722+ {state: ports.SessionBroken, want: "broken"},
723+ }
724+ for _, tt := range tests {
725+ t.Run(tt.want, func(t *testing.T) {
726+ require.Equal(t, tt.want, remoteCatalogState(tt.state))
727+ })
728+ }
729+}
730+
651731 func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {
652732 factory := &controlPTYFactory{entered: make(chan struct{}), release: make(chan struct{})}
653733 d := newTestDaemon(t, factory, stubClock{})
@@ -3,6 +3,7 @@ package daemon
3 import (3 import (
4 "context"4 "context"
5 "encoding/json"5 "encoding/json"
6+ "strings"
6 "sync"7 "sync"
7 "testing"8 "testing"
8 "time"9 "time"
@@ -648,6 +649,85 @@ func TestHandleCommandListingsContainStableIDsMarkersAndCWD(t *testing.T) {
648 require.Equal(t, true, decoded[0]["focused"])649 require.Equal(t, true, decoded[0]["focused"])
649 }650 }
650 651
652+func TestRemoteCatalogJSONOutput(t *testing.T) {
653+ d := newTestDaemon(t, nil, stubClock{})
654+ work := addControlSession(d, "work", "t_work", "p_work")
655+ work.ephemeral = false
656+ work.client = &attachedClient{}
657+ work.mruAt.Store(2)
658+ tb := newTabWithStableID("t_work_2", "p_work_2", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
659+ tb.ctx, tb.cancel = context.WithCancel(d.serveCtx)
660+ work.mu.Lock()
661+ work.tabs = append(work.tabs, tb)
662+ work.mu.Unlock()
663+
664+ build := addControlSession(d, "build", "t_build", "p_build")
665+ build.ephemeral = true
666+ build.mruAt.Store(1)
667+
668+ d.mu.Lock()
669+ d.stopped["old"] = stoppedSession{name: "old", cwd: "/tmp/old", createdAt: 1, state: ports.SessionStopped}
670+ d.mu.Unlock()
671+
672+ listBefore := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"})
673+ require.True(t, listBefore.OK, listBefore.Text)
674+
675+ missingJSON := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog"})
676+ require.False(t, missingJSON.OK)
677+ require.Equal(t, ports.ErrInvalidCommandArgs, missingJSON.Code)
678+
679+ withArgs := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog", Args: []string{"extra"}, JSON: true})
680+ require.False(t, withArgs.OK)
681+ require.Equal(t, ports.ErrInvalidCommandArgs, withArgs.Code)
682+
683+ result := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog", JSON: true})
684+ require.True(t, result.OK, result.Text)
685+ require.True(t, strings.HasSuffix(result.Output, "\n"), "catalog output must be newline-terminated")
686+
687+ var catalog ports.RemoteCatalog
688+ require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))
689+ require.Equal(t, ports.ProtocolVersion, catalog.ProtocolVersion)
690+
691+ raw := map[string]json.RawMessage{}
692+ require.NoError(t, json.Unmarshal([]byte(result.Output), &raw))
693+ require.Len(t, raw, 2)
694+ _, hasProtocol := raw["protocol_version"]
695+ _, hasSessions := raw["sessions"]
696+ require.True(t, hasProtocol && hasSessions)
697+
698+ require.Equal(t, []ports.RemoteCatalogSession{
699+ {Name: "build", State: "running", Ephemeral: true, Tabs: 1, Attached: false},
700+ {Name: "work", State: "running", Ephemeral: false, Tabs: 2, Attached: true},
701+ }, catalog.Sessions)
702+
703+ for _, session := range catalog.Sessions {
704+ require.Equal(t, "running", session.State)
705+ require.NotEqual(t, "old", session.Name)
706+ }
707+
708+ listAfter := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"})
709+ require.True(t, listAfter.OK, listAfter.Text)
710+ require.Equal(t, listBefore.Output, listAfter.Output)
711+ require.Contains(t, listAfter.Output, "work\tnamed\t2\ttrue\t")
712+ require.NotContains(t, listAfter.Output, "old")
713+}
714+
715+func TestRemoteCatalogStateMapping(t *testing.T) {
716+ tests := []struct {
717+ state ports.SessionState
718+ want string
719+ }{
720+ {state: ports.SessionRunning, want: "running"},
721+ {state: ports.SessionStopped, want: "stopped"},
722+ {state: ports.SessionBroken, want: "broken"},
723+ }
724+ for _, tt := range tests {
725+ t.Run(tt.want, func(t *testing.T) {
726+ require.Equal(t, tt.want, remoteCatalogState(tt.state))
727+ })
728+ }
729+}
730+
651 func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {731 func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {
652 factory := &controlPTYFactory{entered: make(chan struct{}), release: make(chan struct{})}732 factory := &controlPTYFactory{entered: make(chan struct{}), release: make(chan struct{})}
653 d := newTestDaemon(t, factory, stubClock{})733 d := newTestDaemon(t, factory, stubClock{})

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 12

Tags 11

↑↓ moveenter openesc close