diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 86c2d442..7620ecfc 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -249,7 +249,7 @@ replaces it. | D272 | 2026-11-11 | Chat text size (#706, tracker #634). Settings → General gets a Chat text row under Appearance: Small, Default, Medium, Large, Larger on `SegmentedBase` (Medium added on 2026-11-11: the owner wanted a step between Default and Large), stored as `chatText` in `settings.json` (`internal/prefs`; missing or unknown is Default). The scales are 0.9, 1, 1.07, 1.15 and 1.3. The chat applies it through one helper (`Chat.ts`) that multiplies the size and line height of each type style it uses, and through the Markdown theme's font size (its other sizes are in em). The terminal and the chrome keep their size. MyGo's `TextScale` is for the whole window, so it does not fit. A pick shows at once. | Owner in #706. | | D273 | 2026-11-11 | The composer completes slash commands and skills (#707, tracker #634; docs/spec/ChatView.md, Slash popup). "/" at the start of the field, with no space after it, opens a popup above the card with the session's commands and skills (name and one-line description), names that start with the typed text first, then names that hold it. Up and Down move, Enter or Tab put "/name " in the field and send nothing, a click does the same, Esc closes it until the text changes; with no match Enter sends as typed. Claude Code gives the list only inside its own process (the stream-json init message), not to a terminal session that contr watches, so package `slash` reads the folders Claude Code reads: the built-in commands (a fixed list), `commands/**/*.md` and `skills/*/SKILL.md` of the project's and the user's `.claude`, and of each plugin in `installed_plugins.json` (prefixed "plugin:"); the frontmatter gives name and description. The read runs off the UI thread and is kept per session folder for the life of the app. The popup takes its list from a `SuggestSource`, so the "@" path popup (#708) can use it with another source. | | D274 | 2026-11-11 | Tool calls in the chat get a kind, a label and a key argument (#709, S1). `transcript.Describe` reads the tool name and input: Skill ("Skill · chat-writing", a plugin prefix is the server, key argument the first line of `args`), Connector (`mcp__claude_ai___`: "Google Calendar · list events"), Plugin (`mcp__plugin____`: "contr · send"), other MCP ("slack · x"), Agent (`Task`, `Agent`: "Agent · ", "general" when none; key argument the description), Web ("Fetch" with host and path, "Search" with the query), Shell (Bash: the description, else the command), File (the base name of the path), Search (Grep, Glob: the pattern), else Other (the name and the old summary). An MCP key argument is the first of `channel`, `query`, `title`, `url`, `id`, `text`, `message`, `prompt` (a plugin tool also `to`, `worker`). A tool word's `_` becomes a space. `Call` keeps the kind, the label, the key argument as `Summary`, and at most 6 input pairs (values cut to 200 runes) for a later detail. The call line and the live line show the label. Icons and the click detail come later in #709. | Owner in #709: skills, connectors and MCP tools showed raw names and no target. S2: a call line starts with a 14px slot: the working glyph while the call runs, else the line icon of its kind in `ink-48` (own drawings `skill`, `connector`, `plugin`, `agent`, `globe`; `terminal`, `file`, `search` reused; none for other tools; no brand logos). A tool name that starts with its server's name does not repeat it: "Slack · send message", "Notion · search". The folded group row text does not change. S3: a click on a call line opens its detail under it (`CollapsibleBase`, open state per call id): the input pairs (key `ink-48`, value `ink-64`), then the first 4 lines of the result excerpt, an error one in `ink-64`, no red. S4 (owner picked option C): a connector, plugin or other MCP call of a known server shows the server's brand mark in place of its kind icon, same 14px slot and `ink-48`, no brand colors (D119, D261). The marks are the monochrome SVGs of Simple Icons (CC0) in `assets/icons/brands/`: Slack, Notion, Gmail, Google Calendar, Google Drive, GitHub, Linear, Figma, Mixpanel, Spotify, Jira, Atlassian, Datadog, Intercom. One table in `chatview/tools.go` maps the server name to a mark, without regard to case, spaces, `_`, `-` and the prefixes `claude.ai `, `claude_ai_` and `plugin__`; a server with no mark keeps the kind icon. | -| D275 | 2026-11-11 | Links in the chat look clickable (#710, tracker #634). A sixth content color, `content-link` (Dark `#8cb8ff` 9.6:1, Light `#1a5fd0` 5.8:1 on the stage), is the link color of the chat's Markdown theme, so Markdown links, bare URLs, file links and chips all take it in one place; MyGo's `ui.Link` underlines a link while the pointer is over it. It differs from `content-running`, so a link does not read as a state. Plain text never uses it (docs/spec/tokens.md, docs/spec/ChatView.md). | +| D275 | 2026-11-11 | Links in the chat look clickable (#710, tracker #634). A sixth content color, `content-link` (Dark `#8cb8ff` 9.6:1, Light `#1a5fd0` 5.8:1 on the stage), is the link color of the chat's Markdown theme, so Markdown links, bare URLs, file links and chips all take it in one place; MyGo's `ui.Link` underlines a link while the pointer is over it. It differs from `content-running`, so a link does not read as a state. Plain text never uses it (docs/spec/tokens.md, docs/spec/ChatView.md). Link color: replaced by D292 (ink with an underline). | | D276 | 2026-11-11 | The composer gets layout B of board Y5 (#711, owner pick, tracker #634; docs/spec/ChatView.md, Composer). The card holds the field (14px on 22px lines) and one 30px round button, with `space-8` padding: Send (`ink-100` fill, arrow up) while the field has text, Stop (1px ring, 9px square) while the agent works and the field is empty, and no button when idle and empty (was Send dimmed, D271). The Working line inside the card goes away. Under the card, outside it, one footer line 18px high, 16px side inset, mono `meta`, `ink-48`: at the left the working glyph and "Working 7s · Bash · gh pr diff 633" while the agent works (what it does clips with `…`; the model text never clips), else "⏎ send · ⇧⏎ new line"; at the right model, effort and context. The team row moves out of the card: it sits directly above it, 16px inset, no fill or border, and has no live text, so working shows once. Replaces the top line and foot parts of D271. | | D277 | 2026-11-11 | Settings → Updates updates hc (#712). The hc row gets an `Update hc` button that runs `hc update --json` off the UI thread after a confirm (`Update hc?`, `hc downloads the newest version and restarts the core. Running terminals keep running.`, `Cancel` and `Update`; Cancel and Escape run nothing) (5 minute limit) and shows `updating hc…`, the new version, `hc is up to date` or hc's error. hc has no check or dry-run flag, so contr shows no "newer hc" before the update and uses no release URL of its own. `hc update` replaces the hc program and restarts the core by itself; the terminals keep running (tried on a scratch core). So the result line says the core restarted on the new version; contr has no restart button of its own and never runs the update without a click (docs/spec/SettingsUpdates.md). | Owner in #712. | | D278 | 2026-11-11 | Settings → Setup: a read-only setup checklist (#713, S1). `Setup` is the first Settings tab: `Setup`, `General`, `Agents`, `Updates` (changes the tab list of D186). Package `internal/setup` checks hc (`hc --version`), the hc core (the frame's live state), the contr plugin (`claude plugin list --json`), the Claude meter (data under 24 h old), gh (`gh auth status`), and the optional codex and `contr` command. Each command has a 5 s limit and runs off the UI thread when Settings opens, on Recheck, and on activation while the tab shows; nothing runs while Settings is closed. No check reads files in `~/.claude` or `~/.codex`. A step shows a line with a Copy button; contr runs no command here (S3 adds a confirmed plugin install, D65) and never edits Claude Code's settings (the meter stays copy only, D66). The Setup tab and the sidebar Settings button get the D146 dot while a required check is not ready. Spec: `docs/spec/SettingsSetup.md`. | Owner, issue #713: a new Mac shows what is missing in one place. | @@ -259,10 +259,11 @@ replaces it. | D282 | 2026-11-11 | A Claude worker gets the PATH of the process that spawns it (#728). `hc update` restarts the core under launchd, whose PATH is short, and each terminal inherits the PATH of the core, so `claude` was not found and the worker ended at once. Spawn now sets `CONTR_PATH` in the terminal: the PATH of the spawning process, then `~/.local/bin`, `/opt/homebrew/bin` and `/usr/local/bin`, without doubles. The clean wrapper (D216) adds these folders after the PATH of the core and removes the variable, so the PATH of the core still comes first. A program that does not start through the clean wrapper (not Claude) gets no change. | Seen on 2026-11-11 after the first `Update hc` (D277): two spawns ended with "claude: not found". | | D283 | 2026-11-11 | The contr plugin row of Settings → Setup gets one action (#713, S3; owner decision D65). `Install` when the plugin is not installed, `Enable` when it is disabled. A click opens a confirm with the exact command, the same text as the copy line; only `Run` runs it, off the UI thread, with a 2 min limit, and only these two lines with the `claude` the checks find. A spinner shows while it runs; a failure shows the last stderr line under the row; a success runs a recheck. The copy line stays. No other row gets an action (meter D66, gh and hc copy only); contr starts no hc core here and edits no file in Claude Code's folders. | Before, the owner had to copy the line to a terminal. The confirm keeps the run explicit. | | D284 | 2026-11-11 | A fenced `contr-flow` block in Claude's answer draws as a flow diagram (#696; ChatView.md, Diagrams). contr has its own syntax, one arrow per line (`A -> B: label`, `A [state]`), and draws it with MyGo's Painter (package `internal/app/diagram`), with tokens only and the chat text size. A web view with Mermaid's JS was ruled out (a web process, no theme tokens) and so was a Mermaid subset (most real Mermaid would fall back). A block that does not parse or is too large (more than 12 nodes, past the layer limits, wider than the column) shows as its code block. Sequence diagrams come in a second PR (D287). | -| D285 | 2026-11-11 | In the chat, a worker shows the mark of its model family in place of its 6px dot (#714, slice A): the Claude Code mascot, the OpenAI mark (Codex) or the DeepSeek mark, 12px, in the note lines and the work group's fold row. The family comes from what contr already knows of the worker: its model label, else the model of its profile, else its program (`claude`, `codex`); no new read. A worker of another program keeps the dot. The state reads as the dot's did: the mark takes the dot's ink (blocked `ink-100`, working `ink-88`, else `ink-30`) and hops as the working Claude mascot does while the worker works (still with Reduce Motion). No hue: blocked stays grey in the chat (D259, D261), and the mascot is grey too, as `claude` is the orchestrator's. Only the chat changes; the sidebar and the tabs keep their glyphs. The marks are from Simple Icons (CC0). | -| D286 | 2026-11-11 | PR and issue chips show GitHub's Octicons (#714, slice B; docs/spec/ChatView.md, Chips and Blocks). A PR shows the pull request icon of its state: open, draft, merged or closed; an issue the issue icon, open or closed. The icons are in `assets/icons/github` (Octicons, MIT, in NOTICE) and `icons.PRIcon`, `icons.IssueIcon` map gh's states to them. They take an ink token (`ink-48`), never GitHub's colors (D119, D261). The dot of the checks (running, passed, failed) stays after the icon, so the chip still shows CI (replaced by D288); the open and closed dots go, as the icon says it. The read of a number (`gh pr view`, D262) adds `isDraft`; no other gh read is added. The board's PR cell shows the same icon. The work rows of the board panel (hidden, D147) are not changed. | The owner asked for GitHub's icons (#714). | +| D285 | 2026-11-11 | In the chat, a worker shows the mark of its model family in place of its 6px dot (#714, slice A): the Claude Code mascot, the OpenAI mark (Codex) or the DeepSeek mark, 12px, in the note lines and the work group's fold row. The family comes from what contr already knows of the worker: its model label, else the model of its profile, else its program (`claude`, `codex`); no new read. A worker of another program keeps the dot. The state reads as the dot's did: the mark takes the dot's ink (blocked `ink-100`, working `ink-88`, else `ink-30`) and hops as the working Claude mascot does while the worker works (still with Reduce Motion). No hue: blocked stays grey in the chat (D259, D261), and the mascot is grey too, as `claude` is the orchestrator's. Only the chat changes; the sidebar and the tabs keep their glyphs. The marks are from Simple Icons (CC0). A worker chip shows the mark too: D292. | +| D286 | 2026-11-11 | PR and issue chips show GitHub's Octicons (#714, slice B; docs/spec/ChatView.md, Chips and Blocks). A PR shows the pull request icon of its state: open, draft, merged or closed; an issue the issue icon, open or closed. The icons are in `assets/icons/github` (Octicons, MIT, in NOTICE) and `icons.PRIcon`, `icons.IssueIcon` map gh's states to them. They take an ink token (`ink-48`), never GitHub's colors (D119, D261). The dot of the checks (running, passed, failed) stays after the icon, so the chip still shows CI (replaced by D288); the open and closed dots go, as the icon says it. The read of a number (`gh pr view`, D262) adds `isDraft`; no other gh read is added. The board's PR cell shows the same icon. The work rows of the board panel (hidden, D147) are not changed. | The owner asked for GitHub's icons (#714). Chip text: replaced by D292 (ink with an underline). | | D287 | 2026-11-11 | A fenced `contr-seq` block in Claude's answer draws as a sequence diagram (#696, PR 2; ChatView.md, Diagrams), in package `internal/app/diagram` like the flow (D284): actors as columns with a dashed lifeline, one line per message (`A -> B: text`, `A --> B: text` for a dashed reply, `A -> A: text` to itself), tokens only and the chat text size. A block that does not parse or is too large (more than 6 actors or 16 messages, an actor over 28 or a message over 40 characters, wider than the column) shows as its code block. The `chat-writing` skill tells Claude when and how to write a `contr-flow` and a `contr-seq` block, and that other diagrams stay as text. | Issue #696, step 2. | -| D288 | 2026-11-11 | PR and issue chips have no checks dot; the Octicon's color shows the state (#741; docs/spec/ChatView.md, Chips and Blocks). It replaces the ink-48 icon and the dot of D286. While a PR is open its checks win: failed `content-failed`, running `content-running`, passed `content-done`; then open `content-done`, draft `ink-48`. Merged `content-link` and closed `ink-48`, so a closed PR does not read as failed. An issue: open `content-done`, closed `content-link`. The palette has no purple, so merged and a closed issue take `content-link`, the nearest hue; no token is added. The number keeps `content-link`. The tooltip and the label still name the state. The board's PR cell does the same. | The owner asked to remove the dots and show the state by color (#741). | +| D288 | 2026-11-11 | PR and issue chips have no checks dot; the Octicon's color shows the state (#741; docs/spec/ChatView.md, Chips and Blocks). It replaces the ink-48 icon and the dot of D286. While a PR is open its checks win: failed `content-failed`, running `content-running`, passed `content-done`; then open `content-done`, draft `ink-48`. Merged `content-link` and closed `ink-48`, so a closed PR does not read as failed. An issue: open `content-done`, closed `content-link`. The palette has no purple, so merged and a closed issue take `content-link`, the nearest hue; no token is added. The number keeps `content-link`. The tooltip and the label still name the state. The board's PR cell does the same. | The owner asked to remove the dots and show the state by color (#741). Chip text: replaced by D292 (ink with an underline). | | D289 | 2026-11-11 | ⌘⇧1 to ⌘⇧8 select the first to the eighth project in the sidebar's order and ⌘⇧9 the last (#744), as ⌘1 to ⌘9 do for tabs (D190). The selection lands as ⌘⌥← / ⌘⌥→ land (D248): the project's orchestrator, else its main checkout; a number with no project does nothing. MyGo matches the physical key with exact modifiers, so ⌘⇧1 arrives as Key1 with ⌘⇧, not as "!", and does not fire ⌘1. A project row has one key chip: the rows before and after the open project keep `⌥←` / `⌥→`, and the other rows show `⇧1` to `⇧8`, and `⇧9` on the last past the eighth. | | D290 | 2026-11-11 | The terminal shows no "not running" line over the grid (#745, owner: "its better that this doesnt exist"; docs/spec/Terminal.md, States). It replaces the not running line of D80. While the program is not running or the connection is lost, the grid stays and is dimmed to 50% (`staleOpacity`); the dim shows the state. The lines for "terminal unavailable: …" and an incompatible core stay. | | D291 | 2026-11-11 | An empty project can start something and can be closed (#746; owner; docs/spec/ProjectRow.md). The menu of a project row has "New orchestrator" while the project has no orchestrator, "New terminal", a separator, "Close all agents" only while the project has agents, and "Close project". The two starts run in that project's folder (the main checkout, else the first worktree, else the stored folder), through the paths of the Projects `+` menu (`NewOrchestrator.StartIn`) and of New tab (`NewTerminal.Start`). "Hide project" is now "Close project": the behavior does not change (the project leaves the sidebar, a toast " closed" with Undo, nothing stops, Open projects brings it back). The projects modal says "Close" and "⌘⌫ close project" for the same action. A click on an empty project selects it; the empty terminal then shows "New orchestrator" and "New terminal ⌘T" for that project, and ⌘T opens there too. Before, "New orchestrator in " showed only for a stored project with no terminal at all; a project whose terminals held no agent got only a disabled "Close all agents". It replaces those parts of D219, D207, D171 and D174. | +| D292 | 2026-11-11 | Chat links are ink with an underline (#747; ChatView.md, Claude's text and Chips). PR, issue, worker and file chips and Markdown links draw their text in `ink-88`, the ink of the text around them, with an underline, so the state icon is the one color and an answer has one kind of link. The worker chip has no dot: it shows the mark of its model family (D285) in `WorkerInk` of its state, the size of the Octicon; another program has no icon. The chip's family travels in its address (`worker.claude`). The underline is in the text's ink: MyGo's markdown has no decoration color for links, so a lower step (`ink-48`) is not possible without a MyGo change. The pre-pass wraps the text in ``; a bare URL is not wrapped (tags would end it) and shows its underline on hover only. Replaces the `content-link` of D275, D286 and D288 for text. | diff --git a/docs/spec/ChatView.md b/docs/spec/ChatView.md index 462642a5..5ca7d5a1 100644 --- a/docs/spec/ChatView.md +++ b/docs/spec/ChatView.md @@ -47,7 +47,7 @@ The sizes in this file are the Default chat text size. The Chat text setting (D2 | Owner prompt | right-aligned, at most 560px, padding `8px 13px`, radius `radius-12`, fill `hover`, text `prose` in `ink-100` | | Prompt images | the images of a prompt (D269), under the bubble, at the right, `space-6` above, gap `space-6`, wrapping: a `thumb` (64px) thumbnail for each image whose bytes are in the transcript, cropped to fill, `radius-12`, 1px `hairline`; while it is read, a `hover` box of its size. A click opens it larger: over the `scrim`, as large as it is and at most the chat less 40px at each side, `radius-12`; Escape or a click outside closes it. The images with no bytes, and those that do not read or decode, show as one quiet chip after the thumbnails: "1 image" / "2 images", mono `meta`, `ink-48`, padding `2px 8px`, `radius-6`, 1px `hairline`. Their "[Image #N]" marks do not show; a prompt of images alone has no bubble. A pending prompt with images shows the chip. | | Sent time | the time the prompt was sent, from its transcript timestamp: one line, mono `meta`, `ink-48` (the lowest text ink with 4.5:1), `space-4` above, under the owner prompt, right-aligned with the bubble. Once per turn: Claude's texts, groups and notes show no time (D266). In the Mac's time zone: "14:32" today, "Yesterday 14:32", else "9 Nov 14:32"; "today" is judged at each build, and nothing ticks (D55). No timestamp, no line; a pending prompt has none. | -| Claude's text | `markdown.View`: `prose` size and line, sans; text `ink-88`, strong (500) `ink-88`; links (Markdown links, bare URLs, file links and chips) in `content-link`, underlined while the pointer is over them (D275), and nothing else in that color; muted `ink-48`; each block as [Content](#content) says | +| Claude's text | `markdown.View`: `prose` size and line, sans; text `ink-88`, strong (500) `ink-88`; links (Markdown links, file links and chips) in the text's ink `ink-88` with an underline in the same ink (D292, replacing the `content-link` of D275); a bare URL has no underline and shows it while the pointer is over it, and nothing else in that color; muted `ink-48`; each block as [Content](#content) says | | Work group, folded | the tool calls, kit notes and background tasks between two texts, as one row 20px high, mono `value`, `ink-48` (`ink-64` on hover): "2 worker notes · 1 background task · 6 tool calls" (a message of another session counts as a worker note; a part with no item is left out, "1 tool call"), a [worker mark](#worker-mark) (or 6px dot) for each worker that sent a note, then `›`. While a call has no result, a working StatusGlyph (14px) comes first. | | Work group, open | `⌄`; under it a card, a wide block: at least 584px (or the content width when less), as wide as its longest line (note, task and call lines alike), up to the content width, `space-6` below the row, padding `8px 12px`, radius `radius-9`, line `hairline`: the note lines, a `hairline` rule, the call lines | | Note line | 20px, gap `space-8`, mono `meta`: the sender's dot, the name (500, `ink-88`), the text on one line (`ink-48`). An owner answer or done mark has the name "owner". A message of another Claude session or a teammate (D254) has the sender as its name ("session" when it has none) and the first line of the message; a subagent's hand-back is "subagent" and "reported". | @@ -260,20 +260,20 @@ The row redraws when the agent state changes; it has no timer of its own ## Chips -Names in Claude's text that contr knows show live (D262): a 6px dot in the -color of the state, then the name as a link (`content-link`, D275). Only text counts: +Names in Claude's text that contr knows show live (D262): an icon in the +color of the state, then the name as a link: `ink-88`, the ink of the text around it, underlined +in the same ink (D292). The icon alone has the state's color. Only text counts: code, code blocks, links, images and HTML are not touched. A name contr does not know stays plain text. -| Chip | Recognized | Known when | Dot | Click | +| Chip | Recognized | Known when | Icon | Click | |---|---|---|---|---| | PR | "#667" | gh says it is a pull request of the session's repository (read by number), or it is the PR of a worker's branch (D104, D145) | GitHub's pull request Octicon of its state, 0.9 of the text size: open, draft, merged, closed (D286). No dot: the icon's color shows the state (D288). While the PR is open its checks win: failed `content-failed`, running `content-running`, passed `content-done`; with no checks open `content-done`, draft `ink-48`. Merged `content-link` (no purple token), closed `ink-48`. A failed check wins over a pending one, a pending one over a passed one. The tooltip and the label name the state. | opens the PR as the agent rows do: in the browser (the PR pane is hidden, D147) | | Issue | "#649" | gh says it is an issue | GitHub's issue Octicon, open or closed, 0.9 of the text size (D286): open `content-done`, closed `content-link` (D288) | opens the issue in the browser | -| Worker | a word that is the name of a worker contr lists ("b-649"); a live one wins | always | needs you `content-failed`, working `content-running`, done (ended) `content-done`, waiting (idle) `content-waiting` | selects the worker in the sidebar | +| Worker | a word that is the name of a worker contr lists ("b-649"); a live one wins | always | the [worker mark](#worker-mark) of its model family, 0.9 of the text size as the Octicons, in the ink of the worker notes (`WorkerInk`): needs you `ink-100`, working `ink-88`, else `ink-30`; it does not hop. No dot: a worker of another program has no icon (D292) | selects the worker in the sidebar | | File | a word with a slash or an extension, with an optional ":42" | it is a file in the session's folder | none: the `file` line icon, 0.9 of the text size, `ink-48` | opens the file in the Review view, as the files panel does (the line is not used) | -- The dot is 6px, round, `space-4` before the text, its center near the - middle of the lowercase letters. The Octicons take the content token of their +- The icon is `space-4` before the text. The Octicons take the content token of their state, never GitHub's color values (D119, D261, D288). The tip of a chip says what it is: "PR #667 · checks passed", "Draft PR #661", "Worker b-649 · needs you", "Open internal/app/x.go". diff --git a/internal/app/chatchips.go b/internal/app/chatchips.go index 6c5b9236..e0b22ea9 100644 --- a/internal/app/chatchips.go +++ b/internal/app/chatchips.go @@ -59,12 +59,20 @@ func (s *chatChips) worker(name string) (agents.Worker, bool) { return found, ok } +// profileModel is the model of worker w's profile, for the mark of its chip (D292). +func (s *chatChips) profileModel(w agents.Worker) string { + if s.f.deps.ModelOf == nil || w.Profile == "" { + return "" + } + return s.f.deps.ModelOf(w.Profile) +} + // Look gives the chip of a worker's name, a number or a path in the session at addr. func (s *chatChips) Look(addr string, t chatview.Token) (chatview.Chip, bool) { switch t.Kind { case chatview.TokenWord: w, ok := s.worker(t.Name) - return chatview.WorkerChip(w), ok + return chatview.WorkerChip(w, s.profileModel(w)), ok case chatview.TokenNumber: n, err := strconv.Atoi(t.Name) if err != nil { @@ -86,7 +94,7 @@ func (s *chatChips) Worker(_ string, name string) (chatview.WorkerInfo, bool) { if !ok { return chatview.WorkerInfo{}, false } - info := chatview.WorkerInfo{Chip: chatview.WorkerChip(w)} + info := chatview.WorkerInfo{Chip: chatview.WorkerChip(w, s.profileModel(w))} if w.Worktree != "" { info.PR = s.f.WorkerPRs.PR(w.Worktree) } diff --git a/internal/app/chatchips_test.go b/internal/app/chatchips_test.go index 6424d333..6fb77546 100644 --- a/internal/app/chatchips_test.go +++ b/internal/app/chatchips_test.go @@ -79,6 +79,17 @@ func TestChatChipsKnowWorkersByName(t *testing.T) { if _, ok := r.look(chatview.TokenWord, "b-650"); ok { t.Error("an unknown name is a chip") } + // The chip's family comes from the model of the worker's profile (D292). + r.f.deps.ModelOf = func(profile string) string { return "opus" } + if c, _ := r.look(chatview.TokenWord, "b-649"); c.Family != chatview.FamilyNone { + t.Errorf("a worker with no profile has family %q", c.Family) + } + for i := range r.f.snapshot().Workers { + r.f.snapshot().Workers[i].Profile = "builder" + } + if c, _ := r.look(chatview.TokenWord, "b-649"); c.Family != chatview.FamilyClaude { + t.Errorf("family %q, want claude from the profile's model", c.Family) + } if r.f.WorkerPRs.GH != r.s.refs.GH { t.Error("the number reads do not share the gh queue") } diff --git a/internal/app/chatview/chips.go b/internal/app/chatview/chips.go index 7e3b5878..6c6468f4 100644 --- a/internal/app/chatview/chips.go +++ b/internal/app/chatview/chips.go @@ -1,13 +1,14 @@ package chatview -// Chips (docs/spec/ChatView.md, D262): names in Claude's text that contr knows show live. A PR +// Chips (docs/spec/ChatView.md, D262, D292): names in Claude's text that contr knows show live. A PR // or an issue number ("#667"), a worker's name ("b-649") and a path of a file in the session's // folder become links of the scheme contr-chip: a pre-pass rewrites the Markdown source, outside -// code and links. The link's icon (markdown.Options.LinkIcon) is a 6px dot in the color of the -// state, or the file icon; a click (OnLink) opens the PR, selects the worker or opens the file. +// code and links, its text underlined. The link's icon (markdown.Options.LinkIcon) is the Octicon, the worker's +// family mark or the file icon in the color of the state; a click (OnLink) opens the PR, selects the worker or opens the file. // The state is part of the link's address, so a paragraph builds again only when a state changes. import ( + "slices" "strconv" "strings" @@ -53,7 +54,7 @@ const ( ChipFile ) -// ChipState is the state a chip's dot shows; StateNone has no dot. +// ChipState is the state a chip shows; StateNone has none. type ChipState uint8 const ( @@ -77,6 +78,8 @@ type Chip struct { Kind ChipKind State ChipState Draft bool // a draft PR + // Family is the model family of a worker chip, for its mark (D292); FamilyNone has no mark. + Family Family } // ChipSource knows the names of the chips. Its methods run on the UI thread. @@ -111,6 +114,9 @@ func chipDest(c Chip, name string) string { if c.Draft && c.Kind == ChipPR { kind += draftWord } + if c.Kind == ChipWorker && c.Family != FamilyNone { + kind += "." + string(c.Family) + } return chipScheme + kind + "/" + stateWords[c.State] + "/" + name } @@ -126,6 +132,14 @@ func parseChipDest(dest string) (Chip, string, bool) { } var c Chip parts[0], c.Draft = strings.CutSuffix(parts[0], draftWord) + if k, f, ok := strings.Cut(parts[0], "."); ok && k == kindWords[ChipWorker] { + switch Family(f) { + case FamilyClaude, FamilyOpenAI, FamilyDeepSeek: + parts[0], c.Family = k, Family(f) + default: + return Chip{}, "", false + } + } for i, w := range kindWords { if i > 0 && w == parts[0] { c.Kind = ChipKind(i) @@ -183,8 +197,8 @@ func ChipColor(p *theme.Palette, s ChipState) (ui.Color, bool) { } const ( - chipDot = 6 // the dot's width - chipDotGap = 4 // between the dot and the text + chipDot = 6 // the dot of a board's worker row + chipDotGap = 4 // between a chip's icon and its text ) // chipIcon is the LinkIcon of the views: the dot of a chip with a state, the file icon of a file @@ -206,18 +220,37 @@ func chipIcon(dest string, size float32) (markdown.InlineObject, bool) { if g := ChipGitHub(c); g != icons.GitHubNone { return githubIcon(c, g, size), true } - if _, ok := ChipColor(theme.Current(), c.State); !ok { + if c.Kind == ChipWorker { + return workerIcon(c, size) + } + return markdown.InlineObject{}, false +} + +// workerIcon is the LinkIcon of a worker chip (D292): the mark of its model family, the size of +// a PR chip's Octicon, in WorkerInk of its state. A worker of another program has no icon. +func workerIcon(c Chip, size float32) (markdown.InlineObject, bool) { + m := familyMark(c.Family) + if m == nil { return markdown.InlineObject{}, false } - // The dot's center is near the middle of the lowercase letters. - return markdown.InlineObject{Width: chipDot + chipDotGap, Height: size*0.3 + chipDot/2, + side := size * 0.9 + return markdown.InlineObject{Width: side + chipDotGap, Height: side * 0.82, Depth: side * 0.18, Draw: func(p *ui.Painter, r ui.Rect) { - if col, ok := ChipColor(theme.Current(), c.State); ok { - p.Fill(ui.Rect{X: r.X, Y: r.Y, W: chipDot, H: chipDot}, col, chipDot/2) - } + p.Icon(m, ui.Rect{X: r.X, Y: r.Y, W: side, H: side}, WorkerInk(theme.Current(), chipStatus(c.State))) }}, true } +// chipStatus is the worker status of a worker chip's state, for WorkerInk. +func chipStatus(s ChipState) string { + switch s { + case StateNeedsYou: + return "blocked" + case StateWorking: + return "working" + } + return "" +} + // ChipGitHub is the Octicon of a PR or an issue chip (D286): a PR by its state (merged, closed, // draft or open), an issue open or closed. Other chips have none. func ChipGitHub(c Chip) icons.GitHub { @@ -393,37 +426,87 @@ func word(w string) (Token, bool) { return Token{}, false } -// rewrite makes each token with a chip a link to its chip in src. -func rewrite(src string, toks []Token, chips []Chip) string { - var b strings.Builder - at := 0 +// rewrite makes each token with a chip a link to its chip in src, its text underlined (D292). +// The label of each link of src, from Labels, gets the same underline, so one answer has one +// kind of link. +func rewrite(src string, toks []Token, chips []Chip, labels []Token) string { + type edit struct { + start, end int + text string + } + var eds []edit for i, t := range toks { c := chips[i] if c.Kind == ChipNone { continue } - b.WriteString(src[at:t.Start]) - b.WriteByte('[') + var b strings.Builder + b.WriteString("[") for _, r := range src[t.Start:t.End] { - if strings.ContainsRune(`\_*~[]`, r) { + if strings.ContainsRune(`\_*~[]<`, r) { b.WriteByte('\\') } b.WriteRune(r) } - b.WriteString("](" + chipDest(c, t.Name) + ` "` + chipTip(c, t.Name) + `")`) - at = t.End + b.WriteString("](" + chipDest(c, t.Name) + ` "` + chipTip(c, t.Name) + `")`) + eds = append(eds, edit{t.Start, t.End, b.String()}) + } + for _, l := range labels { + eds = append(eds, edit{l.Start, l.Start, ""}, edit{l.End, l.End, ""}) + } + slices.SortStableFunc(eds, func(a, b edit) int { return a.start - b.start }) + var b strings.Builder + at := 0 + for _, e := range eds { + b.WriteString(src[at:e.start]) + b.WriteString(e.text) + at = e.end } b.WriteString(src[at:]) return b.String() } +// Labels finds the text of each link written in Markdown src ("[text](url)"), to underline it. +// An autolink ("https://…") is left out: tags around it would end it. Start and End are the +// bytes of the text in the source. +func Labels(src string) []Token { + d := parser.Parse(src) + var out []Token + for i := 1; i < d.Len(); i++ { + in := d.Inlines(i) + var walk func(from, to int) + walk = func(from, to int) { + for k := from; k < to; k = in[k].Next { + n := &in[k] + switch n.Kind { + case parser.Link: + if !n.Auto && k+1 < n.Next { + start, end := in[k+1].Start, in[k+1].End + for j := k + 1; j < n.Next; j++ { + end = max(end, in[j].End) + } + if start < end && end <= len(src) { + out = append(out, Token{Start: start, End: end}) + } + } + case parser.Emphasis, parser.Strong, parser.Strikethrough: + walk(k+1, n.Next) + } + } + } + walk(0, len(in)) + } + return out +} + // chipPart is a part of a text with its tokens, and the source with chips that the last chips // gave. type chipPart struct { - src string - toks []Token - chips []Chip - out string + src string + toks []Token + labels []Token + chips []Chip + out string } // chipText is the source of part i of the text row id with its known names as chips. The tokens @@ -436,8 +519,9 @@ func (ch *Chat) chipText(id string, i int, src string) string { key := id + "/" + strconv.Itoa(i) p := ch.chips[key] if p == nil || p.src != src { - p = &chipPart{src: src, toks: Tokens(src), out: src} + p = &chipPart{src: src, toks: Tokens(src), labels: Labels(src)} p.chips = make([]Chip, len(p.toks)) + p.out = rewrite(src, p.toks, p.chips, p.labels) ch.chips[key] = p } changed := false @@ -452,7 +536,7 @@ func (ch *Chat) chipText(id string, i int, src string) string { } } if changed { - p.out = rewrite(src, p.toks, p.chips) + p.out = rewrite(src, p.toks, p.chips, p.labels) } return p.out } @@ -505,8 +589,9 @@ func RefChip(r github.Ref) Chip { // WorkerChip is the chip of a worker, as the team row counts it (D259): an ended one is done, a // blocked one needs the owner, a working one works, any other waits. -func WorkerChip(w agents.Worker) Chip { - c := Chip{Kind: ChipWorker, State: StateWaiting} +// family is its model family (FamilyOf of the worker and the model of its profile). +func WorkerChip(w agents.Worker, profileModel string) Chip { + c := Chip{Kind: ChipWorker, State: StateWaiting, Family: FamilyOf(w.Model, profileModel, w.Command)} switch { case !w.Live: c.State = StateDone diff --git a/internal/app/chatview/chips_test.go b/internal/app/chatview/chips_test.go index ed040d36..e6ce6b6c 100644 --- a/internal/app/chatview/chips_test.go +++ b/internal/app/chatview/chips_test.go @@ -96,9 +96,9 @@ func TestChipTextRewritesOnlyKnownNames(t *testing.T) { ch.Chips = knownChips() src := "PR #667 and #3 by b-649 in my_file.go:4 and nope.go, `b-649`." got := ch.chipText("a1", 0, src) - want := `PR [#667](contr-chip:pr/passed/667 "PR #667 · checks passed") and #3 by ` + - `[b-649](contr-chip:worker/needs-you/b-649 "Worker b-649 · needs you") in ` + - `[my\_file.go:4](contr-chip:file//my_file.go "Open my_file.go") and nope.go, ` + "`b-649`." + want := `PR [#667](contr-chip:pr/passed/667 "PR #667 · checks passed") and #3 by ` + + `[b-649](contr-chip:worker/needs-you/b-649 "Worker b-649 · needs you") in ` + + `[my\_file.go:4](contr-chip:file//my_file.go "Open my_file.go") and nope.go, ` + "`b-649`." if got != want { t.Errorf("got %s\nwant %s", got, want) } @@ -171,7 +171,7 @@ func TestChipStates(t *testing.T) { {agents.Worker{Live: true}, StateWaiting}, {agents.Worker{Status: "working"}, StateDone}, } { - if got := WorkerChip(tc.w); got != (Chip{Kind: ChipWorker, State: tc.want}) { + if got := WorkerChip(tc.w, ""); got != (Chip{Kind: ChipWorker, State: tc.want}) { t.Errorf("WorkerChip(%+v) = %v", tc.w, got) } } @@ -216,8 +216,15 @@ func TestChipIcon(t *testing.T) { t.Errorf("%+v: %+v %v, want width %v", tc.c, o, ok, tc.w) } } - if o, ok := chipIcon(chipDest(Chip{Kind: ChipWorker, State: StateWorking}, "b-1"), 14); !ok || o.Width != chipDot+chipDotGap { - t.Errorf("worker dot: %+v %v", o, ok) + // A worker shows its family's mark, the size of the Octicon; no dot, and nothing when the + // family is not known (D292). + for _, f := range []Family{FamilyClaude, FamilyOpenAI, FamilyDeepSeek} { + if o, ok := chipIcon(chipDest(Chip{Kind: ChipWorker, State: StateWorking, Family: f}, "b-1"), 14); !ok || o.Width != side+chipDotGap || o.Draw == nil { + t.Errorf("worker mark %s: %+v %v", f, o, ok) + } + } + if o, ok := chipIcon(chipDest(Chip{Kind: ChipWorker, State: StateWorking}, "b-1"), 14); ok { + t.Errorf("a worker of another program has an icon: %+v", o) } if o, ok := chipIcon(chipDest(Chip{Kind: ChipFile}, "a.go"), 14); !ok || o.Draw == nil || o.Width <= 0 { t.Errorf("file icon: %+v %v", o, ok) @@ -271,6 +278,7 @@ func said(id, text string) string { func TestChipClicksOpenTheirSource(t *testing.T) { s := newSession(t, "idle") fc := knownChips() + fc.known["b-649"] = Chip{Kind: ChipWorker, State: StateNeedsYou, Family: FamilyClaude} s.ch.Chips = fc s.write(lPrompt, said("c1", "PR #667 by b-649 in my_file.go, and #3.")) for _, name := range []string{"#667", "b-649", "my_file.go"} { @@ -294,7 +302,8 @@ func TestChipsWorkInsideContentBlocks(t *testing.T) { s := newSession(t, "idle") fc := knownChips() fc.known["668"] = Chip{Kind: ChipPR, State: StateOpen} - fc.known["b-665"] = Chip{Kind: ChipWorker, State: StateWorking} + fc.known["b-649"] = Chip{Kind: ChipWorker, State: StateNeedsYou, Family: FamilyOpenAI} + fc.known["b-665"] = Chip{Kind: ChipWorker, State: StateWorking, Family: FamilyClaude} s.ch.Chips = fc s.write(lPrompt, said("c1", "| PR #668 | Worker |\n|---|---|\n| #667 | text |\n\n"+ "> [!DONE]\n> b-649 is done.\n\n1. my_file.go\n2. b-665\n")) @@ -381,3 +390,37 @@ func TestGitHubColor(t *testing.T) { } } } + +func TestWorkerChipFamily(t *testing.T) { + if c := WorkerChip(agents.Worker{Live: true, Command: "codex"}, ""); c.Family != FamilyOpenAI { + t.Errorf("program: %+v", c) + } + if c := WorkerChip(agents.Worker{Live: true, Command: "x"}, "opus"); c.Family != FamilyClaude { + t.Errorf("profile model: %+v", c) + } + c := Chip{Kind: ChipWorker, State: StateNeedsYou, Family: FamilyDeepSeek} + if got, name, ok := parseChipDest(chipDest(c, "b-1")); !ok || got != c || name != "b-1" { + t.Errorf("round trip: %+v %q %v", got, name, ok) + } + if _, _, ok := parseChipDest("contr-chip:worker.vim/working/b-1"); ok { + t.Error("an unknown family parses") + } + if _, _, ok := parseChipDest("contr-chip:pr.claude/open/4"); ok { + t.Error("a PR with a family parses") + } + for s, want := range map[ChipState]string{StateNeedsYou: "blocked", StateWorking: "working", StateDone: "", StateWaiting: ""} { + if got := chipStatus(s); got != want { + t.Errorf("chipStatus(%v) = %q", s, got) + } + } +} + +func TestLinksAreUnderlined(t *testing.T) { + ch := New(view.Env{}, t.TempDir(), nil) + ch.Chips = knownChips() + src := "See [the *docs*](https://example.com), and https://c.d." + want := "See [the *docs*](https://example.com), and https://c.d." + if got := ch.chipText("l1", 0, src); got != want { + t.Errorf("got %s\nwant %s", got, want) + } +} diff --git a/internal/app/chatview/content.go b/internal/app/chatview/content.go index ec9a97e9..580429ee 100644 --- a/internal/app/chatview/content.go +++ b/internal/app/chatview/content.go @@ -155,7 +155,7 @@ func (ch *Chat) mdTheme(pal *theme.Palette) { th := &markdown.Theme{ FontSize: size, Font: theme.FontFamilySans, CodeFont: theme.FontFamily, LineHeight: ch.ts(theme.StyleProse).Line / size, CodeLineHeight: ch.ts(theme.StyleTerminal).Line / ch.ts(theme.StyleTerminal).Size, - Text: pal.Ink88, Muted: pal.Ink48, Link: pal.ContentLink, Strong: pal.Ink88, StrongWeight: 500, + Text: pal.Ink88, Muted: pal.Ink48, Link: pal.Ink88, Strong: pal.Ink88, StrongWeight: 500, Code: pal.Ink88, CodeBackground: pal.Hover, CodeSize: em(inlineCodeSize), BlockSpacing: em(theme.Space12), QuoteBar: pal.Ink30, QuoteText: pal.Ink88, QuoteBarWidth: remarkBar, Rule: pal.Hairline, diff --git a/internal/app/chatview/content_test.go b/internal/app/chatview/content_test.go index 1848771f..d15cd605 100644 --- a/internal/app/chatview/content_test.go +++ b/internal/app/chatview/content_test.go @@ -274,8 +274,8 @@ func TestListsAndItemText(t *testing.T) { } } -// Links in Claude's text, chips included, are in content-link and the text around them is not, in -// each theme (D275). +// Links in Claude's text, chips included, are in the ink of the text around them, in each theme +// (D292, replacing the content-link of D275); the pre-pass underlines them. func TestLinksHaveTheLinkColor(t *testing.T) { for _, src := range []mygo.ThemeSource{mygo.ThemeDark, mygo.ThemeLight} { themetest.Use(t, src) @@ -283,11 +283,8 @@ func TestLinksHaveTheLinkColor(t *testing.T) { ch := New(view.Env{}, t.TempDir(), nil) ch.mdTheme(pal) for _, th := range []*markdown.Theme{ch.md.Theme, ch.mdNest.Theme, ch.mdHead.Theme, ch.mdNum.Theme} { - if th.Link != pal.ContentLink { - t.Errorf("%v: link color %v, want content-link %v", src, th.Link, pal.ContentLink) - } - if th.Text == th.Link || th.Strong == th.Link { - t.Errorf("%v: plain text has the link color", src) + if th.Link != pal.Ink88 { + t.Errorf("%v: link color %v, want ink-88 %v", src, th.Link, pal.Ink88) } } } diff --git a/internal/app/chatview/sheet_test.go b/internal/app/chatview/sheet_test.go index f27b9032..47943678 100644 --- a/internal/app/chatview/sheet_test.go +++ b/internal/app/chatview/sheet_test.go @@ -152,6 +152,7 @@ const seqAnswer = "How a fix reaches you:\n\n```contr-seq\nowner -> lead: fix #6 const chipsText = "PRs: #668 open, #656 checks running, #667 passed, #664 failed, #663 merged, #660 closed, #661 draft. " + "Issues #649 open, #650 closed. Workers: b-649 needs you, b-665 works, b-658 is done, b-659 waits. " + "The conflict is in internal/app/kit/agentrow.go:42.\n\n" + + "A plain link: [the spec](https://example.com/spec). " + "Stays text: #3, `b-649` and nope/missing.go." // sheetChips knows the names of chipsText. @@ -159,8 +160,8 @@ var sheetChips = &fakeChips{known: map[string]Chip{ "668": {Kind: ChipPR, State: StateOpen}, "656": {Kind: ChipPR, State: StateRunning}, "667": {Kind: ChipPR, State: StatePassed}, "664": {Kind: ChipPR, State: StateFailed}, "663": {Kind: ChipPR, State: StateMerged}, "660": {Kind: ChipPR, State: StateClosed}, "661": {Kind: ChipPR, State: StateOpen, Draft: true}, - "649": {Kind: ChipIssue}, "650": {Kind: ChipIssue, State: StateClosed}, "b-649": {Kind: ChipWorker, State: StateNeedsYou}, "b-665": {Kind: ChipWorker, State: StateWorking}, - "b-658": {Kind: ChipWorker, State: StateDone}, "b-659": {Kind: ChipWorker, State: StateWaiting}, + "649": {Kind: ChipIssue}, "650": {Kind: ChipIssue, State: StateClosed}, "b-649": {Kind: ChipWorker, State: StateNeedsYou, Family: FamilyClaude}, "b-665": {Kind: ChipWorker, State: StateWorking, Family: FamilyOpenAI}, + "b-658": {Kind: ChipWorker, State: StateDone, Family: FamilyDeepSeek}, "b-659": {Kind: ChipWorker, State: StateWaiting}, "internal/app/kit/agentrow.go": {Kind: ChipFile}, }}