diff --git a/AGENTS.md b/AGENTS.md index e6548b2..a1bafc9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,7 +98,7 @@ One package, split by area (all `package main`, same directory): - `previewLines()` - Memoised `buildPreviewLines` for the selected conversation (rebuilt only when selection, query, message count or width changes), avoids per-frame rescans of huge conversations. It waits `previewDebounce` (150ms) after the selection changes (`noteSelection`, `selectedAt`; a `previewSettledMsg` redraws), so scrolling through the list draws only each preview's header and builds nothing for sessions passed on the way (a big one takes 100-200ms); the selected session's own PR lookup waits too. The first selection isn't delayed; tests set it to 0 - `queryMatches()` / `containsAll()` / `nameWords()` / `matchRank()` - the one search rule, shared by the list filter, HITS, the preview's matches, code-block expansion and `ccs mcp`: every word of the query starts a word in the text (`wordStartIndex`: "self" matches "self-revoke" and "selfish", not "itself"; no mid-word fragments), in any order within one part of the search text, i.e. the same message or the id/title/project (parts are joined by `searchSep`, so the list never shows a session with 0 HITS), or every word appears in the session's name with dashes/underscores read as spaces; `highlight` marks each word at word starts. Results rank by `matchRank` (named after the query, then the words together as a phrase via `hasPhrase`, then the rest), newest first within each; typing more narrows from the previous results only when the new query extends the old one (`HasPrefix`) - `hitCount()` / `countHits()` - HITS column, shown only while a search is typed (`searching()`; `topicColWidth` takes the space back otherwise). Memoised per-query HITS count (messages containing every query word), keyed by SessionID, so `formatListItem` doesn't rescan every visible row each frame -- `sortList()` / `liveGroup()` - list order: sessions waiting on a menu or prompt first (amber `?`), then idle ones whose last message asks something (green `?`), then done (idle, its last message newer than when you last looked: `unseen`; a cyan `●`, `doneColour`), idle, busy, and those not open in claude; then search rank (`listItem.rank`), then newest. Statuses change on ticks, so `Update` re-sorts after every message, keeping the cursor on its session: the selection never changes without the user +- `sortList()` / `liveGroup()` - list order: sessions waiting on a menu or prompt first (a magenta `●`, `waitColour`), then idle ones whose last message asks something (light magenta, `askedColour`), then done (idle, its last message newer than when you last looked: `unseen`; a cyan `●`, `doneColour`), idle, busy, and those not open in claude; then search rank (`listItem.rank`), then newest. Statuses change on ticks, so `Update` re-sorts after every message, keeping the cursor on its session: the selection never changes without the user - `prCmd()` / `ghPRBatch()` / `prInfo.status()` / `prLines()` / `prCell()` - a session's PRs are the `pr-link` records Claude Code writes whenever a gh command in it touches a PR (`Conversation.PRs`, github.com addresses only, validated by `prURL` since they go into a GraphQL query). `prCmd` runs from `Update`. The selected session's PRs not looked up yet get a request of their own (`prSelBusy`, `prSelAsked` stops retries until the next round) that never waits behind a round. Once a minute (`prNext`, after the previous round has drained) a round queues every PR not merged or closed of sessions live, active within `prScope` (7 days) or selected, `prBatch` per request (`prQueue`), `prParallel` in flight (`prRound`), each applied as it lands; merged/closed PRs are asked once. A PR GitHub returns null for is `GONE` ("not found"), never left "checking…"; without gh it says it needs gh. `ghPRBatch` asks about `prBatch` PRs per `gh api graphql` call (aliased fields) and reads the data even when gh exits non-zero over a missing PR. "Ready to merge" is GitHub's `mergeStateStatus` CLEAN/HAS_HOOKS/UNSTABLE on an open non-draft PR; `status()` groups the rest: needs you (conflicts, checks failing, named: the rollup's contexts, check runs whose conclusion `failedCheck` counts and commit statuses in FAILURE/ERROR, at most three then +N; also on "optional checks failing", which an UNSTABLE PR says only with a failed check, else "optional checks running" while the rollup is pending; changes requested, behind), waiting (checks running, review required, draft, unknown), then merged, closed. The PRS column counts them by group (`✓` ready, `✗` needs you, plain waiting, `●` merged; closed and not found left out), in GitHub's colours: green ready, amber waiting, red to fix or closed, purple merged, grey draft or not looked up yet; the preview header lists open PRs in that order (at most `maxPRLines`) and one line counting merged/closed; ^O lists all of them first (`linksWithPRs`). No gh on PATH: `fetchPRs` is nil and nothing is looked up - `noteCall()` / `noteResult()` / `noteEnds()` / `runningTasks()` / `taskLines()` - background tasks: Bash run in the background and Monitors (subagents are counted apart, below, so never twice). A Bash or Monitor call (`conv.calls`) becomes a task (`conv.tasks`, `bgTask`) when its result starts with Claude Code's own words for it ("Command running in background", "Monitor started" with its expiry); anywhere else in a result it's only output mentioning them. A `` line marks one completed, failed or stopped by its tool-use id. Running = no end yet, the session open in claude, started after its current claude process (`lastScan`'s procStart: a resume or exit takes tasks with it unreported), and a monitor not past its expiry. A task killed without a notification still counts as running (its output file ends "[killed]"; not read). The list shows `⧗N` in its own light blue (`taskColour`; on the sidebar's title row, before HITS while searching (which stay lined up at the edge), so the grey row below keeps the PRs to itself; the table after the title); the preview lists up to `maxTaskLines` with kind and age, then one line counting the ended - `subagentsOf()` / `runningSubagents()` / `subagentLines()` - a session's subagents (Agent tool, async or teammates), from the `meta.json` Claude Code writes beside each transcript in `/subagents/` (name or description, `finished`), started at the transcript's first line; read at most every `subagentsTTL` (2s) per session. They never appear in the list, only as a pink `◆N` running count beside the ⧗ (`subagentColour`) and a Subagents block in the preview header: one line per running one (at most `maxTaskLines`, with age), then a count of idle, finished and ended with the session. Running = not finished (older Claude Code wrote no flag), the session open in claude, started by its current claude process (`procStart`: a resume or exit takes them with it), and its transcript written within `subagentIdle` (10 min); alive but quiet longer is idle (a teammate waiting for work or shutdown). A start time comes from the first of a transcript's first lines with a timestamp, else its file date @@ -107,7 +107,7 @@ One package, split by area (all `package main`, same directory): - `contextWindow()` / `ctxColour()` - CTX column traffic light (green/yellow/amber/red/flashing, thresholds scaled to a 200k or 1M window inferred from the model, or proven 1M by a context over 200k) - `Conversation.ActiveModel` / `shortModel()` - the model in use: the last reply's `message.model`, or a later `/model` switch parsed from its logged `Set model to …` output (`Model1M` when it names a 1M-context model). MODEL list column shows it short (`opus 5.5 1M`); the preview shows it exactly, noting a switch since the last reply - `sessionStats()` / `tokenUsage.effective()` - preview header: model, context of window, summed usage (one reply spans several lines with the same `message.id`; counted once), effective tokens (1x input, 1.25x 5m write, 2x 1h write, 0.1x read, 5x output) and cost at `pricePerMTok`; the last surfaced API error (`isApiErrorMessage`) until a later successful reply, also shown as a red `!` in the list -- `formatListItem()` - Formats a single list row; `●` live (green when idle, amber while busy; an amber `?` instead when it waits on you, see the overlay) / `!` API error are packed right into a 3-cell column (`colMarks`) just before the title, so titles stay aligned; `✎` trails a user-set name +- `formatListItem()` - Formats a single list row; `●` live (`○` in an app ccs can't open; the circle always stays, its colour gives the state: green idle, amber busy, cyan done, magenta waiting on you, light magenta asked something; see the overlay) / `!` API error are packed right into a 3-cell column (`colMarks`) just before the title, so titles stay aligned; `✎` trails a user-set name - `readLiveSessions()` / `liveSessionPIDs()` - SessionIDs open in a running claude, from `~/.claude/sessions/.json` where the pid is alive and its start time (`ps lstart`, read in UTC) matches the file's `procStart` (UTC), so a recycled pid doesn't count. Files are checked per pid before mapping to sessions, so a stale leftover can't hide the live file - `liveTick()` / `liveCmd()` / `applyLive()` - every `liveInterval` (2s): re-read `~/.claude/sessions` and, for live (and just-exited) sessions, `parseAppended()` their transcripts; updated rows re-sort in place, keeping the cursor; `applyLive` re-matches and re-counts only changed rows (preview cache is keyed by SessionID+Size+query, so it rebuilds only if the selected conversation changed). A live session not in the list yet triggers an early full scan (`startRefresh(true)`, at most every 15s; early scans don't reschedule, so the one-minute chain stays single) - `parseAppended()` / `consumeLines()` - incremental parse: seek to `parsedBytes` (end of the last complete line) and parse only appended lines. A line caught mid-write isn't applied, so parsing resumes at it; full reparse only if the file shrank (prune) or an unterminated last line *was* applied as a record (`tailApplied`). Search text stays identical to `searchTextOf` (which ends with the last timestamp): new messages swap that suffix and lowercase only the new part (`appendedSearchText`); anything else rebuilds. Live reads run in parallel with a per-tick deadline; a read still stuck from an earlier tick isn't restarted (`liveReads`) @@ -141,7 +141,7 @@ One package, split by area (all `package main`, same directory): - `uiState` / `restore()` / `restoreEnv` - before the post-update restart, `uiState()` (screen name, search text and cursor position, selected SessionID, preview scroll, overlay snoozes) goes into `CCS_RESTORE_STATE` for the new process only; `main` reads it once, unsets it, and `restore()` puts the user back (a missing conversation is skipped). A new screen needs a case in `screenName()` and `restore()` - Message box (`chat*`, `deliverMessage()`) - when the selected conversation is live, its preview gets a status line and a one-line message box. Moving the selection never focuses the box (`selectionMoved` clears focus); Enter on a live session opens its tab (`resumeCmd` focuses a live one), ^S or a click focuses the box, and Enter on the empty box opens the session too; Esc or clicking the search row returns to the search. Enter sends via `sendViaSocket` (the session file's `messagingSocketPath`: newline-delimited JSON, an `auth` line with the `peerToken` from `..key`, then a `user` message wrapped in ``, starting with `ccsNote`, which tells the recipient it was typed by the user, not another Claude session, so it answers in its own conversation instead of messaging back; `peerParts` strips the note for the preview); only if the socket can't be reached at all (`errNotConnected`) does it type into the terminal instead (`typeIntoSession`: tmux `send-keys -l`, or iTerm `write text`; control characters refused). Never both. The socket gives no acknowledgement; the recipient logs a `queue-operation` enqueue the moment it arrives (`conv.peerQueued`), then hands it to Claude at once when idle (a user line prefixed "Another Claude session sent a message:") or at its next step when busy (an `attachment` of type `queued_command`, which `parseLine` also reads, so mid-turn messages the user types show too). `checkDelivered` calls it delivered once it's in the conversation; a socket send not enqueued within `heldTimeout` (5s) shows a hedged warning that a bypass-permissions session may be holding it. Delivery receipts (`receiptInbox()` / `serveReceipts()` / `parseReceipt()` / `applyReceipt()`): each socket message carries `msgV:1` and `from:"uds:/ccs--inbox.sock"` (Claude Code only sends receipts to a `.sock` in the recipient's own socket folder, and checks the listener is the sending process); the session connects back with `{"type":"control","action":"peer_message_status","status":…,"orig_msg_id":…}` only when a message doesn't go straight in: held (shown as `⏸ held: approve it in `, replacing the 5s guess), then delivered, denied or expired; refused (`expired` + `status_detail:"refused"`); or dropped (`drop_reason`, `dropped_msg_ids`). `sentMsgs` maps msg_id to session; `waitReceipt` blocks, so idle frames stay still. The inbox is removed when ccs exits (`closeInboxes`), and inboxes of exited ccs processes are reaped (`reapInboxes`). Replies and idle notices sent to the inbox are ignored. Messages from ccs or other sessions (`peerMessage`, kept even though marked meta) show in the preview under a `From ` header. While a live session is selected, `chatTickCmd` checks its transcript and busy/idle status every `chatFastRefresh` (250ms); an idle frame stays static, and `✻ working Ns` only ticks while busy - `noteSent()` / `noteReceived()` / `peerEnds()` / `peerLabels()` / `sessionLinks()` - messages between sessions, both ends: a `SendMessage` call becomes a `sent` Message (`To`, `Note` its summary; after the reply's text) drawn as `To `; a cross-session message received keeps its sender's `from=` socket and `from-name`. Each is a `peerRef` (`conv.peers`, its text's start as `peerKey`), so the other end is the session whose transcript holds the same text (`peerEnds` across all items), else the live session on its socket (`sessionAt`, the pid in `.sock`), else (received) the session titled as its from-name. A sent message found at the other end shows `✓ arrived`. The labels go into the preview through the drawn copy's `peerNotes` (part of `cachedPreview`'s key, so an arrival redraws it). ^O lists those sessions first (`→ to` / `← from`, `sessionLink`): Enter selects it in the list (`selectSession`: clears a search hiding it, expands its folder). `lastSaid` skips sent messages, so one sent after a question doesn't hide it -- `waitSignal()` / `waitQueue()` / `applyStats()` / `banner()` / `waitPopup()` - input-prompt overlay; read the invariant at the top of [the design doc](docs/design/input-prompt-overlay.md) before changing it. Statuses come from the live tick (`scanSessions`, one read per session file) and the fast tick, and reach `chatStatus` only through `setStat`. `waiting` is Claude Code's status for a permission prompt or a question menu (`waitPrompt`, a bright `?` in place of `●`); an `idle` session whose last message ends in `?` is `waitAsked` (a green `?`, the colour of idle: a guess, so it never gets the banner and sorts with idle). A wait starts on entering `waiting` (or a new `statusUpdatedAt` while waiting) and ends on leaving it; `setStat` marks it `waitDue`, and its snooze (`snoozeRec`) ends with it. A session whose file is missing or unreadable keeps its last state while its pid is still the same process (`scanSessions` gets the last scan, `lastScan`). Nothing pops up: waiting sessions sort first (`sortList`) and the title row counts menus and prompts (`waitingCount`, `^X W N waiting`); Ctrl+X W opens the overlay, which owns the keyboard, on the wait not yet seen first (`banner()`). The popups that can sit over it are `overPopups()`. It has its own input (`waitInput`, with `waitDrafts` per session; the main box likewise keeps `chatDrafts`, synced to the selection in `Update`) and transcript cache (`waitPreview`), so the main box's draft and the list's preview are untouched. Its keys are in its hint row; the fast tick follows its session while it's open (`chatTarget`). Enter on an empty box uses `focusCmd`, which never resumes a second claude. While it waits, the session's question is shown (`askLines`: the pending tool call `trackTools` keeps in `conv.pending` until its result; an AskUserQuestion's options numbered as claude shows them, else the tool a permission prompt is about), also in the bottom pane in place of the selected session's message box (`chatAsk`, counted in `chatRows`, at most half the screen, then a dim "… ^X W shows the rest"; the question and each option's description wrap in full rather than being cut; multi-select marked "pick any"; the box is hidden while it waits, `boxHidden`, since a message would be held until the menu is answered, so ^S and clicks don't focus it), with a status line under it saying where to answer (`waitingLine`); for a single single-choice question (`answerable`) a digit on the empty box answers it by pressing that key in its terminal (`typeKeys` without Enter). Permission prompts and multi-select or multi-question menus are only shown, never answered from ccs +- `waitSignal()` / `waitQueue()` / `applyStats()` / `banner()` / `waitPopup()` - input-prompt overlay; read the invariant at the top of [the design doc](docs/design/input-prompt-overlay.md) before changing it. Statuses come from the live tick (`scanSessions`, one read per session file) and the fast tick, and reach `chatStatus` only through `setStat`. `waiting` is Claude Code's status for a permission prompt or a question menu (`waitPrompt`, a magenta `●`); an `idle` session whose last message ends in `?` is `waitAsked` (a light magenta `●`: a guess, so it never gets the banner). A wait starts on entering `waiting` (or a new `statusUpdatedAt` while waiting) and ends on leaving it; `setStat` marks it `waitDue`, and its snooze (`snoozeRec`) ends with it. A session whose file is missing or unreadable keeps its last state while its pid is still the same process (`scanSessions` gets the last scan, `lastScan`). Nothing pops up: waiting sessions sort first (`sortList`) and the title row counts menus and prompts (`waitingCount`, `^X W N waiting`); Ctrl+X W opens the overlay, which owns the keyboard, on the wait not yet seen first (`banner()`). The popups that can sit over it are `overPopups()`. It has its own input (`waitInput`, with `waitDrafts` per session; the main box likewise keeps `chatDrafts`, synced to the selection in `Update`) and transcript cache (`waitPreview`), so the main box's draft and the list's preview are untouched. Its keys are in its hint row; the fast tick follows its session while it's open (`chatTarget`). Enter on an empty box uses `focusCmd`, which never resumes a second claude. While it waits, the session's question is shown (`askLines`: the pending tool call `trackTools` keeps in `conv.pending` until its result; an AskUserQuestion's options numbered as claude shows them, else the tool a permission prompt is about), also in the bottom pane in place of the selected session's message box (`chatAsk`, counted in `chatRows`, at most half the screen, then a dim "… ^X W shows the rest"; the question and each option's description wrap in full rather than being cut; multi-select marked "pick any"; the box is hidden while it waits, `boxHidden`, since a message would be held until the menu is answered, so ^S and clicks don't focus it), with a status line under it saying where to answer (`waitingLine`); for a single single-choice question (`answerable`) a digit on the empty box answers it by pressing that key in its terminal (`typeKeys` without Enter). Permission prompts and multi-select or multi-question menus are only shown, never answered from ccs - `autoFocusCmd()` / `applyAutoFocus()` / `userIdle` - auto-focus (^X A, off by default, saved in `settings`; the header says `auto-focus`): from each live tick, a session that starts waiting on a menu or prompt gets its tab or pane selected (`focusSession(pid, false)`: never `activate`, so iTerm isn't pulled in front of another app). Once per wait (`focusHandled`, by statusUpdatedAt, kept across restarts in `focusRecordPath`, so a restart doesn't switch to a question again but one that came up meanwhile is switched to); questions already waiting at startup or when ^X A turns it on are switched to as well; only after `autoFocusIdle` (2s) since the last key press (`userIdle`: CoreGraphics' seconds since a key-down, read through `osascript -l JavaScript`; the mouse doesn't count, it can't answer anything and is rarely still; unknown means never), so keystrokes meant elsewhere can't answer the menu; and not again until the question it switched to is answered (`focusHeld`). Each step goes once per wait to the activity log (`logActivity`, `~/Library/Logs/ccs/activity.log` beside the update log; `noteFocus`): waiting, you're typing, switched, couldn't find its tab, holding on another, answered. Both logs share `appendLog`, which rotates at 1MB to `.1`. Tests stub `userIdle` and `autoFocusTab` - `acctKey()` / `acctPopup()` / `cswapActionCmd()` - account switcher (Ctrl+X U, or click the account email in the search row via `accountSpan()`), backed by the cswap CLI (`cswapPath`, absent means Ctrl+X U just says to install it): rows from `cswap list --json` (active marked, 5h/7d from `usage` else `lastGoodUsage`, `re-login` when `relogin_required`, as claude-dashboard). Digits or ↑↓ Enter run `cswap switch `, `+` runs `cswap add`, both detached with a deadline, one at a time; the popup owns the keyboard and is overlaid last in `View`. After a change the list and the allowance/account are re-read (an in-flight allowance fetch is marked `allowanceStale` and repeated). Tests use a fake cswap on PATH; never run switch/add against real accounts in tests - `deleteConversation()` - Removes conversation file and updates UI state @@ -162,7 +162,7 @@ The default, a sidebar (^X T switches to the table below it): ccs · claude code search · v0.66.0 · refreshed just now · ^X W 1 waiting · ^X A auto-focus off · ^X I auto-update on ^X G group ? help ^C quit > type to search... (N/total) ───────────────────────────┬──────────────────────────────────────────────── - ? fix login flow ┃Project: /path/to/project + ● fix login flow ┃Project: /path/to/project 5m · 92k ctx · app ┃Session: abc123... ● refactor auth │Size: 12MB · 28 messages · 3h ago now · 281k ctx · api ✓1│PRs: ✓ repo#12 add the thing · ready to merge diff --git a/README.md b/README.md index 87b71da..0c86f68 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ export CCS_CLAUDE_ARGS="--permission-mode auto" - Background sessions (`claude --bg`) show `bg ` 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 ` (`✓ arrived` once it's in that session's transcript), the recipient's `From ` - A live session run by an app without a terminal (T3 Code, for one) shows a hollow `○` and `in `: 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 diff --git a/docs/design/input-prompt-overlay.md b/docs/design/input-prompt-overlay.md index 8a1592e..17e16bc 100644 --- a/docs/design/input-prompt-overlay.md +++ b/docs/design/input-prompt-overlay.md @@ -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. diff --git a/ui.go b/ui.go index 9e02836..46553f6 100644 --- a/ui.go +++ b/ui.go @@ -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. @@ -2133,8 +2139,8 @@ 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. @@ -2142,17 +2148,18 @@ func (m model) listMarks(item listItem) (string, string) { // 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 diff --git a/ui_test.go b/ui_test.go index cc9d0c1..c5099e0 100644 --- a/ui_test.go +++ b/ui_test.go @@ -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) } } diff --git a/wait.go b/wait.go index 710d5a0..c054f09 100644 --- a/wait.go +++ b/wait.go @@ -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 != "" { diff --git a/wait_test.go b/wait_test.go index 6505aa0..8959dc1 100644 --- a/wait_test.go +++ b/wait_test.go @@ -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) } } } @@ -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) } }