diff --git a/NOTICE b/NOTICE index a249abb3..52cb373e 100644 --- a/NOTICE +++ b/NOTICE @@ -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 diff --git a/assets/icons/brands/deepseek.svg b/assets/icons/brands/deepseek.svg new file mode 100644 index 00000000..e19a4597 --- /dev/null +++ b/assets/icons/brands/deepseek.svg @@ -0,0 +1 @@ +DeepSeek \ No newline at end of file diff --git a/assets/icons/brands/openai.svg b/assets/icons/brands/openai.svg new file mode 100644 index 00000000..ebbdab0e --- /dev/null +++ b/assets/icons/brands/openai.svg @@ -0,0 +1 @@ +OpenAI \ No newline at end of file diff --git a/cmd/contr/e2e_see_test.go b/cmd/contr/e2e_see_test.go index 2b46a8ed..6d8bf30a 100644 --- a/cmd/contr/e2e_see_test.go +++ b/cmd/contr/e2e_see_test.go @@ -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, diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 53c383fb..72667d4e 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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). | diff --git a/docs/spec/ChatView.md b/docs/spec/ChatView.md index 2023a38d..56a9fdea 100644 --- a/docs/spec/ChatView.md +++ b/docs/spec/ChatView.md @@ -48,7 +48,7 @@ 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 " | @@ -56,6 +56,7 @@ The sizes in this file are the Default chat text size. The Chat text setting (D2 | 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:"), diff --git a/internal/app/chat.go b/internal/app/chat.go index b01f9372..702e4a9a 100644 --- a/internal/app/chat.go +++ b/internal/app/chat.go @@ -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 diff --git a/internal/app/chatview/chat.go b/internal/app/chatview/chat.go index 09cc6233..61d1da2a 100644 --- a/internal/app/chatview/chat.go +++ b/internal/app/chatview/chat.go @@ -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 diff --git a/internal/app/chatview/family.go b/internal/app/chatview/family.go new file mode 100644 index 00000000..cc668e55 --- /dev/null +++ b/internal/app/chatview/family.go @@ -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) + }) +} diff --git a/internal/app/chatview/family_test.go b/internal/app/chatview/family_test.go new file mode 100644 index 00000000..ed9a731b --- /dev/null +++ b/internal/app/chatview/family_test.go @@ -0,0 +1,101 @@ +package chatview + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "github.com/contursofran/contr/internal/agents" + "github.com/contursofran/contr/internal/app/apptest" + "github.com/contursofran/contr/internal/app/theme" +) + +// The family comes from the model, then the profile's model, then the program (D285). +func TestFamilyOf(t *testing.T) { + for _, tc := range []struct { + model, profile, command string + want Family + }{ + {"opus", "", "", FamilyClaude}, + {"claude-sonnet-5-5", "", "", FamilyClaude}, + {"", "", "/usr/local/bin/claude", FamilyClaude}, + {"", "haiku", "node", FamilyClaude}, + {"gpt-6.1-sol", "", "", FamilyOpenAI}, + {"", "", "codex", FamilyOpenAI}, + {"o4-mini", "", "", FamilyOpenAI}, + {"DeepSeek-V4.1-Flash", "", "opencode", FamilyDeepSeek}, + {"", "deepseek-chat", "opencode", FamilyDeepSeek}, + {"", "", "opencode", FamilyNone}, + {"", "", "zsh", FamilyNone}, + {"", "", "", FamilyNone}, + {"llama", "", "", FamilyNone}, + {"kimi", "", "claude", FamilyClaude}, + } { + if got := FamilyOf(tc.model, tc.profile, tc.command); got != tc.want { + t.Errorf("FamilyOf(%q, %q, %q) = %q, want %q", tc.model, tc.profile, tc.command, got, tc.want) + } + } +} + +// Each family has a mark; none has none. +func TestFamilyMark(t *testing.T) { + for _, f := range []Family{FamilyClaude, FamilyOpenAI, FamilyDeepSeek} { + if familyMark(f) == nil { + t.Errorf("no mark for %q", f) + } + } + if familyMark(FamilyNone) != nil { + t.Error("a mark for no family") + } +} + +// The ink of a worker tells its state: blocked ink-100, working ink-88, idle and done ink-30; no +// hue (D259, D285). +func TestWorkerInk(t *testing.T) { + pal := theme.Current() + for status, want := range map[string]ui.Color{"blocked": pal.Ink100, "working": pal.Ink88, "idle": pal.Ink30, "done": pal.Ink30, "": pal.Ink30} { + if got := WorkerInk(pal, status); got != want { + t.Errorf("WorkerInk(%q) = %v, want %v", status, got, want) + } + } +} + +// A worker of a known family shows its mark in place of the dot, in a box of its own, with no +// red (D285, D259). Another program keeps the dot. +func TestWorkerMarkByState(t *testing.T) { + for _, status := range []string{"working", "idle", "blocked", "done"} { + r := newRig(t) + r.ch.ModelOf = func(p string) string { + if p == "builder" { + return "sonnet" + } + return "" + } + r.ch.Worker = func(addr string) (agents.Worker, bool) { + return agents.Worker{Name: "b-631", Address: addr, Status: status, Profile: "builder"}, addr == "w2:t1" + } + r.savePath(term) + r.selectTerm(term) + r.chat() + r.write(lPrompt, lNote1, lSaid2) + box := apptest.Box(t, r.tt, "b-631 "+status) + if box.W != markSize || box.H != markSize { + t.Errorf("%s: mark is %vx%v, want %v", status, box.W, box.H, markSize) + } + if n := reddish(r.tt.Image(), box); n > 0 { + t.Errorf("%s: the mark has %d red pixels", status, n) + } + } + + r := newRig(t) + r.ch.Worker = func(addr string) (agents.Worker, bool) { + return agents.Worker{Name: "b-631", Address: addr, Status: "working", Command: "opencode"}, addr == "w2:t1" + } + r.savePath(term) + r.selectTerm(term) + r.chat() + r.write(lPrompt, lNote1, lSaid2) + if box := apptest.Box(t, r.tt, "b-631 working"); box.W != dotSize { + t.Errorf("an unknown program shows a %v box, want the %v dot", box.W, dotSize) + } +} diff --git a/internal/app/chatview/sheet_test.go b/internal/app/chatview/sheet_test.go index b1bc12d5..21141934 100644 --- a/internal/app/chatview/sheet_test.go +++ b/internal/app/chatview/sheet_test.go @@ -10,7 +10,8 @@ package chatview // older board (frozen), the live board and a queue, and ChatBlocksBlocked a queue with a blocked // step (D267). ChatViewComposer shows the composer card in each state (D271), ChatViewImages the // images of prompts and the composer's image chips (D269), CallLines an open work group with a -// call line of each kind and a running call (D274). +// call line of each kind and a running call (D274), ChatWorkerMarks the mark of each model family +// in each state (D285). import ( "strconv" @@ -20,6 +21,7 @@ import ( "github.com/egoist/mygo/ui" + "github.com/contursofran/contr/internal/agents" "github.com/contursofran/contr/internal/app/apptest" "github.com/contursofran/contr/internal/app/theme" "github.com/contursofran/contr/internal/github" @@ -69,6 +71,7 @@ func TestSpecSheet(t *testing.T) { open := true detail.s.ch.calls[id] = &open } + marks := sheetMarks(t) apptest.SpecSheet(t, []apptest.SheetPart{ {Name: "ChatView", W: sheetW, H: sheetH, View: working.view}, {Name: "ChatViewWide", W: 1500, H: 420, View: wide.view}, @@ -84,7 +87,27 @@ func TestSpecSheet(t *testing.T) { {Name: "CallLines", W: sheetW, H: 420, View: calls.view}, {Name: "CallLinesBrands", W: sheetW, H: 420, View: brandsView.view}, {Name: "CallDetail", W: sheetW, H: 360, View: detail.view}, - }, []string{"ChatViewComposer", "ChatView", "ChatViewWide", "ChatViewTeam", "ChatViewChips", "ChatContent", "ChatViewScrolled", "ChatBlocks", "ChatBlocksBlocked", "ChatViewImages", "CallLines", "CallLinesBrands", "CallDetail"}) + {Name: "ChatWorkerMarks", W: sheetW, H: 600, View: marks.view}, + }, []string{"ChatWorkerMarks", "ChatViewComposer", "ChatView", "ChatViewWide", "ChatViewTeam", "ChatViewChips", "ChatContent", "ChatViewScrolled", "ChatBlocks", "ChatBlocksBlocked", "ChatViewImages", "CallLines", "CallLinesBrands", "CallDetail"}) +} + +// sheetMarks is an open work group with a note of a worker of each family (Claude, OpenAI, +// DeepSeek) in each state (working, idle, blocked), and one of another program (D285). +func sheetMarks(t *testing.T) sheetView { + lines := []string{lPrompt} + workers := map[string]agents.Worker{} + n := 0 + for _, fam := range []struct{ name, model, command string }{{"claude", "opus", "claude"}, {"codex", "gpt-6.1", "codex"}, {"deepseek", "deepseek-v4.1-flash", "opencode"}, {"shell", "", "zsh"}} { + for _, status := range []string{"working", "idle", "blocked"} { + n++ + addr, name := "w"+strconv.Itoa(n)+":t1", fam.name+"-"+status + workers[addr] = agents.Worker{Name: name, Address: addr, Status: status, Model: fam.model, Command: fam.command} + lines = append(lines, `{"type":"user","uuid":"m`+strconv.Itoa(n)+`","message":{"content":"`+name+`: `+status+`, PR #633"}}`) + } + } + v := sheetChat(t, "idle", append(lines, said("s1", "Done."))...) + v.s.ch.Worker = func(addr string) (agents.Worker, bool) { w, ok := workers[addr]; return w, ok } + return v } // contentAnswer has each kind of content: headings, lists, a quote, the five remarks, a table diff --git a/internal/app/chatview/view.go b/internal/app/chatview/view.go index 3f0aa773..16f577d4 100644 --- a/internal/app/chatview/view.go +++ b/internal/app/chatview/view.go @@ -295,25 +295,37 @@ func (ch *Chat) groupRow(c *ui.Context, p ui.CollapsibleParts, r Row, open *bool // dot is the state of the worker at addr: blocked in ink-100, working in ink-88, else ink-30. A // blocked worker gets no red: the team row's "N needs you" is the chat's one red for it (D259). +// A worker of a known model family shows its mark in that ink in place of the dot (D285). func (ch *Chat) dot(c *ui.Context, addr string) { pal := theme.Current() color, state := pal.Ink30, "idle" if ch.Worker != nil { if w, ok := ch.Worker(addr); ok { - state = w.Status - switch w.Status { - case "blocked": - color = pal.Ink100 - case "working": - color = pal.Ink88 + state, color = w.Status, WorkerInk(pal, w.Status) + label := w.DisplayName() + " " + state + if m := familyMark(ch.familyOf(w)); m != nil { + ch.mark(c, m, label, color, w.Status == "working") + return } - ch.dotBox(c, w.DisplayName()+" "+state, color) + ch.dotBox(c, label, color) return } } ch.dotBox(c, addr+" "+state, color) } +// WorkerInk is the ink of a worker's dot or mark in status: blocked ink-100, working ink-88, else +// ink-30 (D259, D285). +func WorkerInk(pal *theme.Palette, status string) ui.Color { + switch status { + case "blocked": + return pal.Ink100 + case "working": + return pal.Ink88 + } + return pal.Ink30 +} + func (ch *Chat) dotBox(c *ui.Context, label string, color ui.Color) { ui.Box(c).Label(label).Size(dotSize, dotSize).Shrink(0).Radius(dotSize / 2).Background(color) }