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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions docs/DECISIONS.md

Large diffs are not rendered by default.

14 changes: 7 additions & 7 deletions docs/spec/ChatView.md
Original file line number Diff line number Diff line change
Expand Up @@ -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". |
Expand Down Expand Up @@ -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".
Expand Down
12 changes: 10 additions & 2 deletions internal/app/chatchips.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
Expand Down
11 changes: 11 additions & 0 deletions internal/app/chatchips_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
143 changes: 114 additions & 29 deletions internal/app/chatview/chips.go
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -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 (
Expand All @@ -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.
Expand Down Expand Up @@ -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
}

Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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("[<u>")
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("</u>](" + 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, "<u>"}, edit{l.End, l.End, "</u>"})
}
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
Expand All @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading