Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <app>` 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 <state>` on the grey row (`hostedApp` excludes them); Enter opens `claude attach <id>` in a new tmux window or iTerm tab (`attachCmd`, never in place), ^X K pressed twice runs `claude stop <id>` (`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 <app>` 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 <state>` on the grey row (`hostedApp` excludes them); Enter opens `claude attach <id>` in a new tmux window or iTerm tab (`attachCmd`, never in place), ^X K pressed twice runs `claude stop <id>` (`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
Expand Down
13 changes: 11 additions & 2 deletions live.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}
12 changes: 9 additions & 3 deletions live_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
11 changes: 10 additions & 1 deletion ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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), "…")))
}
Expand Down Expand Up @@ -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
}
Loading