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
8 changes: 4 additions & 4 deletions AGENTS.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export CCS_CLAUDE_ARGS="--permission-mode auto"
- Background sessions (`claude --bg`) show `bg <state>` on the grey row: Enter opens one with `claude attach` in a new tmux window or iTerm tab, and `Ctrl+X K` twice stops it. One started from another session's `claude --bg` sits under it
- Messages between sessions show at both ends: the sender's preview shows each `SendMessage` as `To <session>` (`✓ arrived` once it's in that session's transcript), the recipient's `From <session>`
- A live session run by an app without a terminal (T3 Code, for one) shows a hollow `○` and `in <app>`: ccs can't switch to it, so Enter says to open it there; messaging it (`Ctrl+S`) and forking it (`Ctrl+F`) still work
- Each open session has a circle, `●` (or `○` in an app ccs can't open, like T3 Code), coloured by its state: magenta waiting on you, light magenta asked you something, cyan done, green idle, amber busy
- The list is in sections by state, each with a header: waiting on you, asked you something, done (finished since you last looked at it in ccs, a cyan `●`; it moves to idle once you select it and move on), idle, busy, not open in `claude`
- `Ctrl+X W` - Sessions waiting on you: their question, a box to answer, Enter opens the session's tab, Esc snoozes
- `Ctrl+X I` - Automatic updates, on by default: a new ccs release downloads in the background and installs once you're away (ccs not focused, or no key for 3 minutes, with nothing unsent), then ccs restarts where you were and says it updated itself, with what changed. Off, new versions are offered in a popup instead. The header shows which (`auto-update on`), remembered across restarts
Expand Down
2 changes: 1 addition & 1 deletion docs/design/input-prompt-overlay.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Any change to the overlay must keep these true:
- **A wait starts when a session enters `waiting`**, from any other status, or gets a new `statusUpdatedAt` while waiting (when the file has one). It ends when the session leaves `waiting`. A new prompt that arrives within one tick is a new wait, and so is busy-then-waiting in a file with no timestamp.
- **A snooze, and having been seen, belong to one wait** and end with it. A wait is seen once the overlay has shown it; only seen or snoozed waits stop being the one the overlay opens on, so a later wait is never passed over. Opening the overlay shows snoozed sessions without ending their snoozes. A snooze survives ccs restarting itself after an update, matched to the wait by `statusUpdatedAt` (without one it can't be matched, so it goes).
- **A session is forgotten only once its process is gone.** A session file that's missing or can't be parsed keeps the last known state while its pid is still the process that wrote it: the same start time, or for `keepTicks` when that was never known. Not once its pid's file names another session (/clear). Nothing is pruned, snoozed or shown because of a gap.
- **Nothing pops up.** A waiting session sorts to the top of the list with a bright `?`, and the title row counts menus and prompts (`^X W N waiting`); every key, Esc included, does what it would without them. The count offers Ctrl+X W only when Ctrl+X W would work (not while another popup or a prompt has the keys). Only Ctrl+X W opens the overlay, which then owns the keyboard until closed, on the wait not yet seen (`banner()`) first.
- **Nothing pops up.** A waiting session sorts to the top of the list with a magenta `●`, and the title row counts menus and prompts (`^X W N waiting`); every key, Esc included, does what it would without them. The count offers Ctrl+X W only when Ctrl+X W would work (not while another popup or a prompt has the keys). Only Ctrl+X W opens the overlay, which then owns the keyboard until closed, on the wait not yet seen (`banner()`) first.
- **Text is only ever delivered to the session shown when Enter was pressed.** Each session keeps its own draft in both boxes (the main box's follows the selection), and text that wasn't sent goes back to its session's draft in the box it came from, appended to anything written since. It's never dropped silently: a kept draft is discarded only when its session exits, and ccs says so.
- **ccs never types into a session that is showing a menu, or whose pid couldn't be checked.** Typed keys could pick an option, or reach a stranger's recycled pid, so the typing fallback re-reads the session file just before typing and refuses a `waiting` or unverified session.
- **The popups over the overlay are listed in one place** (`overPopups`, bottom first). `View` draws the overlay and then them in that order, and `popupAboveWait` feeds key routing and the mouse. Nothing opens over the overlay from the keyboard, so in practice only the update offer sits above it.
Expand Down
27 changes: 17 additions & 10 deletions ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -592,15 +592,21 @@ func groupOf(it listItem) string {
// (negative, in pin order) share one.
func sectionOf(state int) int { return max(state, -1) }

// doneColour marks a session that finished since you last looked.
const doneColour = "1;36"
// doneColour marks a session that finished since you last looked;
// waitColour one waiting on a menu or prompt, askedColour one whose last
// message asks something.
const (
doneColour = "1;36"
waitColour = "38;5;201"
askedColour = "38;5;219"
)

// pinnedSection heads the pinned sessions, above every state.
var pinnedSection = struct{ name, colour string }{"pinned", "1;37"}

// stateSections name liveGroup's values, the list's sections, and colour them.
var stateSections = [...]struct{ name, colour string }{
{"waiting on you", "1;" + amber}, {"asked you something", "32"}, {"done", doneColour}, {"idle", "32"}, {"busy", amber}, {"not open in claude", "90"}, {"settled", "90"}, {"snoozed", "90"},
{"waiting on you", "1;" + waitColour}, {"asked you something", askedColour}, {"done", doneColour}, {"idle", "32"}, {"busy", amber}, {"not open in claude", "90"}, {"settled", "90"}, {"snoozed", "90"},
}

// spawnParent is the session whose claude -p started c, "" if none is known.
Expand Down Expand Up @@ -2133,26 +2139,27 @@ func listTopic(conv Conversation, w int) string {
func (m model) listMarks(item listItem) (string, string) {
conv := item.conv
// Status icons, packed right against the topic so titles stay aligned:
// ● open in a running claude (? instead when it waits on you: bright for
// a prompt, green when its last message asks something), ! its last
// ● open in a running claude (○ in an app ccs can't open; coloured by its
// state, magenta when it waits on you), ! its last
// action errored. A session another one started sits └ under it instead
// of a mark: entrypoint sdk-cli alone can't tell a script's claude -p from
// a session typed in through an app.
// ponytail: the glyphs are ambiguous-width, so a marked row may sit one
// cell off on CJK-width terminals - cosmetic only.
var icons, colouredIcons string
n := 0
// Green when idle, amber while busy, an amber ? when a menu or prompt
// waits on you; a green ? when idle and its last message asks something.
// Green when idle, amber while busy, cyan when done since you looked,
// magenta when a menu or prompt waits on you, light magenta when idle and
// its last message asks something.
liveGlyph, liveColour := "●", "32"
if m.hostedApp(conv.SessionID) != "" { // open in an app ccs can't switch to
liveGlyph = "○"
}
switch m.waitSignal(conv) {
switch m.waitSignal(conv) { // the circle stays (● or ○ says whether ccs can open it); its colour says why
case waitPrompt:
liveGlyph, liveColour = "?", "1;"+amber
liveColour = waitColour
case waitAsked:
liveGlyph = "?"
liveColour = askedColour
default:
if m.chatStatus[conv.SessionID].status == "busy" {
liveColour = amber
Expand Down
4 changes: 2 additions & 2 deletions ui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1201,8 +1201,8 @@ func TestLiveMarkerColours(t *testing.T) {
if r := row("busy"); !strings.Contains(r, "\033["+amber+"m●") {
t.Errorf("busy should be an amber ●: %q", r)
}
if r := row("waiting"); !strings.Contains(r, "\033[1;"+amber+"m?") {
t.Errorf("waiting on you should be an amber ?: %q", r)
if r := row("waiting"); !strings.Contains(r, "\033["+waitColour+"m●") {
t.Errorf("waiting on you should be a magenta ●: %q", r)
}
}

Expand Down
2 changes: 1 addition & 1 deletion wait.go
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,7 @@ func askLines(conv Conversation, width int) []string {
if q.MultiSelect {
question += " (pick any)"
}
out = append(append(out, ""), wrapped("\033[1;"+amber+"m? ", "\033[1;"+amber+"m", question)...)
out = append(append(out, ""), wrapped("\033[1;"+waitColour+"m? ", "\033[1;"+waitColour+"m", question)...)
for i, o := range q.Options {
out = append(out, wrapped(fmt.Sprintf(" \033[1m%d\033[0m ", i+1), "", cleanText(o.Label))...)
if o.Description != "" {
Expand Down
16 changes: 10 additions & 6 deletions wait_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -390,8 +390,8 @@ func TestWaitMarkersAndHeaderCount(t *testing.T) {
}
for _, id := range []string{"live", "asks"} {
for _, item := range m.filtered {
if item.conv.SessionID == id && !strings.Contains(strip2(m.formatListItem(item, false)), "? ") {
t.Errorf("%s should show ? in place of ●", id)
if item.conv.SessionID == id && !strings.Contains(strip2(m.formatListItem(item, false)), "● ") {
t.Errorf("%s keeps its ●, coloured for the question", id)
}
}
}
Expand Down Expand Up @@ -971,12 +971,16 @@ func TestQuestionMarkColours(t *testing.T) {
m := initialModel(buildItems([]Conversation{conv}), "", nil)
m.live = map[string]bool{"s": true}
m.chatStatus = map[string]sessionStat{"s": {status: "idle"}}
if _, marks := m.listMarks(listItem{conv: conv}); !strings.Contains(marks, "\033[32m?") {
t.Errorf("idle with a question in its last message: a green ?, got %q", marks)
if _, marks := m.listMarks(listItem{conv: conv}); !strings.Contains(marks, "\033["+askedColour+"m●") {
t.Errorf("idle with a question in its last message: a light magenta ●, got %q", marks)
}
m.chatStatus["s"] = sessionStat{status: "waiting"}
if _, marks := m.listMarks(listItem{conv: conv}); !strings.Contains(marks, "\033[1;"+amber+"m?") {
t.Errorf("a menu or prompt waiting: an amber ?, got %q", marks)
if _, marks := m.listMarks(listItem{conv: conv}); !strings.Contains(marks, "\033["+waitColour+"m●") {
t.Errorf("a menu or prompt waiting: a magenta ●, got %q", marks)
}
m.hosts = map[string]string{"s": "T3 Code"}
if _, marks := m.listMarks(listItem{conv: conv}); !strings.Contains(marks, "\033["+waitColour+"m○") {
t.Errorf("one in an app keeps its hollow ○ while waiting: %q", marks)
}
}

Expand Down
Loading