diff --git a/docs/_ext/fastmcp_autodoc.py b/docs/_ext/fastmcp_autodoc.py index f6d0811..8bb6382 100644 --- a/docs/_ext/fastmcp_autodoc.py +++ b/docs/_ext/fastmcp_autodoc.py @@ -881,10 +881,14 @@ def _resolve_tool_refs( doctree: nodes.document, fromdocname: str, ) -> None: - """Resolve ``{tool}`` and ``{toolref}`` placeholders into links. + """Resolve ``{tool}``, ``{toolref}``, and ``{toolicon*}`` placeholders. - ``{tool}`` renders as ``code`` + safety badge. + ``{tool}`` renders as ``code`` + safety badge (text + icon). ``{toolref}`` renders as ``code`` only (no badge). + ``{toolicon}``/``{tooliconl}`` — icon-only badge left of code. + ``{tooliconr}`` — icon-only badge right of code. + ``{tooliconil}`` — icon-only badge inside code, left of text. + ``{tooliconir}`` — icon-only badge inside code, right of text. Runs at ``doctree-resolved`` — after all labels are registered and standard ``{ref}`` resolution is done. @@ -896,6 +900,7 @@ def _resolve_tool_refs( for node in list(doctree.findall(_tool_ref_placeholder)): target = node.get("reftarget", "") show_badge = node.get("show_badge", True) + icon_pos = node.get("icon_pos", "") label_info = domain.labels.get(target) if label_info is None: node.replace_self(nodes.literal("", target.replace("-", "_"))) @@ -914,13 +919,44 @@ def _resolve_tool_refs( newnode["classes"].append("reference") newnode["classes"].append("internal") - newnode += nodes.literal("", tool_name) - - if show_badge: + if icon_pos: tool_info = tool_data.get(tool_name) + badge = None if tool_info: - newnode += nodes.Text(" ") - newnode += _safety_badge(tool_info.safety) + badge = _safety_badge(tool_info.safety) + badge["classes"].append("icon-only") + if icon_pos.startswith("inline"): + badge["classes"].append("icon-only-inline") + badge.children.clear() + badge += nodes.Text("") + + if icon_pos == "left": + if badge: + newnode += badge + newnode += nodes.literal("", tool_name) + elif icon_pos == "right": + newnode += nodes.literal("", tool_name) + if badge: + newnode += badge + elif icon_pos == "inline-left": + code_node = nodes.literal("", "") + if badge: + code_node += badge + code_node += nodes.Text(tool_name) + newnode += code_node + elif icon_pos == "inline-right": + code_node = nodes.literal("", "") + code_node += nodes.Text(tool_name) + if badge: + code_node += badge + newnode += code_node + else: + newnode += nodes.literal("", tool_name) + if show_badge: + tool_info = tool_data.get(tool_name) + if tool_info: + newnode += nodes.Text(" ") + newnode += _safety_badge(tool_info.safety) node.replace_self(newnode) @@ -962,6 +998,39 @@ def _toolref_role( return [node], [] +def _make_toolicon_role( + icon_pos: str, +) -> t.Callable[..., tuple[list[nodes.Node], list[nodes.system_message]]]: + """Create an icon-only tool reference role for a given position.""" + + def role_fn( + name: str, + rawtext: str, + text: str, + lineno: int, + inliner: object, + options: dict[str, object] | None = None, + content: list[str] | None = None, + ) -> tuple[list[nodes.Node], list[nodes.system_message]]: + target = text.strip().replace("_", "-") + node = _tool_ref_placeholder( + rawtext, + reftarget=target, + show_badge=False, + icon_pos=icon_pos, + ) + return [node], [] + + return role_fn + + +_toolicon_role = _make_toolicon_role("left") +_tooliconl_role = _make_toolicon_role("left") +_tooliconr_role = _make_toolicon_role("right") +_tooliconil_role = _make_toolicon_role("inline-left") +_tooliconir_role = _make_toolicon_role("inline-right") + + def _badge_role( name: str, rawtext: str, @@ -987,6 +1056,11 @@ def setup(app: Sphinx) -> ExtensionMetadata: app.connect("doctree-resolved", _resolve_tool_refs) app.add_role("tool", _tool_role) app.add_role("toolref", _toolref_role) + app.add_role("toolicon", _toolicon_role) + app.add_role("tooliconl", _tooliconl_role) + app.add_role("tooliconr", _tooliconr_role) + app.add_role("tooliconil", _tooliconil_role) + app.add_role("tooliconir", _tooliconir_role) app.add_role("badge", _badge_role) app.add_directive("fastmcp-tool", FastMCPToolDirective) app.add_directive("fastmcp-tool-input", FastMCPToolInputDirective) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index 874e40d..652ea82 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -309,6 +309,75 @@ h4:has(> .sd-badge) { .sd-badge.sd-bg-warning::before { content: "✏️"; } /* mutating */ .sd-badge.sd-bg-danger::before { content: "💣"; } /* destructive */ +/* ── Icon-only badge ({toolicon*} roles) ───────────────── + * Outside variants (l/r): colored square box with emoji. + * Inside variants (il/ir): bare emoji, no box — blends + * into the code chip as a seamless annotation. + * ────────────────────────────────────────────────────────── */ + +/* Outside icon links: flexbox collapses whitespace nodes */ +a.reference:has(> .sd-badge.icon-only) { + display: inline-flex; + align-items: center; + gap: 3px; +} + +a.reference:has(> .sd-badge.icon-only) > code { + margin: 0; +} + +/* Outside variants: colored square box */ +.sd-badge.icon-only { + display: inline-flex !important; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 0; + box-sizing: border-box; + border-radius: 3px; + gap: 0; + font-size: 0; + line-height: 1; + min-width: 0; + min-height: 0; + margin: 0; +} + +.sd-badge.icon-only::before { + font-size: 10px; + line-height: 1; + font-style: normal; + font-weight: normal; + margin: 0; + display: block; + opacity: 0.9; +} + +/* Inline variants (inside ): bare emoji, no box */ +.sd-badge.icon-only-inline { + background: transparent !important; + border: none !important; + padding: 0; + width: auto; + height: auto; + border-radius: 0; + vertical-align: -0.01em; + margin-right: 0.12em; + margin-left: 0; +} + +.sd-badge.icon-only-inline::before { + font-size: 0.78rem; + opacity: 0.85; +} + +/* Inline-right: tighter than inline-left */ +code.docutils .sd-badge.icon-only-inline:last-child { + margin-left: 0.1em; + margin-right: 0; +} + /* ── Context-aware badge sizing ─────────────────────────── */ h2 .sd-badge, h3 .sd-badge { diff --git a/docs/demo.md b/docs/demo.md index 3b43dfc..a933aae 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -24,6 +24,22 @@ Standalone badges via `{badge}`: {toolref}`capture-pane` · {toolref}`send-keys` · {toolref}`search-panes` · {toolref}`wait-for-text` · {toolref}`kill-pane` · {toolref}`create-session` · {toolref}`split-window` +### `{tooliconl}` — icon left, outside code + +{tooliconl}`capture-pane` · {tooliconl}`send-keys` · {tooliconl}`search-panes` · {tooliconl}`wait-for-text` · {tooliconl}`kill-pane` · {tooliconl}`create-session` · {tooliconl}`split-window` + +### `{tooliconr}` — icon right, outside code + +{tooliconr}`capture-pane` · {tooliconr}`send-keys` · {tooliconr}`search-panes` · {tooliconr}`wait-for-text` · {tooliconr}`kill-pane` · {tooliconr}`create-session` · {tooliconr}`split-window` + +### `{tooliconil}` — icon inline-left, inside code + +{tooliconil}`capture-pane` · {tooliconil}`send-keys` · {tooliconil}`search-panes` · {tooliconil}`wait-for-text` · {tooliconil}`kill-pane` · {tooliconil}`create-session` · {tooliconil}`split-window` + +### `{tooliconir}` — icon inline-right, inside code + +{tooliconir}`capture-pane` · {tooliconir}`send-keys` · {tooliconir}`search-panes` · {tooliconir}`wait-for-text` · {tooliconir}`kill-pane` · {tooliconir}`create-session` · {tooliconir}`split-window` + ### `{ref}` — plain text link {ref}`capture-pane` · {ref}`send-keys` · {ref}`search-panes` · {ref}`wait-for-text` · {ref}`kill-pane` · {ref}`create-session` · {ref}`split-window` diff --git a/docs/recipes.md b/docs/recipes.md index c40ad61..a1a5738 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -33,7 +33,7 @@ or what port it chose. > command and working directory, not terminal content. The dev server printed > its URL to the terminal minutes ago, so I need to search terminal content. -The agent calls {tool}`search-panes` with `pattern: "Local:"` and +The agent calls {tooliconl}`search-panes` with `pattern: "Local:"` and `session_name: "myapp"`. The response comes back with pane `%5` in the `react` window, matched line: `Local: http://localhost:5173/`. @@ -42,21 +42,21 @@ window, matched line: `Local: http://localhost:5173/`. > The server is alive and its URL is known. I do not need to start anything. > I just need an idle pane for running tests. -The agent calls {tool}`list-panes` on the `myapp` session. Several panes show +The agent calls {tooliconl}`list-panes` on the `myapp` session. Several panes show `pane_current_command: zsh` -- idle shells. It picks `%4` in the same window. ### Act -The agent calls {tool}`send-keys` in pane `%4`: +The agent calls {tooliconl}`send-keys` in pane `%4`: `PLAYWRIGHT_BASE_URL=http://localhost:5173 pnpm exec playwright test` -Then it calls {tool}`wait-for-text` on pane `%4` with `pattern: "passed|failed|timed out"`, `regex: true`, and `timeout: 120`. Once the -wait resolves, it calls {tool}`capture-pane` on `%4` with `start: -80` to +Then it calls {tooliconl}`wait-for-text` on pane `%4` with `pattern: "passed|failed|timed out"`, `regex: true`, and `timeout: 120`. Once the +wait resolves, it calls {tooliconl}`capture-pane` on `%4` with `start: -80` to read the test results. ```{tip} The agent's first instinct might be to *start* a Vite server. But -{tool}`search-panes` reveals one is already running. This avoids a port +{tooliconl}`search-panes` reveals one is already running. This avoids a port conflict, a wasted pane, and the most common agent mistake: treating tmux like a blank shell. ``` @@ -85,8 +85,8 @@ suite needs a live API server. > First I need to know what exists in the `backend` session. If a server is > already running, I should reuse it instead of starting a duplicate. -The agent calls {tool}`list-panes` for the `backend` session. No pane is -running a server process. A {tool}`search-panes` call for `"listening"` +The agent calls {tooliconl}`list-panes` for the `backend` session. No pane is +running a server process. A {tooliconl}`search-panes` call for `"listening"` returns no matches. ### Decide @@ -96,16 +96,16 @@ returns no matches. ### Act -The agent calls {tool}`split-window` with `session_name: "backend"` to -create a new pane, then calls {tool}`send-keys` in that pane: +The agent calls {tooliconl}`split-window` with `session_name: "backend"` to +create a new pane, then calls {tooliconl}`send-keys` in that pane: `npm run serve`. -The agent calls {tool}`wait-for-text` on the server pane with +The agent calls {tooliconl}`wait-for-text` on the server pane with `pattern: "Listening on"` and `timeout: 30`. Once the wait resolves, the -agent calls {tool}`send-keys` in the original pane: -`npm test -- --integration`, then {tool}`wait-for-text` with +agent calls {tooliconl}`send-keys` in the original pane: +`npm test -- --integration`, then {tooliconl}`wait-for-text` with `pattern: "passed|failed|error"` and `regex: true`, then -{tool}`capture-pane` to read the test results. +{tooliconl}`capture-pane` to read the test results. ```{warning} Calling {toolref}`capture-pane` immediately after {toolref}`send-keys` is a @@ -139,7 +139,7 @@ them failed, but they stepped away and do not remember which pane. > slow. Instead I will search for common failure indicators across all panes > at once. -The agent calls {tool}`search-panes` with +The agent calls {tooliconl}`search-panes` with `pattern: "FAIL|ERROR|error:|Traceback"`, `regex: true`, scoped to `session_name: "ci"`. @@ -150,12 +150,12 @@ The agent calls {tool}`search-panes` with ### Act -The agent calls {tool}`capture-pane` on `%3` with `start: -60`, then on +The agent calls {tooliconl}`capture-pane` on `%3` with `start: -60`, then on `%6` with `start: -60`. ```{tip} If the error scrolled off the visible screen, use `content_start: -200` (or -deeper) when calling {tool}`search-panes`. The `content_start` parameter +deeper) when calling {tooliconl}`search-panes`. The `content_start` parameter makes search reach into scrollback history, not just the visible screen. ``` @@ -183,7 +183,7 @@ interrupt it, verify the pane is responsive, and re-run the command. > `enter: false` or tmux will send Ctrl-C followed by Enter, which could > confirm a prompt I did not intend to answer. -The agent calls {tool}`send-keys` with `keys: "C-c"` and `enter: false` on +The agent calls {tooliconl}`send-keys` with `keys: "C-c"` and `enter: false` on the target pane. ### Decide @@ -192,7 +192,7 @@ the target pane. > for a shell prompt to reappear. Developers use custom prompts, so I cannot > just look for `$`. -The agent calls {tool}`wait-for-text` with `pattern: "[$#>%] *$"`, +The agent calls {tooliconl}`wait-for-text` with `pattern: "[$#>%] *$"`, `regex: true`, and `timeout: 5`. > If the wait resolves, the shell is back. If it times out, the process @@ -202,8 +202,8 @@ The agent calls {tool}`wait-for-text` with `pattern: "[$#>%] *$"`, ### Act If the wait times out, the agent sends `C-\` (also with `enter: false`). If -that also fails, it calls {tool}`kill-pane` on the stuck pane, then -{tool}`split-window` to create a replacement, then {tool}`send-keys` to +that also fails, it calls {tooliconl}`kill-pane` on the stuck pane, then +{tooliconl}`split-window` to create a replacement, then {tooliconl}`send-keys` to re-run. ```{warning} @@ -233,9 +233,9 @@ fresh results. ### Discover -The agent calls {tool}`list-panes` to find the pane by title, cwd, or +The agent calls {tooliconl}`list-panes` to find the pane by title, cwd, or current command. If more than one pane is plausible, it uses -{tool}`capture-pane` with a small range to confirm the target. +{tooliconl}`capture-pane` with a small range to confirm the target. ### Decide @@ -245,10 +245,10 @@ current command. If more than one pane is plausible, it uses ### Act -The agent calls {tool}`clear-pane`, then {tool}`send-keys` with -`keys: "pytest"`, then {tool}`wait-for-text` with +The agent calls {tooliconl}`clear-pane`, then {tooliconl}`send-keys` with +`keys: "pytest"`, then {tooliconl}`wait-for-text` with `pattern: "passed|failed|error"` and `regex: true`, then -{tool}`capture-pane` to read the fresh output. +{tooliconl}`capture-pane` to read the fresh output. ### The non-obvious part @@ -275,7 +275,7 @@ server pane", "the test pane"). > Before creating anything, I need to check whether a session with this name > already exists. Creating a duplicate will fail. -The agent calls {tool}`list-sessions`. No session named `myproject` exists. +The agent calls {tooliconl}`list-sessions`. No session named `myproject` exists. ### Decide @@ -285,16 +285,16 @@ The agent calls {tool}`list-sessions`. No session named `myproject` exists. ### Act -The agent calls {tool}`create-session` with `session_name: "myproject"` and -`start_directory: "/home/dev/myproject"`. Then {tool}`split-window` twice +The agent calls {tooliconl}`create-session` with `session_name: "myproject"` and +`start_directory: "/home/dev/myproject"`. Then {tooliconl}`split-window` twice (with `direction: "right"` and `direction: "below"`), followed by -{tool}`select-layout` with `layout: "main-vertical"`. +{tooliconl}`select-layout` with `layout: "main-vertical"`. -The agent calls {tool}`set-pane-title` on each pane: `editor`, `server`, +The agent calls {tooliconl}`set-pane-title` on each pane: `editor`, `server`, `tests`. -The agent calls {tool}`send-keys` in the server pane: `npm run dev`, then -{tool}`wait-for-text` for `pattern: "ready|listening|Local:"` with +The agent calls {tooliconl}`send-keys` in the server pane: `npm run dev`, then +{tooliconl}`wait-for-text` for `pattern: "ready|listening|Local:"` with `regex: true` and `timeout: 30`. ```{tip}