Skip to content

Plugin examples

The templates

File > New Project, Plugin, Template offers five starting points. Each is a working plugin; open one, read it, change it.

Template What it is What it teaches
Tool: text in, text out (word_count) Counts the words in a piece of text The smallest tool: one run(args), a description, two examples. Walked through in Your first plugin.
Tool: reads your project (todo_finder) Lists every TODO or FIXME comment with its file and line Asking for "context": ["files"] and receiving the project; examples that pin their own files so they answer the same with no project open.
Panel: a tab you draw (file_sizes_panel) Charts the lines in each file of the open project A tab drawn with QPainter, measured from the font, following the theme and the project. Shown in Your first panel.
Panel: a status bar readout (spend_readout) This session's API spend, live placement: statusbar, and subscribing to usage so it updates after every request.
Plugin: everything at once (Project Pulse) Four tools, a tab, a status bar readout, a dialog, two menu actions, four hooks and a skill, sharing state How the pieces fit together: shared code in a plain module, hooks that record under a lock, panels that poll.

A dependency lister (a tool)

What does the project import, and which of those are its own files? This tool reads the project with Python's ast module, so it reports imports, not words that look like them.

manifest.json:

{
  "name": "dependency_lister",
  "api_version": 1,
  "version": "1.0.0",
  "description": "List the modules the open project imports, split into the project's own files and everything else. Use before adding a requirements file or when asked what the app depends on.",
  "permissions": [],
  "contributions": [
    {
      "type": "tool",
      "entry": "plugin.py",
      "parameters": { "type": "object", "properties": {}, "required": [] },
      "context": ["files"],
      "examples": [
        {
          "name": "splits local from outside",
          "args": {},
          "context": { "files": {
            "main.py": "import os\nimport requests\nfrom ui import Window\n",
            "ui.py": "from PySide6.QtWidgets import QWidget\n"
          } },
          "expect_contains": "Outside: os, PySide6, requests"
        },
        {
          "name": "a file that does not parse is named",
          "args": {},
          "context": { "files": { "broken.py": "def (:\n" } },
          "expect_contains": "could not read broken.py"
        }
      ],
      "timeout": 15
    }
  ]
}

plugin.py:

import ast


def run(args, context):
    files = context.get("files") or {}
    local = {name[:-3].replace("/", ".") for name in files if name.endswith(".py")}
    outside, inside, unreadable = set(), set(), []
    for name, source in sorted(files.items()):
        if not name.endswith(".py"):
            continue
        try:
            tree = ast.parse(source)
        except SyntaxError:
            unreadable.append(name)
            continue
        for node in ast.walk(tree):
            if isinstance(node, ast.Import):
                roots = [alias.name.split(".")[0] for alias in node.names]
            elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
                roots = [node.module.split(".")[0]]
            else:
                continue
            for root in roots:
                (inside if root in local else outside).add(root)
    lines = ["Outside: " + (", ".join(sorted(outside, key=str.lower)) or "none"),
             "The project's own: " + (", ".join(sorted(inside)) or "none")]
    for name in unreadable:
        lines.append("(could not read %s: it does not parse)" % name)
    return chr(10).join(lines)

Worth copying: the second example covers the failure case, and both pin their own files. When this page was written the first example failed, because the tool sorts names without regard to case (os, PySide6, requests) and the expectation had them the other way round. That is the examples doing their job: Whittl held the tool back until they agreed.

A project summary (a menu action)

A menu action is for something you do on demand. This one writes SUMMARY.md into the open project, listing every file and its length, and appears under Edit > Plugins as Write a project summary.

manifest.json:

{
  "name": "project_summary",
  "api_version": 1,
  "version": "1.0.0",
  "description": "Writes SUMMARY.md into the open project: every file and its line count.",
  "permissions": [],
  "contributions": [
    {
      "type": "menu_action",
      "entry": "actions.py",
      "function": "write_summary",
      "label": "Write a project summary"
    }
  ]
}

actions.py:

import os


def write_summary(context):
    project = context.project()
    folder = project.get("dir")
    if not folder:
        return "Open a project first."
    files = project.get("files") or {}
    rows = sorted(((text.count(chr(10)) + 1, name) for name, text in files.items()),
                  reverse=True)
    lines = ["# " + project.get("name", "Project"), ""]
    lines += ["- %s: %d lines" % (name, count) for count, name in rows]
    with open(os.path.join(folder, "SUMMARY.md"), "w", encoding="utf-8") as f:
        f.write(chr(10).join(lines) + chr(10))
    return "Wrote SUMMARY.md (%d files)" % len(rows)

The string it returns is shown in the status bar. It runs inside Whittl and the window waits for it, so a menu action should finish in moments.

Type hints, please (a hook)

A generation.before hook can add a line of guidance to every prompt. This one asks for type hints on new functions, except in plugin projects:

manifest.json:

{
  "name": "type_hints_please",
  "api_version": 1,
  "version": "1.0.0",
  "description": "Asks the AI for type hints on every new function, in every project.",
  "permissions": [],
  "contributions": [
    {
      "type": "hook",
      "entry": "hooks.py",
      "function": "before_generation",
      "point": "generation.before"
    }
  ]
}

hooks.py:

def before_generation(payload):
    if payload.get("target") == "plugin":
        return None
    return "Give every new function type hints for its arguments and return value."

The hook receives the payload for its point, and the string it returns joins the prompt for that generation. It runs on a helper thread with a 3 second deadline: return quickly and touch no widgets. For guidance that is long, or that should only apply to some projects, a skill is usually the better tool; a hook is right when the guidance depends on something only code can see, such as the time or the files in the project.