workspaces: remote locations ([user@]host:/abs/path) with host labels, refusals until kernels can run there, and the host in the tui header
Signed-off-by: dawn <90008@klbr.net>
4 unmodified lines5678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626312646566676869704 unmodified lines7576777825798081828393 unmodified lines4 unmodified linesanother folder; the folder picker browses through the daemon, so it only everoffers folders the daemon itself can see (and works the same once the daemonis remote).
## locations
every workspace is a location (`src/albedo/harness/location.gleam`):
- **local**: a plain absolute path, stored exactly as it always was. no prefix anywhere, on the wire or on screen. `~` (the daemon's home) parses as local too; whether a folder exists is the caller's question.- **remote**: `[user@]host:/abs/path` (an IPv6 host in brackets). a text is remote when it does not start with `/` or `~` and the part before the first `:` holds no `/`. user and host start with a letter or digit, so neither is ever an ssh option. the path must be absolute and is normalised (`//`, `.`, `..`, trailing slash), so one remote directory has one key. a remote `~` is refused: the remote home is only known once albedo connects there.
session info carries the parsed location beside the `workspace` string:
```json{"workspace": "mayer@chernobog:/home/mayer/proj/albedo", "location": {"host": "chernobog", "user": "mayer", "path": "/home/mayer/proj/albedo", "label": "chernobog"}}{"workspace": "/Users/dawn/proj", "location": {"host": null, "user": null, "path": "/Users/dawn/proj", "label": null}}```
`label` is how clients show the host: the alias, with `user@` dropped whenit is the user `ssh -G <host>` reports (local config only, no network),cached per host for the daemon's life. without ssh the user stays.
nothing runs at a remote location yet. what is only a key works as it is:the work ledger and paperclips scope, recent folders, session search bycwd, family moves. what needs the remote filesystem refuses or skips byname, and never looks for the path on the daemon's own disk:
- a turn in a remote session answers 409 `kernels on chernobog aren't available yet` (the kernel refuses to boot there too).- `/fs/list`, `/fs/repo` and `/fs/preview` answer 400 `folders on chernobog aren't available yet`.- AGENTS.md, SYSTEM.md and the other instruction files, and skills, are read from the home directories only; the instructions extension warns `project instruction files on chernobog aren't available yet` and the skills catalog lists `project skills on chernobog aren't available yet` among its diagnostics.- memory is a daemon-side file keyed by the workspace string, so a remote workspace gets its own.
the tui shows a remote workspace as `label:/path`: the chat header reads`✦ albedo on chernobog:/home/mayer/proj/albedo`, folds only the path, keepsthe host in every layout that shows a place, and colors the host with astable hue derived from its label, lifted to the theme's contrast. recentfolders and the sessions view lead remote entries with their host.
## moving a session
`POST /sessions/:id/workspace {"workspace": "/abs/dir"}` answers the session'sinfo. the session must be idle and the target an existing absolute directory.info. the session must be idle and the target an existing absolute directory,or a remote location (stored canonically; it can't be checked before albedoconnects to the host). `POST /sessions` takes the same forms.the kernel is dropped (python variables start fresh), the transcript stays,and a note records the move.
every descendant of the session (children, their children; open or closed)4 unmodified lines
## browsing
all three routes take `path`: absolute, or starting with `~`, which expandsto the daemon's home. anything else is a 400; a path that is not an existingto the daemon's home. anything else is a 400, a remote location included; a path that is not an existingdirectory is a 404. errors are `{"error": "..."}`. `/health` lists`workspace_browser` when these routes exist.
### `GET /fs/list?path=P`93 unmodified lines123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157"""Sessions at remote locations: `[user@]host:/abs/path` workspaces.
Until kernels can run on another host, a remote location is a stored key thedaemon parses, labels and refuses to act on. These cover what a client sees:the canonical spelling and parsed `location`, the host label's `user@`elision against `ssh -G`, rejected forms, moving between local and remote,and the refusals that keep the daemon from looking for a remote path on itsown disk."""
import jsonimport shutilimport subprocessimport unittestimport urllib.errorimport urllib.parse
from harness import Albedo, exclusive
HOST = "albedo-e2e-nowhere"
def ssh_user(host): """The user ssh would pick for `host`, or None without ssh.""" if shutil.which("ssh") is None: return None output = subprocess.run( ["ssh", "-G", host], capture_output=True, text=True, check=True ).stdout return next( line.split(" ", 1)[1] for line in output.splitlines() if line.startswith("user ") )
class LocationsTest(unittest.TestCase): def setUp(self): self.app = Albedo().__enter__() self.addCleanup(self.app.__exit__, None, None, None)
def api(self, path, body=None): with self.app.api(path, body) as response: return json.load(response)
def failure(self, path, body=None): with self.assertRaises(urllib.error.HTTPError) as caught: self.api(path, body) return caught.exception.code, json.load(caught.exception)["error"]
def create(self, workspace): return self.api( "/sessions", {"workspace": workspace, "provider": self.app.profile} )
def test_remote_workspace_is_stored_canonically_with_its_location(self): created = self.create(f"mayer@{HOST}:/home/mayer//proj/./albedo/") canonical = f"mayer@{HOST}:/home/mayer/proj/albedo" self.assertEqual(created["workspace"], canonical) location = created["location"] self.assertEqual( (location["host"], location["user"], location["path"]), (HOST, "mayer", "/home/mayer/proj/albedo"), ) listed = next(s for s in self.api("/sessions") if s["id"] == created["id"]) self.assertEqual(listed["workspace"], canonical)
local = self.create(str(self.app.workspace)) self.assertEqual(local["workspace"], str(self.app.workspace)) self.assertEqual( local["location"], { "host": None, "user": None, "path": str(self.app.workspace), "label": None, }, )
def test_host_label_drops_the_user_ssh_would_pick_anyway(self): self.assertEqual(self.create(f"{HOST}:/srv")["location"]["label"], HOST) other = "someone-else" self.assertEqual( self.create(f"{other}@{HOST}:/srv")["location"]["label"], f"{other}@{HOST}", ) default = ssh_user(HOST) if default is None: self.skipTest("ssh is not installed: the label keeps any user") self.assertEqual( self.create(f"{default}@{HOST}:/srv")["location"]["label"], HOST )
# `albedo new` takes the active profile, which other tests on the shared # daemon rewrite; a daemon of its own keeps that profile this fixture's. @exclusive def test_the_cli_starts_a_remote_session_without_resolving_it_locally(self): session = json.loads(self.app.cli("new", f"{HOST}:/srv/cli"))["session"] listed = next(s for s in self.api("/sessions") if s["id"] == session) self.assertEqual(listed["workspace"], f"{HOST}:/srv/cli")
def test_unusable_locations_are_rejected(self): for workspace, said in ( (f"{HOST}:~/proj", "must be absolute"), (f"{HOST}:proj", "must be absolute"), (f"{HOST}:", "must be absolute"), ("-oProxyCommand=touch:/tmp", "not a valid host"), (f"-l@{HOST}:/srv", "not a valid user"), ("relative/path", "absolute path or host:/absolute/path"), ("dir/with:colon", "absolute path or host:/absolute/path"), ): with self.subTest(workspace=workspace): status, message = self.failure( "/sessions", {"workspace": workspace, "provider": self.app.profile} ) self.assertEqual(status, 400) self.assertIn(said, message)
def test_remote_session_refuses_what_needs_the_host(self): session = self.create(f"{HOST}:/srv/app")["id"] status, message = self.failure( f"/sessions/{session}/events", {"content": "hello"} ) self.assertEqual(status, 409) self.assertEqual(message, f"kernels on {HOST} aren't available yet")
# Its extensions still compose around the project files they skip. self.assertTrue(self.api(f"/sessions/{session}/commands"))
for route in ("list", "repo", "preview"): with self.subTest(route=route): query = urllib.parse.urlencode({"path": f"{HOST}:/srv/app"}) status, message = self.failure(f"/fs/{route}?{query}") self.assertEqual(status, 400) self.assertEqual(message, f"folders on {HOST} aren't available yet")
def test_a_session_moves_to_a_remote_location_and_back(self): session = self.app.session() moved = self.api( f"/sessions/{session}/workspace", {"workspace": f"{HOST}:/srv//app/"} ) self.assertEqual(moved["workspace"], f"{HOST}:/srv/app") self.assertEqual(moved["location"]["host"], HOST) status, message = self.failure( f"/sessions/{session}/workspace", {"workspace": f"{HOST}:~/app"} ) self.assertEqual(status, 409) self.assertIn("must be absolute", message) back = self.api( f"/sessions/{session}/workspace", {"workspace": str(self.app.workspace)} ) self.assertEqual(back["workspace"], str(self.app.workspace)) self.assertIsNone(back["location"]["host"])
if __name__ == "__main__": unittest.main()29 unmodified lines303132333435363738394041424344454647363738394849505152149 unmodified linesExpand all29 unmodified lines Sessions []daemon.Session LoginRequired bool}
// absoluteWorkspace resolves a local workspace against the current// directory; a location on another host is the daemon's to read.func absoluteWorkspace(workspace string) string { if host, _ := daemon.SplitLocation(workspace); host != "" { return workspace } if abs, err := filepath.Abs(workspace); err == nil { return abs } return workspace}
func (s *Service) PrepareOpen(ctx context.Context, options OpenOptions) (PreparedOpen, error) { id, workspace, fresh := options.SessionID, options.Workspace, options.Fresh absWorkspace, err := filepath.Abs(workspace) if err != nil { absWorkspace = workspace } absWorkspace := absoluteWorkspace(workspace)
conn, err := s.Connect(ctx) if err != nil { return PreparedOpen{}, err149 unmodified linesExpand all5 unmodified lines6789101112131420 unmodified lines35363738383940414243444539404142434445464748495051525354555657585960616263646566676869707172737475767778107 unmodified linesExpand all18618718818919019119219319421 unmodified lines21621721821919222022122222322433 unmodified lines5 unmodified lines "encoding/json" "errors" "fmt" "net/http" "strings")
type DeletionResult struct { OK bool `json:"ok"`20 unmodified lines Checkpoint int `json:"checkpoint"`}
type Session struct { ID string `json:"id"` Title string `json:"title,omitempty"` LastAssistantAt *int64 `json:"last_assistant_at,omitempty"` Workspace string `json:"workspace"` Model string `json:"model"` Effort string `json:"effort,omitempty"` Protocol string `json:"protocol"` Provider string `json:"provider"` ID string `json:"id"` Title string `json:"title,omitempty"` LastAssistantAt *int64 `json:"last_assistant_at,omitempty"` Workspace string `json:"workspace"` Location *Location `json:"location,omitempty"` Model string `json:"model"` Effort string `json:"effort,omitempty"` Protocol string `json:"protocol"` Provider string `json:"provider"`}
// Location is a session's workspace as the daemon parsed it. Host and User// are nil for a local one; Label is how the host reads, with a user ssh// would pick anyway left out.type Location struct { Host *string `json:"host"` User *string `json:"user"` Path string `json:"path"` Label *string `json:"label"`}
// SplitLocation reads a workspace as the daemon stores it: a plain path is// local, and [user@]host:/abs/path is on that host. A remote path is always// absolute and a host never holds a slash, so the first ":/" before any// slash ends the host.func SplitLocation(workspace string) (host, path string) { if workspace == "" || workspace[0] == '/' || workspace[0] == '~' { return "", workspace } i := strings.Index(workspace, ":/") if i <= 0 || strings.Contains(workspace[:i], "/") { return "", workspace } return workspace[:i], workspace[i+1:]}
func CreateSession(ctx context.Context, conn *Connection, body CreateSessionRequest) (Session, error) { handle, err := NewCreation(body) if err != nil { return Session{}, err107 unmodified linesExpand all var wire struct { ID *string `json:"id"` Title *string `json:"title"` Workspace *string `json:"workspace"` Location *Location `json:"location"` Model *string `json:"model"` Protocol *string `json:"protocol"` Provider *string `json:"provider"` Effort json.RawMessage `json:"effort"`21 unmodified lines if err := json.Unmarshal(wire.Effort, &effort); err != nil { return Session{}, &responseFieldError{field: "effort", cause: err} } } session := Session{ID: *wire.ID, Title: *wire.Title, Workspace: *wire.Workspace, Model: *wire.Model, Protocol: *wire.Protocol, Provider: *wire.Provider} session := Session{ID: *wire.ID, Title: *wire.Title, Workspace: *wire.Workspace, Location: wire.Location, Model: *wire.Model, Protocol: *wire.Protocol, Provider: *wire.Provider} if effort != nil { session.Effort = *effort } if !bytes.Equal(bytes.TrimSpace(wire.LastAssistantAt), []byte("null")) {33 unmodified lines578 unmodified linesExpand all579580581582583583584585585586587588588589590591592749 unmodified linesExpand all578 unmodified linesExpand all m.AddError(operationError(msg.Err, "Could not move this session: ", "Session may have moved; check its folder before trying again.")) } return nil } m.ActiveSession.Workspace, m.Workspace = msg.Workspace, msg.Workspace m.ActiveSession.Workspace, m.ActiveSession.Location, m.Workspace = msg.Workspace, msg.Location, msg.Workspace if i := slices.IndexFunc(m.Sessions, func(s daemon.Session) bool { return s.ID == msg.SessionID }); i >= 0 { m.Sessions[i].Workspace = msg.Workspace m.Sessions[i].Workspace, m.Sessions[i].Location = msg.Workspace, msg.Location } m.State = AppStateChat return m.Chat.Moved(msg.Workspace, msg.Retry) return m.Chat.Moved(daemon.Session{Workspace: msg.Workspace, Location: msg.Location}, msg.Retry)}
func (m *AppModel) AddNotice(message string) { if m.ActiveSession != nil && m.Chat.History != nil {749 unmodified linesExpand all320 unmodified linesExpand all321322323324325326327328329108 unmodified linesExpand all4384394404414424434444454461149 unmodified linesExpand all1596159715981599159815991600160116021603160416051606505 unmodified linesExpand all21122113211421152113211621172118211921202121212221232124212521262127212814 unmodified lines214321442145214621362147214821382149215021512152215321542155215621572158214421592160214621612162216321642165261 unmodified linesExpand all320 unmodified linesExpand all SessionID string Status daemon.AgentStatus AgentName string Workspace string Host string // the label of a remote workspace's host; "" when local Model string Effort string Provider string CopyStatus string108 unmodified linesExpand all SessionID: session.ID, Generation: gen, AgentName: "albedo", Workspace: session.Workspace, Host: sessionHost(*session), Model: session.Model, Effort: session.Effort, Provider: session.Provider, client: client,1149 unmodified linesExpand all}
// Moved takes the session's new folder, then sends again the turn a// missing folder refused.func (m *ChatModel) Moved(workspace string, retry *WorkspaceRetry) tea.Cmd { m.Workspace = workspacefunc (m *ChatModel) Moved(moved daemon.Session, retry *WorkspaceRetry) tea.Cmd { workspace := moved.Workspace m.Workspace, m.Host = workspace, sessionHost(moved) m.Renderer.Workspace = workspace m.rebuildSettledLines() // settled rows name paths from the old workspace m.refreshViewportContent() if retry == nil {505 unmodified linesExpand all// short, shedding the least useful parts first: the middle of the path, the// brand, the glance counts from the last, then the path down to its name,// which alone may squeeze the rule to one cell.func (m ChatModel) header(width int) string { workspace := homePath(cmp.Or(m.Workspace, "chat")) host, workspace := daemon.SplitLocation(cmp.Or(m.Workspace, "chat")) // a remote workspace's host stays whole in every layout lead := "" if host == "" { workspace = homePath(workspace) } else { host = cmp.Or(m.Host, host) lead = host + ":" } model := m.Model if m.Effort != "" { model += ":" + m.Effort }14 unmodified lines } layouts = append(layouts, layout{brand: false, place: filepath.Base(workspace), counts: 0, rule: 1}) for _, l := range layouts { right := strings.Join(append(counts[:l.counts:l.counts], model), " ") left := l.place left := lead + l.place if l.brand { left = "✦ " + m.AgentName + " on " + l.place left = "✦ " + m.AgentName + " on " + left } if lipgloss.Width(left)+lipgloss.Width(right)+l.rule+2 > width { continue } place := m.Styles.Muted.Render(l.place) if host != "" { place = hostStyle(host).Render(host) + m.Styles.Muted.Render(":"+l.place) } if l.brand { return titleRule(width, located(m.AgentName, l.place), m.Styles.Faint.Render(right)) return titleRule(width, brand(m.AgentName)+m.Styles.Faint.Render(" on ")+place, m.Styles.Faint.Render(right)) } return titleRule(width, m.Styles.Muted.Render(l.place), m.Styles.Faint.Render(right)) return titleRule(width, place, m.Styles.Faint.Render(right)) } return m.Styles.Faint.Render(ansi.Truncate(model, width, "…"))}
261 unmodified linesExpand all59 unmodified lines60616263646566676833 unmodified lines10210310410510510610710810911011111211311311411511611711711811811912012112212312412312512612712812925 unmodified lines1551561571581571581591591601611621631641656 unmodified lines17217317417517417617717817918031 unmodified lines212213214215216217214218219220221222126 unmodified linesExpand all349350351352349353354355356357259 unmodified linesExpand all59 unmodified lines Err error Retry *WorkspaceRetry SessionID string Workspace string Location *daemon.Location}
type FolderPickerCancelMsg struct{}
33 unmodified lines// path is, and whether it exists.func moveCmd(src folderSource, id, workspace, input string, retry *WorkspaceRetry) tea.Cmd { return func() tea.Msg { target := folderRequest(workspace, input) if !strings.HasPrefix(target, "/") { if host, _ := daemon.SplitLocation(target); host == "" && !strings.HasPrefix(target, "/") { list, err := src.List(target) if err != nil { return FolderMovedMsg{SessionID: id, Retry: retry, Err: err} } target = list.Path } s, err := src.Move(id, target) return FolderMovedMsg{SessionID: id, Workspace: cmp.Or(s.Workspace, target), Retry: retry, Err: err} return FolderMovedMsg{SessionID: id, Workspace: cmp.Or(s.Workspace, target), Location: s.Location, Retry: retry, Err: err} }}
// folderRequest is how the daemon is asked about a typed folder: ~ and// absolute paths as they are, anything else under the current folder.// folderRequest is how the daemon is asked about a typed folder: ~,// absolute paths and host:/paths as they are, anything else under the// current folder.func folderRequest(workspace, typed string) string { if typed != "/" { typed = strings.TrimSuffix(typed, "/") } if strings.HasPrefix(typed, "/") || typed == "~" || strings.HasPrefix(typed, "~/") { if host, _ := daemon.SplitLocation(typed); host != "" || strings.HasPrefix(typed, "/") || typed == "~" || strings.HasPrefix(typed, "~/") { return typed } return path.Join(workspace, typed)}25 unmodified lines return weight}
type recentFolder struct { last *int64 path string score float64 last *int64 path, host string score float64}
// recentFolders are the distinct workspaces of sessions, most frecent// first, with the current one leading.6 unmodified lines } i, ok := index[s.Workspace] if !ok { i, index[s.Workspace] = len(out), len(out) out = append(out, recentFolder{path: s.Workspace}) out = append(out, recentFolder{path: s.Workspace, host: sessionHost(s)}) } f := &out[i] f.score += frecency(s.LastAssistantAt, now) if s.LastAssistantAt != nil && (f.last == nil || *s.LastAssistantAt > *f.last) {31 unmodified lines// folderRow is one folder in the picker's list.type folderRow struct { age *int64 path, name string // host labels a remote folder's host. host string matched []int matched []int // recent rows show where the folder is beside its name. recent bool // repo says the folder may be in a repository worth asking about. repo bool126 unmodified linesExpand all var candidates []folderRow if !listing { m.section = "recent" for _, f := range recentFolders(m.sessions, m.workspace(), time.Now()) { candidates = append(candidates, folderRow{path: f.path, name: path.Base(f.path), recent: true, age: f.last, repo: true}) candidates = append(candidates, folderRow{path: f.path, host: f.host, name: path.Base(f.path), recent: true, age: f.last, repo: true}) } } else { m.listed = folderRequest(m.workspace(), dir) m.section = "in " + m.homed(m.listed)259 unmodified linesExpand all173 unmodified linesExpand all17417517617717817918018118218318418518618718818919019115 unmodified lines207208209210201211212213214215291 unmodified linesExpand all173 unmodified linesExpand all } return best}
// where is the folder a row is in, led by its host when it is remote.func (m FolderPicker) where(row folderRow) string { host, p := daemon.SplitLocation(row.path) crumbs := crumbParts(m.homed(path.Dir(p))) if host != "" { crumbs = append([]string{cmp.Or(row.host, host)}, crumbs...) } return strings.Join(crumbs, " › ")}
// row is a folder in the sessions view's grammar: bar, glyph, name with// the matched letters lit, then quiet columns for where, head and age.func (m FolderPicker) row(row folderRow, selected, here, latest bool, width int, now time.Time) string { glyph, glyphStyle := "· ", DefaultStyles.Faint15 unmodified lines bar = selectBar() } line := bar + glyphStyle.Render(glyph) + " " + litName(row.name, row.matched, nameW, selected) if whereW > 0 { line += " " + DefaultStyles.Faint.Render(svCell(strings.Join(crumbParts(m.homed(path.Dir(row.path))), " › "), whereW, false)) line += " " + DefaultStyles.Faint.Render(svCell(m.where(row), whereW, false)) } line += " " + DefaultStyles.Faint.Render(svCell(repoTag(m.repos[row.path]), tagW, false)) age := "" if here {291 unmodified linesExpand all123234567826 unmodified lines353637383940414243444546474849505152535455565758596061626364656667// The chat header drops whole parts as the terminal narrows instead of// cutting one short. The layout depends only on the width, so the rule is// checked across every width here; an e2e would see one terminal size.// cutting one short, and a remote workspace's host is never one of them.// The layout depends only on the width, so the rule is checked across every// width here; an e2e would see one terminal size.package tui
import ( "strings"26 unmodified lines if header := ansi.Strip(m.header(100)); !strings.Contains(header, "pending work 1 open vents 1") { t.Fatalf("a wide header without a sidebar should count every glance: %q", header) }}
func TestRemoteHeaderKeepsTheHostWholeAndFoldsOnlyThePath(t *testing.T) { label := "chernobog" m := newTestChatModel(t, &daemon.Session{ ID: "s", Workspace: "mayer@chernobog:/home/mayer/proj/albedo", Location: &daemon.Location{Label: &label}, }) m.AgentName, m.Model = "albedo", "claude-opus-4-6" const model = "claude-opus-4-6" if header := ansi.Strip(m.header(120)); !strings.Contains(header, "✦ albedo on chernobog:/home/mayer/proj/albedo") { t.Fatalf("a wide header should name the host and the whole path: %q", header) } for width := 1; width <= 120; width++ { header := ansi.Strip(m.header(width)) if got := ansi.StringWidth(header); got > width { t.Fatalf("width %d: header %q is %d wide", width, header, got) } if strings.Contains(header, "mayer@") { t.Fatalf("width %d: header %q shows the user the label left out", width, header) } if strings.Contains(header, "/") && !strings.Contains(header, "chernobog:") { t.Fatalf("width %d: header %q shows the path without its host", width, header) } if width >= len("chernobog:albedo")+len(model)+3 && !strings.Contains(header, "chernobog:") { t.Fatalf("width %d: header %q dropped the host", width, header) } }}123456789101112131415161718192021222324252627282930313233package tui
import ( "cmp"
"albedo/cli/internal/daemon")
// sessionHost is how a session's host reads: the daemon's label, which// leaves out a user ssh would pick anyway, else the host as stored. Local// sessions have none.func sessionHost(s daemon.Session) string { if s.Location != nil && s.Location.Label != nil { return *s.Location.Label } host, _ := daemon.SplitLocation(s.Workspace) return host}
// placeText is a workspace as one line of text: a local path under home// starts with ~, a remote one leads with its host (label, else as stored).func placeText(workspace, label string) string { host, p := daemon.SplitLocation(workspace) if host == "" { return homePath(workspace) } return cmp.Or(label, host) + ":" + p}
// sessionPlace is where a session works, as placeText shows it.func sessionPlace(s daemon.Session) string { return placeText(s.Workspace, sessionHost(s))}85 unmodified lines8687888990919293949596979899100101102447 unmodified linesExpand all85 unmodified lines ArchiveView bool Saving bool}
// workspacePlace is the viewer's folder as text, its host labelled the way// the daemon labels it on any session there.func (m SessionViewer) workspacePlace() string { if i := slices.IndexFunc(m.raw, func(s daemon.Session) bool { return s.Workspace == m.Workspace }); i >= 0 { return sessionPlace(m.raw[i]) } return placeText(m.Workspace, "")}
func NewSessionViewer(workspace string) SessionViewer { p := NewPickerModel("", sessionViewerActions(workspace), true, "new") p.SearchInput.Placeholder = "search sessions, models, folders…" st := p.SearchInput.Styles()447 unmodified linesExpand all196 unmodified linesExpand all197198199200201201202203204205267 unmodified linesExpand all47347447547647747747847948048111 unmodified lines493494495496497497498499500501110 unmodified linesExpand all196 unmodified linesExpand all label := "albedo" if m.ArchiveView { label = "albedo archive" } return " " + titleRule(width-1, located(label, sessionText(homePath(m.Workspace))), "") return " " + titleRule(width-1, located(label, sessionText(m.workspacePlace())), "")}
func (m SessionViewer) footer(width int, now time.Time) string { if m.ConfirmDelete != "" {267 unmodified linesExpand all if s.Model != "" { meta = append(meta, DefaultStyles.Muted.Render(sessionText(s.Model))) } if s.Workspace != "" { meta = append(meta, DefaultStyles.Muted.Render(sessionText(homePath(s.Workspace)))) meta = append(meta, DefaultStyles.Muted.Render(sessionText(sessionPlace(s)))) } meta = append(meta, DefaultStyles.Faint.Render(presentation.AssistantAge(s.LastAssistantAt, now))) if c := m.previews[s.ID]; c != nil && !c.loading && !c.err { meta = append(meta, DefaultStyles.Faint.Render(fmt.Sprintf("%d messages", c.Total)))11 unmodified lines if logo := svLogo(); height >= len(logo)+8 { lines = append(append(lines, ""), logo...) } lines = append(lines, "", gradientText("New session", true), "", DefaultStyles.Muted.Render("start fresh in ")+lipgloss.NewStyle().Render(sessionText(homePath(m.Workspace))), "", DefaultStyles.Muted.Render("start fresh in ")+lipgloss.NewStyle().Render(sessionText(m.workspacePlace())), "", DefaultStyles.Faint.Render("enter ")+DefaultStyles.Muted.Render("begin")) case "archive": lines = []string{"", "", DefaultStyles.Muted.Bold(true).Render("▤ Archive"), "", DefaultStyles.Muted.Render("Sessions kept out of the main list."), "",110 unmodified linesExpand all