diff --git a/NOTICE b/NOTICE index ab769164..a249abb3 100644 --- a/NOTICE +++ b/NOTICE @@ -21,6 +21,11 @@ Copyright 2026 Fabien Penso, licensed under the Apache License, Version 2.0. Each file with adapted code has a header comment that says so and names the 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. + 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 the MIT License; changes: the viewBox is cropped to the shape, and it has one diff --git a/assets/icons.go b/assets/icons.go index 1d199220..874543f5 100644 --- a/assets/icons.go +++ b/assets/icons.go @@ -6,9 +6,10 @@ package assets import "embed" // Icons holds the status icons and the line icons of docs/spec/README.md as -// `icons/.svg`. +// `icons/.svg`, and the brand marks of servers (Simple Icons, CC0) as +// `icons/brands/.svg`. // -//go:embed icons/*.svg +//go:embed icons/*.svg icons/brands/*.svg var Icons embed.FS // Fonts holds the faces of JetBrains Mono and Inter as `fonts/.ttf`. diff --git a/assets/icons/brands/atlassian.svg b/assets/icons/brands/atlassian.svg new file mode 100644 index 00000000..87521386 --- /dev/null +++ b/assets/icons/brands/atlassian.svg @@ -0,0 +1 @@ +Atlassian \ No newline at end of file diff --git a/assets/icons/brands/datadog.svg b/assets/icons/brands/datadog.svg new file mode 100644 index 00000000..fafc6d5c --- /dev/null +++ b/assets/icons/brands/datadog.svg @@ -0,0 +1 @@ +Datadog \ No newline at end of file diff --git a/assets/icons/brands/figma.svg b/assets/icons/brands/figma.svg new file mode 100644 index 00000000..6a06daa6 --- /dev/null +++ b/assets/icons/brands/figma.svg @@ -0,0 +1 @@ +Figma \ No newline at end of file diff --git a/assets/icons/brands/github.svg b/assets/icons/brands/github.svg new file mode 100644 index 00000000..538ec5bf --- /dev/null +++ b/assets/icons/brands/github.svg @@ -0,0 +1 @@ +GitHub \ No newline at end of file diff --git a/assets/icons/brands/gmail.svg b/assets/icons/brands/gmail.svg new file mode 100644 index 00000000..9ee779ad --- /dev/null +++ b/assets/icons/brands/gmail.svg @@ -0,0 +1 @@ +Gmail \ No newline at end of file diff --git a/assets/icons/brands/googlecalendar.svg b/assets/icons/brands/googlecalendar.svg new file mode 100644 index 00000000..6049d100 --- /dev/null +++ b/assets/icons/brands/googlecalendar.svg @@ -0,0 +1 @@ +Google Calendar \ No newline at end of file diff --git a/assets/icons/brands/googledrive.svg b/assets/icons/brands/googledrive.svg new file mode 100644 index 00000000..9f44a507 --- /dev/null +++ b/assets/icons/brands/googledrive.svg @@ -0,0 +1 @@ +Google Drive \ No newline at end of file diff --git a/assets/icons/brands/intercom.svg b/assets/icons/brands/intercom.svg new file mode 100644 index 00000000..3a0ec690 --- /dev/null +++ b/assets/icons/brands/intercom.svg @@ -0,0 +1 @@ +Intercom \ No newline at end of file diff --git a/assets/icons/brands/jira.svg b/assets/icons/brands/jira.svg new file mode 100644 index 00000000..417c09c5 --- /dev/null +++ b/assets/icons/brands/jira.svg @@ -0,0 +1 @@ +Jira \ No newline at end of file diff --git a/assets/icons/brands/linear.svg b/assets/icons/brands/linear.svg new file mode 100644 index 00000000..f3770d65 --- /dev/null +++ b/assets/icons/brands/linear.svg @@ -0,0 +1 @@ +Linear \ No newline at end of file diff --git a/assets/icons/brands/mixpanel.svg b/assets/icons/brands/mixpanel.svg new file mode 100644 index 00000000..4ce12626 --- /dev/null +++ b/assets/icons/brands/mixpanel.svg @@ -0,0 +1 @@ +Mixpanel \ No newline at end of file diff --git a/assets/icons/brands/notion.svg b/assets/icons/brands/notion.svg new file mode 100644 index 00000000..2917f42e --- /dev/null +++ b/assets/icons/brands/notion.svg @@ -0,0 +1 @@ +Notion \ No newline at end of file diff --git a/assets/icons/brands/slack.svg b/assets/icons/brands/slack.svg new file mode 100644 index 00000000..004e2663 --- /dev/null +++ b/assets/icons/brands/slack.svg @@ -0,0 +1 @@ +Slack \ No newline at end of file diff --git a/assets/icons/brands/spotify.svg b/assets/icons/brands/spotify.svg new file mode 100644 index 00000000..8d4d095f --- /dev/null +++ b/assets/icons/brands/spotify.svg @@ -0,0 +1 @@ +Spotify \ No newline at end of file diff --git a/cmd/contr/e2e_chat_call_test.go b/cmd/contr/e2e_chat_call_test.go index 67fbe9c3..a4853976 100644 --- a/cmd/contr/e2e_chat_call_test.go +++ b/cmd/contr/e2e_chat_call_test.go @@ -18,8 +18,8 @@ func TestChatCallDetail(t *testing.T) { c.waitLabel(t, "lead, ") c.settle(t) c.press(t, "Chat") - c.waitLabel(t, "work group 10 tool calls") - c.press(t, "work group 10 tool calls") + c.waitLabel(t, "work group 12 tool calls") + c.press(t, "work group 12 tool calls") c.waitLabel(t, "tool call Bash") c.settle(t) c.press(t, "tool call Bash") diff --git a/cmd/contr/e2e_see_test.go b/cmd/contr/e2e_see_test.go index 6fa5d599..2b46a8ed 100644 --- a/cmd/contr/e2e_see_test.go +++ b/cmd/contr/e2e_see_test.go @@ -229,10 +229,16 @@ var seeFixtures = map[string]seeFixture{ press: []string{"Chat", "wait:image 2"}, }, "chat-tools": { - about: "the chat fixture with a turn of mixed tool calls, its work group open (D274): a call line of each kind with its icon (skill, Slack connector, contr plugin, other MCP, agent, fetch, Bash, Read, Grep), a tool of no kind with an empty slot, the label and key argument of each, and the Bash call open on its detail", + about: "the chat fixture with a turn of mixed tool calls, its work group open (D274): a call line of each kind with its icon (skill, Slack connector, contr plugin, Linear MCP and Notion connector with their brand marks, an unknown MCP server with the connector icon, agent, fetch, Bash, Read, Grep), a tool of no kind with an empty slot, the label and key argument of each, and the Bash call open on its detail", setup: seeChatTools, ready: "lead, ", - press: []string{"Chat", "wait:work group 10 tool calls", "work group 10 tool calls", "wait:tool call Grep", "tool call Bash", "wait:call detail Bash"}, + press: []string{"Chat", "wait:work group 12 tool calls", "work group 12 tool calls", "wait:tool call Grep", "tool call Bash", "wait:call detail Bash"}, + }, + "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/"}, }, "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", @@ -468,6 +474,8 @@ func seeChatTools(t *testing.T, h *harness) { {"mcp__claude_ai_Slack__slack_send_message", map[string]any{"channel": "#dev", "text": "the call line has icons"}}, {"mcp__plugin_contr_contr__send", map[string]any{"to": "b-709", "body": "merge main"}}, {"mcp__linear__list_issues", map[string]any{"query": "chat rows"}}, + {"mcp__claude_ai_Notion__notion_search", map[string]any{"query": "chat spec"}}, + {"mcp__acme__list_things", map[string]any{"query": "rows"}}, {"Task", map[string]any{"subagent_type": "Explore", "description": "Find the call line"}}, {"WebFetch", map[string]any{"url": "https://example.com/docs/chat"}}, {"Bash", map[string]any{"command": "go test ./internal/app/chatview", "description": "Run the chat tests"}}, diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index cad683f8..69791718 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -248,7 +248,7 @@ replaces it. | D271 | 2026-11-10 | The chat's composer is one card (#682; owner's pick "one card"; docs/spec/ChatView.md, Composer). One surface (`hover` fill, 1px `input-line` border, `radius-12`) holds, from top to bottom: the team row or the live line as its top line, with no box of its own (none: no top line); room for attachment chips; the field, which grows from 1 to 8 lines of `prose` (21px lines) and then scrolls; at its right, in line with its last line, one round 28px button; and a foot with the model and context in mono `meta`, `ink-48`, under the field and outside it. The button is Send with text (an `ink-100` fill with a `ground` arrow, the one strong thing in the card), also while the agent works, as the message queues (D265); Stop (a quiet outline with a square) while the agent works and the field is empty; else Send dimmed, which takes no click. Enter, ⇧Enter and Esc do what they did (D252). At rest with the team row the card is 77px high; the team row, its gap and the old field box were 76px. | The owner found the old input weak: Stop and the model took the field's width, the team row and the field read as two strips, and no clear send action showed. One card ties them, keeps the dock low (questions and to-dos stay in the notch), and adds no hue. | | 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. | +| 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). | | 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. | diff --git a/docs/spec/ChatView.md b/docs/spec/ChatView.md index 3497d695..2023a38d 100644 --- a/docs/spec/ChatView.md +++ b/docs/spec/ChatView.md @@ -52,7 +52,7 @@ The sizes in this file are the Default chat text size. The Chat text setting (D2 | 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), 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 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__` 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` | diff --git a/internal/app/chatview/sheet_test.go b/internal/app/chatview/sheet_test.go index af252851..b1bc12d5 100644 --- a/internal/app/chatview/sheet_test.go +++ b/internal/app/chatview/sheet_test.go @@ -53,6 +53,15 @@ func TestSpecSheet(t *testing.T) { images.s.ch.attached[term] = 2 composer := sheetComposer(t) calls := sheetChat(t, "working", append(mixedCalls[:len(mixedCalls)-1], callOf("k11", "Grep", `{"pattern":"kindIcon"}`))...) + brandCalls := []string{lPrompt} + for i, n := range []string{"mcp__claude_ai_Slack__slack_send_message", "mcp__claude_ai_Notion__notion_search", + "mcp__claude_ai_Gmail__search_threads", "mcp__claude_ai_Google_Calendar__list_events", "mcp__github__get_issue", + "mcp__plugin_design_figma__get_design", "mcp__plugin_engineering_datadog__search_logs", "mcp__claude_ai_Atlassian__get_issue", + "mcp__acme__list_things"} { + id := "b" + strconv.Itoa(i) + brandCalls = append(brandCalls, callOf(id, n, `{"query":"chat rows"}`), resultOf(id)) + } + brandsView := sheetChat(t, "idle", append(brandCalls, said("s1", "Done."))...) detail := sheetChat(t, "idle", lPrompt, callOf("k1", "Bash", `{"command":"go test ./internal/app/chatview","description":"Run the chat tests"}`), `{"type":"user","uuid":"rk1","timestamp":"2026-11-10T10:00:02.4Z","message":{"content":[{"type":"tool_result","tool_use_id":"k1","content":"ok \tchatview\t2.1s\nok \ttranscript\t0.4s"}]}}`, callOf("k2", "Read", `{"file_path":"/repo/a.go"}`), errOf("k2", "File does not exist."), said("s1", "Done.")) @@ -73,8 +82,9 @@ func TestSpecSheet(t *testing.T) { {Name: "ChatViewComposer", W: sheetW, H: composerSheetH, View: composer}, {Name: "ChatViewImages", W: sheetW, H: 420, View: images.view}, {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", "CallDetail"}) + }, []string{"ChatViewComposer", "ChatView", "ChatViewWide", "ChatViewTeam", "ChatViewChips", "ChatContent", "ChatViewScrolled", "ChatBlocks", "ChatBlocksBlocked", "ChatViewImages", "CallLines", "CallLinesBrands", "CallDetail"}) } // contentAnswer has each kind of content: headings, lists, a quote, the five remarks, a table diff --git a/internal/app/chatview/tools.go b/internal/app/chatview/tools.go index 57080683..799fa714 100644 --- a/internal/app/chatview/tools.go +++ b/internal/app/chatview/tools.go @@ -1,6 +1,10 @@ package chatview -import "github.com/contursofran/contr/internal/transcript" +import ( + "strings" + + "github.com/contursofran/contr/internal/transcript" +) // kindIcons are the line icons of the tool kinds (D274). KindOther has none. var kindIcons = map[transcript.ToolKind]string{ @@ -17,3 +21,61 @@ var kindIcons = map[transcript.ToolKind]string{ // kindIcon gives the line icon of a tool kind, "" when it has none. func kindIcon(k transcript.ToolKind) string { return kindIcons[k] } + +// brands maps a server name, in lower case with no spaces, "_" or "-", to its brand mark in +// assets/icons/brands (D274). A server that is not here keeps the kind icon. +var brands = map[string]string{ + "slack": "slack", + "notion": "notion", + "gmail": "gmail", + "googlecalendar": "googlecalendar", + "googledrive": "googledrive", + "github": "github", + "linear": "linear", + "figma": "figma", + "mixpanel": "mixpanel", + "spotify": "spotify", + "jira": "jira", + "atlassian": "atlassian", + "datadog": "datadog", + "intercom": "intercom", +} + +// brandOf gives the brand mark of a call's server, "" when it has none. Only connector, plugin +// and other MCP calls have a server. Case and the prefixes "claude.ai ", "claude_ai_" and +// "plugin__" do not count: "mcp__plugin_design_slack__x" and "claude.ai Slack" are both +// Slack. +func brandOf(call *transcript.Call) string { + rest, ok := strings.CutPrefix(call.Name, "mcp__") + if !ok { + return "" + } + srv, _, _ := strings.Cut(rest, "__") + return brand(srv) +} + +// brand gives the brand mark of a server name, "" when it has none. +func brand(server string) string { + s := strings.ToLower(strings.TrimSpace(server)) + for _, p := range []string{"claude.ai ", "claude_ai_"} { + s = strings.TrimPrefix(s, p) + } + if rest, ok := strings.CutPrefix(s, "plugin_"); ok { + // "plugin__": the plugin name and the server name can both have "_", so + // try each tail, longest first. + for { + _, tail, more := strings.Cut(rest, "_") + if !more { + return "" + } + if b := brands[squash(tail)]; b != "" { + return b + } + rest = tail + } + } + return brands[squash(s)] +} + +// squash drops the spaces, "_" and "-" of a name. +func squash(s string) string { return strings.NewReplacer(" ", "", "_", "", "-", "").Replace(s) } diff --git a/internal/app/chatview/tools_test.go b/internal/app/chatview/tools_test.go index f06e43b7..b7377220 100644 --- a/internal/app/chatview/tools_test.go +++ b/internal/app/chatview/tools_test.go @@ -9,6 +9,7 @@ import ( "github.com/egoist/mygo/ui" "github.com/contursofran/contr/internal/app/apptest" + "github.com/contursofran/contr/internal/app/icons" "github.com/contursofran/contr/internal/app/theme" "github.com/contursofran/contr/internal/transcript" ) @@ -47,8 +48,8 @@ func TestCallLineIcon(t *testing.T) { tt := ui.NewTester(v.view, sheetW, 700) tt.Frame() for label, icon := range map[string]string{ - "Skill · chat-writing": "skill", "Slack · send message": "connector", "contr · send": "plugin", - "linear · list issues": "connector", "Agent · Explore": "agent", "Fetch": "globe", + "Skill · chat-writing": "skill", "Slack · send message": LabelBrand + " slack", "contr · send": "plugin", + "linear · list issues": LabelBrand + " linear", "Agent · Explore": "agent", "Fetch": "globe", "Bash": "terminal", "Read": "file", "Grep": "search", } { if apptest.Absent(tt, LabelCall+" "+label) { @@ -168,3 +169,56 @@ func TestExcerptText(t *testing.T) { t.Errorf("long: %q", got) } } + +// TestBrand: a server name maps to its mark without regard to case and to the "claude.ai ", +// "claude_ai_" and "plugin__" prefixes; an unknown server has none (D274). +func TestBrand(t *testing.T) { + for in, want := range map[string]string{ + "Slack": "slack", "claude.ai Slack": "slack", "claude_ai_Slack": "slack", "SLACK": "slack", + "claude_ai_Google_Calendar": "googlecalendar", "Google Drive": "googledrive", + "plugin_design_figma": "figma", "plugin_engineering_datadog": "datadog", + "plugin_design_google_calendar": "googlecalendar", "linear": "linear", "github": "github", + "Atlassian": "atlassian", "jira": "jira", "contr": "", "plugin_contr_contr": "", "": "", + "acme": "", "plugin_design": "", + } { + if got := brand(in); got != want { + t.Errorf("brand(%q) = %q, want %q", in, got, want) + } + } + for name, want := range map[string]string{ + "mcp__claude_ai_Notion__search": "notion", "mcp__plugin_design_slack__x": "slack", + "mcp__acme__x": "", "Bash": "", "Skill": "", + } { + if got := brandOf(&transcript.Call{Name: name}); got != want { + t.Errorf("brandOf(%q) = %q, want %q", name, got, want) + } + } +} + +// TestCallLineBrand: a call of a known server shows its brand mark; an unknown server keeps the +// generic connector icon (D274). +func TestCallLineBrand(t *testing.T) { + v := sheetChat(t, "idle", lPrompt, + callOf("k1", "mcp__claude_ai_Notion__notion_search", `{"query":"specs"}`), resultOf("k1"), + callOf("k2", "mcp__acme__list_things", `{"query":"x"}`), resultOf("k2"), said("s1", "Done.")) + tt := ui.NewTester(v.view, sheetW, 400) + tt.Frame() + if apptest.Absent(tt, LabelCallIcon+" "+LabelBrand+" notion") { + t.Error("a Notion call shows no Notion mark") + } + if apptest.Absent(tt, LabelCallIcon+" connector") { + t.Error("an unknown server does not keep the connector icon") + } + if !apptest.Absent(tt, LabelCallIcon+" "+LabelBrand+" acme") { + t.Error("an unknown server shows a mark") + } +} + +// TestBrandMarks: each mark of the table is embedded and parses. +func TestBrandMarks(t *testing.T) { + for _, b := range brands { + if icons.Brand(b) == nil { + t.Errorf("no mark %q", b) + } + } +} diff --git a/internal/app/chatview/view.go b/internal/app/chatview/view.go index e356e9a8..3f0aa773 100644 --- a/internal/app/chatview/view.go +++ b/internal/app/chatview/view.go @@ -35,6 +35,9 @@ const ( LabelCall = "tool call" // LabelCallIcon names the kind icon of a call line, followed by the icon's name. LabelCallIcon = "tool icon" + // LabelBrand follows LabelCallIcon when the icon is the brand mark of the call's server, + // followed by the mark's name (D274). + LabelBrand = "brand" // LabelCallDetail names the detail under an open call line, followed by the call's name. LabelCallDetail = "call detail" // LabelFade names the gradient over the bottom of the transcript, above the dock. @@ -373,6 +376,8 @@ func (ch *Chat) callHead(c *ui.Context, t ui.Element, e transcript.Entry, name s slot.Children(func() { if !call.Done { kit.StatusGlyph(c, kit.GlyphOpts{State: kit.GlyphWorking, Small: true}) + } else if b := brandOf(call); b != "" && icons.Brand(b) != nil { + ui.Icon(c, icons.Brand(b)).Size(callIconSize, callIconSize).Label(LabelCallIcon + " " + LabelBrand + " " + b) } else if icon := kindIcon(call.Kind); icon != "" { if s := icons.Line(icon); s != nil { ui.Icon(c, s).Size(callIconSize, callIconSize).Label(LabelCallIcon + " " + icon) diff --git a/internal/app/icons/icons.go b/internal/app/icons/icons.go index 6f7b2aad..bcd35d75 100644 --- a/internal/app/icons/icons.go +++ b/internal/app/icons/icons.go @@ -6,6 +6,7 @@ package icons import ( "log" + "strings" "sync" "time" @@ -90,25 +91,8 @@ var ( func svg(name string) *ui.SVG { loadOnce.Do(func() { parsed = make(map[string]*ui.SVG) - files, err := assets.Icons.ReadDir("icons") - if err != nil { - log.Printf("icons: %v", err) - return - } - for _, f := range files { - base := f.Name()[:len(f.Name())-len(".svg")] - data, err := assets.Icons.ReadFile("icons/" + f.Name()) - if err != nil { - log.Printf("icons: %v", err) - continue - } - s, err := ui.ParseSVG(data) - if err != nil { - log.Printf("icons: %s: %v", f.Name(), err) - continue - } - parsed[base] = s - } + load("icons", "") + load("icons/brands", brandPrefix) }) return parsed[name] } @@ -163,3 +147,32 @@ func Monitor(c *ui.Context, size float32) ui.Element { } return ui.Icon(c, svg("monitor")).Size(size, size).Label("monitor") } + +// brandPrefix starts the key of a brand mark in parsed. +const brandPrefix = "brand/" + +// load parses the SVG files of folder dir into parsed, each under prefix and its base name. +func load(dir, prefix string) { + files, err := assets.Icons.ReadDir(dir) + if err != nil { + log.Printf("icons: %v", err) + return + } + for _, f := range files { + if f.IsDir() { + continue + } + base := strings.TrimSuffix(f.Name(), ".svg") + data, err := assets.Icons.ReadFile(dir + "/" + f.Name()) + if err != nil { + log.Printf("icons: %v", err) + continue + } + s, err := ui.ParseSVG(data) + if err != nil { + log.Printf("icons: %s: %v", f.Name(), err) + continue + } + parsed[prefix+base] = s + } +} diff --git a/internal/app/icons/line.go b/internal/app/icons/line.go index 02d7a1f0..4def616e 100644 --- a/internal/app/icons/line.go +++ b/internal/app/icons/line.go @@ -16,3 +16,8 @@ var LineNames = [...]string{ // Line returns the line icon with this name, or nil when the file is missing // or does not parse (logged once). func Line(name string) *ui.SVG { return svg(name) } + +// Brand returns the brand mark with this name (`icons/brands/.svg`, from Simple Icons), or +// nil when there is none. A mark has one fill, so it takes the color of the text like a line +// icon (D274). +func Brand(name string) *ui.SVG { return svg(brandPrefix + name) }