diff --git a/docs/_ext/fastmcp_autodoc.py b/docs/_ext/fastmcp_autodoc.py index a21f185..7bdbc69 100644 --- a/docs/_ext/fastmcp_autodoc.py +++ b/docs/_ext/fastmcp_autodoc.py @@ -65,18 +65,35 @@ TAG_DESTRUCTIVE = "destructive" _MODEL_MODULE = "libtmux_mcp.models" -_MODEL_CLASSES: set[str] = { - "SessionInfo", - "WindowInfo", - "PaneInfo", - "PaneContentMatch", - "ServerInfo", - "OptionResult", - "OptionSetResult", - "EnvironmentResult", - "EnvironmentSetResult", - "WaitForTextResult", -} +_model_classes_cache: set[str] | None = None + + +def _discover_model_classes() -> set[str]: + """Discover all BaseModel subclasses in libtmux_mcp.models. + + Results are cached after first call. Only discovers classes whose + ``__module__`` matches ``_MODEL_MODULE`` to prevent third-party leakage. + """ + global _model_classes_cache + if _model_classes_cache is not None: + return _model_classes_cache + import inspect as _inspect + + from pydantic import BaseModel + + try: + mod = importlib.import_module(_MODEL_MODULE) + except ImportError: + logger.warning("fastmcp_autodoc: could not import %s", _MODEL_MODULE) + _model_classes_cache = set() + return _model_classes_cache + _model_classes_cache = { + name + for name, obj in _inspect.getmembers(mod, _inspect.isclass) + if issubclass(obj, BaseModel) + and getattr(obj, "__module__", "") == _MODEL_MODULE + } + return _model_classes_cache # --------------------------------------------------------------------------- @@ -111,6 +128,40 @@ class ToolInfo: return_annotation: str +@dataclass +class ResourceInfo: + """Collected metadata for a single MCP resource.""" + + name: str + qualified_name: str + title: str + uri_template: str + docstring: str + params: list[ParamInfo] + return_annotation: str + + +@dataclass +class ModelFieldInfo: + """Extracted field information for a Pydantic model.""" + + name: str + type_str: str + required: bool + default: str + description: str + + +@dataclass +class ModelInfo: + """Collected metadata for a single Pydantic model.""" + + name: str + qualified_name: str + docstring: str + fields: list[ModelFieldInfo] + + # --------------------------------------------------------------------------- # Docstring + signature parsing # --------------------------------------------------------------------------- @@ -302,7 +353,7 @@ def _single_type_xref(name: str) -> addnodes.pending_xref: Known model classes are qualified to ``libtmux_mcp.models.X``. Builtins (``str``, ``list``, ``int``, etc.) target the Python domain. """ - target = f"{_MODEL_MODULE}.{name}" if name in _MODEL_CLASSES else name + target = f"{_MODEL_MODULE}.{name}" if name in _discover_model_classes() else name return addnodes.pending_xref( "", nodes.literal("", name), @@ -459,6 +510,76 @@ def _safety_badge(safety: str) -> _safety_badge_node: return badge +class _resource_badge_node(nodes.General, nodes.Inline, nodes.Element): # type: ignore[misc] + """Custom node for resource badges with ARIA attributes in HTML output.""" + + +def _visit_resource_badge_html(self: t.Any, node: _resource_badge_node) -> None: + """Emit opening ```` with classes, role, and aria-label.""" + classes = " ".join(node.get("classes", [])) + self.body.append( + f'' + ) + + +def _depart_resource_badge_html(self: t.Any, node: _resource_badge_node) -> None: + """Close the ````.""" + self.body.append("") + + +def _resource_badge() -> _resource_badge_node: + """Create a blue resource badge node with ARIA attributes.""" + _base = ["sd-sphinx-override", "sd-badge"] + badge = _resource_badge_node( + "", + nodes.Text("resource"), + classes=[*_base, "sd-bg-info", "sd-bg-text-info"], + ) + return badge + + +class _model_badge_node(nodes.General, nodes.Inline, nodes.Element): # type: ignore[misc] + """Custom node for model badges with ARIA attributes in HTML output.""" + + +def _visit_model_badge_html(self: t.Any, node: _model_badge_node) -> None: + """Emit opening ```` with classes, role, and aria-label.""" + classes = " ".join(node.get("classes", [])) + self.body.append(f'') + + +def _depart_model_badge_html(self: t.Any, node: _model_badge_node) -> None: + """Close the ````.""" + self.body.append("") + + +def _model_badge() -> _model_badge_node: + """Create a purple model badge node with ARIA attributes.""" + _base = ["sd-sphinx-override", "sd-badge"] + badge = _model_badge_node( + "", + nodes.Text("model"), + classes=[*_base, "sd-bg-primary", "sd-bg-text-primary"], + ) + return badge + + +class _resource_ref_placeholder(nodes.General, nodes.Inline, nodes.Element): # type: ignore[misc] + """Placeholder node for ``{resource}`` and ``{resourceref}`` roles. + + Resolved at ``doctree-resolved`` by ``_resolve_resource_refs``. + The ``show_badge`` attribute controls whether the resource badge is appended. + """ + + +class _model_ref_placeholder(nodes.General, nodes.Inline, nodes.Element): # type: ignore[misc] + """Placeholder node for ``{model}`` and ``{modelref}`` roles. + + Resolved at ``doctree-resolved`` by ``_resolve_model_refs``. + The ``show_badge`` attribute controls whether the model badge is appended. + """ + + # --------------------------------------------------------------------------- # Tool collection (runs at builder-inited) # --------------------------------------------------------------------------- @@ -512,6 +633,38 @@ def decorator(func: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]: return decorator +class _ResourceCollector: + """Mock FastMCP that captures resource registrations.""" + + def __init__(self) -> None: + self.resources: list[ResourceInfo] = [] + self._current_module: str = "" + + def resource( + self, + uri_template: str, + title: str = "", + **kwargs: t.Any, + ) -> t.Callable[[t.Callable[..., t.Any]], t.Callable[..., t.Any]]: + def decorator(func: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]: + self.resources.append( + ResourceInfo( + name=func.__name__, + qualified_name=f"{self._current_module}.{func.__name__}", + title=title or func.__name__.replace("_", " ").title(), + uri_template=uri_template, + docstring=func.__doc__ or "", + params=_extract_params(func), + return_annotation=_format_annotation( + inspect.signature(func).return_annotation, + ), + ) + ) + return func + + return decorator + + def _collect_tools(app: Sphinx) -> None: """Collect tool metadata from libtmux_mcp source at build time.""" collector = _ToolCollector() @@ -541,6 +694,111 @@ def _collect_tools(app: Sphinx) -> None: app.env.fastmcp_tools = {tool.name: tool for tool in collector.tools} # type: ignore[attr-defined] +def _collect_resources(app: Sphinx) -> None: + """Collect resource metadata from libtmux_mcp source at build time.""" + collector = _ResourceCollector() + + resource_modules = ["hierarchy"] + + for mod_name in resource_modules: + collector._current_module = mod_name + try: + mod = importlib.import_module(f"libtmux_mcp.resources.{mod_name}") + if hasattr(mod, "register"): + mod.register(collector) + except Exception: + logger.warning( + "fastmcp_autodoc: failed to load resource module %s", + mod_name, + exc_info=True, + ) + + app.env.fastmcp_resources = {r.name: r for r in collector.resources} # type: ignore[attr-defined] + + +def _collect_models(app: Sphinx) -> None: + """Collect Pydantic model metadata from libtmux_mcp.models at build time.""" + from pydantic import BaseModel + + try: + mod = importlib.import_module(_MODEL_MODULE) + except ImportError: + logger.warning("fastmcp_autodoc: could not import %s", _MODEL_MODULE) + app.env.fastmcp_models = {} # type: ignore[attr-defined] + return + + models: dict[str, ModelInfo] = {} + for name, obj in inspect.getmembers(mod, inspect.isclass): + if not issubclass(obj, BaseModel): + continue + if getattr(obj, "__module__", "") != _MODEL_MODULE: + continue + + fields: list[ModelFieldInfo] = [] + for field_name, field_info in obj.model_fields.items(): + # Determine type string + ann = obj.__annotations__.get(field_name, "") + type_str = _format_annotation(ann) + + # Determine required / default + has_default_factory = ( + hasattr(field_info, "default_factory") + and field_info.default_factory is not None + ) + has_default = not field_info.is_required() and not has_default_factory + + if has_default_factory: + required = False + factory = field_info.default_factory + # Show factory name for common factories + default_str = f"{factory.__name__}()" if factory else "" + elif has_default: + required = False + default_val = field_info.default + if default_val is None: + default_str = "None" + elif isinstance(default_val, bool): + default_str = str(default_val) + elif isinstance(default_val, str): + default_str = repr(default_val) + else: + default_str = str(default_val) + else: + required = True + default_str = "" + + # Extract description from Field(description=...) + description = "" + if hasattr(field_info, "description") and field_info.description: + description = field_info.description + + fields.append( + ModelFieldInfo( + name=field_name, + type_str=type_str, + required=required, + default=default_str, + description=description, + ) + ) + + models[name] = ModelInfo( + name=name, + qualified_name=f"{_MODEL_MODULE}.{name}", + docstring=obj.__doc__ or "", + fields=fields, + ) + + app.env.fastmcp_models = models # type: ignore[attr-defined] + + +def _collect_all(app: Sphinx) -> None: + """Collect tools, resources, and models at build time.""" + _collect_tools(app) + _collect_resources(app) + _collect_models(app) + + # --------------------------------------------------------------------------- # Directives # --------------------------------------------------------------------------- @@ -773,9 +1031,9 @@ def run(self) -> list[nodes.Node]: rows: list[list[str | nodes.Node]] = [] for tool in sorted(tier_tools, key=lambda t: t.name): first_line = _first_paragraph(tool.docstring) - # Link to the tool's section on its area page + section_id = tool.name.replace("_", "-") ref = nodes.reference("", "", internal=True) - ref["refuri"] = f"{tool.area}/#{tool.name.replace('_', '-')}" + ref["refuri"] = f"#{section_id}" ref += nodes.literal("", tool.name) rows.append( [ @@ -790,6 +1048,410 @@ def run(self) -> list[nodes.Node]: return result_nodes +class FastMCPResourceDirective(SphinxDirective): + """Autodocument a single MCP resource as a proper section. + + Creates a section node (visible in ToC) containing: + - Resource badge + one-line description + - URI template literal block + - Optional parameter table + - Return type + + Usage:: + + ```{fastmcp-resource} hierarchy.get_sessions + ``` + """ + + required_arguments = 1 + optional_arguments = 0 + has_content = True + final_argument_whitespace = False + + def run(self) -> list[nodes.Node]: + """Build resource section nodes.""" + arg = self.arguments[0] + func_name = arg.split(".")[-1] if "." in arg else arg + + resources: dict[str, ResourceInfo] = getattr(self.env, "fastmcp_resources", {}) + resource = resources.get(func_name) + + if resource is None: + return [ + self.state.document.reporter.warning( + f"fastmcp-resource: resource '{func_name}' not found. " + f"Available: {', '.join(sorted(resources.keys()))}", + line=self.lineno, + ) + ] + + return self._build_resource_section(resource) + + def _build_resource_section(self, resource: ResourceInfo) -> list[nodes.Node]: + """Build section: title, badge, description, URI template, params.""" + document = self.state.document + + # Section with anchor ID + section_id = f"resource-{resource.name.replace('_', '-')}" + section = nodes.section() + section["ids"].append(section_id) + document.note_explicit_target(section) + + # Title: resource name + resource badge + title_node = nodes.title("", "") + title_node += nodes.literal("", resource.name) + title_node += nodes.Text(" ") + title_node += _resource_badge() + section += title_node + + # Description paragraph + first_para = _first_paragraph(resource.docstring) + if first_para: + desc_para = _parse_rst_inline(first_para, self.state, self.lineno) + section += desc_para + + # URI template as literal block + uri_block = nodes.literal_block("", resource.uri_template) + uri_block["language"] = "none" + uri_block["classes"].append("fastmcp-uri-template") + section += uri_block + + # Returns + if resource.return_annotation: + returns_para = nodes.paragraph("") + returns_para += nodes.strong("", "Returns: ") + type_para = _make_type_xref(resource.return_annotation) + for child in type_para.children: + returns_para += child.deepcopy() + section += returns_para + + # Parameter table + if resource.params: + section += _make_para(nodes.strong("", "Parameters")) + headers = ["Parameter", "Type", "Required", "Default", "Description"] + rows: list[list[str | nodes.Node]] = [] + for p in resource.params: + desc_node = ( + _parse_rst_inline(p.description, self.state, self.lineno) + if p.description + else nodes.paragraph("", "\u2014") + ) + + type_cell, _is_enum = _make_type_cell_smart(p.type_str) + + default_cell: str | nodes.Node = "\u2014" + if p.default and p.default != "None": + default_cell = _make_para(_make_literal(p.default)) + + rows.append( + [ + _make_para(_make_literal(p.name)), + type_cell, + "yes" if p.required else "no", + default_cell, + desc_node, + ] + ) + section += _make_table(headers, rows, col_widths=[15, 15, 8, 10, 52]) + + return [section] + + +class FastMCPResourceSummaryDirective(SphinxDirective): + """Generate a summary table of all resources. + + Produces a single table with URI Template, Title, and Description columns. + + Usage:: + + ```{fastmcp-resourcesummary} + ``` + """ + + required_arguments = 0 + optional_arguments = 0 + has_content = False + + def run(self) -> list[nodes.Node]: + """Build resource summary table.""" + resources: dict[str, ResourceInfo] = getattr(self.env, "fastmcp_resources", {}) + + if not resources: + return [ + self.state.document.reporter.warning( + "fastmcp-resourcesummary: no resources found.", + line=self.lineno, + ) + ] + + headers = ["URI Template", "Title", "Description"] + rows: list[list[str | nodes.Node]] = [] + for resource in sorted(resources.values(), key=lambda r: r.uri_template): + first_line = _first_paragraph(resource.docstring) + ref = nodes.reference("", "", internal=True) + section_id = f"resource-{resource.name.replace('_', '-')}" + ref["refuri"] = f"#{section_id}" + ref += nodes.literal("", resource.uri_template) + rows.append( + [ + _make_para(ref), + resource.title, + _parse_rst_inline(first_line, self.state, self.lineno), + ] + ) + + return [_make_table(headers, rows, col_widths=[35, 15, 50])] + + +class FastMCPModelDirective(SphinxDirective): + """Autodocument a single Pydantic model as a proper section. + + Creates a section node (visible in ToC) containing: + - Model badge + docstring + - Field table (Field, Type, Required, Default, Description) + + Options: + - ``:fields:`` — comma-separated allowlist of fields to include + - ``:exclude:`` — comma-separated denylist of fields to exclude + + Usage:: + + ```{fastmcp-model} SessionInfo + ``` + """ + + required_arguments = 1 + optional_arguments = 0 + has_content = True + final_argument_whitespace = False + option_spec: t.ClassVar[dict[str, t.Any]] = { + "fields": lambda x: x, + "exclude": lambda x: x, + } + + def run(self) -> list[nodes.Node]: + """Build model section nodes.""" + model_name = self.arguments[0].strip() + + models: dict[str, ModelInfo] = getattr(self.env, "fastmcp_models", {}) + model = models.get(model_name) + + if model is None: + return [ + self.state.document.reporter.warning( + f"fastmcp-model: model '{model_name}' not found. " + f"Available: {', '.join(sorted(models.keys()))}", + line=self.lineno, + ) + ] + + return self._build_model_section(model) + + def _build_model_section(self, model: ModelInfo) -> list[nodes.Node]: + """Build section: title, badge, docstring, field table.""" + document = self.state.document + + # Section with anchor ID + section_id = f"model-{model.name}" + section = nodes.section() + section["ids"].append(section_id) + document.note_explicit_target(section) + + # Title: model name + model badge + title_node = nodes.title("", "") + title_node += nodes.literal("", model.name) + title_node += nodes.Text(" ") + title_node += _model_badge() + section += title_node + + # Docstring + first_para = _first_paragraph(model.docstring) + if first_para: + desc_para = _parse_rst_inline(first_para, self.state, self.lineno) + section += desc_para + + # Field table + fields = self._filter_fields(model.fields) + if fields: + section += self._build_field_table(fields) + + return [section] + + def _filter_fields(self, fields: list[ModelFieldInfo]) -> list[ModelFieldInfo]: + """Apply :fields: and :exclude: options.""" + result = list(fields) + fields_opt = self.options.get("fields") + if fields_opt: + allow = {f.strip() for f in fields_opt.split(",")} + result = [f for f in result if f.name in allow] + exclude_opt = self.options.get("exclude") + if exclude_opt: + deny = {f.strip() for f in exclude_opt.split(",")} + result = [f for f in result if f.name not in deny] + return result + + def _build_field_table(self, fields: list[ModelFieldInfo]) -> nodes.table: + """Build a field table.""" + headers = ["Field", "Type", "Required", "Default", "Description"] + rows: list[list[str | nodes.Node]] = [] + for f in fields: + type_cell, _is_enum = _make_type_cell_smart(f.type_str) + + default_cell: str | nodes.Node = "\u2014" + if f.default and f.default != "None": + default_cell = _make_para(_make_literal(f.default)) + + desc = f.description or "\u2014" + + rows.append( + [ + _make_para(_make_literal(f.name)), + type_cell, + "yes" if f.required else "no", + default_cell, + desc, + ] + ) + return _make_table(headers, rows, col_widths=[15, 15, 8, 10, 52]) + + +class FastMCPModelFieldsDirective(SphinxDirective): + """Emit the field table for a model without a section wrapper. + + Useful for embedding model fields inline in other content. + + Options: + - ``:fields:`` — comma-separated allowlist of fields to include + - ``:exclude:`` — comma-separated denylist of fields to exclude + - ``:link-header:`` — if set, adds a header linking to the model section + + Usage:: + + ```{fastmcp-model-fields} SessionInfo + ``` + """ + + required_arguments = 1 + optional_arguments = 0 + has_content = False + option_spec: t.ClassVar[dict[str, t.Any]] = { + "fields": lambda x: x, + "exclude": lambda x: x, + "link-header": lambda x: x, + } + + def run(self) -> list[nodes.Node]: + """Build field table nodes.""" + model_name = self.arguments[0].strip() + + models: dict[str, ModelInfo] = getattr(self.env, "fastmcp_models", {}) + model = models.get(model_name) + + if model is None: + return [ + self.state.document.reporter.warning( + f"fastmcp-model-fields: model '{model_name}' not found.", + line=self.lineno, + ) + ] + + result: list[nodes.Node] = [] + + # Optional link header + link_header = self.options.get("link-header") + if link_header is not None: + ref = nodes.reference("", "", internal=True) + section_id = f"model-{model.name}" + ref["refuri"] = f"#{section_id}" + ref += nodes.literal("", model.name) + result.append(_make_para(ref)) + + # Filter and build table + fields = self._filter_fields(model.fields) + if fields: + headers = ["Field", "Type", "Required", "Default", "Description"] + rows: list[list[str | nodes.Node]] = [] + for f in fields: + type_cell, _is_enum = _make_type_cell_smart(f.type_str) + + default_cell: str | nodes.Node = "\u2014" + if f.default and f.default != "None": + default_cell = _make_para(_make_literal(f.default)) + + desc = f.description or "\u2014" + + rows.append( + [ + _make_para(_make_literal(f.name)), + type_cell, + "yes" if f.required else "no", + default_cell, + desc, + ] + ) + result.append(_make_table(headers, rows, col_widths=[15, 15, 8, 10, 52])) + + return result + + def _filter_fields(self, fields: list[ModelFieldInfo]) -> list[ModelFieldInfo]: + """Apply :fields: and :exclude: options.""" + result = list(fields) + fields_opt = self.options.get("fields") + if fields_opt: + allow = {f.strip() for f in fields_opt.split(",")} + result = [f for f in result if f.name in allow] + exclude_opt = self.options.get("exclude") + if exclude_opt: + deny = {f.strip() for f in exclude_opt.split(",")} + result = [f for f in result if f.name not in deny] + return result + + +class FastMCPModelSummaryDirective(SphinxDirective): + """Generate a summary table of all models. + + Produces a single table with Model and Description columns. + + Usage:: + + ```{fastmcp-modelsummary} + ``` + """ + + required_arguments = 0 + optional_arguments = 0 + has_content = False + + def run(self) -> list[nodes.Node]: + """Build model summary table.""" + models: dict[str, ModelInfo] = getattr(self.env, "fastmcp_models", {}) + + if not models: + return [ + self.state.document.reporter.warning( + "fastmcp-modelsummary: no models found.", + line=self.lineno, + ) + ] + + headers = ["Model", "Description"] + rows: list[list[str | nodes.Node]] = [] + for model in sorted(models.values(), key=lambda m: m.name): + first_line = _first_paragraph(model.docstring) + ref = nodes.reference("", "", internal=True) + section_id = f"model-{model.name}" + ref["refuri"] = f"#{section_id}" + ref += nodes.literal("", model.name) + rows.append( + [ + _make_para(ref), + _parse_rst_inline(first_line, self.state, self.lineno), + ] + ) + + return [_make_table(headers, rows, col_widths=[30, 70])] + + # --------------------------------------------------------------------------- # Extension setup # --------------------------------------------------------------------------- @@ -1059,16 +1721,248 @@ def _badge_role( return [_safety_badge(text.strip())], [] +def _resource_role( + 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]]: + """Inline role ``:resource:`get-sessions``` → linked name + resource badge.""" + target = text.strip().replace("_", "-") + node = _resource_ref_placeholder(rawtext, reftarget=target, show_badge=True) + return [node], [] + + +def _resourceref_role( + 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]]: + """Inline role ``:resourceref:`get-sessions``` → code-linked, no badge.""" + target = text.strip().replace("_", "-") + node = _resource_ref_placeholder(rawtext, reftarget=target, show_badge=False) + return [node], [] + + +def _model_role( + 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]]: + """Inline role ``:model:`SessionInfo``` → linked name + model badge.""" + target = text.strip() + node = _model_ref_placeholder(rawtext, reftarget=target, show_badge=True) + return [node], [] + + +def _modelref_role( + 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]]: + """Inline role ``:modelref:`SessionInfo``` → code-linked, no badge.""" + target = text.strip() + node = _model_ref_placeholder(rawtext, reftarget=target, show_badge=False) + return [node], [] + + +def _register_resource_labels(app: Sphinx, doctree: nodes.document) -> None: + """Register resource sections with StandardDomain for site-wide {ref} links. + + Same pattern as ``_register_tool_labels`` but for sections with + ``resource-`` prefixed IDs. + """ + domain = t.cast("StandardDomain", app.env.get_domain("std")) + docname = app.env.docname + for section in doctree.findall(nodes.section): + if not section["ids"]: + continue + section_id = section["ids"][0] + if not section_id.startswith("resource-"): + continue + if section.children and isinstance(section[0], nodes.title): + title_node = section[0] + resource_name = "" + for child in title_node.children: + if isinstance(child, nodes.literal): + resource_name = child.astext() + break + if not resource_name: + continue + domain.anonlabels[section_id] = (docname, section_id) + domain.labels[section_id] = (docname, section_id, resource_name) + + +def _register_model_labels(app: Sphinx, doctree: nodes.document) -> None: + """Register model sections with StandardDomain for site-wide {ref} links. + + Same pattern as ``_register_tool_labels`` but for sections with + ``model-`` prefixed IDs. + """ + domain = t.cast("StandardDomain", app.env.get_domain("std")) + docname = app.env.docname + for section in doctree.findall(nodes.section): + if not section["ids"]: + continue + section_id = section["ids"][0] + if not section_id.startswith("model-"): + continue + if section.children and isinstance(section[0], nodes.title): + title_node = section[0] + model_name = "" + for child in title_node.children: + if isinstance(child, nodes.literal): + model_name = child.astext() + break + if not model_name: + continue + domain.anonlabels[section_id] = (docname, section_id) + domain.labels[section_id] = (docname, section_id, model_name) + + +def _resolve_resource_refs( + app: Sphinx, + doctree: nodes.document, + fromdocname: str, +) -> None: + """Resolve ``{resource}`` and ``{resourceref}`` placeholders. + + ``{resource}`` renders as ``code`` + resource badge. + ``{resourceref}`` renders as ``code`` only (no badge). + """ + domain = t.cast("StandardDomain", app.env.get_domain("std")) + builder = app.builder + + for node in list(doctree.findall(_resource_ref_placeholder)): + target = node.get("reftarget", "") + show_badge = node.get("show_badge", True) + # Try resource-prefixed label + label_key = f"resource-{target}" + label_info = domain.labels.get(label_key) + if label_info is None: + node.replace_self(nodes.literal("", target.replace("-", "_"))) + continue + + todocname, labelid, _title = label_info + resource_name = target.replace("-", "_") + + newnode = nodes.reference("", "", internal=True) + try: + newnode["refuri"] = builder.get_relative_uri(fromdocname, todocname) + if labelid: + newnode["refuri"] += "#" + labelid + except Exception: + logger.warning( + "fastmcp_autodoc: failed to resolve URI for %s -> %s", + fromdocname, + todocname, + ) + newnode["refuri"] = "#" + labelid + newnode["classes"].append("reference") + newnode["classes"].append("internal") + + newnode += nodes.literal("", resource_name) + if show_badge: + newnode += nodes.Text(" ") + newnode += _resource_badge() + + node.replace_self(newnode) + + +def _resolve_model_refs( + app: Sphinx, + doctree: nodes.document, + fromdocname: str, +) -> None: + """Resolve ``{model}`` and ``{modelref}`` placeholders. + + ``{model}`` renders as ``code`` + model badge. + ``{modelref}`` renders as ``code`` only (no badge). + """ + domain = t.cast("StandardDomain", app.env.get_domain("std")) + builder = app.builder + + for node in list(doctree.findall(_model_ref_placeholder)): + target = node.get("reftarget", "") + show_badge = node.get("show_badge", True) + # Try model-prefixed label + label_key = f"model-{target}" + label_info = domain.labels.get(label_key) + if label_info is None: + node.replace_self(nodes.literal("", target)) + continue + + todocname, labelid, _title = label_info + + newnode = nodes.reference("", "", internal=True) + try: + newnode["refuri"] = builder.get_relative_uri(fromdocname, todocname) + if labelid: + newnode["refuri"] += "#" + labelid + except Exception: + logger.warning( + "fastmcp_autodoc: failed to resolve URI for %s -> %s", + fromdocname, + todocname, + ) + newnode["refuri"] = "#" + labelid + newnode["classes"].append("reference") + newnode["classes"].append("internal") + + newnode += nodes.literal("", target) + if show_badge: + newnode += nodes.Text(" ") + newnode += _model_badge() + + node.replace_self(newnode) + + def setup(app: Sphinx) -> ExtensionMetadata: """Register the fastmcp_autodoc extension.""" + # Nodes app.add_node( _safety_badge_node, html=(_visit_safety_badge_html, _depart_safety_badge_html), ) - app.connect("builder-inited", _collect_tools) + app.add_node( + _resource_badge_node, + html=(_visit_resource_badge_html, _depart_resource_badge_html), + ) + app.add_node( + _model_badge_node, + html=(_visit_model_badge_html, _depart_model_badge_html), + ) + + # Collection + app.connect("builder-inited", _collect_all) + + # Label registration app.connect("doctree-read", _register_tool_labels) + app.connect("doctree-read", _register_resource_labels) + app.connect("doctree-read", _register_model_labels) + + # Ref resolution app.connect("doctree-resolved", _add_section_badges) app.connect("doctree-resolved", _resolve_tool_refs) + app.connect("doctree-resolved", _resolve_resource_refs) + app.connect("doctree-resolved", _resolve_model_refs) + + # Tool roles app.add_role("tool", _tool_role) app.add_role("toolref", _toolref_role) app.add_role("toolicon", _toolicon_role) @@ -1077,10 +1971,45 @@ def setup(app: Sphinx) -> ExtensionMetadata: app.add_role("tooliconil", _tooliconil_role) app.add_role("tooliconir", _tooliconir_role) app.add_role("badge", _badge_role) + + # Resource roles + app.add_role("resource", _resource_role) + app.add_role("resourceref", _resourceref_role) + + # Model roles + app.add_role("model", _model_role) + app.add_role("modelref", _modelref_role) + + # Tool directives app.add_directive("fastmcp-tool", FastMCPToolDirective) app.add_directive("fastmcp-tool-input", FastMCPToolInputDirective) app.add_directive("fastmcp-toolsummary", FastMCPToolSummaryDirective) + # Resource directives + app.add_directive("fastmcp-resource", FastMCPResourceDirective) + app.add_directive("fastmcp-resourcesummary", FastMCPResourceSummaryDirective) + + # Model directives + app.add_directive("fastmcp-model", FastMCPModelDirective) + app.add_directive("fastmcp-model-fields", FastMCPModelFieldsDirective) + app.add_directive("fastmcp-modelsummary", FastMCPModelSummaryDirective) + + # CSS + app.add_css_file("css/fastmcp_autodoc.css") + + # Strip badge nodes from toctree/sidebar title extraction. + # SphinxContentsFilter walks title nodes to build toctree text. + # Without this, badges inside titles propagate to the sidebar. + # This is the same pattern Sphinx uses for visit_image (SkipNode). + from sphinx.transforms import SphinxContentsFilter + + def _skip_badge(self: t.Any, node: nodes.Node) -> None: + raise nodes.SkipNode + + SphinxContentsFilter.visit__safety_badge_node = _skip_badge # type: ignore[attr-defined] + SphinxContentsFilter.visit__resource_badge_node = _skip_badge # type: ignore[attr-defined] + SphinxContentsFilter.visit__model_badge_node = _skip_badge # type: ignore[attr-defined] + return { "version": "0.1.0", "parallel_read_safe": True, diff --git a/docs/_static/css/fastmcp_autodoc.css b/docs/_static/css/fastmcp_autodoc.css new file mode 100644 index 0000000..8edd903 --- /dev/null +++ b/docs/_static/css/fastmcp_autodoc.css @@ -0,0 +1,37 @@ +/* fastmcp_autodoc.css — resource badge, model badge, URI template, table polish */ + +/* Resource badge (blue) — additional ARIA styling */ +span.sd-badge[aria-label*="resource"] { + user-select: none; +} + +/* Model badge (purple) — additional ARIA styling */ +span.sd-badge[aria-label*="model"] { + user-select: none; +} + +/* URI template block — left-border accent, monospace */ +.fastmcp-uri-template { + border-left: 3px solid var(--sd-color-info, #0dcaf0); + padding: 0.5em 1em; + background: var(--sd-color-info-bg, #f0f9ff); + font-family: var(--sd-fontfamily-monospace, monospace); +} + +/* Badge in section headings — medium size, not full h1 scale */ +h1 .sd-badge, +h2 .sd-badge { + font-size: 0.5em; + vertical-align: middle; +} + +/* Sidebar: strip background from inline code (tool/resource/model names) */ +.sidebar-tree code.literal { + background: none; +} + +/* Field/param table polish — tighter padding */ +.fastmcp-autodoc-table td, +.fastmcp-autodoc-table th { + padding: 0.35em 0.6em; +} diff --git a/docs/demo.md b/docs/demo.md index 2147547..7b45cfd 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -72,6 +72,26 @@ Use {tooliconl}`search-panes` to find text across all panes. If you know which p The fundamental pattern: {toolref}`send-keys` → {toolref}`wait-for-text` → {toolref}`capture-pane`. For discovery: {toolref}`list-sessions` → {toolref}`list-panes` → {toolref}`get-pane-info`. +## Resource references + +### `{resource}` — code-linked with badge + +{resource}`get-sessions` · {resource}`get-session` · {resource}`get-session-windows` · {resource}`get-window` · {resource}`get-pane` · {resource}`get-pane-content` + +### `{resourceref}` — code-linked, no badge + +{resourceref}`get-sessions` · {resourceref}`get-session` · {resourceref}`get-session-windows` · {resourceref}`get-window` · {resourceref}`get-pane` · {resourceref}`get-pane-content` + +## Model references + +### `{model}` — code-linked with badge + +{model}`SessionInfo` · {model}`WindowInfo` · {model}`PaneInfo` · {model}`PaneSnapshot` · {model}`ServerInfo` · {model}`WaitForTextResult` + +### `{modelref}` — code-linked, no badge + +{modelref}`SessionInfo` · {modelref}`WindowInfo` · {modelref}`PaneInfo` · {modelref}`PaneSnapshot` · {modelref}`ServerInfo` · {modelref}`WaitForTextResult` + ## Environment variable references {envvar}`LIBTMUX_SOCKET` · {envvar}`LIBTMUX_SAFETY` · {envvar}`LIBTMUX_SOCKET_PATH` · {envvar}`LIBTMUX_TMUX_BIN` diff --git a/docs/redirects.txt b/docs/redirects.txt index 1baa24d..a5a3d7d 100644 --- a/docs/redirects.txt +++ b/docs/redirects.txt @@ -12,3 +12,45 @@ "concepts" "topics/concepts" "safety" "topics/safety" "guides/troubleshooting" "topics/troubleshooting" +"tools/sessions" "tools/index" +"tools/windows" "tools/index" +"tools/panes" "tools/index" +"tools/options" "tools/index" +"tools/capture-pane" "tools/pane/capture-pane" +"tools/clear-pane" "tools/pane/clear-pane" +"tools/create-session" "tools/server/create-session" +"tools/create-window" "tools/session/create-window" +"tools/display-message" "tools/pane/display-message" +"tools/enter-copy-mode" "tools/pane/enter-copy-mode" +"tools/exit-copy-mode" "tools/pane/exit-copy-mode" +"tools/get-pane-info" "tools/pane/get-pane-info" +"tools/get-server-info" "tools/server/get-server-info" +"tools/kill-pane" "tools/pane/kill-pane" +"tools/kill-server" "tools/server/kill-server" +"tools/kill-session" "tools/session/kill-session" +"tools/kill-window" "tools/window/kill-window" +"tools/list-panes" "tools/window/list-panes" +"tools/list-sessions" "tools/server/list-sessions" +"tools/list-windows" "tools/session/list-windows" +"tools/move-window" "tools/window/move-window" +"tools/paste-text" "tools/pane/paste-text" +"tools/pipe-pane" "tools/pane/pipe-pane" +"tools/rename-session" "tools/session/rename-session" +"tools/rename-window" "tools/window/rename-window" +"tools/resize-pane" "tools/pane/resize-pane" +"tools/resize-window" "tools/window/resize-window" +"tools/search-panes" "tools/pane/search-panes" +"tools/select-layout" "tools/window/select-layout" +"tools/select-pane" "tools/pane/select-pane" +"tools/select-window" "tools/session/select-window" +"tools/send-keys" "tools/pane/send-keys" +"tools/set-environment" "tools/server/set-environment" +"tools/set-option" "tools/server/set-option" +"tools/set-pane-title" "tools/pane/set-pane-title" +"tools/show-environment" "tools/server/show-environment" +"tools/show-option" "tools/server/show-option" +"tools/snapshot-pane" "tools/pane/snapshot-pane" +"tools/split-window" "tools/window/split-window" +"tools/swap-pane" "tools/pane/swap-pane" +"tools/wait-for-content-change" "tools/pane/wait-for-content-change" +"tools/wait-for-text" "tools/pane/wait-for-text" diff --git a/docs/reference/api/models.md b/docs/reference/api/models.md index 57b63b9..34954af 100644 --- a/docs/reference/api/models.md +++ b/docs/reference/api/models.md @@ -1,8 +1,40 @@ # Models -```{eval-rst} -.. automodule:: libtmux_mcp.models - :members: - :undoc-members: - :show-inheritance: +```{fastmcp-modelsummary} +``` + +```{fastmcp-model} SessionInfo +``` + +```{fastmcp-model} WindowInfo +``` + +```{fastmcp-model} PaneInfo +``` + +```{fastmcp-model} PaneContentMatch +``` + +```{fastmcp-model} ServerInfo +``` + +```{fastmcp-model} OptionResult +``` + +```{fastmcp-model} OptionSetResult +``` + +```{fastmcp-model} EnvironmentResult +``` + +```{fastmcp-model} EnvironmentSetResult +``` + +```{fastmcp-model} WaitForTextResult +``` + +```{fastmcp-model} PaneSnapshot +``` + +```{fastmcp-model} ContentChangeResult ``` diff --git a/docs/reference/api/resources.md b/docs/reference/api/resources.md index 245d916..7f99e3f 100644 --- a/docs/reference/api/resources.md +++ b/docs/reference/api/resources.md @@ -1,8 +1,28 @@ # Resources -```{eval-rst} -.. automodule:: libtmux_mcp.resources.hierarchy - :members: - :undoc-members: - :show-inheritance: +```{fastmcp-resourcesummary} +``` + +## Session Resources + +```{fastmcp-resource} hierarchy.get_sessions +``` + +```{fastmcp-resource} hierarchy.get_session +``` + +```{fastmcp-resource} hierarchy.get_session_windows +``` + +## Window Resources + +```{fastmcp-resource} hierarchy.get_window +``` + +## Pane Resources + +```{fastmcp-resource} hierarchy.get_pane +``` + +```{fastmcp-resource} hierarchy.get_pane_content ``` diff --git a/docs/tools/index.md b/docs/tools/index.md index c3de5ba..c3c7b98 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -8,18 +8,36 @@ All tools accept an optional `socket_name` parameter for multi-server support. I **Reading terminal content?** - Know which pane? → {tool}`capture-pane` +- Need text + cursor + mode in one call? → {tool}`snapshot-pane` - Don't know which pane? → {tool}`search-panes` -- Need to wait for output? → {tool}`wait-for-text` +- Need to wait for specific output? → {tool}`wait-for-text` +- Need to wait for *any* change? → {tool}`wait-for-content-change` - Only need metadata (PID, path, size)? → {tool}`get-pane-info` +- Need an arbitrary tmux variable? → {tool}`display-message` **Running a command?** - {tool}`send-keys` — then {tool}`wait-for-text` + {tool}`capture-pane` +- Pasting multi-line text? → {tool}`paste-text` **Creating workspace structure?** - New session → {tool}`create-session` - New window → {tool}`create-window` - New pane → {tool}`split-window` +**Navigating?** +- Switch pane → {tool}`select-pane` (by ID or direction) +- Switch window → {tool}`select-window` (by ID, index, or direction) + +**Rearranging layout?** +- Swap two panes → {tool}`swap-pane` +- Move window → {tool}`move-window` +- Change layout → {tool}`select-layout` + +**Scrollback / copy mode?** +- Enter copy mode → {tool}`enter-copy-mode` +- Exit copy mode → {tool}`exit-copy-mode` +- Log output to file → {tool}`pipe-pane` + **Changing settings?** - tmux options → {tool}`show-option` / {tool}`set-option` - Environment vars → {tool}`show-environment` / {tool}`set-environment` @@ -91,6 +109,24 @@ Query a tmux option value. Show tmux environment variables. ::: +:::{grid-item-card} snapshot_pane +:link: snapshot-pane +:link-type: ref +Rich capture: content + cursor + mode + scroll. +::: + +:::{grid-item-card} wait_for_content_change +:link: wait-for-content-change +:link-type: ref +Wait for any screen change. +::: + +:::{grid-item-card} display_message +:link: display-message +:link-type: ref +Query arbitrary tmux format strings. +::: + :::: ## Act @@ -178,6 +214,54 @@ Set a tmux option. Set a tmux environment variable. ::: +:::{grid-item-card} select_pane +:link: select-pane +:link-type: ref +Focus a pane by ID or direction. +::: + +:::{grid-item-card} select_window +:link: select-window +:link-type: ref +Focus a window by ID, index, or direction. +::: + +:::{grid-item-card} swap_pane +:link: swap-pane +:link-type: ref +Exchange positions of two panes. +::: + +:::{grid-item-card} move_window +:link: move-window +:link-type: ref +Move window to another index or session. +::: + +:::{grid-item-card} pipe_pane +:link: pipe-pane +:link-type: ref +Stream pane output to a file. +::: + +:::{grid-item-card} enter_copy_mode +:link: enter-copy-mode +:link-type: ref +Enter copy mode for scrollback. +::: + +:::{grid-item-card} exit_copy_mode +:link: exit-copy-mode +:link-type: ref +Exit copy mode. +::: + +:::{grid-item-card} paste_text +:link: paste-text +:link-type: ref +Paste multi-line text via tmux buffer. +::: + :::: ## Destroy @@ -216,8 +300,8 @@ Kill the entire tmux server. ```{toctree} :hidden: -sessions -windows -panes -options +server/index +session/index +window/index +pane/index ``` diff --git a/docs/tools/options.md b/docs/tools/options.md deleted file mode 100644 index 5c3c1b8..0000000 --- a/docs/tools/options.md +++ /dev/null @@ -1,138 +0,0 @@ -# Options & Environment - -## Inspect - -```{fastmcp-tool} option_tools.show_option -``` - -**Use when** you need to check a tmux configuration value — buffer limits, -history size, status bar settings, etc. - -**Side effects:** None. Readonly. - -**Example:** - -```json -{ - "tool": "show_option", - "arguments": { - "option": "history-limit" - } -} -``` - -Response: - -```json -{ - "option": "history-limit", - "value": "2000" -} -``` - -```{fastmcp-tool-input} option_tools.show_option -``` - ---- - -```{fastmcp-tool} env_tools.show_environment -``` - -**Use when** you need to inspect tmux environment variables. - -**Side effects:** None. Readonly. - -**Example:** - -```json -{ - "tool": "show_environment", - "arguments": {} -} -``` - -Response: - -```json -{ - "variables": { - "SHELL": "/bin/zsh", - "TERM": "xterm-256color", - "HOME": "/home/user", - "USER": "user", - "LANG": "C.UTF-8" - } -} -``` - -```{fastmcp-tool-input} env_tools.show_environment -``` - -## Act - -```{fastmcp-tool} option_tools.set_option -``` - -**Use when** you need to change tmux behavior — adjusting history limits, -enabling mouse support, changing status bar format. - -**Side effects:** Changes the tmux option value. - -**Example:** - -```json -{ - "tool": "set_option", - "arguments": { - "option": "history-limit", - "value": "50000" - } -} -``` - -Response: - -```json -{ - "option": "history-limit", - "value": "50000", - "status": "set" -} -``` - -```{fastmcp-tool-input} option_tools.set_option -``` - ---- - -```{fastmcp-tool} env_tools.set_environment -``` - -**Use when** you need to set a tmux environment variable. - -**Side effects:** Sets the variable in the tmux server. - -**Example:** - -```json -{ - "tool": "set_environment", - "arguments": { - "name": "MY_VAR", - "value": "hello" - } -} -``` - -Response: - -```json -{ - "name": "MY_VAR", - "value": "hello", - "status": "set" -} -``` - -```{fastmcp-tool-input} env_tools.set_environment -``` diff --git a/docs/tools/pane/capture-pane.md b/docs/tools/pane/capture-pane.md new file mode 100644 index 0000000..2b949d3 --- /dev/null +++ b/docs/tools/pane/capture-pane.md @@ -0,0 +1,40 @@ +```{fastmcp-tool} pane_tools.capture_pane +``` + +**Use when** you need to read what's currently displayed in a terminal — +after running a command, checking output, or verifying state. + +**Avoid when** you need to search across multiple panes at once — use +{tooliconl}`search-panes`. If you only need pane metadata (not content), use +{tooliconl}`get-pane-info`. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "capture_pane", + "arguments": { + "pane_id": "%0", + "start": -50 + } +} +``` + +Response (string): + +```text +$ echo "Running tests..." +Running tests... +$ echo "PASS: test_auth (0.3s)" +PASS: test_auth (0.3s) +$ echo "FAIL: test_upload (AssertionError)" +FAIL: test_upload (AssertionError) +$ echo "3 tests: 2 passed, 1 failed" +3 tests: 2 passed, 1 failed +$ +``` + +```{fastmcp-tool-input} pane_tools.capture_pane +``` diff --git a/docs/tools/pane/clear-pane.md b/docs/tools/pane/clear-pane.md new file mode 100644 index 0000000..f670544 --- /dev/null +++ b/docs/tools/pane/clear-pane.md @@ -0,0 +1,26 @@ +```{fastmcp-tool} pane_tools.clear_pane +``` + +**Use when** you want a clean terminal before capturing output. + +**Side effects:** Clears the pane's visible content. + +**Example:** + +```json +{ + "tool": "clear_pane", + "arguments": { + "pane_id": "%0" + } +} +``` + +Response (string): + +```text +Pane cleared: %0 +``` + +```{fastmcp-tool-input} pane_tools.clear_pane +``` diff --git a/docs/tools/pane/display-message.md b/docs/tools/pane/display-message.md new file mode 100644 index 0000000..1c7c0f6 --- /dev/null +++ b/docs/tools/pane/display-message.md @@ -0,0 +1,33 @@ +```{fastmcp-tool} pane_tools.display_message +``` + +**Use when** you need to query arbitrary tmux variables — zoom state, pane +dead flag, client activity, or any `#{format}` string that isn't covered by +other tools. + +**Avoid when** a dedicated tool already provides the information — e.g. use +{tooliconl}`snapshot-pane` for cursor position and mode, or +{tooliconl}`get-pane-info` for standard metadata. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "display_message", + "arguments": { + "format_string": "zoomed=#{window_zoomed_flag} dead=#{pane_dead}", + "pane_id": "%0" + } +} +``` + +Response (string): + +```text +zoomed=0 dead=0 +``` + +```{fastmcp-tool-input} pane_tools.display_message +``` diff --git a/docs/tools/pane/enter-copy-mode.md b/docs/tools/pane/enter-copy-mode.md new file mode 100644 index 0000000..a720e35 --- /dev/null +++ b/docs/tools/pane/enter-copy-mode.md @@ -0,0 +1,44 @@ +```{fastmcp-tool} pane_tools.enter_copy_mode +``` + +**Use when** you need to scroll through scrollback history in a pane. +Optionally scroll up immediately after entering. Use +{tooliconl}`snapshot-pane` afterward to read the `scroll_position` and +visible content. + +**Side effects:** Puts the pane into copy mode. The pane stops receiving +new output until you exit copy mode. + +**Example:** + +```json +{ + "tool": "enter_copy_mode", + "arguments": { + "pane_id": "%0", + "scroll_up": 50 + } +} +``` + +Response: + +```json +{ + "pane_id": "%0", + "pane_index": "0", + "pane_width": "80", + "pane_height": "24", + "pane_current_command": "zsh", + "pane_current_path": "/home/user/myproject", + "pane_pid": "12345", + "pane_title": "", + "pane_active": "1", + "window_id": "@0", + "session_id": "$0", + "is_caller": null +} +``` + +```{fastmcp-tool-input} pane_tools.enter_copy_mode +``` diff --git a/docs/tools/pane/exit-copy-mode.md b/docs/tools/pane/exit-copy-mode.md new file mode 100644 index 0000000..174dcd2 --- /dev/null +++ b/docs/tools/pane/exit-copy-mode.md @@ -0,0 +1,40 @@ +```{fastmcp-tool} pane_tools.exit_copy_mode +``` + +**Use when** you're done scrolling through scrollback and want the pane to +resume receiving output. + +**Side effects:** Exits copy mode, returning the pane to normal. + +**Example:** + +```json +{ + "tool": "exit_copy_mode", + "arguments": { + "pane_id": "%0" + } +} +``` + +Response: + +```json +{ + "pane_id": "%0", + "pane_index": "0", + "pane_width": "80", + "pane_height": "24", + "pane_current_command": "zsh", + "pane_current_path": "/home/user/myproject", + "pane_pid": "12345", + "pane_title": "", + "pane_active": "1", + "window_id": "@0", + "session_id": "$0", + "is_caller": null +} +``` + +```{fastmcp-tool-input} pane_tools.exit_copy_mode +``` diff --git a/docs/tools/pane/get-pane-info.md b/docs/tools/pane/get-pane-info.md new file mode 100644 index 0000000..85ce43b --- /dev/null +++ b/docs/tools/pane/get-pane-info.md @@ -0,0 +1,42 @@ +```{fastmcp-tool} pane_tools.get_pane_info +``` + +**Use when** you need pane dimensions, PID, current working directory, or +other metadata without reading the terminal content. + +**Avoid when** you need the actual text — use {tooliconl}`capture-pane`. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "get_pane_info", + "arguments": { + "pane_id": "%0" + } +} +``` + +Response: + +```json +{ + "pane_id": "%0", + "pane_index": "0", + "pane_width": "80", + "pane_height": "24", + "pane_current_command": "zsh", + "pane_current_path": "/home/user/myproject", + "pane_pid": "12345", + "pane_title": "", + "pane_active": "1", + "window_id": "@0", + "session_id": "$0", + "is_caller": null +} +``` + +```{fastmcp-tool-input} pane_tools.get_pane_info +``` diff --git a/docs/tools/pane/index.md b/docs/tools/pane/index.md new file mode 100644 index 0000000..dab3682 --- /dev/null +++ b/docs/tools/pane/index.md @@ -0,0 +1,155 @@ +# Pane + +Tools for pane-level operations: reading content, sending input, navigation, scrollback, and lifecycle. + +## Inspect + +::::{grid} 1 2 3 3 +:gutter: 2 2 3 3 + +:::{grid-item-card} capture_pane +:link: capture-pane +:link-type: ref +Read visible content of a pane. +::: + +:::{grid-item-card} get_pane_info +:link: get-pane-info +:link-type: ref +Get detailed pane metadata. +::: + +:::{grid-item-card} search_panes +:link: search-panes +:link-type: ref +Search text across panes. +::: + +:::{grid-item-card} wait_for_text +:link: wait-for-text +:link-type: ref +Wait for text to appear in a pane. +::: + +:::{grid-item-card} snapshot_pane +:link: snapshot-pane +:link-type: ref +Rich capture: content + cursor + mode + scroll. +::: + +:::{grid-item-card} wait_for_content_change +:link: wait-for-content-change +:link-type: ref +Wait for any screen change. +::: + +:::{grid-item-card} display_message +:link: display-message +:link-type: ref +Query arbitrary tmux format strings. +::: + +:::: + +## Act + +::::{grid} 1 2 3 3 +:gutter: 2 2 3 3 + +:::{grid-item-card} send_keys +:link: send-keys +:link-type: ref +Send commands or keystrokes to a pane. +::: + +:::{grid-item-card} set_pane_title +:link: set-pane-title +:link-type: ref +Set pane title. +::: + +:::{grid-item-card} clear_pane +:link: clear-pane +:link-type: ref +Clear pane content. +::: + +:::{grid-item-card} resize_pane +:link: resize-pane +:link-type: ref +Adjust pane dimensions. +::: + +:::{grid-item-card} select_pane +:link: select-pane +:link-type: ref +Focus a pane by ID or direction. +::: + +:::{grid-item-card} swap_pane +:link: swap-pane +:link-type: ref +Exchange positions of two panes. +::: + +:::{grid-item-card} pipe_pane +:link: pipe-pane +:link-type: ref +Stream pane output to a file. +::: + +:::{grid-item-card} enter_copy_mode +:link: enter-copy-mode +:link-type: ref +Enter copy mode for scrollback. +::: + +:::{grid-item-card} exit_copy_mode +:link: exit-copy-mode +:link-type: ref +Exit copy mode. +::: + +:::{grid-item-card} paste_text +:link: paste-text +:link-type: ref +Paste multi-line text via tmux buffer. +::: + +:::: + +## Destroy + +::::{grid} 1 2 3 3 +:gutter: 2 2 3 3 + +:::{grid-item-card} kill_pane +:link: kill-pane +:link-type: ref +Destroy a pane. +::: + +:::: + +```{toctree} +:hidden: + +capture-pane +get-pane-info +search-panes +wait-for-text +snapshot-pane +wait-for-content-change +display-message +send-keys +set-pane-title +clear-pane +resize-pane +select-pane +swap-pane +pipe-pane +enter-copy-mode +exit-copy-mode +paste-text +kill-pane +``` diff --git a/docs/tools/pane/kill-pane.md b/docs/tools/pane/kill-pane.md new file mode 100644 index 0000000..7f6158b --- /dev/null +++ b/docs/tools/pane/kill-pane.md @@ -0,0 +1,29 @@ +```{fastmcp-tool} pane_tools.kill_pane +``` + +**Use when** you're done with a specific terminal and want to remove it +without affecting sibling panes. + +**Avoid when** you want to remove the entire window — use {tooliconl}`kill-window`. + +**Side effects:** Destroys the pane. Not reversible. + +**Example:** + +```json +{ + "tool": "kill_pane", + "arguments": { + "pane_id": "%1" + } +} +``` + +Response (string): + +```text +Pane killed: %1 +``` + +```{fastmcp-tool-input} pane_tools.kill_pane +``` diff --git a/docs/tools/pane/paste-text.md b/docs/tools/pane/paste-text.md new file mode 100644 index 0000000..aa974bc --- /dev/null +++ b/docs/tools/pane/paste-text.md @@ -0,0 +1,31 @@ +```{fastmcp-tool} pane_tools.paste_text +``` + +**Use when** you need to paste multi-line text into a pane — e.g. a code +block, a config snippet, or a heredoc. Uses tmux paste buffers for clean +multi-line input instead of sending text line-by-line via +{tooliconl}`send-keys`. + +**Side effects:** Pastes text into the pane. With `bracket=true` (default), +uses bracketed paste mode so the terminal knows this is pasted text. + +**Example:** + +```json +{ + "tool": "paste_text", + "arguments": { + "text": "def hello():\n print('world')\n", + "pane_id": "%0" + } +} +``` + +Response (string): + +```text +Text pasted to pane %0 +``` + +```{fastmcp-tool-input} pane_tools.paste_text +``` diff --git a/docs/tools/pane/pipe-pane.md b/docs/tools/pane/pipe-pane.md new file mode 100644 index 0000000..48f4261 --- /dev/null +++ b/docs/tools/pane/pipe-pane.md @@ -0,0 +1,33 @@ +```{fastmcp-tool} pane_tools.pipe_pane +``` + +**Use when** you need to log pane output to a file — useful for monitoring +long-running processes or capturing output that scrolls past the visible +area. + +**Avoid when** you only need a one-time capture — use {tooliconl}`capture-pane` +with `start`/`end` to read scrollback. + +**Side effects:** Starts or stops piping output to a file. Call with +`output_path=null` to stop. + +**Example:** + +```json +{ + "tool": "pipe_pane", + "arguments": { + "pane_id": "%0", + "output_path": "/tmp/build.log" + } +} +``` + +Response (string): + +```text +Piping pane %0 to /tmp/build.log +``` + +```{fastmcp-tool-input} pane_tools.pipe_pane +``` diff --git a/docs/tools/pane/resize-pane.md b/docs/tools/pane/resize-pane.md new file mode 100644 index 0000000..2cc7fcd --- /dev/null +++ b/docs/tools/pane/resize-pane.md @@ -0,0 +1,40 @@ +```{fastmcp-tool} pane_tools.resize_pane +``` + +**Use when** you need to adjust pane dimensions. + +**Side effects:** Changes pane size. May affect adjacent panes. + +**Example:** + +```json +{ + "tool": "resize_pane", + "arguments": { + "pane_id": "%0", + "height": 15 + } +} +``` + +Response: + +```json +{ + "pane_id": "%0", + "pane_index": "0", + "pane_width": "80", + "pane_height": "15", + "pane_current_command": "zsh", + "pane_current_path": "/home/user/myproject", + "pane_pid": "12345", + "pane_title": "", + "pane_active": "1", + "window_id": "@0", + "session_id": "$0", + "is_caller": null +} +``` + +```{fastmcp-tool-input} pane_tools.resize_pane +``` diff --git a/docs/tools/pane/search-panes.md b/docs/tools/pane/search-panes.md new file mode 100644 index 0000000..6dd8f16 --- /dev/null +++ b/docs/tools/pane/search-panes.md @@ -0,0 +1,47 @@ +```{fastmcp-tool} pane_tools.search_panes +``` + +**Use when** you need to find specific text across multiple panes — locating +which pane has an error, finding a running process, or checking output +without knowing which pane to look in. + +**Avoid when** you already know the target pane — use {tooliconl}`capture-pane` +directly. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "search_panes", + "arguments": { + "pattern": "FAIL", + "session_name": "dev" + } +} +``` + +Response: + +```json +[ + { + "pane_id": "%0", + "pane_current_command": "zsh", + "pane_current_path": "/home/user/myproject", + "window_id": "@0", + "window_name": "editor", + "session_id": "$0", + "session_name": "dev", + "matched_lines": [ + "FAIL: test_upload (AssertionError)", + "3 tests: 2 passed, 1 failed" + ], + "is_caller": null + } +] +``` + +```{fastmcp-tool-input} pane_tools.search_panes +``` diff --git a/docs/tools/pane/select-pane.md b/docs/tools/pane/select-pane.md new file mode 100644 index 0000000..7495aa6 --- /dev/null +++ b/docs/tools/pane/select-pane.md @@ -0,0 +1,42 @@ +```{fastmcp-tool} pane_tools.select_pane +``` + +**Use when** you need to focus a specific pane — by ID for a known target, +or by direction (`up`, `down`, `left`, `right`, `last`, `next`, `previous`) +to navigate a multi-pane layout. + +**Side effects:** Changes the active pane in the window. + +**Example:** + +```json +{ + "tool": "select_pane", + "arguments": { + "direction": "down", + "window_id": "@0" + } +} +``` + +Response: + +```json +{ + "pane_id": "%1", + "pane_index": "1", + "pane_width": "80", + "pane_height": "11", + "pane_current_command": "zsh", + "pane_current_path": "/home/user/myproject", + "pane_pid": "12400", + "pane_title": "", + "pane_active": "1", + "window_id": "@0", + "session_id": "$0", + "is_caller": null +} +``` + +```{fastmcp-tool-input} pane_tools.select_pane +``` diff --git a/docs/tools/pane/send-keys.md b/docs/tools/pane/send-keys.md new file mode 100644 index 0000000..4bdaa75 --- /dev/null +++ b/docs/tools/pane/send-keys.md @@ -0,0 +1,32 @@ +```{fastmcp-tool} pane_tools.send_keys +``` + +**Use when** you need to type commands, press keys, or interact with a +terminal. This is the primary way to execute commands in tmux panes. + +**Avoid when** you need to run something and immediately capture the result — +send keys first, then use {tooliconl}`capture-pane` or {tooliconl}`wait-for-text`. + +**Side effects:** Sends keystrokes to the pane. If `enter` is true (default), +the command executes. + +**Example:** + +```json +{ + "tool": "send_keys", + "arguments": { + "keys": "npm start", + "pane_id": "%2" + } +} +``` + +Response (string): + +```text +Keys sent to pane %2 +``` + +```{fastmcp-tool-input} pane_tools.send_keys +``` diff --git a/docs/tools/pane/set-pane-title.md b/docs/tools/pane/set-pane-title.md new file mode 100644 index 0000000..65ec2d8 --- /dev/null +++ b/docs/tools/pane/set-pane-title.md @@ -0,0 +1,40 @@ +```{fastmcp-tool} pane_tools.set_pane_title +``` + +**Use when** you want to label a pane for identification. + +**Side effects:** Changes the pane title. + +**Example:** + +```json +{ + "tool": "set_pane_title", + "arguments": { + "pane_id": "%0", + "title": "build" + } +} +``` + +Response: + +```json +{ + "pane_id": "%0", + "pane_index": "0", + "pane_width": "80", + "pane_height": "24", + "pane_current_command": "zsh", + "pane_current_path": "/home/user/myproject", + "pane_pid": "12345", + "pane_title": "build", + "pane_active": "1", + "window_id": "@0", + "session_id": "$0", + "is_caller": null +} +``` + +```{fastmcp-tool-input} pane_tools.set_pane_title +``` diff --git a/docs/tools/pane/snapshot-pane.md b/docs/tools/pane/snapshot-pane.md new file mode 100644 index 0000000..3ee2747 --- /dev/null +++ b/docs/tools/pane/snapshot-pane.md @@ -0,0 +1,47 @@ +```{fastmcp-tool} pane_tools.snapshot_pane +``` + +**Use when** you need a complete picture of a pane in a single call — visible +text plus cursor position, whether the pane is in copy mode, scroll offset, +and scrollback history size. Replaces separate `capture_pane` + +`get_pane_info` calls when you need to reason about cursor location or +terminal mode. + +**Avoid when** you only need raw text — {tooliconl}`capture-pane` is lighter. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "snapshot_pane", + "arguments": { + "pane_id": "%0" + } +} +``` + +Response: + +```json +{ + "pane_id": "%0", + "content": "$ npm test\n\nPASS src/auth.test.ts\nTests: 3 passed\n$", + "cursor_x": 2, + "cursor_y": 4, + "pane_width": 80, + "pane_height": 24, + "pane_in_mode": false, + "pane_mode": null, + "scroll_position": null, + "history_size": 142, + "title": "", + "pane_current_command": "zsh", + "pane_current_path": "/home/user/myproject", + "is_caller": null +} +``` + +```{fastmcp-tool-input} pane_tools.snapshot_pane +``` diff --git a/docs/tools/pane/swap-pane.md b/docs/tools/pane/swap-pane.md new file mode 100644 index 0000000..d179952 --- /dev/null +++ b/docs/tools/pane/swap-pane.md @@ -0,0 +1,41 @@ +```{fastmcp-tool} pane_tools.swap_pane +``` + +**Use when** you want to rearrange pane positions without changing content — +e.g. moving a log pane from bottom to top. + +**Side effects:** Exchanges the visual positions of two panes. + +**Example:** + +```json +{ + "tool": "swap_pane", + "arguments": { + "source_pane_id": "%0", + "target_pane_id": "%1" + } +} +``` + +Response: + +```json +{ + "pane_id": "%0", + "pane_index": "1", + "pane_width": "80", + "pane_height": "11", + "pane_current_command": "zsh", + "pane_current_path": "/home/user/myproject", + "pane_pid": "12345", + "pane_title": "", + "pane_active": "1", + "window_id": "@0", + "session_id": "$0", + "is_caller": null +} +``` + +```{fastmcp-tool-input} pane_tools.swap_pane +``` diff --git a/docs/tools/pane/wait-for-content-change.md b/docs/tools/pane/wait-for-content-change.md new file mode 100644 index 0000000..da84111 --- /dev/null +++ b/docs/tools/pane/wait-for-content-change.md @@ -0,0 +1,38 @@ +```{fastmcp-tool} pane_tools.wait_for_content_change +``` + +**Use when** you've sent a command and need to wait for *something* to happen, +but you don't know what the output will look like. Unlike +{tooliconl}`wait-for-text`, this waits for *any* screen change rather than a +specific pattern. + +**Avoid when** you know the expected output — {tooliconl}`wait-for-text` is more +precise and avoids false positives from unrelated output. + +**Side effects:** None. Readonly. Blocks until content changes or timeout. + +**Example:** + +```json +{ + "tool": "wait_for_content_change", + "arguments": { + "pane_id": "%0", + "timeout": 10 + } +} +``` + +Response: + +```json +{ + "changed": true, + "pane_id": "%0", + "elapsed_seconds": 1.234, + "timed_out": false +} +``` + +```{fastmcp-tool-input} pane_tools.wait_for_content_change +``` diff --git a/docs/tools/pane/wait-for-text.md b/docs/tools/pane/wait-for-text.md new file mode 100644 index 0000000..91aaaf6 --- /dev/null +++ b/docs/tools/pane/wait-for-text.md @@ -0,0 +1,42 @@ +```{fastmcp-tool} pane_tools.wait_for_text +``` + +**Use when** you need to block until specific output appears — waiting for a +server to start, a build to complete, or a prompt to return. + +**Avoid when** the expected text may never appear — always set a reasonable +`timeout`. For known output, {tooliconl}`capture-pane` after a known delay +may suffice, but `wait_for_text` is preferred because it adapts to variable +timing. + +**Side effects:** None. Readonly. Blocks until text appears or timeout. + +**Example:** + +```json +{ + "tool": "wait_for_text", + "arguments": { + "pattern": "Server listening", + "pane_id": "%2", + "timeout": 30 + } +} +``` + +Response: + +```json +{ + "found": true, + "matched_lines": [ + "Server listening on port 8000" + ], + "pane_id": "%2", + "elapsed_seconds": 0.002, + "timed_out": false +} +``` + +```{fastmcp-tool-input} pane_tools.wait_for_text +``` diff --git a/docs/tools/panes.md b/docs/tools/panes.md deleted file mode 100644 index 3a7b8f1..0000000 --- a/docs/tools/panes.md +++ /dev/null @@ -1,366 +0,0 @@ -# Panes - -## Inspect - -```{fastmcp-tool} pane_tools.capture_pane -``` - -**Use when** you need to read what's currently displayed in a terminal — -after running a command, checking output, or verifying state. - -**Avoid when** you need to search across multiple panes at once — use -{tooliconl}`search-panes`. If you only need pane metadata (not content), use -{tooliconl}`get-pane-info`. - -**Side effects:** None. Readonly. - -**Example:** - -```json -{ - "tool": "capture_pane", - "arguments": { - "pane_id": "%0", - "start": -50 - } -} -``` - -Response (string): - -```text -$ echo "Running tests..." -Running tests... -$ echo "PASS: test_auth (0.3s)" -PASS: test_auth (0.3s) -$ echo "FAIL: test_upload (AssertionError)" -FAIL: test_upload (AssertionError) -$ echo "3 tests: 2 passed, 1 failed" -3 tests: 2 passed, 1 failed -$ -``` - -```{fastmcp-tool-input} pane_tools.capture_pane -``` - ---- - -```{fastmcp-tool} pane_tools.get_pane_info -``` - -**Use when** you need pane dimensions, PID, current working directory, or -other metadata without reading the terminal content. - -**Avoid when** you need the actual text — use {tooliconl}`capture-pane`. - -**Side effects:** None. Readonly. - -**Example:** - -```json -{ - "tool": "get_pane_info", - "arguments": { - "pane_id": "%0" - } -} -``` - -Response: - -```json -{ - "pane_id": "%0", - "pane_index": "0", - "pane_width": "80", - "pane_height": "24", - "pane_current_command": "zsh", - "pane_current_path": "/home/user/myproject", - "pane_pid": "12345", - "pane_title": "", - "pane_active": "1", - "window_id": "@0", - "session_id": "$0", - "is_caller": null -} -``` - -```{fastmcp-tool-input} pane_tools.get_pane_info -``` - ---- - -```{fastmcp-tool} pane_tools.search_panes -``` - -**Use when** you need to find specific text across multiple panes — locating -which pane has an error, finding a running process, or checking output -without knowing which pane to look in. - -**Avoid when** you already know the target pane — use {tooliconl}`capture-pane` -directly. - -**Side effects:** None. Readonly. - -**Example:** - -```json -{ - "tool": "search_panes", - "arguments": { - "pattern": "FAIL", - "session_name": "dev" - } -} -``` - -Response: - -```json -[ - { - "pane_id": "%0", - "pane_current_command": "zsh", - "pane_current_path": "/home/user/myproject", - "window_id": "@0", - "window_name": "editor", - "session_id": "$0", - "session_name": "dev", - "matched_lines": [ - "FAIL: test_upload (AssertionError)", - "3 tests: 2 passed, 1 failed" - ], - "is_caller": null - } -] -``` - -```{fastmcp-tool-input} pane_tools.search_panes -``` - ---- - -```{fastmcp-tool} pane_tools.wait_for_text -``` - -**Use when** you need to block until specific output appears — waiting for a -server to start, a build to complete, or a prompt to return. - -**Avoid when** the expected text may never appear — always set a reasonable -`timeout`. For known output, {tooliconl}`capture-pane` after a known delay -may suffice, but `wait_for_text` is preferred because it adapts to variable -timing. - -**Side effects:** None. Readonly. Blocks until text appears or timeout. - -**Example:** - -```json -{ - "tool": "wait_for_text", - "arguments": { - "pattern": "Server listening", - "pane_id": "%2", - "timeout": 30 - } -} -``` - -Response: - -```json -{ - "found": true, - "matched_lines": [ - "Server listening on port 8000" - ], - "pane_id": "%2", - "elapsed_seconds": 0.002, - "timed_out": false -} -``` - -```{fastmcp-tool-input} pane_tools.wait_for_text -``` - -## Act - -```{fastmcp-tool} pane_tools.send_keys -``` - -**Use when** you need to type commands, press keys, or interact with a -terminal. This is the primary way to execute commands in tmux panes. - -**Avoid when** you need to run something and immediately capture the result — -send keys first, then use {tooliconl}`capture-pane` or {tooliconl}`wait-for-text`. - -**Side effects:** Sends keystrokes to the pane. If `enter` is true (default), -the command executes. - -**Example:** - -```json -{ - "tool": "send_keys", - "arguments": { - "keys": "npm start", - "pane_id": "%2" - } -} -``` - -Response (string): - -```text -Keys sent to pane %2 -``` - -```{fastmcp-tool-input} pane_tools.send_keys -``` - ---- - -```{fastmcp-tool} pane_tools.set_pane_title -``` - -**Use when** you want to label a pane for identification. - -**Side effects:** Changes the pane title. - -**Example:** - -```json -{ - "tool": "set_pane_title", - "arguments": { - "pane_id": "%0", - "title": "build" - } -} -``` - -Response: - -```json -{ - "pane_id": "%0", - "pane_index": "0", - "pane_width": "80", - "pane_height": "24", - "pane_current_command": "zsh", - "pane_current_path": "/home/user/myproject", - "pane_pid": "12345", - "pane_title": "build", - "pane_active": "1", - "window_id": "@0", - "session_id": "$0", - "is_caller": null -} -``` - -```{fastmcp-tool-input} pane_tools.set_pane_title -``` - ---- - -```{fastmcp-tool} pane_tools.clear_pane -``` - -**Use when** you want a clean terminal before capturing output. - -**Side effects:** Clears the pane's visible content. - -**Example:** - -```json -{ - "tool": "clear_pane", - "arguments": { - "pane_id": "%0" - } -} -``` - -Response (string): - -```text -Pane cleared: %0 -``` - -```{fastmcp-tool-input} pane_tools.clear_pane -``` - ---- - -```{fastmcp-tool} pane_tools.resize_pane -``` - -**Use when** you need to adjust pane dimensions. - -**Side effects:** Changes pane size. May affect adjacent panes. - -**Example:** - -```json -{ - "tool": "resize_pane", - "arguments": { - "pane_id": "%0", - "height": 15 - } -} -``` - -Response: - -```json -{ - "pane_id": "%0", - "pane_index": "0", - "pane_width": "80", - "pane_height": "15", - "pane_current_command": "zsh", - "pane_current_path": "/home/user/myproject", - "pane_pid": "12345", - "pane_title": "", - "pane_active": "1", - "window_id": "@0", - "session_id": "$0", - "is_caller": null -} -``` - -```{fastmcp-tool-input} pane_tools.resize_pane -``` - -## Destroy - -```{fastmcp-tool} pane_tools.kill_pane -``` - -**Use when** you're done with a specific terminal and want to remove it -without affecting sibling panes. - -**Avoid when** you want to remove the entire window — use {tooliconl}`kill-window`. - -**Side effects:** Destroys the pane. Not reversible. - -**Example:** - -```json -{ - "tool": "kill_pane", - "arguments": { - "pane_id": "%1" - } -} -``` - -Response (string): - -```text -Pane killed: %1 -``` - -```{fastmcp-tool-input} pane_tools.kill_pane -``` diff --git a/docs/tools/server/create-session.md b/docs/tools/server/create-session.md new file mode 100644 index 0000000..fdc1a49 --- /dev/null +++ b/docs/tools/server/create-session.md @@ -0,0 +1,36 @@ +```{fastmcp-tool} server_tools.create_session +``` + +**Use when** you need a new isolated workspace. Sessions are the top-level +container — create one before creating windows or panes. + +**Avoid when** a session with the target name already exists — check with +{tooliconl}`list-sessions` first, or the command will fail. + +**Side effects:** Creates a new tmux session with one window and one pane. + +**Example:** + +```json +{ + "tool": "create_session", + "arguments": { + "session_name": "dev" + } +} +``` + +Response: + +```json +{ + "session_id": "$1", + "session_name": "dev", + "window_count": 1, + "session_attached": "0", + "session_created": "1774521872" +} +``` + +```{fastmcp-tool-input} server_tools.create_session +``` diff --git a/docs/tools/server/get-server-info.md b/docs/tools/server/get-server-info.md new file mode 100644 index 0000000..2e1727d --- /dev/null +++ b/docs/tools/server/get-server-info.md @@ -0,0 +1,33 @@ +```{fastmcp-tool} server_tools.get_server_info +``` + +**Use when** you need to verify the tmux server is running, check its PID, +or inspect server-level state before creating sessions. + +**Avoid when** you only need session names — use {tooliconl}`list-sessions`. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "get_server_info", + "arguments": {} +} +``` + +Response: + +```json +{ + "is_alive": true, + "socket_name": null, + "socket_path": null, + "session_count": 2, + "version": "3.6a" +} +``` + +```{fastmcp-tool-input} server_tools.get_server_info +``` diff --git a/docs/tools/server/index.md b/docs/tools/server/index.md new file mode 100644 index 0000000..f14d294 --- /dev/null +++ b/docs/tools/server/index.md @@ -0,0 +1,69 @@ +# Server + +Tools for server-level operations: session discovery, server info, configuration, and lifecycle. + +::::{grid} 1 2 3 3 +:gutter: 2 2 3 3 + +:::{grid-item-card} list_sessions +:link: list-sessions +:link-type: ref +List all active sessions. +::: + +:::{grid-item-card} get_server_info +:link: get-server-info +:link-type: ref +Get tmux server info. +::: + +:::{grid-item-card} create_session +:link: create-session +:link-type: ref +Start a new tmux session. +::: + +:::{grid-item-card} kill_server +:link: kill-server +:link-type: ref +Kill the entire tmux server. +::: + +:::{grid-item-card} show_option +:link: show-option +:link-type: ref +Query a tmux option value. +::: + +:::{grid-item-card} set_option +:link: set-option +:link-type: ref +Set a tmux option. +::: + +:::{grid-item-card} show_environment +:link: show-environment +:link-type: ref +Show tmux environment variables. +::: + +:::{grid-item-card} set_environment +:link: set-environment +:link-type: ref +Set a tmux environment variable. +::: + +:::: + +```{toctree} +:hidden: + +list-sessions +get-server-info +create-session +kill-server +show-option +set-option +show-environment +set-environment +``` diff --git a/docs/tools/server/kill-server.md b/docs/tools/server/kill-server.md new file mode 100644 index 0000000..c4a0194 --- /dev/null +++ b/docs/tools/server/kill-server.md @@ -0,0 +1,27 @@ +```{fastmcp-tool} server_tools.kill_server +``` + +**Use when** you need to tear down the entire tmux server. This kills every +session, window, and pane. + +**Avoid when** you only need to remove one session — use {tooliconl}`kill-session`. + +**Side effects:** Destroys everything. Not reversible. + +**Example:** + +```json +{ + "tool": "kill_server", + "arguments": {} +} +``` + +Response (string): + +```text +Server killed successfully +``` + +```{fastmcp-tool-input} server_tools.kill_server +``` diff --git a/docs/tools/server/list-sessions.md b/docs/tools/server/list-sessions.md new file mode 100644 index 0000000..adca66e --- /dev/null +++ b/docs/tools/server/list-sessions.md @@ -0,0 +1,36 @@ +```{fastmcp-tool} server_tools.list_sessions +``` + +**Use when** you need session names, IDs, or attached status before deciding +which session to target. + +**Avoid when** you need window or pane details — use {tooliconl}`list-windows` or +{tooliconl}`list-panes` instead. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "list_sessions", + "arguments": {} +} +``` + +Response: + +```json +[ + { + "session_id": "$0", + "session_name": "myproject", + "window_count": 2, + "session_attached": "0", + "session_created": "1774521871" + } +] +``` + +```{fastmcp-tool-input} server_tools.list_sessions +``` diff --git a/docs/tools/server/set-environment.md b/docs/tools/server/set-environment.md new file mode 100644 index 0000000..4d50c8a --- /dev/null +++ b/docs/tools/server/set-environment.md @@ -0,0 +1,31 @@ +```{fastmcp-tool} env_tools.set_environment +``` + +**Use when** you need to set a tmux environment variable. + +**Side effects:** Sets the variable in the tmux server. + +**Example:** + +```json +{ + "tool": "set_environment", + "arguments": { + "name": "MY_VAR", + "value": "hello" + } +} +``` + +Response: + +```json +{ + "name": "MY_VAR", + "value": "hello", + "status": "set" +} +``` + +```{fastmcp-tool-input} env_tools.set_environment +``` diff --git a/docs/tools/server/set-option.md b/docs/tools/server/set-option.md new file mode 100644 index 0000000..a27d5be --- /dev/null +++ b/docs/tools/server/set-option.md @@ -0,0 +1,32 @@ +```{fastmcp-tool} option_tools.set_option +``` + +**Use when** you need to change tmux behavior — adjusting history limits, +enabling mouse support, changing status bar format. + +**Side effects:** Changes the tmux option value. + +**Example:** + +```json +{ + "tool": "set_option", + "arguments": { + "option": "history-limit", + "value": "50000" + } +} +``` + +Response: + +```json +{ + "option": "history-limit", + "value": "50000", + "status": "set" +} +``` + +```{fastmcp-tool-input} option_tools.set_option +``` diff --git a/docs/tools/server/show-environment.md b/docs/tools/server/show-environment.md new file mode 100644 index 0000000..66171ce --- /dev/null +++ b/docs/tools/server/show-environment.md @@ -0,0 +1,32 @@ +```{fastmcp-tool} env_tools.show_environment +``` + +**Use when** you need to inspect tmux environment variables. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "show_environment", + "arguments": {} +} +``` + +Response: + +```json +{ + "variables": { + "SHELL": "/bin/zsh", + "TERM": "xterm-256color", + "HOME": "/home/user", + "USER": "user", + "LANG": "C.UTF-8" + } +} +``` + +```{fastmcp-tool-input} env_tools.show_environment +``` diff --git a/docs/tools/server/show-option.md b/docs/tools/server/show-option.md new file mode 100644 index 0000000..4aa597a --- /dev/null +++ b/docs/tools/server/show-option.md @@ -0,0 +1,30 @@ +```{fastmcp-tool} option_tools.show_option +``` + +**Use when** you need to check a tmux configuration value — buffer limits, +history size, status bar settings, etc. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "show_option", + "arguments": { + "option": "history-limit" + } +} +``` + +Response: + +```json +{ + "option": "history-limit", + "value": "2000" +} +``` + +```{fastmcp-tool-input} option_tools.show_option +``` diff --git a/docs/tools/session/create-window.md b/docs/tools/session/create-window.md new file mode 100644 index 0000000..db6602a --- /dev/null +++ b/docs/tools/session/create-window.md @@ -0,0 +1,38 @@ +```{fastmcp-tool} session_tools.create_window +``` + +**Use when** you need a new terminal workspace within an existing session. + +**Side effects:** Creates a new window. Attaches to it if `attach` is true. + +**Example:** + +```json +{ + "tool": "create_window", + "arguments": { + "session_name": "dev", + "window_name": "logs" + } +} +``` + +Response: + +```json +{ + "window_id": "@2", + "window_name": "logs", + "window_index": "3", + "session_id": "$0", + "session_name": "dev", + "pane_count": 1, + "window_layout": "b25f,80x24,0,0,5", + "window_active": "1", + "window_width": "80", + "window_height": "24" +} +``` + +```{fastmcp-tool-input} session_tools.create_window +``` diff --git a/docs/tools/session/index.md b/docs/tools/session/index.md new file mode 100644 index 0000000..17053b3 --- /dev/null +++ b/docs/tools/session/index.md @@ -0,0 +1,48 @@ +# Session + +Tools for session-level operations: window management, session renaming, and lifecycle. + +::::{grid} 1 2 3 3 +:gutter: 2 2 3 3 + +:::{grid-item-card} list_windows +:link: list-windows +:link-type: ref +List windows in a session. +::: + +:::{grid-item-card} create_window +:link: create-window +:link-type: ref +Add a window to a session. +::: + +:::{grid-item-card} rename_session +:link: rename-session +:link-type: ref +Rename a session. +::: + +:::{grid-item-card} select_window +:link: select-window +:link-type: ref +Focus a window by ID, index, or direction. +::: + +:::{grid-item-card} kill_session +:link: kill-session +:link-type: ref +Destroy a session and all its windows. +::: + +:::: + +```{toctree} +:hidden: + +list-windows +create-window +rename-session +select-window +kill-session +``` diff --git a/docs/tools/session/kill-session.md b/docs/tools/session/kill-session.md new file mode 100644 index 0000000..9f27f1e --- /dev/null +++ b/docs/tools/session/kill-session.md @@ -0,0 +1,29 @@ +```{fastmcp-tool} session_tools.kill_session +``` + +**Use when** you're done with a workspace and want to clean up. Kills all +windows and panes in the session. + +**Avoid when** you only want to close one window — use {tooliconl}`kill-window`. + +**Side effects:** Destroys the session and all its contents. Not reversible. + +**Example:** + +```json +{ + "tool": "kill_session", + "arguments": { + "session_name": "old-workspace" + } +} +``` + +Response (string): + +```text +Session killed: old-workspace +``` + +```{fastmcp-tool-input} session_tools.kill_session +``` diff --git a/docs/tools/session/list-windows.md b/docs/tools/session/list-windows.md new file mode 100644 index 0000000..240931b --- /dev/null +++ b/docs/tools/session/list-windows.md @@ -0,0 +1,54 @@ +```{fastmcp-tool} session_tools.list_windows +``` + +**Use when** you need window names, indices, or layout metadata within a +session before selecting a window to work with. + +**Avoid when** you need pane-level detail — use {tooliconl}`list-panes`. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "list_windows", + "arguments": { + "session_name": "dev" + } +} +``` + +Response: + +```json +[ + { + "window_id": "@0", + "window_name": "editor", + "window_index": "1", + "session_id": "$0", + "session_name": "dev", + "pane_count": 2, + "window_layout": "c195,80x24,0,0[80x12,0,0,0,80x11,0,13,1]", + "window_active": "1", + "window_width": "80", + "window_height": "24" + }, + { + "window_id": "@1", + "window_name": "server", + "window_index": "2", + "session_id": "$0", + "session_name": "dev", + "pane_count": 1, + "window_layout": "b25f,80x24,0,0,2", + "window_active": "0", + "window_width": "80", + "window_height": "24" + } +] +``` + +```{fastmcp-tool-input} session_tools.list_windows +``` diff --git a/docs/tools/session/rename-session.md b/docs/tools/session/rename-session.md new file mode 100644 index 0000000..b335a39 --- /dev/null +++ b/docs/tools/session/rename-session.md @@ -0,0 +1,33 @@ +```{fastmcp-tool} session_tools.rename_session +``` + +**Use when** a session name no longer reflects its purpose. + +**Side effects:** Renames the session. Existing references by old name will break. + +**Example:** + +```json +{ + "tool": "rename_session", + "arguments": { + "session_name": "old-name", + "new_name": "new-name" + } +} +``` + +Response: + +```json +{ + "session_id": "$0", + "session_name": "new-name", + "window_count": 2, + "session_attached": "0", + "session_created": "1774521871" +} +``` + +```{fastmcp-tool-input} session_tools.rename_session +``` diff --git a/docs/tools/session/select-window.md b/docs/tools/session/select-window.md new file mode 100644 index 0000000..0ebe289 --- /dev/null +++ b/docs/tools/session/select-window.md @@ -0,0 +1,39 @@ +```{fastmcp-tool} session_tools.select_window +``` + +**Use when** you need to switch focus to a different window — by ID, index, +or direction (`next`, `previous`, `last`). + +**Side effects:** Changes the active window in the session. + +**Example:** + +```json +{ + "tool": "select_window", + "arguments": { + "direction": "next", + "session_name": "dev" + } +} +``` + +Response: + +```json +{ + "window_id": "@1", + "window_name": "server", + "window_index": "2", + "session_id": "$0", + "session_name": "dev", + "pane_count": 1, + "window_layout": "b25f,80x24,0,0,2", + "window_active": "1", + "window_width": "80", + "window_height": "24" +} +``` + +```{fastmcp-tool-input} session_tools.select_window +``` diff --git a/docs/tools/sessions.md b/docs/tools/sessions.md deleted file mode 100644 index dd7ef23..0000000 --- a/docs/tools/sessions.md +++ /dev/null @@ -1,213 +0,0 @@ -# Sessions - -## Inspect - -```{fastmcp-tool} server_tools.list_sessions -``` - -**Use when** you need session names, IDs, or attached status before deciding -which session to target. - -**Avoid when** you need window or pane details — use {tooliconl}`list-windows` or -{tooliconl}`list-panes` instead. - -**Side effects:** None. Readonly. - -**Example:** - -```json -{ - "tool": "list_sessions", - "arguments": {} -} -``` - -Response: - -```json -[ - { - "session_id": "$0", - "session_name": "myproject", - "window_count": 2, - "session_attached": "0", - "session_created": "1774521871" - } -] -``` - -```{fastmcp-tool-input} server_tools.list_sessions -``` - ---- - -```{fastmcp-tool} server_tools.get_server_info -``` - -**Use when** you need to verify the tmux server is running, check its PID, -or inspect server-level state before creating sessions. - -**Avoid when** you only need session names — use {tooliconl}`list-sessions`. - -**Side effects:** None. Readonly. - -**Example:** - -```json -{ - "tool": "get_server_info", - "arguments": {} -} -``` - -Response: - -```json -{ - "is_alive": true, - "socket_name": null, - "socket_path": null, - "session_count": 2, - "version": "3.6a" -} -``` - -```{fastmcp-tool-input} server_tools.get_server_info -``` - -## Act - -```{fastmcp-tool} server_tools.create_session -``` - -**Use when** you need a new isolated workspace. Sessions are the top-level -container — create one before creating windows or panes. - -**Avoid when** a session with the target name already exists — check with -{tooliconl}`list-sessions` first, or the command will fail. - -**Side effects:** Creates a new tmux session with one window and one pane. - -**Example:** - -```json -{ - "tool": "create_session", - "arguments": { - "session_name": "dev" - } -} -``` - -Response: - -```json -{ - "session_id": "$1", - "session_name": "dev", - "window_count": 1, - "session_attached": "0", - "session_created": "1774521872" -} -``` - -```{fastmcp-tool-input} server_tools.create_session -``` - ---- - -```{fastmcp-tool} session_tools.rename_session -``` - -**Use when** a session name no longer reflects its purpose. - -**Side effects:** Renames the session. Existing references by old name will break. - -**Example:** - -```json -{ - "tool": "rename_session", - "arguments": { - "session_name": "old-name", - "new_name": "new-name" - } -} -``` - -Response: - -```json -{ - "session_id": "$0", - "session_name": "new-name", - "window_count": 2, - "session_attached": "0", - "session_created": "1774521871" -} -``` - -```{fastmcp-tool-input} session_tools.rename_session -``` - -## Destroy - -```{fastmcp-tool} session_tools.kill_session -``` - -**Use when** you're done with a workspace and want to clean up. Kills all -windows and panes in the session. - -**Avoid when** you only want to close one window — use {tooliconl}`kill-window`. - -**Side effects:** Destroys the session and all its contents. Not reversible. - -**Example:** - -```json -{ - "tool": "kill_session", - "arguments": { - "session_name": "old-workspace" - } -} -``` - -Response (string): - -```text -Session killed: old-workspace -``` - -```{fastmcp-tool-input} session_tools.kill_session -``` - ---- - -```{fastmcp-tool} server_tools.kill_server -``` - -**Use when** you need to tear down the entire tmux server. This kills every -session, window, and pane. - -**Avoid when** you only need to remove one session — use {tooliconl}`kill-session`. - -**Side effects:** Destroys everything. Not reversible. - -**Example:** - -```json -{ - "tool": "kill_server", - "arguments": {} -} -``` - -Response (string): - -```text -Server killed successfully -``` - -```{fastmcp-tool-input} server_tools.kill_server -``` diff --git a/docs/tools/window/index.md b/docs/tools/window/index.md new file mode 100644 index 0000000..bcf091b --- /dev/null +++ b/docs/tools/window/index.md @@ -0,0 +1,62 @@ +# Window + +Tools for window-level operations: pane management, layout, resizing, and lifecycle. + +::::{grid} 1 2 3 3 +:gutter: 2 2 3 3 + +:::{grid-item-card} list_panes +:link: list-panes +:link-type: ref +List panes in a window. +::: + +:::{grid-item-card} split_window +:link: split-window +:link-type: ref +Split a window into panes. +::: + +:::{grid-item-card} rename_window +:link: rename-window +:link-type: ref +Rename a window. +::: + +:::{grid-item-card} select_layout +:link: select-layout +:link-type: ref +Set window layout. +::: + +:::{grid-item-card} resize_window +:link: resize-window +:link-type: ref +Adjust window dimensions. +::: + +:::{grid-item-card} move_window +:link: move-window +:link-type: ref +Move window to another index or session. +::: + +:::{grid-item-card} kill_window +:link: kill-window +:link-type: ref +Destroy a window and all its panes. +::: + +:::: + +```{toctree} +:hidden: + +list-panes +split-window +rename-window +select-layout +resize-window +move-window +kill-window +``` diff --git a/docs/tools/window/kill-window.md b/docs/tools/window/kill-window.md new file mode 100644 index 0000000..1f84c80 --- /dev/null +++ b/docs/tools/window/kill-window.md @@ -0,0 +1,28 @@ +```{fastmcp-tool} window_tools.kill_window +``` + +**Use when** you're done with a window and all its panes. + +**Avoid when** you only want to remove one pane — use {tooliconl}`kill-pane`. + +**Side effects:** Destroys the window and all its panes. Not reversible. + +**Example:** + +```json +{ + "tool": "kill_window", + "arguments": { + "window_id": "@1" + } +} +``` + +Response (string): + +```text +Window killed: @1 +``` + +```{fastmcp-tool-input} window_tools.kill_window +``` diff --git a/docs/tools/window/list-panes.md b/docs/tools/window/list-panes.md new file mode 100644 index 0000000..a093ed4 --- /dev/null +++ b/docs/tools/window/list-panes.md @@ -0,0 +1,56 @@ +```{fastmcp-tool} window_tools.list_panes +``` + +**Use when** you need to discover which panes exist in a window before +sending keys or capturing output. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "list_panes", + "arguments": { + "session_name": "dev" + } +} +``` + +Response: + +```json +[ + { + "pane_id": "%0", + "pane_index": "0", + "pane_width": "80", + "pane_height": "15", + "pane_current_command": "zsh", + "pane_current_path": "/home/user/myproject", + "pane_pid": "12345", + "pane_title": "build", + "pane_active": "1", + "window_id": "@0", + "session_id": "$0", + "is_caller": null + }, + { + "pane_id": "%1", + "pane_index": "1", + "pane_width": "80", + "pane_height": "8", + "pane_current_command": "zsh", + "pane_current_path": "/home/user/myproject", + "pane_pid": "12400", + "pane_title": "", + "pane_active": "0", + "window_id": "@0", + "session_id": "$0", + "is_caller": null + } +] +``` + +```{fastmcp-tool-input} window_tools.list_panes +``` diff --git a/docs/tools/window/move-window.md b/docs/tools/window/move-window.md new file mode 100644 index 0000000..db687e2 --- /dev/null +++ b/docs/tools/window/move-window.md @@ -0,0 +1,39 @@ +```{fastmcp-tool} window_tools.move_window +``` + +**Use when** you need to reorder windows within a session or move a window +to a different session entirely. + +**Side effects:** Changes the window's index or parent session. + +**Example:** + +```json +{ + "tool": "move_window", + "arguments": { + "window_id": "@1", + "destination_index": "1" + } +} +``` + +Response: + +```json +{ + "window_id": "@1", + "window_name": "server", + "window_index": "1", + "session_id": "$0", + "session_name": "dev", + "pane_count": 1, + "window_layout": "b25f,80x24,0,0,2", + "window_active": "0", + "window_width": "80", + "window_height": "24" +} +``` + +```{fastmcp-tool-input} window_tools.move_window +``` diff --git a/docs/tools/window/rename-window.md b/docs/tools/window/rename-window.md new file mode 100644 index 0000000..c2c8c6d --- /dev/null +++ b/docs/tools/window/rename-window.md @@ -0,0 +1,38 @@ +```{fastmcp-tool} window_tools.rename_window +``` + +**Use when** a window name no longer reflects its purpose. + +**Side effects:** Renames the window. + +**Example:** + +```json +{ + "tool": "rename_window", + "arguments": { + "session_name": "dev", + "new_name": "build" + } +} +``` + +Response: + +```json +{ + "window_id": "@0", + "window_name": "build", + "window_index": "1", + "session_id": "$0", + "session_name": "dev", + "pane_count": 2, + "window_layout": "7f9f,80x24,0,0[80x15,0,0,0,80x8,0,16,1]", + "window_active": "1", + "window_width": "80", + "window_height": "24" +} +``` + +```{fastmcp-tool-input} window_tools.rename_window +``` diff --git a/docs/tools/window/resize-window.md b/docs/tools/window/resize-window.md new file mode 100644 index 0000000..b59ca70 --- /dev/null +++ b/docs/tools/window/resize-window.md @@ -0,0 +1,39 @@ +```{fastmcp-tool} window_tools.resize_window +``` + +**Use when** you need to adjust the window dimensions. + +**Side effects:** Changes window size. + +**Example:** + +```json +{ + "tool": "resize_window", + "arguments": { + "session_name": "dev", + "width": 120, + "height": 40 + } +} +``` + +Response: + +```json +{ + "window_id": "@0", + "window_name": "editor", + "window_index": "1", + "session_id": "$0", + "session_name": "dev", + "pane_count": 2, + "window_layout": "baaa,120x40,0,0[120x20,0,0,0,120x19,0,21,1]", + "window_active": "1", + "window_width": "120", + "window_height": "40" +} +``` + +```{fastmcp-tool-input} window_tools.resize_window +``` diff --git a/docs/tools/window/select-layout.md b/docs/tools/window/select-layout.md new file mode 100644 index 0000000..8a2146f --- /dev/null +++ b/docs/tools/window/select-layout.md @@ -0,0 +1,39 @@ +```{fastmcp-tool} window_tools.select_layout +``` + +**Use when** you want to rearrange panes — `even-horizontal`, +`even-vertical`, `main-horizontal`, `main-vertical`, or `tiled`. + +**Side effects:** Rearranges all panes in the window. + +**Example:** + +```json +{ + "tool": "select_layout", + "arguments": { + "session_name": "dev", + "layout": "even-vertical" + } +} +``` + +Response: + +```json +{ + "window_id": "@0", + "window_name": "editor", + "window_index": "1", + "session_id": "$0", + "session_name": "dev", + "pane_count": 2, + "window_layout": "even-vertical,80x24,0,0[80x12,0,0,0,80x11,0,13,1]", + "window_active": "1", + "window_width": "80", + "window_height": "24" +} +``` + +```{fastmcp-tool-input} window_tools.select_layout +``` diff --git a/docs/tools/window/split-window.md b/docs/tools/window/split-window.md new file mode 100644 index 0000000..07acdec --- /dev/null +++ b/docs/tools/window/split-window.md @@ -0,0 +1,41 @@ +```{fastmcp-tool} window_tools.split_window +``` + +**Use when** you need side-by-side or stacked terminals within the same +window. + +**Side effects:** Creates a new pane by splitting an existing one. + +**Example:** + +```json +{ + "tool": "split_window", + "arguments": { + "session_name": "dev", + "direction": "right" + } +} +``` + +Response: + +```json +{ + "pane_id": "%4", + "pane_index": "1", + "pane_width": "39", + "pane_height": "24", + "pane_current_command": "zsh", + "pane_current_path": "/home/user/myproject", + "pane_pid": "3732", + "pane_title": "", + "pane_active": "0", + "window_id": "@0", + "session_id": "$0", + "is_caller": null +} +``` + +```{fastmcp-tool-input} window_tools.split_window +``` diff --git a/docs/tools/windows.md b/docs/tools/windows.md deleted file mode 100644 index 4b022f5..0000000 --- a/docs/tools/windows.md +++ /dev/null @@ -1,358 +0,0 @@ -# Windows - -## Inspect - -```{fastmcp-tool} session_tools.list_windows -``` - -**Use when** you need window names, indices, or layout metadata within a -session before selecting a window to work with. - -**Avoid when** you need pane-level detail — use {tooliconl}`list-panes`. - -**Side effects:** None. Readonly. - -**Example:** - -```json -{ - "tool": "list_windows", - "arguments": { - "session_name": "dev" - } -} -``` - -Response: - -```json -[ - { - "window_id": "@0", - "window_name": "editor", - "window_index": "1", - "session_id": "$0", - "session_name": "dev", - "pane_count": 2, - "window_layout": "c195,80x24,0,0[80x12,0,0,0,80x11,0,13,1]", - "window_active": "1", - "window_width": "80", - "window_height": "24" - }, - { - "window_id": "@1", - "window_name": "server", - "window_index": "2", - "session_id": "$0", - "session_name": "dev", - "pane_count": 1, - "window_layout": "b25f,80x24,0,0,2", - "window_active": "0", - "window_width": "80", - "window_height": "24" - } -] -``` - -```{fastmcp-tool-input} session_tools.list_windows -``` - ---- - -```{fastmcp-tool} window_tools.list_panes -``` - -**Use when** you need to discover which panes exist in a window before -sending keys or capturing output. - -**Side effects:** None. Readonly. - -**Example:** - -```json -{ - "tool": "list_panes", - "arguments": { - "session_name": "dev" - } -} -``` - -Response: - -```json -[ - { - "pane_id": "%0", - "pane_index": "0", - "pane_width": "80", - "pane_height": "15", - "pane_current_command": "zsh", - "pane_current_path": "/home/user/myproject", - "pane_pid": "12345", - "pane_title": "build", - "pane_active": "1", - "window_id": "@0", - "session_id": "$0", - "is_caller": null - }, - { - "pane_id": "%1", - "pane_index": "1", - "pane_width": "80", - "pane_height": "8", - "pane_current_command": "zsh", - "pane_current_path": "/home/user/myproject", - "pane_pid": "12400", - "pane_title": "", - "pane_active": "0", - "window_id": "@0", - "session_id": "$0", - "is_caller": null - } -] -``` - -```{fastmcp-tool-input} window_tools.list_panes -``` - -## Act - -```{fastmcp-tool} session_tools.create_window -``` - -**Use when** you need a new terminal workspace within an existing session. - -**Side effects:** Creates a new window. Attaches to it if `attach` is true. - -**Example:** - -```json -{ - "tool": "create_window", - "arguments": { - "session_name": "dev", - "window_name": "logs" - } -} -``` - -Response: - -```json -{ - "window_id": "@2", - "window_name": "logs", - "window_index": "3", - "session_id": "$0", - "session_name": "dev", - "pane_count": 1, - "window_layout": "b25f,80x24,0,0,5", - "window_active": "1", - "window_width": "80", - "window_height": "24" -} -``` - -```{fastmcp-tool-input} session_tools.create_window -``` - ---- - -```{fastmcp-tool} window_tools.split_window -``` - -**Use when** you need side-by-side or stacked terminals within the same -window. - -**Side effects:** Creates a new pane by splitting an existing one. - -**Example:** - -```json -{ - "tool": "split_window", - "arguments": { - "session_name": "dev", - "direction": "right" - } -} -``` - -Response: - -```json -{ - "pane_id": "%4", - "pane_index": "1", - "pane_width": "39", - "pane_height": "24", - "pane_current_command": "zsh", - "pane_current_path": "/home/user/myproject", - "pane_pid": "3732", - "pane_title": "", - "pane_active": "0", - "window_id": "@0", - "session_id": "$0", - "is_caller": null -} -``` - -```{fastmcp-tool-input} window_tools.split_window -``` - ---- - -```{fastmcp-tool} window_tools.rename_window -``` - -**Use when** a window name no longer reflects its purpose. - -**Side effects:** Renames the window. - -**Example:** - -```json -{ - "tool": "rename_window", - "arguments": { - "session_name": "dev", - "new_name": "build" - } -} -``` - -Response: - -```json -{ - "window_id": "@0", - "window_name": "build", - "window_index": "1", - "session_id": "$0", - "session_name": "dev", - "pane_count": 2, - "window_layout": "7f9f,80x24,0,0[80x15,0,0,0,80x8,0,16,1]", - "window_active": "1", - "window_width": "80", - "window_height": "24" -} -``` - -```{fastmcp-tool-input} window_tools.rename_window -``` - ---- - -```{fastmcp-tool} window_tools.select_layout -``` - -**Use when** you want to rearrange panes — `even-horizontal`, -`even-vertical`, `main-horizontal`, `main-vertical`, or `tiled`. - -**Side effects:** Rearranges all panes in the window. - -**Example:** - -```json -{ - "tool": "select_layout", - "arguments": { - "session_name": "dev", - "layout": "even-vertical" - } -} -``` - -Response: - -```json -{ - "window_id": "@0", - "window_name": "editor", - "window_index": "1", - "session_id": "$0", - "session_name": "dev", - "pane_count": 2, - "window_layout": "even-vertical,80x24,0,0[80x12,0,0,0,80x11,0,13,1]", - "window_active": "1", - "window_width": "80", - "window_height": "24" -} -``` - -```{fastmcp-tool-input} window_tools.select_layout -``` - ---- - -```{fastmcp-tool} window_tools.resize_window -``` - -**Use when** you need to adjust the window dimensions. - -**Side effects:** Changes window size. - -**Example:** - -```json -{ - "tool": "resize_window", - "arguments": { - "session_name": "dev", - "width": 120, - "height": 40 - } -} -``` - -Response: - -```json -{ - "window_id": "@0", - "window_name": "editor", - "window_index": "1", - "session_id": "$0", - "session_name": "dev", - "pane_count": 2, - "window_layout": "baaa,120x40,0,0[120x20,0,0,0,120x19,0,21,1]", - "window_active": "1", - "window_width": "120", - "window_height": "40" -} -``` - -```{fastmcp-tool-input} window_tools.resize_window -``` - -## Destroy - -```{fastmcp-tool} window_tools.kill_window -``` - -**Use when** you're done with a window and all its panes. - -**Avoid when** you only want to remove one pane — use {tooliconl}`kill-pane`. - -**Side effects:** Destroys the window and all its panes. Not reversible. - -**Example:** - -```json -{ - "tool": "kill_window", - "arguments": { - "window_id": "@1" - } -} -``` - -Response (string): - -```text -Window killed: @1 -``` - -```{fastmcp-tool-input} window_tools.kill_window -``` diff --git a/src/libtmux_mcp/models.py b/src/libtmux_mcp/models.py index 563f911..ebde388 100644 --- a/src/libtmux_mcp/models.py +++ b/src/libtmux_mcp/models.py @@ -139,3 +139,43 @@ class WaitForTextResult(BaseModel): pane_id: str = Field(description="Pane ID that was polled") elapsed_seconds: float = Field(description="Time spent waiting in seconds") timed_out: bool = Field(description="Whether the timeout was reached") + + +class PaneSnapshot(BaseModel): + """Rich screen capture with metadata: content, cursor, mode, and scroll state.""" + + pane_id: str = Field(description="Pane ID (e.g. '%1')") + content: str = Field(description="Visible pane text") + cursor_x: int = Field(description="Cursor column (0-based)") + cursor_y: int = Field(description="Cursor row (0-based)") + pane_width: int = Field(description="Pane width in columns") + pane_height: int = Field(description="Pane height in rows") + pane_in_mode: bool = Field(description="True if pane is in copy-mode or view-mode") + pane_mode: str | None = Field( + default=None, description="Mode name (e.g. 'copy-mode') or None if normal" + ) + scroll_position: int | None = Field( + default=None, + description="Lines scrolled back in copy mode (None if not in copy mode)", + ) + history_size: int = Field(description="Total scrollback lines available") + title: str | None = Field(default=None, description="Pane title") + pane_current_command: str | None = Field( + default=None, description="Running command" + ) + pane_current_path: str | None = Field( + default=None, description="Current working directory" + ) + is_caller: bool | None = Field( + default=None, + description="True if this is the MCP caller's own pane", + ) + + +class ContentChangeResult(BaseModel): + """Result of waiting for any screen content change.""" + + changed: bool = Field(description="Whether the content changed before timeout") + pane_id: str = Field(description="Pane ID that was polled") + elapsed_seconds: float = Field(description="Time spent waiting in seconds") + timed_out: bool = Field(description="Whether the timeout was reached") diff --git a/src/libtmux_mcp/tools/pane_tools.py b/src/libtmux_mcp/tools/pane_tools.py index 7fb3cc7..77869c4 100644 --- a/src/libtmux_mcp/tools/pane_tools.py +++ b/src/libtmux_mcp/tools/pane_tools.py @@ -17,10 +17,17 @@ _get_server, _resolve_pane, _resolve_session, + _resolve_window, _serialize_pane, handle_tool_errors, ) -from libtmux_mcp.models import PaneContentMatch, PaneInfo, WaitForTextResult +from libtmux_mcp.models import ( + ContentChangeResult, + PaneContentMatch, + PaneInfo, + PaneSnapshot, + WaitForTextResult, +) if t.TYPE_CHECKING: from fastmcp import FastMCP @@ -617,6 +624,579 @@ def _check() -> bool: ) +@handle_tool_errors +def snapshot_pane( + pane_id: str | None = None, + session_name: str | None = None, + session_id: str | None = None, + window_id: str | None = None, + socket_name: str | None = None, +) -> PaneSnapshot: + """Take a rich snapshot of a tmux pane: content + cursor + mode + scroll state. + + Returns everything capture_pane and get_pane_info return, plus cursor + position, copy-mode state, and scroll position — in a single call. + Use this instead of separate capture_pane + get_pane_info calls when + you need to reason about cursor location or pane mode. + + Parameters + ---------- + pane_id : str, optional + Pane ID (e.g. '%1'). + session_name : str, optional + Session name for pane resolution. + session_id : str, optional + Session ID (e.g. '$1') for pane resolution. + window_id : str, optional + Window ID for pane resolution. + socket_name : str, optional + tmux socket name. + + Returns + ------- + PaneSnapshot + Rich snapshot with content, cursor, mode, and scroll state. + """ + server = _get_server(socket_name=socket_name) + pane = _resolve_pane( + server, + pane_id=pane_id, + session_name=session_name, + session_id=session_id, + window_id=window_id, + ) + + # Fetch all metadata in a single display-message call using tab separators + fmt = "\t".join( + [ + "#{cursor_x}", + "#{cursor_y}", + "#{pane_width}", + "#{pane_height}", + "#{pane_in_mode}", + "#{pane_mode}", + "#{scroll_position}", + "#{history_size}", + "#{pane_title}", + "#{pane_current_command}", + "#{pane_current_path}", + ] + ) + result = pane.cmd("display-message", "-p", "-t", pane.pane_id, fmt) + parts = result.stdout[0].split("\t") if result.stdout else [""] * 11 + + content = "\n".join(pane.capture_pane()) + + pane_in_mode = parts[4] == "1" + pane_mode_raw = parts[5] + scroll_raw = parts[6] + + caller_pane_id = _get_caller_pane_id() + return PaneSnapshot( + pane_id=pane.pane_id or "", + content=content, + cursor_x=int(parts[0]) if parts[0] else 0, + cursor_y=int(parts[1]) if parts[1] else 0, + pane_width=int(parts[2]) if parts[2] else 0, + pane_height=int(parts[3]) if parts[3] else 0, + pane_in_mode=pane_in_mode, + pane_mode=pane_mode_raw if pane_mode_raw else None, + scroll_position=int(scroll_raw) if scroll_raw else None, + history_size=int(parts[7]) if parts[7] else 0, + title=parts[8] if parts[8] else None, + pane_current_command=parts[9] if parts[9] else None, + pane_current_path=parts[10] if parts[10] else None, + is_caller=(pane.pane_id == caller_pane_id if caller_pane_id else None), + ) + + +@handle_tool_errors +def wait_for_content_change( + pane_id: str | None = None, + session_name: str | None = None, + session_id: str | None = None, + window_id: str | None = None, + timeout: float = 8.0, + interval: float = 0.05, + socket_name: str | None = None, +) -> ContentChangeResult: + """Wait for any content change in a tmux pane. + + Captures the current pane content, then polls until the content differs + or the timeout is reached. Use this after send_keys when you don't know + what the output will be — it waits for "something happened" rather than + a specific pattern. + + Parameters + ---------- + pane_id : str, optional + Pane ID (e.g. '%1'). + session_name : str, optional + Session name for pane resolution. + session_id : str, optional + Session ID (e.g. '$1') for pane resolution. + window_id : str, optional + Window ID for pane resolution. + timeout : float + Maximum seconds to wait. Default 8.0. + interval : float + Seconds between polls. Default 0.05 (50ms). + socket_name : str, optional + tmux socket name. + + Returns + ------- + ContentChangeResult + Result with changed status and timing info. + """ + import time + + from libtmux.test.retry import retry_until + + server = _get_server(socket_name=socket_name) + pane = _resolve_pane( + server, + pane_id=pane_id, + session_name=session_name, + session_id=session_id, + window_id=window_id, + ) + + assert pane.pane_id is not None + initial_content = pane.capture_pane() + start_time = time.monotonic() + + def _check() -> bool: + current = pane.capture_pane() + return current != initial_content + + changed = retry_until( + _check, + seconds=timeout, + interval=interval, + raises=False, + ) + + elapsed = time.monotonic() - start_time + return ContentChangeResult( + changed=changed, + pane_id=pane.pane_id, + elapsed_seconds=round(elapsed, 3), + timed_out=not changed, + ) + + +@handle_tool_errors +def select_pane( + pane_id: str | None = None, + direction: t.Literal["up", "down", "left", "right", "last", "next", "previous"] + | None = None, + window_id: str | None = None, + window_index: str | None = None, + session_name: str | None = None, + session_id: str | None = None, + socket_name: str | None = None, +) -> PaneInfo: + """Select (focus) a tmux pane by ID or direction. + + Use this to navigate between panes. Provide either pane_id for direct + selection, or direction for relative navigation within a window. + + Parameters + ---------- + pane_id : str, optional + Pane ID (e.g. '%1') for direct selection. + direction : str, optional + Relative direction: 'up', 'down', 'left', 'right', 'last' + (previously active), 'next', or 'previous'. + window_id : str, optional + Window ID for directional navigation scope. + window_index : str, optional + Window index for directional navigation scope. + session_name : str, optional + Session name for resolution. + session_id : str, optional + Session ID for resolution. + socket_name : str, optional + tmux socket name. + + Returns + ------- + PaneInfo + The now-active pane. + """ + from fastmcp.exceptions import ToolError + + if pane_id is None and direction is None: + msg = "Provide either pane_id or direction." + raise ToolError(msg) + + server = _get_server(socket_name=socket_name) + + if pane_id is not None: + pane = _resolve_pane(server, pane_id=pane_id) + pane.select() + return _serialize_pane(pane) + + # Directional navigation + _DIRECTION_FLAGS: dict[str, str] = { + "up": "-U", + "down": "-D", + "left": "-L", + "right": "-R", + "last": "-l", + } + + window = _resolve_window( + server, + window_id=window_id, + window_index=window_index, + session_name=session_name, + session_id=session_id, + ) + + assert direction is not None + if direction in _DIRECTION_FLAGS: + window.select_pane(_DIRECTION_FLAGS[direction]) + elif direction == "next": + window.cmd("select-pane", "-t", "+1") + elif direction == "previous": + window.cmd("select-pane", "-t", "-1") + + # Query the active pane ID directly from tmux to avoid stale cache + target = window.window_id or "" + result = window.cmd("display-message", "-p", "-t", target, "#{pane_id}") + active_pane_id = result.stdout[0] if result.stdout else None + if active_pane_id: + active_pane = server.panes.get(pane_id=active_pane_id, default=None) + if active_pane is not None: + return _serialize_pane(active_pane) + + # Fallback + active_pane = window.active_pane + assert active_pane is not None + return _serialize_pane(active_pane) + + +@handle_tool_errors +def swap_pane( + source_pane_id: str, + target_pane_id: str, + socket_name: str | None = None, +) -> PaneInfo: + """Swap the positions of two panes. + + Exchanges the visual positions of two panes. Both panes must exist. + Use this to rearrange pane layout without changing content. + + Parameters + ---------- + source_pane_id : str + Pane ID of the first pane (e.g. '%1'). + target_pane_id : str + Pane ID of the second pane (e.g. '%2'). + socket_name : str, optional + tmux socket name. + + Returns + ------- + PaneInfo + The source pane after swap (now in target's position). + """ + server = _get_server(socket_name=socket_name) + # Validate both panes exist + source = _resolve_pane(server, pane_id=source_pane_id) + _resolve_pane(server, pane_id=target_pane_id) + + server.cmd("swap-pane", "-s", source_pane_id, "-t", target_pane_id) + source.refresh() + return _serialize_pane(source) + + +@handle_tool_errors +def pipe_pane( + pane_id: str | None = None, + output_path: str | None = None, + append: bool = True, + session_name: str | None = None, + session_id: str | None = None, + window_id: str | None = None, + socket_name: str | None = None, +) -> str: + """Start or stop piping pane output to a file. + + When output_path is given, starts logging all pane output to the file. + When output_path is None, stops any active pipe for the pane. + + Parameters + ---------- + pane_id : str, optional + Pane ID (e.g. '%1'). + output_path : str, optional + File path to write output to. None stops piping. + append : bool + Whether to append to the file. Default True. If False, overwrites. + session_name : str, optional + Session name for pane resolution. + session_id : str, optional + Session ID for pane resolution. + window_id : str, optional + Window ID for pane resolution. + socket_name : str, optional + tmux socket name. + + Returns + ------- + str + Confirmation message. + """ + server = _get_server(socket_name=socket_name) + pane = _resolve_pane( + server, + pane_id=pane_id, + session_name=session_name, + session_id=session_id, + window_id=window_id, + ) + + if output_path is None: + pane.cmd("pipe-pane") + return f"Piping stopped for pane {pane.pane_id}" + + redirect = ">>" if append else ">" + pane.cmd("pipe-pane", f"cat {redirect} {output_path}") + return f"Piping pane {pane.pane_id} to {output_path}" + + +@handle_tool_errors +def display_message( + format_string: str, + pane_id: str | None = None, + session_name: str | None = None, + session_id: str | None = None, + window_id: str | None = None, + socket_name: str | None = None, +) -> str: + """Query tmux using a format string. + + Expands tmux format variables against a target pane. Use this as a + generic introspection tool to query any tmux variable, e.g. + '#{window_zoomed_flag}', '#{pane_dead}', '#{client_activity}'. + + Parameters + ---------- + format_string : str + tmux format string (e.g. '#{cursor_x} #{cursor_y}'). + pane_id : str, optional + Pane ID (e.g. '%1'). + session_name : str, optional + Session name for pane resolution. + session_id : str, optional + Session ID for pane resolution. + window_id : str, optional + Window ID for pane resolution. + socket_name : str, optional + tmux socket name. + + Returns + ------- + str + Expanded format string result. + """ + server = _get_server(socket_name=socket_name) + pane = _resolve_pane( + server, + pane_id=pane_id, + session_name=session_name, + session_id=session_id, + window_id=window_id, + ) + result = pane.cmd("display-message", "-p", "-t", pane.pane_id, format_string) + return "\n".join(result.stdout) if result.stdout else "" + + +@handle_tool_errors +def enter_copy_mode( + pane_id: str | None = None, + scroll_up: int | None = None, + session_name: str | None = None, + session_id: str | None = None, + window_id: str | None = None, + socket_name: str | None = None, +) -> PaneInfo: + """Enter copy mode in a tmux pane, optionally scrolling up. + + Use to navigate scrollback history. After entering copy mode, use + snapshot_pane to read the scroll_position and content. + + Parameters + ---------- + pane_id : str, optional + Pane ID (e.g. '%1'). + scroll_up : int, optional + Number of lines to scroll up immediately after entering copy mode. + session_name : str, optional + Session name for pane resolution. + session_id : str, optional + Session ID for pane resolution. + window_id : str, optional + Window ID for pane resolution. + socket_name : str, optional + tmux socket name. + + Returns + ------- + PaneInfo + Serialized pane info. + """ + server = _get_server(socket_name=socket_name) + pane = _resolve_pane( + server, + pane_id=pane_id, + session_name=session_name, + session_id=session_id, + window_id=window_id, + ) + pane.cmd("copy-mode", "-t", pane.pane_id) + if scroll_up is not None and scroll_up > 0: + pane.cmd( + "send-keys", + "-X", + "-N", + str(scroll_up), + "scroll-up", + ) + pane.refresh() + return _serialize_pane(pane) + + +@handle_tool_errors +def exit_copy_mode( + pane_id: str | None = None, + session_name: str | None = None, + session_id: str | None = None, + window_id: str | None = None, + socket_name: str | None = None, +) -> PaneInfo: + """Exit copy mode in a tmux pane. + + Returns the pane to normal mode. Use after scrolling through + scrollback history. + + Parameters + ---------- + pane_id : str, optional + Pane ID (e.g. '%1'). + session_name : str, optional + Session name for pane resolution. + session_id : str, optional + Session ID for pane resolution. + window_id : str, optional + Window ID for pane resolution. + socket_name : str, optional + tmux socket name. + + Returns + ------- + PaneInfo + Serialized pane info. + """ + server = _get_server(socket_name=socket_name) + pane = _resolve_pane( + server, + pane_id=pane_id, + session_name=session_name, + session_id=session_id, + window_id=window_id, + ) + pane.cmd("send-keys", "-t", pane.pane_id, "-X", "cancel") + pane.refresh() + return _serialize_pane(pane) + + +@handle_tool_errors +def paste_text( + text: str, + pane_id: str | None = None, + bracket: bool = True, + session_name: str | None = None, + session_id: str | None = None, + window_id: str | None = None, + socket_name: str | None = None, +) -> str: + """Paste multi-line text into a pane using tmux paste buffers. + + Uses tmux's load-buffer and paste-buffer for clean multi-line input, + avoiding the issues of sending text line-by-line via send_keys. + Supports bracketed paste mode for terminals that handle it. + + Parameters + ---------- + text : str + The text to paste. + pane_id : str, optional + Pane ID (e.g. '%1'). + bracket : bool + Whether to use bracketed paste mode. Default True. + Bracketed paste wraps the text in escape sequences that tell + the terminal "this is pasted text, not typed input". + session_name : str, optional + Session name for pane resolution. + session_id : str, optional + Session ID for pane resolution. + window_id : str, optional + Window ID for pane resolution. + socket_name : str, optional + tmux socket name. + + Returns + ------- + str + Confirmation message. + """ + import subprocess + import tempfile + + server = _get_server(socket_name=socket_name) + pane = _resolve_pane( + server, + pane_id=pane_id, + session_name=session_name, + session_id=session_id, + window_id=window_id, + ) + + # Write text to a temp file and load into tmux buffer + # (libtmux's cmd() doesn't support stdin) + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: + f.write(text) + tmppath = f.name + + try: + # Build tmux command args for loading buffer + tmux_bin: str = getattr(server, "tmux_bin", None) or "tmux" + load_args: list[str] = [tmux_bin] + if server.socket_name: + load_args.extend(["-L", server.socket_name]) + if server.socket_path: + load_args.extend(["-S", str(server.socket_path)]) + load_args.extend(["load-buffer", tmppath]) + subprocess.run(load_args, check=True, capture_output=True) + + # Paste from buffer into pane + paste_args = ["-d"] # delete buffer after paste + if bracket: + paste_args.append("-p") # bracketed paste mode + paste_args.extend(["-t", pane.pane_id or ""]) + pane.cmd("paste-buffer", *paste_args) + finally: + from pathlib import Path + + Path(tmppath).unlink() + + return f"Text pasted to pane {pane.pane_id}" + + def register(mcp: FastMCP) -> None: """Register pane-level tools with the MCP instance.""" mcp.tool(title="Send Keys", annotations=ANNOTATIONS_CREATE, tags={TAG_MUTATING})( @@ -648,3 +1228,36 @@ def register(mcp: FastMCP) -> None: mcp.tool(title="Wait For Text", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})( wait_for_text ) + mcp.tool(title="Snapshot Pane", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})( + snapshot_pane + ) + mcp.tool( + title="Wait For Content Change", + annotations=ANNOTATIONS_RO, + tags={TAG_READONLY}, + )(wait_for_content_change) + mcp.tool( + title="Select Pane", annotations=ANNOTATIONS_MUTATING, tags={TAG_MUTATING} + )(select_pane) + mcp.tool(title="Swap Pane", annotations=ANNOTATIONS_MUTATING, tags={TAG_MUTATING})( + swap_pane + ) + mcp.tool(title="Pipe Pane", annotations=ANNOTATIONS_MUTATING, tags={TAG_MUTATING})( + pipe_pane + ) + mcp.tool(title="Display Message", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})( + display_message + ) + mcp.tool( + title="Enter Copy Mode", + annotations=ANNOTATIONS_MUTATING, + tags={TAG_MUTATING}, + )(enter_copy_mode) + mcp.tool( + title="Exit Copy Mode", + annotations=ANNOTATIONS_MUTATING, + tags={TAG_MUTATING}, + )(exit_copy_mode) + mcp.tool(title="Paste Text", annotations=ANNOTATIONS_CREATE, tags={TAG_MUTATING})( + paste_text + ) diff --git a/src/libtmux_mcp/tools/session_tools.py b/src/libtmux_mcp/tools/session_tools.py index 1fd1b30..761d800 100644 --- a/src/libtmux_mcp/tools/session_tools.py +++ b/src/libtmux_mcp/tools/session_tools.py @@ -219,6 +219,75 @@ def kill_session( return f"Session killed: {name}" +@handle_tool_errors +def select_window( + window_id: str | None = None, + window_index: str | None = None, + direction: t.Literal["next", "previous", "last"] | None = None, + session_name: str | None = None, + session_id: str | None = None, + socket_name: str | None = None, +) -> WindowInfo: + """Select (focus) a tmux window by ID, index, or direction. + + Use to navigate between windows. Provide window_id or window_index + for direct selection, or direction for relative navigation. + + Parameters + ---------- + window_id : str, optional + Window ID (e.g. '@1') for direct selection. + window_index : str, optional + Window index for direct selection. + direction : str, optional + Relative direction: 'next', 'previous', or 'last'. + session_name : str, optional + Session name for resolution. + session_id : str, optional + Session ID for resolution. + socket_name : str, optional + tmux socket name. + + Returns + ------- + WindowInfo + The now-active window. + """ + from fastmcp.exceptions import ToolError + + if window_id is None and window_index is None and direction is None: + msg = "Provide window_id, window_index, or direction." + raise ToolError(msg) + + server = _get_server(socket_name=socket_name) + + if window_id is not None or window_index is not None: + from libtmux_mcp._utils import _resolve_window + + window = _resolve_window( + server, + window_id=window_id, + window_index=window_index, + session_name=session_name, + session_id=session_id, + ) + window.select() + return _serialize_window(window) + + # Directional navigation + session = _resolve_session(server, session_name=session_name, session_id=session_id) + _DIR_MAP = {"next": "+", "previous": "-", "last": "!"} + assert direction is not None + flag = _DIR_MAP.get(direction) + if flag is None: + msg = f"Invalid direction: {direction!r}. Valid: next, previous, last" + raise ToolError(msg) + session.cmd("select-window", "-t", flag) + + active_window = session.active_window + return _serialize_window(active_window) + + def register(mcp: FastMCP) -> None: """Register session-level tools with the MCP instance.""" mcp.tool(title="List Windows", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})( @@ -235,3 +304,6 @@ def register(mcp: FastMCP) -> None: annotations=ANNOTATIONS_DESTRUCTIVE, tags={TAG_DESTRUCTIVE}, )(kill_session) + mcp.tool( + title="Select Window", annotations=ANNOTATIONS_MUTATING, tags={TAG_MUTATING} + )(select_window) diff --git a/src/libtmux_mcp/tools/window_tools.py b/src/libtmux_mcp/tools/window_tools.py index 548a04a..165a382 100644 --- a/src/libtmux_mcp/tools/window_tools.py +++ b/src/libtmux_mcp/tools/window_tools.py @@ -368,6 +368,57 @@ def resize_window( return _serialize_window(window) +@handle_tool_errors +def move_window( + window_id: str | None = None, + window_index: str | None = None, + session_name: str | None = None, + session_id: str | None = None, + destination_index: str = "", + destination_session: str | None = None, + socket_name: str | None = None, +) -> WindowInfo: + """Move a window to a different index or session. + + Reorder windows within a session or move a window to another session. + + Parameters + ---------- + window_id : str, optional + Window ID (e.g. '@1'). + window_index : str, optional + Window index within the session. + session_name : str, optional + Source session name. + session_id : str, optional + Source session ID. + destination_index : str + Target window index. Default empty string (next available). + destination_session : str, optional + Target session name or ID. Default is current session. + socket_name : str, optional + tmux socket name. + + Returns + ------- + WindowInfo + Serialized window after move. + """ + server = _get_server(socket_name=socket_name) + window = _resolve_window( + server, + window_id=window_id, + window_index=window_index, + session_name=session_name, + session_id=session_id, + ) + window.move_window( + destination=destination_index, + session=destination_session, + ) + return _serialize_window(window) + + def register(mcp: FastMCP) -> None: """Register window-level tools with the MCP instance.""" mcp.tool(title="List Panes", annotations=ANNOTATIONS_RO, tags={TAG_READONLY})( @@ -390,3 +441,6 @@ def register(mcp: FastMCP) -> None: mcp.tool( title="Resize Window", annotations=ANNOTATIONS_MUTATING, tags={TAG_MUTATING} )(resize_window) + mcp.tool( + title="Move Window", annotations=ANNOTATIONS_MUTATING, tags={TAG_MUTATING} + )(move_window) diff --git a/tests/docs/_ext/test_fastmcp_autodoc.py b/tests/docs/_ext/test_fastmcp_autodoc.py index d457572..1876999 100644 --- a/tests/docs/_ext/test_fastmcp_autodoc.py +++ b/tests/docs/_ext/test_fastmcp_autodoc.py @@ -665,8 +665,31 @@ def test_collect_real_tools() -> None: assert socket_param.required is False +# --------------------------------------------------------------------------- +# _discover_model_classes +# --------------------------------------------------------------------------- + + +def test_discover_model_classes_finds_all_12() -> None: + """_discover_model_classes finds all 12 Pydantic models.""" + classes = fastmcp_autodoc._discover_model_classes() + assert len(classes) == 12 + + +def test_discover_model_classes_includes_pane_snapshot() -> None: + """_discover_model_classes includes PaneSnapshot (previously missing).""" + classes = fastmcp_autodoc._discover_model_classes() + assert "PaneSnapshot" in classes + + +def test_discover_model_classes_includes_content_change_result() -> None: + """_discover_model_classes includes ContentChangeResult (previously missing).""" + classes = fastmcp_autodoc._discover_model_classes() + assert "ContentChangeResult" in classes + + def test_collect_real_tools_total_count() -> None: - """All 27 tools should be collected.""" + """All 38 tools should be collected.""" collector = fastmcp_autodoc._ToolCollector() import importlib @@ -683,4 +706,4 @@ def test_collect_real_tools_total_count() -> None: mod = importlib.import_module(f"libtmux_mcp.tools.{mod_name}") mod.register(collector) - assert len(collector.tools) == 27 + assert len(collector.tools) == 38 diff --git a/tests/docs/_ext/test_fastmcp_models.py b/tests/docs/_ext/test_fastmcp_models.py new file mode 100644 index 0000000..03abfa4 --- /dev/null +++ b/tests/docs/_ext/test_fastmcp_models.py @@ -0,0 +1,280 @@ +"""Tests for fastmcp_autodoc model collection and nodes.""" + +from __future__ import annotations + +import typing as t +from unittest.mock import MagicMock + +import fastmcp_autodoc +import pytest + +# --------------------------------------------------------------------------- +# _collect_models +# --------------------------------------------------------------------------- + + +def _collect_models_result() -> dict[str, fastmcp_autodoc.ModelInfo]: + """Run _collect_models and return the result dict.""" + app = MagicMock() + app.env = MagicMock() + fastmcp_autodoc._collect_models(app) + result: dict[str, fastmcp_autodoc.ModelInfo] = app.env.fastmcp_models + return result + + +def test_collect_models_discovers_all_12() -> None: + """_collect_models finds all 12 Pydantic models in libtmux_mcp.models.""" + models = _collect_models_result() + assert len(models) == 12 + + +class ModelNameFixture(t.NamedTuple): + """Test fixture for verifying model names.""" + + test_id: str + name: str + + +MODEL_NAME_FIXTURES: list[ModelNameFixture] = [ + ModelNameFixture(test_id="SessionInfo", name="SessionInfo"), + ModelNameFixture(test_id="WindowInfo", name="WindowInfo"), + ModelNameFixture(test_id="PaneInfo", name="PaneInfo"), + ModelNameFixture(test_id="PaneContentMatch", name="PaneContentMatch"), + ModelNameFixture(test_id="ServerInfo", name="ServerInfo"), + ModelNameFixture(test_id="OptionResult", name="OptionResult"), + ModelNameFixture(test_id="OptionSetResult", name="OptionSetResult"), + ModelNameFixture(test_id="EnvironmentResult", name="EnvironmentResult"), + ModelNameFixture(test_id="EnvironmentSetResult", name="EnvironmentSetResult"), + ModelNameFixture(test_id="WaitForTextResult", name="WaitForTextResult"), + ModelNameFixture(test_id="PaneSnapshot", name="PaneSnapshot"), + ModelNameFixture(test_id="ContentChangeResult", name="ContentChangeResult"), +] + + +@pytest.mark.parametrize( + MODEL_NAME_FIXTURES[0]._fields, + MODEL_NAME_FIXTURES, + ids=[f.test_id for f in MODEL_NAME_FIXTURES], +) +def test_collect_models_includes_model(test_id: str, name: str) -> None: + """_collect_models includes each expected model.""" + models = _collect_models_result() + assert name in models + + +# --------------------------------------------------------------------------- +# Field counts +# --------------------------------------------------------------------------- + + +class FieldCountFixture(t.NamedTuple): + """Test fixture for field count verification.""" + + test_id: str + model_name: str + expected_count: int + + +FIELD_COUNT_FIXTURES: list[FieldCountFixture] = [ + FieldCountFixture( + test_id="SessionInfo_5", + model_name="SessionInfo", + expected_count=5, + ), + FieldCountFixture( + test_id="PaneSnapshot_14", + model_name="PaneSnapshot", + expected_count=14, + ), + FieldCountFixture( + test_id="WindowInfo_10", + model_name="WindowInfo", + expected_count=10, + ), + FieldCountFixture( + test_id="PaneInfo_12", + model_name="PaneInfo", + expected_count=12, + ), + FieldCountFixture( + test_id="ContentChangeResult_4", + model_name="ContentChangeResult", + expected_count=4, + ), + FieldCountFixture( + test_id="WaitForTextResult_5", + model_name="WaitForTextResult", + expected_count=5, + ), +] + + +@pytest.mark.parametrize( + FIELD_COUNT_FIXTURES[0]._fields, + FIELD_COUNT_FIXTURES, + ids=[f.test_id for f in FIELD_COUNT_FIXTURES], +) +def test_model_field_count( + test_id: str, + model_name: str, + expected_count: int, +) -> None: + """Models have the expected number of fields.""" + models = _collect_models_result() + model = models[model_name] + assert len(model.fields) == expected_count + + +# --------------------------------------------------------------------------- +# Field description extraction +# --------------------------------------------------------------------------- + + +def test_field_description_extraction() -> None: + """Field(description=...) values are extracted correctly.""" + models = _collect_models_result() + session = models["SessionInfo"] + field_map = {f.name: f for f in session.fields} + + assert "session_id" in field_map + assert field_map["session_id"].description == "Session ID (e.g. '$1')" + + assert "window_count" in field_map + assert field_map["window_count"].description == "Number of windows" + + +# --------------------------------------------------------------------------- +# Required vs optional detection +# --------------------------------------------------------------------------- + + +def test_required_vs_optional_detection() -> None: + """Required fields and optional fields are distinguished correctly.""" + models = _collect_models_result() + session = models["SessionInfo"] + field_map = {f.name: f for f in session.fields} + + # session_id has no default → required + assert field_map["session_id"].required is True + assert field_map["session_id"].default == "" + + # session_name has default=None → optional + assert field_map["session_name"].required is False + assert field_map["session_name"].default == "None" + + # window_count has no default → required + assert field_map["window_count"].required is True + + +# --------------------------------------------------------------------------- +# default_factory handling +# --------------------------------------------------------------------------- + + +def test_default_factory_handling() -> None: + """WaitForTextResult.matched_lines uses default_factory=list.""" + models = _collect_models_result() + wait_result = models["WaitForTextResult"] + field_map = {f.name: f for f in wait_result.fields} + + matched_lines = field_map["matched_lines"] + assert matched_lines.required is False + assert matched_lines.default == "list()" + + +# --------------------------------------------------------------------------- +# _model_badge_node +# --------------------------------------------------------------------------- + + +def test_model_badge_classes() -> None: + """_model_badge creates badge node with correct CSS classes.""" + badge = fastmcp_autodoc._model_badge() + assert isinstance(badge, fastmcp_autodoc._model_badge_node) + assert "sd-bg-primary" in badge["classes"] + assert "sd-bg-text-primary" in badge["classes"] + assert "sd-sphinx-override" in badge["classes"] + assert "sd-badge" in badge["classes"] + assert badge.astext() == "model" + + +# --------------------------------------------------------------------------- +# Model roles +# --------------------------------------------------------------------------- + + +def test_model_role_creates_placeholder() -> None: + """_model_role creates _model_ref_placeholder with show_badge=True.""" + result_nodes, _messages = fastmcp_autodoc._model_role( + "model", ":model:`SessionInfo`", "SessionInfo", 1, None + ) + assert len(result_nodes) == 1 + node = result_nodes[0] + assert isinstance(node, fastmcp_autodoc._model_ref_placeholder) + assert node["reftarget"] == "SessionInfo" + assert node["show_badge"] is True + + +def test_modelref_role_creates_placeholder() -> None: + """_modelref_role creates _model_ref_placeholder with show_badge=False.""" + result_nodes, _messages = fastmcp_autodoc._modelref_role( + "modelref", ":modelref:`SessionInfo`", "SessionInfo", 1, None + ) + assert len(result_nodes) == 1 + node = result_nodes[0] + assert isinstance(node, fastmcp_autodoc._model_ref_placeholder) + assert node["reftarget"] == "SessionInfo" + assert node["show_badge"] is False + + +# --------------------------------------------------------------------------- +# :fields: and :exclude: filtering +# --------------------------------------------------------------------------- + + +def test_model_directive_fields_allowlist() -> None: + """FastMCPModelDirective :fields: option filters to allowed fields.""" + directive = fastmcp_autodoc.FastMCPModelDirective.__new__( + fastmcp_autodoc.FastMCPModelDirective + ) + directive.options = {"fields": "session_id, window_count"} + + all_fields = [ + fastmcp_autodoc.ModelFieldInfo("session_id", "str", True, "", ""), + fastmcp_autodoc.ModelFieldInfo("session_name", "str | None", False, "None", ""), + fastmcp_autodoc.ModelFieldInfo("window_count", "int", True, "", ""), + ] + + filtered = directive._filter_fields(all_fields) + names = [f.name for f in filtered] + assert names == ["session_id", "window_count"] + + +def test_model_directive_exclude_denylist() -> None: + """FastMCPModelDirective :exclude: option removes denied fields.""" + directive = fastmcp_autodoc.FastMCPModelDirective.__new__( + fastmcp_autodoc.FastMCPModelDirective + ) + directive.options = {"exclude": "session_name"} + + all_fields = [ + fastmcp_autodoc.ModelFieldInfo("session_id", "str", True, "", ""), + fastmcp_autodoc.ModelFieldInfo("session_name", "str | None", False, "None", ""), + fastmcp_autodoc.ModelFieldInfo("window_count", "int", True, "", ""), + ] + + filtered = directive._filter_fields(all_fields) + names = [f.name for f in filtered] + assert names == ["session_id", "window_count"] + + +# --------------------------------------------------------------------------- +# Qualified names +# --------------------------------------------------------------------------- + + +def test_model_qualified_name() -> None: + """Model qualified_name includes full module path.""" + models = _collect_models_result() + assert models["SessionInfo"].qualified_name == "libtmux_mcp.models.SessionInfo" + assert models["PaneSnapshot"].qualified_name == "libtmux_mcp.models.PaneSnapshot" diff --git a/tests/docs/_ext/test_fastmcp_resources.py b/tests/docs/_ext/test_fastmcp_resources.py new file mode 100644 index 0000000..e4b66d4 --- /dev/null +++ b/tests/docs/_ext/test_fastmcp_resources.py @@ -0,0 +1,202 @@ +"""Tests for fastmcp_autodoc resource collection and nodes.""" + +from __future__ import annotations + +import typing as t + +import fastmcp_autodoc +import pytest + +# --------------------------------------------------------------------------- +# _ResourceCollector +# --------------------------------------------------------------------------- + + +def test_resource_collector_captures_registrations() -> None: + """_ResourceCollector captures resource metadata from register() calls.""" + collector = fastmcp_autodoc._ResourceCollector() + collector._current_module = "hierarchy" + + @collector.resource("tmux://sessions{?socket_name}", title="All Sessions") + def get_sessions(socket_name: str | None = None) -> str: + """List all tmux sessions. + + Parameters + ---------- + socket_name : str, optional + tmux socket name. + + Returns + ------- + str + """ + return "" + + assert len(collector.resources) == 1 + resource = collector.resources[0] + assert resource.name == "get_sessions" + assert resource.qualified_name == "hierarchy.get_sessions" + assert resource.title == "All Sessions" + assert resource.uri_template == "tmux://sessions{?socket_name}" + assert len(resource.params) == 1 + assert resource.params[0].name == "socket_name" + assert resource.params[0].required is False + + +def test_resource_collector_default_title() -> None: + """_ResourceCollector uses func name as default title.""" + collector = fastmcp_autodoc._ResourceCollector() + collector._current_module = "hierarchy" + + @collector.resource("tmux://sessions") + def get_sessions() -> str: + """List sessions.""" + return "" + + assert collector.resources[0].title == "Get Sessions" + + +# --------------------------------------------------------------------------- +# Real resource collection +# --------------------------------------------------------------------------- + + +def test_collect_real_resources_total_count() -> None: + """All 6 resources should be collected from hierarchy.py.""" + collector = fastmcp_autodoc._ResourceCollector() + collector._current_module = "hierarchy" + + import importlib + + mod = importlib.import_module("libtmux_mcp.resources.hierarchy") + mod.register(collector) + + assert len(collector.resources) == 6 + + +class RealResourceFixture(t.NamedTuple): + """Test fixture for real resource verification.""" + + test_id: str + name: str + uri_template: str + title: str + + +REAL_RESOURCE_FIXTURES: list[RealResourceFixture] = [ + RealResourceFixture( + test_id="get_sessions", + name="get_sessions", + uri_template="tmux://sessions{?socket_name}", + title="All Sessions", + ), + RealResourceFixture( + test_id="get_session", + name="get_session", + uri_template="tmux://sessions/{session_name}{?socket_name}", + title="Session Detail", + ), + RealResourceFixture( + test_id="get_session_windows", + name="get_session_windows", + uri_template="tmux://sessions/{session_name}/windows{?socket_name}", + title="Session Windows", + ), + RealResourceFixture( + test_id="get_window", + name="get_window", + uri_template="tmux://sessions/{session_name}/windows/{window_index}{?socket_name}", + title="Window Detail", + ), + RealResourceFixture( + test_id="get_pane", + name="get_pane", + uri_template="tmux://panes/{pane_id}{?socket_name}", + title="Pane Detail", + ), + RealResourceFixture( + test_id="get_pane_content", + name="get_pane_content", + uri_template="tmux://panes/{pane_id}/content{?socket_name}", + title="Pane Content", + ), +] + + +@pytest.mark.parametrize( + REAL_RESOURCE_FIXTURES[0]._fields, + REAL_RESOURCE_FIXTURES, + ids=[f.test_id for f in REAL_RESOURCE_FIXTURES], +) +def test_collect_real_resource_details( + test_id: str, + name: str, + uri_template: str, + title: str, +) -> None: + """Real resources have correct URI templates and titles.""" + collector = fastmcp_autodoc._ResourceCollector() + collector._current_module = "hierarchy" + + import importlib + + mod = importlib.import_module("libtmux_mcp.resources.hierarchy") + mod.register(collector) + + resources = {r.name: r for r in collector.resources} + resource = resources[name] + assert resource.uri_template == uri_template + assert resource.title == title + + +# --------------------------------------------------------------------------- +# _resource_badge_node +# --------------------------------------------------------------------------- + + +def test_resource_badge_classes() -> None: + """_resource_badge creates badge node with correct CSS classes.""" + badge = fastmcp_autodoc._resource_badge() + assert isinstance(badge, fastmcp_autodoc._resource_badge_node) + assert "sd-bg-info" in badge["classes"] + assert "sd-bg-text-info" in badge["classes"] + assert "sd-sphinx-override" in badge["classes"] + assert "sd-badge" in badge["classes"] + assert badge.astext() == "resource" + + +# --------------------------------------------------------------------------- +# Resource roles +# --------------------------------------------------------------------------- + + +def test_resource_role_creates_placeholder() -> None: + """_resource_role creates _resource_ref_placeholder with show_badge=True.""" + result_nodes, _messages = fastmcp_autodoc._resource_role( + "resource", ":resource:`get-sessions`", "get-sessions", 1, None + ) + assert len(result_nodes) == 1 + node = result_nodes[0] + assert isinstance(node, fastmcp_autodoc._resource_ref_placeholder) + assert node["reftarget"] == "get-sessions" + assert node["show_badge"] is True + + +def test_resourceref_role_creates_placeholder() -> None: + """_resourceref_role creates _resource_ref_placeholder with show_badge=False.""" + result_nodes, _messages = fastmcp_autodoc._resourceref_role( + "resourceref", ":resourceref:`get-sessions`", "get-sessions", 1, None + ) + assert len(result_nodes) == 1 + node = result_nodes[0] + assert isinstance(node, fastmcp_autodoc._resource_ref_placeholder) + assert node["reftarget"] == "get-sessions" + assert node["show_badge"] is False + + +def test_resource_role_normalizes_underscores() -> None: + """_resource_role converts underscores to hyphens in target.""" + result_nodes, _ = fastmcp_autodoc._resource_role( + "resource", ":resource:`get_sessions`", "get_sessions", 1, None + ) + assert result_nodes[0]["reftarget"] == "get-sessions" diff --git a/tests/test_pane_tools.py b/tests/test_pane_tools.py index 640ce1c..315bc46 100644 --- a/tests/test_pane_tools.py +++ b/tests/test_pane_tools.py @@ -8,16 +8,30 @@ from fastmcp.exceptions import ToolError from libtmux.test.retry import retry_until -from libtmux_mcp.models import PaneContentMatch, WaitForTextResult +from libtmux_mcp.models import ( + ContentChangeResult, + PaneContentMatch, + PaneSnapshot, + WaitForTextResult, +) from libtmux_mcp.tools.pane_tools import ( capture_pane, clear_pane, + display_message, + enter_copy_mode, + exit_copy_mode, get_pane_info, kill_pane, + paste_text, + pipe_pane, resize_pane, search_panes, + select_pane, send_keys, set_pane_title, + snapshot_pane, + swap_pane, + wait_for_content_change, wait_for_text, ) @@ -507,3 +521,292 @@ def test_wait_for_text_invalid_regex(mcp_server: Server, mcp_pane: Pane) -> None pane_id=mcp_pane.pane_id, socket_name=mcp_server.socket_name, ) + + +# --------------------------------------------------------------------------- +# snapshot_pane tests +# --------------------------------------------------------------------------- + + +def test_snapshot_pane(mcp_server: Server, mcp_pane: Pane) -> None: + """snapshot_pane returns rich metadata alongside content.""" + result = snapshot_pane( + pane_id=mcp_pane.pane_id, + socket_name=mcp_server.socket_name, + ) + assert isinstance(result, PaneSnapshot) + assert result.pane_id == mcp_pane.pane_id + assert isinstance(result.content, str) + assert result.cursor_x >= 0 + assert result.cursor_y >= 0 + assert result.pane_width > 0 + assert result.pane_height > 0 + assert result.pane_in_mode is False + assert result.pane_mode is None + assert result.history_size >= 0 + + +def test_snapshot_pane_cursor_moves(mcp_server: Server, mcp_pane: Pane) -> None: + """snapshot_pane reflects cursor position changes.""" + mcp_pane.send_keys("echo hello_snapshot", enter=True) + retry_until( + lambda: "hello_snapshot" in "\n".join(mcp_pane.capture_pane()), + 2, + raises=True, + ) + + result = snapshot_pane( + pane_id=mcp_pane.pane_id, + socket_name=mcp_server.socket_name, + ) + assert "hello_snapshot" in result.content + assert result.pane_current_command is not None + + +# --------------------------------------------------------------------------- +# wait_for_content_change tests +# --------------------------------------------------------------------------- + + +def test_wait_for_content_change_detects_change( + mcp_server: Server, mcp_pane: Pane +) -> None: + """wait_for_content_change detects screen changes.""" + import threading + + # Send a command after a brief delay to trigger a change + def _send_later() -> None: + import time + + time.sleep(0.2) + mcp_pane.send_keys("echo CHANGE_DETECTED_xyz", enter=True) + + thread = threading.Thread(target=_send_later) + thread.start() + + result = wait_for_content_change( + pane_id=mcp_pane.pane_id, + timeout=3.0, + socket_name=mcp_server.socket_name, + ) + thread.join() + assert isinstance(result, ContentChangeResult) + assert result.changed is True + assert result.timed_out is False + assert result.elapsed_seconds > 0 + + +def test_wait_for_content_change_timeout(mcp_server: Server, mcp_pane: Pane) -> None: + """wait_for_content_change times out when no change occurs.""" + # Wait for the shell prompt to settle before testing for "no change" + import time + + time.sleep(0.5) + + result = wait_for_content_change( + pane_id=mcp_pane.pane_id, + timeout=0.5, + socket_name=mcp_server.socket_name, + ) + assert isinstance(result, ContentChangeResult) + assert result.changed is False + assert result.timed_out is True + + +# --------------------------------------------------------------------------- +# select_pane tests +# --------------------------------------------------------------------------- + + +def test_select_pane_by_id(mcp_server: Server, mcp_session: Session) -> None: + """select_pane focuses a specific pane by ID.""" + window = mcp_session.active_window + pane1 = window.active_pane + assert pane1 is not None + window.split() + + # Select the first pane + result = select_pane( + pane_id=pane1.pane_id, + socket_name=mcp_server.socket_name, + ) + assert result.pane_id == pane1.pane_id + + +def test_select_pane_directional(mcp_server: Server, mcp_session: Session) -> None: + """select_pane navigates using direction.""" + window = mcp_session.active_window + pane1 = window.active_pane + assert pane1 is not None + pane2 = window.split() # creates pane below; pane1 stays active + + # pane1 is active, select "down" should go to pane2 + result = select_pane( + direction="down", + window_id=window.window_id, + socket_name=mcp_server.socket_name, + ) + assert result.pane_id == pane2.pane_id + + +def test_select_pane_requires_target(mcp_server: Server) -> None: + """select_pane raises ToolError when neither pane_id nor direction given.""" + with pytest.raises(ToolError, match="Provide either"): + select_pane(socket_name=mcp_server.socket_name) + + +# --------------------------------------------------------------------------- +# swap_pane tests +# --------------------------------------------------------------------------- + + +def test_swap_pane(mcp_server: Server, mcp_session: Session) -> None: + """swap_pane exchanges two pane positions.""" + window = mcp_session.active_window + pane1 = window.active_pane + assert pane1 is not None + pane2 = window.split() + + assert pane1.pane_id is not None + assert pane2.pane_id is not None + + result = swap_pane( + source_pane_id=pane1.pane_id, + target_pane_id=pane2.pane_id, + socket_name=mcp_server.socket_name, + ) + assert result.pane_id == pane1.pane_id + + +# --------------------------------------------------------------------------- +# pipe_pane tests +# --------------------------------------------------------------------------- + + +def test_pipe_pane_start_stop( + mcp_server: Server, mcp_pane: Pane, tmp_path: t.Any +) -> None: + """pipe_pane starts and stops piping output to a file.""" + log_file = str(tmp_path / "pane_output.log") + + # Start piping + result = pipe_pane( + pane_id=mcp_pane.pane_id, + output_path=log_file, + socket_name=mcp_server.socket_name, + ) + assert "piping" in result.lower() + + # Stop piping + result = pipe_pane( + pane_id=mcp_pane.pane_id, + output_path=None, + socket_name=mcp_server.socket_name, + ) + assert "stopped" in result.lower() + + +# --------------------------------------------------------------------------- +# display_message tests +# --------------------------------------------------------------------------- + + +def test_display_message(mcp_server: Server, mcp_pane: Pane) -> None: + """display_message expands tmux format strings.""" + result = display_message( + format_string="#{pane_width}x#{pane_height}", + pane_id=mcp_pane.pane_id, + socket_name=mcp_server.socket_name, + ) + assert "x" in result + parts = result.split("x") + assert len(parts) == 2 + assert parts[0].isdigit() + assert parts[1].isdigit() + + +def test_display_message_zoomed_flag(mcp_server: Server, mcp_session: Session) -> None: + """display_message queries arbitrary tmux variables.""" + window = mcp_session.active_window + pane = window.active_pane + assert pane is not None + result = display_message( + format_string="#{window_zoomed_flag}", + pane_id=pane.pane_id, + socket_name=mcp_server.socket_name, + ) + assert result in ("0", "1") + + +# --------------------------------------------------------------------------- +# enter_copy_mode / exit_copy_mode tests +# --------------------------------------------------------------------------- + + +def test_enter_and_exit_copy_mode(mcp_server: Server, mcp_pane: Pane) -> None: + """enter_copy_mode enters copy mode, exit_copy_mode leaves it.""" + enter_result = enter_copy_mode( + pane_id=mcp_pane.pane_id, + socket_name=mcp_server.socket_name, + ) + assert enter_result.pane_id == mcp_pane.pane_id + + # Verify pane is in copy mode via snapshot + snap = snapshot_pane( + pane_id=mcp_pane.pane_id, + socket_name=mcp_server.socket_name, + ) + assert snap.pane_in_mode is True + + exit_result = exit_copy_mode( + pane_id=mcp_pane.pane_id, + socket_name=mcp_server.socket_name, + ) + assert exit_result.pane_id == mcp_pane.pane_id + + +def test_enter_copy_mode_with_scroll(mcp_server: Server, mcp_pane: Pane) -> None: + """enter_copy_mode can scroll up immediately.""" + # Generate some scrollback history + for i in range(20): + mcp_pane.send_keys(f"echo scrollback_line_{i}", enter=True) + retry_until( + lambda: "scrollback_line_19" in "\n".join(mcp_pane.capture_pane()), + 2, + raises=True, + ) + + enter_result = enter_copy_mode( + pane_id=mcp_pane.pane_id, + scroll_up=5, + socket_name=mcp_server.socket_name, + ) + assert enter_result.pane_id == mcp_pane.pane_id + + # Clean up: exit copy mode + exit_copy_mode( + pane_id=mcp_pane.pane_id, + socket_name=mcp_server.socket_name, + ) + + +# --------------------------------------------------------------------------- +# paste_text tests +# --------------------------------------------------------------------------- + + +def test_paste_text(mcp_server: Server, mcp_pane: Pane) -> None: + """paste_text pastes text into a pane via tmux buffer.""" + result = paste_text( + text="echo PASTE_TEST_marker_xyz", + pane_id=mcp_pane.pane_id, + socket_name=mcp_server.socket_name, + ) + assert "pasted" in result.lower() + + # Verify the text appeared in the pane + retry_until( + lambda: "PASTE_TEST_marker_xyz" in "\n".join(mcp_pane.capture_pane()), + 2, + raises=True, + ) diff --git a/tests/test_session_tools.py b/tests/test_session_tools.py index e3d6b39..6f8997f 100644 --- a/tests/test_session_tools.py +++ b/tests/test_session_tools.py @@ -12,6 +12,7 @@ kill_session, list_windows, rename_session, + select_window, ) if t.TYPE_CHECKING: @@ -192,6 +193,57 @@ def test_list_windows_with_filters( second_session.kill() +# --------------------------------------------------------------------------- +# select_window tests +# --------------------------------------------------------------------------- + + +def test_select_window_by_id(mcp_server: Server, mcp_session: Session) -> None: + """select_window focuses a window by ID.""" + win1 = mcp_session.active_window + mcp_session.new_window(window_name="select_target") + + result = select_window( + window_id=win1.window_id, + socket_name=mcp_server.socket_name, + ) + assert result.window_id == win1.window_id + + +def test_select_window_by_index(mcp_server: Server, mcp_session: Session) -> None: + """select_window focuses a window by index.""" + win1 = mcp_session.active_window + mcp_session.new_window(window_name="select_idx") + + result = select_window( + window_index=win1.window_index, + session_name=mcp_session.session_name, + socket_name=mcp_server.socket_name, + ) + assert result.window_id == win1.window_id + + +def test_select_window_direction_next(mcp_server: Server, mcp_session: Session) -> None: + """select_window navigates to next window.""" + win1 = mcp_session.active_window + win2 = mcp_session.new_window(window_name="next_win") + + # Make win1 active + win1.select() + result = select_window( + direction="next", + session_name=mcp_session.session_name, + socket_name=mcp_server.socket_name, + ) + assert result.window_id == win2.window_id + + +def test_select_window_requires_target(mcp_server: Server) -> None: + """select_window raises ToolError without target or direction.""" + with pytest.raises(ToolError, match="Provide"): + select_window(socket_name=mcp_server.socket_name) + + def test_kill_session_requires_target(mcp_server: Server) -> None: """kill_session refuses to kill without an explicit target.""" with pytest.raises(ToolError, match="Refusing to kill"): diff --git a/tests/test_window_tools.py b/tests/test_window_tools.py index a0ff002..929c40c 100644 --- a/tests/test_window_tools.py +++ b/tests/test_window_tools.py @@ -10,6 +10,7 @@ from libtmux_mcp.tools.window_tools import ( kill_window, list_panes, + move_window, rename_window, resize_window, select_layout, @@ -202,6 +203,42 @@ def test_list_panes_with_filters( assert len(result) >= expected_min_count +# --------------------------------------------------------------------------- +# move_window tests +# --------------------------------------------------------------------------- + + +def test_move_window_reorder(mcp_server: Server, mcp_session: Session) -> None: + """move_window changes a window's index.""" + win = mcp_session.new_window(window_name="move_me") + result = move_window( + window_id=win.window_id, + destination_index="99", + socket_name=mcp_server.socket_name, + ) + assert result.window_id == win.window_id + assert result.window_index == "99" + + +def test_move_window_to_another_session( + mcp_server: Server, mcp_session: Session +) -> None: + """move_window moves a window to a different session.""" + target_session = mcp_server.new_session(session_name="move_target") + win = mcp_session.new_window(window_name="move_cross") + window_id = win.window_id + + result = move_window( + window_id=window_id, + destination_session=target_session.session_id, + socket_name=mcp_server.socket_name, + ) + assert result.window_id == window_id + + # Cleanup + target_session.kill() + + def test_kill_window_requires_window_id(mcp_server: Server) -> None: """kill_window requires window_id as a positional argument.""" with pytest.raises(ToolError, match="missing 1 required positional argument"):