diff --git a/cli/internal/tui/chat.go b/cli/internal/tui/chat.go index 2c163d6..9fb70b5 100644 --- a/cli/internal/tui/chat.go +++ b/cli/internal/tui/chat.go @@ -2202,7 +2202,7 @@ func (m ChatModel) header(width int) string { } else { host = cmp.Or(m.Host, host) lead = host + ":" - workspace = underHome(workspace, m.hostHome) + workspace = scpRelative(underHome(workspace, m.hostHome)) } model := m.Model if m.Effort != "" { diff --git a/cli/internal/tui/folder_picker.go b/cli/internal/tui/folder_picker.go index 39a2888..186c7a4 100644 --- a/cli/internal/tui/folder_picker.go +++ b/cli/internal/tui/folder_picker.go @@ -951,7 +951,7 @@ func (m FolderPicker) parentQuery() string { if rest == "~" { return m.input.Value() } - return host + ":" + path.Dir(rest) + "/" + return host + ":" + scpRelative(path.Dir(rest)) + "/" } up := m.homed(parentPlace(dir)) if strings.HasSuffix(up, "/") { diff --git a/cli/internal/tui/folder_picker_test.go b/cli/internal/tui/folder_picker_test.go index c4df333..6a70539 100644 --- a/cli/internal/tui/folder_picker_test.go +++ b/cli/internal/tui/folder_picker_test.go @@ -209,10 +209,11 @@ func TestPickerBrowsesAHostFoldingUnderItsHome(t *testing.T) { m = settle(t, m, cmd) m, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyTab}) m = settle(t, m, cmd) - if got := m.input.Value(); got != "chernobog:~/proj/" { + // under the host's home, scp-style: relative to it + if got := m.input.Value(); got != "chernobog:proj/" { t.Fatalf("tab went to %q", got) } - if frame := pickerFrame(m); !strings.Contains(frame, "in chernobog:~/proj") || !strings.Contains(frame, "albedo") { + if frame := pickerFrame(m); !strings.Contains(frame, "in chernobog:proj") || !strings.Contains(frame, "albedo") { t.Fatalf("the host's folder did not list:\n%s", frame) } // shift+tab goes back up through the canonical path, still folded @@ -231,8 +232,8 @@ func TestPickerBrowsesAHostFoldingUnderItsHome(t *testing.T) { // back among the recent folders, the remote one reads like the preview's rule m.setQuery("") m.cursor = slices.IndexFunc(m.rows, func(r folderRow) bool { return r.hostKey != "" }) - if row := m.row(m.rows[m.cursor], false, false, false, 60, time.Now()); !strings.Contains(ansi.Strip(row), "chernobog:~ › p") { - t.Fatalf("a recent remote folder's where should lead with host:~: %q", ansi.Strip(row)) + if row := m.row(m.rows[m.cursor], false, false, false, 60, time.Now()); !strings.Contains(ansi.Strip(row), "chernobog:proj") { + t.Fatalf("a recent remote folder's where should read host:path under its home: %q", ansi.Strip(row)) } if len(src.warmed) != 1 { t.Fatalf("the host was warmed %d times, want once: %v", len(src.warmed), src.warmed) diff --git a/cli/internal/tui/folder_picker_view.go b/cli/internal/tui/folder_picker_view.go index 96c584f..ab90bb7 100644 --- a/cli/internal/tui/folder_picker_view.go +++ b/cli/internal/tui/folder_picker_view.go @@ -222,7 +222,7 @@ func (m FolderPicker) where(row folderRow) string { return strings.Join(crumbParts(m.homed(path.Dir(p))), " › ") } // the host leads the first crumb, as in the preview's rule - crumbs := crumbParts(underHome(path.Dir(p), m.homes[m.canonical(host)])) + crumbs := crumbParts(scpRelative(underHome(path.Dir(p), m.homes[m.canonical(host)]))) crumbs[0] = cmp.Or(row.host, m.labelOf(host)) + ":" + crumbs[0] return strings.Join(crumbs, " › ") } diff --git a/cli/internal/tui/header_test.go b/cli/internal/tui/header_test.go index f03ed85..0492780 100644 --- a/cli/internal/tui/header_test.go +++ b/cli/internal/tui/header_test.go @@ -89,7 +89,7 @@ func TestRemoteHeaderFoldsUnderTheHostsHomeAndSaysWhileConnecting(t *testing.T) t.Fatalf("connecting should animate (animating %v):\n%s", m.animating(), view) } m.Status.KernelLink, m.hostHome = "attached", "/home/mayer" - if header := ansi.Strip(m.header(120)); !strings.Contains(header, "✦ albedo on chernobog:~/proj/albedo") { + if header := ansi.Strip(m.header(120)); !strings.Contains(header, "✦ albedo on chernobog:proj/albedo") { t.Fatalf("the path should fold under the host's own home: %q", header) } if got := m.statusLine(); strings.Contains(got, "connecting") { diff --git a/cli/internal/tui/location.go b/cli/internal/tui/location.go index 11f8635..c868452 100644 --- a/cli/internal/tui/location.go +++ b/cli/internal/tui/location.go @@ -19,15 +19,16 @@ func sessionHost(s daemon.Session) string { return host } -// placeText is a workspace as one line of text: a path under its home -// starts with ~, and a remote one leads with its host (label, else as -// stored). home is the path ~ names where the workspace is; empty, a local -// path folds under your own home and a remote one stays whole. +// placeText is a workspace as one line of text: a local path under your +// home starts with ~, and a remote one leads with its host (label, else as +// stored), relative to that host's home as scp reads it. home is the path ~ +// names where the workspace is; empty, a local path folds under your own +// home and a remote one stays whole. func placeText(workspace, label, home string) string { host, p := daemon.SplitLocation(workspace) switch { case host != "": - return cmp.Or(label, host) + ":" + underHome(p, home) + return cmp.Or(label, host) + ":" + scpRelative(underHome(p, home)) case home == "": return homePath(workspace) } diff --git a/cli/internal/tui/theme.go b/cli/internal/tui/theme.go index 00c0a27..2bfd0ff 100644 --- a/cli/internal/tui/theme.go +++ b/cli/internal/tui/theme.go @@ -336,6 +336,15 @@ func underHome(p, home string) string { return "~" + rest } +// scpRelative is a remote path after its host's colon as scp reads it: +// one under ~ relative to the home, which itself stays ~. +func scpRelative(p string) string { + if rest, ok := strings.CutPrefix(p, "~/"); ok { + return rest + } + return p +} + // languageHues are our own colors for common languages, by linguist name. // Each starts from linguist's hue, softened to a pastel and nudged so // languages that often share a repository stay apart. diff --git a/cli/test/e2e/tui_folders_test.go b/cli/test/e2e/tui_folders_test.go index 95cb47c..860b52f 100644 --- a/cli/test/e2e/tui_folders_test.go +++ b/cli/test/e2e/tui_folders_test.go @@ -266,7 +266,7 @@ func TestTUIStartsASessionOnAHostPickedLikeAFolder(t *testing.T) { t.Fatalf("gohost: did not list the host's home:\n%s", view) } d.Type("proj/" + filepath.Base(picked)[:6]) - if view := d.View(); !strings.Contains(view, "in gohost:~/proj") || !strings.Contains(view, filepath.Base(picked)) { + if view := d.View(); !strings.Contains(view, "in gohost:proj") || !strings.Contains(view, filepath.Base(picked)) { t.Fatalf("the host's folder did not list:\n%s", view) } started, ok := d.Key(tea.KeyEnter).(tui.FolderNewSessionMsg) diff --git a/robot-docs/workspaces.md b/robot-docs/workspaces.md index 6ed6e54..60dc872 100644 --- a/robot-docs/workspaces.md +++ b/robot-docs/workspaces.md @@ -115,9 +115,10 @@ memory and work items, and reads cover the whole group. a worktree or jj workspace of the same repository is not linked by itself yet; that and suggesting links from shared history are later work. -the tui shows a remote workspace as `label:path`, the path folded under -that host's own home once a listing or probe reported it: the chat header -reads `✦ albedo on chernobog:~/proj/albedo`, folds only the path, keeps the +the tui shows a remote workspace as `label:path`, the path relative to that +host's own home once a listing or probe reported it, as scp reads it (the +home itself is `label:~`, a path outside it stays absolute): the chat header +reads `✦ albedo on chernobog:proj/albedo`, folds only the path, keeps the host in every layout that shows a place, and colors the host with a stable hue derived from its label, lifted to the theme's contrast. the host goes faint while the session's kernel boots or reattaches (the status line says @@ -287,7 +288,7 @@ folder (`cli/internal/tui/folder_picker.go`, `parseQuery`): `chernobog:~/proj/` browse under it, `chernobog:/srv/` from its root. everything after the last slash filters, as locally. the listing comes from `/fs/list` with the location; its canonical `path` and `home` fold - rows to `chernobog:~/proj/albedo`. + rows to `chernobog:proj/albedo`. - hosts complete from `GET /hosts` (recent first, then ssh config), with the hosts of the sessions already listed as a fallback. a bare word still filters the recent folders exactly as before and only adds the hosts whose