Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9d01cf9
Chat: @ in the composer completes file paths (#708, D279)
contursofran Nov 11, 2026
9804760
Chat: @ paths leave out deleted files and list a nested repository once
contursofran Nov 11, 2026
b4c40f3
Chat: see fixture and e2e test of the @ path popup
contursofran Nov 11, 2026
10bb4a0
Chat: the composer shows Claude Code's suggested next prompt (#727, D…
contursofran Nov 11, 2026
8748c70
Docs: Path popup in the ChatView spec, D279
contursofran Nov 11, 2026
7923f4c
Merge main
contursofran Nov 11, 2026
4f4fd25
Merge main into agent/708-path-autocomplete
contursofran Nov 11, 2026
9f27428
Chat: next prompt docs (D281), spec sheet part and chat-next fixture …
contursofran Nov 11, 2026
512dfa9
Merge main
contursofran Nov 11, 2026
76df9b1
Chat: brand marks of known servers on call lines (#709 S4, D274)
contursofran Nov 11, 2026
90d3932
Merge remote-tracking branch 'origin/main' into agent/709-brand-marks
contursofran Nov 11, 2026
6a5ea10
Merge main into agent/708-path-autocomplete
contursofran Nov 11, 2026
80e1c52
Merge agent/708-path-autocomplete and main into agent/727-prompt-sugg…
contursofran Nov 11, 2026
e7391c0
Merge remote-tracking branch 'origin/agent/727-prompt-suggestion' int…
contursofran Nov 11, 2026
eec430a
Chat: a worker shows the mark of its model family (#714, D285)
contursofran Nov 11, 2026
5fc85a7
Merge main into agent/714-model-icons
contursofran Nov 11, 2026
31df843
Tests: remove the doubled chat-paths fixture entry
contursofran Nov 11, 2026
99e8f31
Merge main into agent/714-model-icons
contursofran Nov 11, 2026
a65b382
Merge main into agent/714-model-icons
contursofran Nov 11, 2026
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
3 changes: 2 additions & 1 deletion NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ changes.
The brand marks in assets/icons/brands/ are icons of Simple Icons
(https://github.com/simple-icons/simple-icons), v16.37.0, released under
CC0 1.0 Universal; they are used without changes. The marks belong to their
owners; contr uses them only to show which service a tool call goes to.
owners; contr uses them only to show which service a tool call goes to, or
which model family a worker runs on.

The Claude Code mascot (assets/icons/claude-mascot.svg) is the "Claude Code"
icon of LobeHub icons (https://github.com/lobehub/lobe-icons), licensed under
Expand Down
1 change: 1 addition & 0 deletions assets/icons/brands/deepseek.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions assets/icons/brands/openai.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 0 additions & 6 deletions cmd/contr/e2e_see_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,12 +240,6 @@ var seeFixtures = map[string]seeFixture{
ready: "lead, ",
press: []string{"Chat", "type:Look at @app", "wait:@internal/app/"},
},
"chat-paths": {
about: "the chat fixture with \"Look at @app\" typed in the composer (D279): the popup above the card with the folder and files of the repo whose paths hold app, the paths with a part that starts with it first",
setup: seeChatPaths,
ready: "lead, ",
press: []string{"Chat", "type:Look at @app", "wait:@internal/app/"},
},
"session": {
about: "the orchestrator lead with the todos and artifacts of canvas board 11 (D197)",
setup: seeSession,
Expand Down
1 change: 1 addition & 0 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,3 +258,4 @@ replaces it.
| D281 | 2026-11-11 | The composer shows Claude Code's suggested next prompt (#727, tracker #634). Claude Code does not write the suggestion to the transcript (it is made with the transcript off) and `terminal.read` gives text without styles, so contr reads the selected terminal's screen as escape sequences (the MyGo terminal's `Snapshot`) and takes the text after the last `❯` only when all of it is faint (SGR 2), as a real session draws it; typed text, a sent prompt or a menu choice is not faint. The read runs 300 ms after output, off the UI thread, through a new `OnOutput` hook of `ghostty.Conn`. While the field is empty, the suggestion is the field's placeholder; Tab or Right takes it, after the slash popup's Tab. Only the selected terminal has a screen, so other sessions show none (docs/spec/ChatView.md, Composer). | Owner in #727. |
| 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. |
| 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). |
3 changes: 2 additions & 1 deletion docs/spec/ChatView.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,15 @@ The sizes in this file are the Default chat text size. The Chat text setting (D2
| 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 |
| 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 6px dot for each worker that sent a note, then `›`. While a call has no result, a working StatusGlyph (14px) comes first. |
| 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". |
| Task line | a background task that ended (D254), as a note line without a dot: "Background task" (500, `ink-88`), its status (`ink-48`), then "· " and its summary on one line (`ink-48`), without a leading "Background command " |
| Call line | 20px, gap `space-8`, mono `meta`: a 14px slot with a working StatusGlyph while it runs, else the line icon of the kind in `ink-48` (`skill`, `connector` for connectors and other MCP tools, `plugin`, `agent`, `globe` for web, `terminal` for shell, `file`, `search`; empty for other tools, D274); a connector or MCP call of a known server shows the server's brand mark instead (Simple Icons, monochrome, same size and `ink-48`, no brand colors: Slack, Notion, Gmail, Google Calendar, Google Drive, GitHub, Linear, Figma, Mixpanel, Spotify, Jira, Atlassian, Datadog, Intercom; case and the prefixes `claude.ai `, `claude_ai_`, `plugin_<plugin>_` do not count; D274), the label (`ink-64`, at least 48px: "Bash", "Skill · chat-writing", "Slack · send message", "contr · send", "Agent · Explore", "Fetch"; D274), the key argument on one line (`ink-48`: a file's base name, Bash's description or command, a pattern, a URL's host and path, an MCP tool's channel, query or target), "error" in `blocked` when the result is an error, the duration (`ink-48`): "0.4s", "12s", "2m 5s", from the timestamps of the call and its result, none when one is missing |
| Call detail | a click on a call line (or Enter or Space while it has the focus; MyGo's `CollapsibleBase`) opens a detail under it, and a second click closes it; open state per call id. Indented past the icon slot, mono `meta` (the chat text size, D272): each input pair as "key:" in `ink-48` and the value in `ink-64` (at most 6, values cut to 200 runes), then `space-4` and the start of the result: its first 4 lines with "…" when it has more (at most 200 runes), in `ink-48`; an error result in `ink-64`, no red (only the line's "error" is `blocked`). A call with no input shows only the result; a running call only its input (D274). |
| Failed task | a task line whose status is "failed" or "killed" stays outside the fold: no dot, "Background task" and the status in `ink-100`, the summary in `ink-88`; no red (D259). Every other note folds, a worker's message too ("ended its turn without a report", "NAME: blocked, …"): the team row and the notch tell what needs the owner. |
| Dot | 6px, round: `ink-100` for a blocked worker (no red: the team row's "N needs you" is the chat's one red for it, D259), `ink-88` for a working one, else `ink-30` |
| Worker mark | in place of the dot, for a worker of a known model family (D285): 12px, the Claude Code mascot (Claude: a model `opus`, `sonnet`, `haiku`, `claude-…` or the program `claude`), the OpenAI mark (`gpt-…`, `o3`, `o4`, the program `codex`) or the DeepSeek mark (`deepseek-…`), from Simple Icons, monochrome. The family comes from the worker's model, else its profile's model, else its program. In the dot's ink; while the worker works it hops 1.5px every 800ms as the working mascot does (still with Reduce Motion). No hue: the mascot is grey here too. Another program keeps the dot. |

Hidden entries do not show: meta lines, system reminders, hook output (an
attachment, a user line in a `<…-hook>` tag, or "Stop hook feedback:"),
Expand Down
1 change: 1 addition & 0 deletions internal/app/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ func listPaths(ctx context.Context, root, dir string) ([]chatview.Suggestion, er
func (f *Frame) attachChat(env view.Env, src agentstate.AgentSource) (stop func()) {
f.Chat = chatview.New(env, f.Session.StateDir, nil)
f.Chat.Worker = f.agentAt
f.Chat.ModelOf = f.deps.ModelOf
f.Chat.Meta = f.chatMeta
f.Chat.NextPrompt = f.nextPrompt
f.Chat.Team = f.chatTeam
Expand Down
3 changes: 3 additions & 0 deletions internal/app/chatview/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ type Chat struct {
// Worker gives the agent at an hc address, for the state of the workers that sent notes.
// May be nil.
Worker func(addr string) (agents.Worker, bool)
// ModelOf names the model that an agent profile starts, for the mark of a worker (D285). May
// be nil.
ModelOf func(profile string) string
// Hints shows the key chip of the switch while ⌘ is held (D245).
Hints bool
// Send types text into the terminal at addr and presses Enter. It blocks: it runs off the UI
Expand Down
98 changes: 98 additions & 0 deletions internal/app/chatview/family.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package chatview

// The mark of a worker (docs/spec/ChatView.md, D285): in the chat, a worker that sent a note shows
// the mark of the model family it runs on in place of its 6px dot. The state shows in the mark's
// ink and motion, as the dot had it: blocked ink-100, working ink-88 with the hop of the Claude
// mascot, else ink-30. No hue (D259, D261). A worker of another program keeps the dot.

import (
"path/filepath"
"strings"

"github.com/egoist/mygo/ui"

"github.com/contursofran/contr/internal/agents"
"github.com/contursofran/contr/internal/app/icons"
"github.com/contursofran/contr/internal/app/kit"
)

// Family is the model family of a worker.
type Family string

// The families with a mark. FamilyNone keeps the dot.
const (
FamilyNone Family = ""
FamilyClaude Family = "claude"
FamilyOpenAI Family = "openai"
FamilyDeepSeek Family = "deepseek"
)

// markSize is the side of a worker's mark: the small glyph's size.
const markSize = 12

// FamilyOf is the family of a model name or a program: the model first, then the profile's model,
// then the program. "opus", "claude-sonnet-5-5", "claude" are Claude; "gpt-6.1", "o4", "codex"
// are OpenAI; "deepseek-v4" is DeepSeek. Case does not count. Any other name: FamilyNone.
func FamilyOf(model, profileModel, command string) Family {
for _, s := range []string{model, profileModel, filepath.Base(command)} {
if f := familyOfName(strings.ToLower(strings.TrimSpace(s))); f != FamilyNone {
return f
}
}
return FamilyNone
}

func familyOfName(s string) Family {
switch {
case s == "" || s == ".":
return FamilyNone
case strings.Contains(s, "deepseek"):
return FamilyDeepSeek
case strings.Contains(s, "claude"), strings.Contains(s, "opus"), strings.Contains(s, "sonnet"),
strings.Contains(s, "haiku"), strings.Contains(s, "anthropic"):
return FamilyClaude
case strings.Contains(s, "codex"), strings.Contains(s, "gpt"), strings.Contains(s, "openai"),
s == "o3" || s == "o4" || strings.HasPrefix(s, "o3-") || strings.HasPrefix(s, "o4-"):
return FamilyOpenAI
}
return FamilyNone
}

// familyOf is the family of worker w, from what contr knows of it: its model label, the model of
// its profile and its program. No new read.
func (ch *Chat) familyOf(w agents.Worker) Family {
pm := ""
if ch.ModelOf != nil && w.Profile != "" {
pm = ch.ModelOf(w.Profile)
}
return FamilyOf(w.Model, pm, w.Command)
}

// familyMark is the SVG of family f: the Claude mascot, or a brand mark of Simple Icons. Nil for
// FamilyNone or a missing file.
func familyMark(f Family) *ui.SVG {
switch f {
case FamilyClaude:
return kit.ClaudeMascot()
case FamilyOpenAI, FamilyDeepSeek:
return icons.Brand(string(f))
}
return nil
}

// mark draws mark m of a worker in color, labelled. While working it hops as the Claude mascot
// does (kit.ClaudeHopOffset); Reduce Motion keeps it still.
func (ch *Chat) mark(c *ui.Context, m *ui.SVG, label string, color ui.Color, working bool) {
still := c.Preferences().ReduceMotion
state := kit.GlyphIdle
if working {
state = kit.GlyphWorking
}
ui.Box(c).Label(label).Size(markSize, markSize).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
if working && !still {
p.AnimationFrameRate(30)
}
r.Y -= kit.ClaudeHopOffset(state, p.Now(), still)
p.Icon(m, r, color)
})
}
Loading
Loading