diff --git a/AGENTS.md b/AGENTS.md index 2bf436d..1353640 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,7 +120,7 @@ One package, split by area (all `package main`, same directory): - `parseCache` - Reuses parsed conversations whose file size+mtime are unchanged, so refreshes only reparse changed files - `openResumeTab()` / `resumeInTmuxWindow()` / `resumeInITermTab()` - Opens the selected conversation in a new tmux window or iTerm tab (focus stays on ccs); falls back to exec-in-place elsewhere - `recordOpen()` / `reopenCheckCmd()` / `reopenPopup()` / `reopenCmd()` - reopening after a restart: each live tick (once the last record has been read) writes the live SessionIDs with `bootID()` (Linux boot_id, else macOS `kern.boottime`) to `openRecordPath` when the set changes. At startup, a record from another boot offers its sessions that aren't open again and are still listed, in a popup that owns the keyboard (enter reopens all in new tabs via `openResumeTab`, skipping any open by then and stopping at the first failure; esc not now). Only works if a ccs was running before the restart -- `focusSession()` / `tmuxPaneForTTY()` - opening a live session (Enter on its empty message box): find its terminal by the claude pid's tty (`liveSessionPIDs()`) and select that tmux pane or iTerm tab instead of resuming a second copy. A claude with no terminal (tty `??`: an app such as T3 Code started it with pipes) can't be switched to: `hostOf` (cached per pid and start time, from the live tick) names the app (the first `.app` bundle among its parents, else its parent command), the list shows a hollow `○` and `in ` on the grey row, Enter says to open it there (`hostedElsewhere`, in `focusCmd`/`resumeCmd`; ^F still forks, ^S still messages it over its socket) and auto-focus skips it. Tests stub `hostOf`. Background sessions (`claude --bg`) have no terminal either, but can be opened: every `agentsInterval` (10s) the live tick asks `claude agents --json` (`listAgents`, stubbed in tests; nil on failure keeps the last answer) for `kind: background` sessions (`m.bg`: short `id`, `state`). They keep `●` and show `bg ` on the grey row (`hostedApp` excludes them); Enter opens `claude attach ` in a new tmux window or iTerm tab (`attachCmd`, never in place), ^X K pressed twice runs `claude stop ` (`stopCmd`), and they nest under the session whose Bash ran `claude --bg` (`isSpawn`) +- `focusSession()` / `tmuxPaneForTTY()` - opening a live session (Enter on its empty message box): find its terminal by the claude pid's tty (`liveSessionPIDs()`) and select that tmux pane or iTerm tab instead of resuming a second copy. A claude with no terminal (tty `??`: an app such as T3 Code started it with pipes) can't be switched to: `hostOf` (cached per pid and start time, from the live tick) names the app (the first `.app` bundle among its parents, edition dropped by `appName`: `T3 Code`; else its parent command), the list shows a hollow `○` and `in ` on the grey row, Enter says to open it there as a grey note, not a red error (`hostedElsewhere`, `note()`: `noteMsg` marks the error row's text as information; in `focusCmd`/`resumeCmd`; ^F still forks, ^S still messages it over its socket) and auto-focus skips it. Tests stub `hostOf`. Background sessions (`claude --bg`) have no terminal either, but can be opened: every `agentsInterval` (10s) the live tick asks `claude agents --json` (`listAgents`, stubbed in tests; nil on failure keeps the last answer) for `kind: background` sessions (`m.bg`: short `id`, `state`). They keep `●` and show `bg ` on the grey row (`hostedApp` excludes them); Enter opens `claude attach ` in a new tmux window or iTerm tab (`attachCmd`, never in place), ^X K pressed twice runs `claude stop ` (`stopCmd`), and they nest under the session whose Bash ran `claude --bg` (`isSpawn`) - Ctrl+F forks: `openResumeTab` / exec with `--fork-session` - `latestRelease()` / `newerVersion()` / `chooseUpgrader()` / `upgrader` - Self-update: at startup and every `updateCheckInterval` (2 min; `updateCheckBackoff` 10 min after a failed check), read the newest tag from the `github.com/.../releases/latest` redirect (not the REST API, whose 60/h unauthenticated limit shared IPs exhaust). If newer, `upgrader.Prepare` starts the slow, side-effect-free part in the background (Homebrew: `git pull` of just the ccs tap + `brew fetch`; otherwise download + verify against `checksums.txt`) while a popup over the preview offers Enter = install and re-exec, Esc = later (for that version, this session). `Install` waits for an in-flight prepare, then: Homebrew kegs (`/Cellar/`) only ever `brew upgrade agentic-utils/tap/ccs` (never overwritten); `/nix/` notify only; anything else atomically renames the verified binary over the old. The header shows the current step and elapsed seconds (`updateProgress`). Keys are ignored for `updateKeyGrace` after the popup opens. `dev` builds never check - `releaseChangelog()` / `parseChangelog()` / `changelogBlock()` - when an update is offered, the popup lists what changed in every release after the running version up to the new one (the `releases.atom` feed, not the REST API; it holds the latest 10 releases), commit subjects under each version, capped at `maxChangelogLines`. GoReleaser drops merge commits and hashes from the notes. A failed fetch is logged and the popup shows no list diff --git a/live.go b/live.go index fc6f3d7..297e2d4 100644 --- a/live.go +++ b/live.go @@ -750,7 +750,7 @@ func findHost(pid int) string { } comm = strings.TrimSpace(comm) if i := strings.Index(comm, ".app/"); i >= 0 { - return filepath.Base(comm[:i]) + return appName(filepath.Base(comm[:i])) } if first == "" { first = filepath.Base(comm) @@ -780,7 +780,7 @@ func (m *model) hostedElsewhere(id string) bool { if h == "" || !m.live[id] { return false } - m.errorMsg = "Runs in " + h + ", not a terminal: open it there (^S messages it, ^F forks it)" + m.note("In " + h + ": open it there · ^S messages it") return true } @@ -861,3 +861,12 @@ func stopCmd(name string, b bgSession) tea.Cmd { return stopDoneMsg{name, err} } } + +// appName is an app's name without a parenthesised edition: "T3 Code (Beta)" +// reads "T3 Code", short enough for the list's grey row. +func appName(s string) string { + if i := strings.Index(s, " ("); i > 0 { + return s[:i] + } + return s +} diff --git a/live_test.go b/live_test.go index 504bc11..4fd508d 100644 --- a/live_test.go +++ b/live_test.go @@ -376,15 +376,21 @@ func TestHostedSessionIsNotSwitchable(t *testing.T) { m.width, m.height = 140, 30 m.live = map[string]bool{"t3": true} m.livePIDs = map[string]int{"t3": 99999} - m.hosts = map[string]string{"t3": "T3 Code (Beta)"} + m.hosts = map[string]string{"t3": "T3 Code"} m.chatStatus = map[string]sessionStat{"t3": {status: "idle"}} v := strip2(m.View()) - if !strings.Contains(v, "○ from t3") || !strings.Contains(v, "in T3 Code (Beta)") { + if !strings.Contains(v, "○ from t3") || !strings.Contains(v, "in T3 Code ·") { t.Errorf("a session in an app gets a hollow mark and says where it runs:\n%s", v) } - if cmd := m.resumeCmd(conv, false); cmd != nil || !strings.Contains(m.errorMsg, "Runs in T3 Code (Beta)") { + if cmd := m.resumeCmd(conv, false); cmd != nil || !strings.Contains(m.errorMsg, "In T3 Code: open it there") { t.Errorf("enter doesn't try to switch to or resume it: %q", m.errorMsg) } + if m.noteMsg != m.errorMsg { + t.Error("saying where it runs is a note, not a red error") + } + if got := appName("T3 Code (Beta)"); got != "T3 Code" { + t.Errorf("the edition is dropped: %q", got) + } if m.resuming { t.Error("nothing is opening") } diff --git a/ui.go b/ui.go index c312d5d..683df07 100644 --- a/ui.go +++ b/ui.go @@ -80,6 +80,7 @@ type model struct { renameIndex int // Index of item being renamed renameInput textinput.Model errorMsg string // Show deletion/prune errors + noteMsg string // errorMsg set by note(): information, not an error, so not red preview *previewCache // memoised preview lines for the selected conversation hits *hitCounter // memoised per-query hit counts, keyed by SessionID lastFilterQuery string // lowercased query the current m.filtered was built from @@ -746,7 +747,7 @@ func (m model) update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.err != nil { m.errorMsg = "Couldn't stop " + msg.name + ": " + msg.err.Error() } else { - m.errorMsg = "Stopped " + msg.name + " (Enter resumes it)" + m.note("Stopped " + msg.name + " (Enter resumes it)") } return m, nil @@ -1878,6 +1879,9 @@ func (m model) viewScreen() string { // Show error message if set if m.errorMsg != "" { errorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196")) + if m.errorMsg == m.noteMsg { + errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("250")) + } msg := strings.Join(strings.Fields(m.errorMsg), " ") // one row: listLayout counts it as one sections = append(sections, " "+errorStyle.Render(ansi.Truncate(msg, max(m.width-3, 1), "…"))) } @@ -2505,3 +2509,8 @@ func editKey(in textinput.Model, msg tea.Msg) (textinput.Model, tea.Cmd) { } return in, cmd } + +// note shows msg in the error row, plainly: something to know, not a failure. +func (m *model) note(msg string) { + m.errorMsg, m.noteMsg = msg, msg +}