Compare commits

...

10 Commits

Author SHA1 Message Date
Mortdecai f6a1cc4ebd docs: README, INSTALL.md (AI-guided setup) 2026-03-29 19:16:39 -04:00
Mortdecai e37b907326 test: end-to-end integration test — full session flow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:16:15 -04:00
Mortdecai 382e083471 feat: CLI entry point — mcp, tui, list, open, help subcommands 2026-03-29 19:14:49 -04:00
Mortdecai 707e8b7e31 feat: image rendering — kitty graphics, sixel, and ASCII art backends 2026-03-29 19:13:45 -04:00
Mortdecai f16f16ef77 feat: Textual TUI app — widget rendering, socket client, user events
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:12:52 -04:00
Mortdecai e9c0cd7f15 feat: MCP server with kitt_* tools and Unix socket management
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:10:48 -04:00
Mortdecai 5a509cbbbb feat: terminal backend abstraction — kitty, tmux, and plain backends
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:08:02 -04:00
Mortdecai dc910d442d feat: project directory management — create, log, read, list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:06:31 -04:00
Mortdecai d376e52908 feat: JSON-lines protocol with command and event dataclasses
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:05:15 -04:00
Mortdecai 8fd01c1275 fix: use setuptools instead of hatchling for build backend 2026-03-29 19:03:31 -04:00
24 changed files with 1859 additions and 2 deletions
+245
View File
@@ -0,0 +1,245 @@
# Kitty-Workbench — AI-Guided Setup
You are an AI assistant helping a user set up Kitty-Workbench. Follow these steps in order. Do not ask the user what they have — detect it yourself. Present options conversationally.
## Step 0: Clone and Install
If the repo is not already cloned locally, clone it:
```bash
git clone <repo-url>
cd kitty-workbench
```
Install the package:
```bash
pip install .
```
If `pip install` fails due to PEP 668, use:
```bash
pip install --break-system-packages .
```
Verify it installed:
```bash
kitty-workbench --version
```
If already installed, skip to Step 1.
## Step 1: Detect the Environment
Run these commands and collect the output. Do NOT ask the user — just run them:
```bash
# Python
python3 --version
# Platform
uname -s -r -m
# Terminal environment
echo "TERM_PROGRAM=$TERM_PROGRAM"
echo "KITTY_PID=$KITTY_PID"
echo "TMUX=$TMUX"
echo "VTE_VERSION=$VTE_VERSION"
# Available tools
which kitty 2>/dev/null && echo "kitty: found" || echo "kitty: not found"
which tmux 2>/dev/null && echo "tmux: found" || echo "tmux: not found"
which chafa 2>/dev/null && echo "chafa: found" || echo "chafa: not found"
which img2sixel 2>/dev/null && echo "img2sixel: found" || echo "img2sixel: not found"
# Access topology — is the user local, SSH'd, or on remote desktop?
echo "SSH_CONNECTION=$SSH_CONNECTION"
echo "DISPLAY=$DISPLAY"
echo "WAYLAND_DISPLAY=$WAYLAND_DISPLAY"
who am i 2>/dev/null
# Kitty remote control (only if kitty is installed)
kitty @ ls 2>&1 | head -1 || true
```
## Step 2: Evaluate Options
Based on what you detected, determine:
### Access topology
- **Native** (no `$SSH_CONNECTION`, has `$DISPLAY` or `$WAYLAND_DISPLAY`): All backends work.
- **Remote desktop** (RDP/VNC session detected): All backends work — same as native.
- **SSH** (`$SSH_CONNECTION` is set): Only **tmux** works reliably for pane splitting. Kitty splits won't work (`kitty @` can't reach the user's local kitty instance). The plain backend can't open windows on the user's local machine.
### Available backends
| Backend | Requirement | Check |
|---------|------------|-------|
| **kitty** | `$KITTY_PID` set, or `kitty` on PATH + `kitty @ ls` succeeds | Best image quality, native splits. Only works for native/remote-desktop users. |
| **tmux** | `$TMUX` set, or `tmux` on PATH | Good image quality (sixel), works over SSH. Recommended default. |
| **plain** | Always available | Opens TUI in a separate terminal window. No splits. |
### Image protocol
| Protocol | Check |
|----------|-------|
| **kitty graphics** | Only if using kitty backend |
| **sixel** | `$TERM_PROGRAM` is one of: iTerm2, WezTerm, foot, contour, xterm, mlterm, mintty. Or `$VTE_VERSION` >= 7600 (GNOME Terminal 46+). Or `$TERM` contains "xterm-kitty". |
| **ASCII art** | `chafa` on PATH. Always works as fallback. |
| **none** | Plain text placeholder with file path. |
## Step 3: Present Options
Based on your evaluation, present the user's options conversationally. Be specific about what they have and what each option gives them.
**If over SSH:**
Tell the user tmux is their only practical option. If tmux is installed, recommend it. If not, recommend installing it (`apt install tmux` / `brew install tmux`). Mention that images will work if their local terminal supports sixel.
**If native/remote desktop with tmux available:**
Present tmux as the ready-now option. Optionally mention kitty as an upgrade for better image quality if they want to install it later.
**If native/remote desktop in kitty:**
Great — they get the best experience. Just need `allow_remote_control yes` in kitty.conf.
**If no tmux and no kitty:**
Recommend installing tmux (simplest) or kitty (best experience). Plain backend works but has no split panes.
## Step 4: Execute Setup
Based on what the user chose:
1. **Create project directory:**
```bash
mkdir -p ~/Kitty-Workbench
```
2. **If kitty backend chosen**, ensure remote control is enabled:
```bash
# Check if already configured
grep -q "allow_remote_control" ~/.config/kitty/kitty.conf 2>/dev/null
```
If not configured, add:
```bash
mkdir -p ~/.config/kitty
echo "allow_remote_control yes" >> ~/.config/kitty/kitty.conf
```
3. **Optional: install chafa** for ASCII art image fallback:
- Linux: `sudo apt install chafa` or `sudo pacman -S chafa`
- macOS: `brew install chafa`
## Step 5: Configure MCP
Add the Kitty-Workbench MCP server to the user's AI CLI configuration.
**Detect which CLI they're using** by checking for config files:
```bash
ls ~/.claude/settings.json 2>/dev/null && echo "Claude Code detected"
ls ~/.gemini/settings.json 2>/dev/null && echo "Gemini CLI detected"
ls ~/.config/gemini/settings.json 2>/dev/null && echo "Gemini CLI detected (alt path)"
```
**For Claude Code**, add to `~/.claude/settings.json` under `mcpServers`:
```json
{
"mcpServers": {
"kitty-workbench": {
"command": "kitty-workbench",
"args": ["mcp"]
}
}
}
```
**For Gemini CLI**, add to the appropriate MCP config.
**For other/unknown CLIs**, print the MCP configuration JSON and tell the user to add it to their CLI's MCP settings. The server command is `kitty-workbench mcp` on stdio transport.
## Step 6: Smoke Test
Verify everything works:
```bash
# Check the CLI works
kitty-workbench list
# Check the project directory exists
ls ~/Kitty-Workbench/
```
If the user is in tmux or kitty right now, you can also test the full flow:
- Call `kitt_open` with a test project
- Verify the TUI pane appears
- Call `kitt_close` to clean up
If not in the right terminal environment, skip the live test — it will work when they start a session later.
## Step 7: Write START.md
Write `~/Kitty-Workbench/START.md` — a personalized startup guide for this user's environment.
Include:
- Setup date
- Detected backend and image protocol
- Platform details
- MCP configuration that was applied (which CLI, what config)
- How to start a session (step by step for their specific setup)
- What optional packages are installed
- How to upgrade to a better backend later (if applicable)
Example:
```markdown
# Kitty-Workbench — Start Guide
**Setup date:** 2026-03-29
**Backend:** tmux
**Image protocol:** sixel
**Platform:** Linux (Ubuntu 24.04)
## Starting a Session
1. Open a tmux session (or use an existing one)
2. Start your AI CLI
3. Tell the AI to open a Kitty-Workbench project — it will call `kitt_open` and the display pane appears as a tmux split
## MCP Configuration
Configured in `~/.claude/settings.json`:
```json
{
"mcpServers": {
"kitty-workbench": {
"command": "kitty-workbench",
"args": ["mcp"]
}
}
}
```
## Environment
- Python: 3.11.4
- Textual: 0.79.0
- tmux: 3.4
- sixel: supported (GNOME Terminal 46)
- chafa: installed
## Upgrading
To switch to kitty backend later:
1. Install kitty
2. Add `allow_remote_control yes` to `~/.config/kitty/kitty.conf`
3. Launch your AI CLI from within kitty
4. Backend auto-detects — no config change needed
```
Adapt the content to match what you actually detected and configured. This file is for future AI sessions to read, so be precise.
## Done
Tell the user setup is complete and how to start their first session.
+165
View File
@@ -0,0 +1,165 @@
# Kitty-Workbench
An MCP server that gives your AI a rich, interactive display panel — right in your terminal.
You talk to the AI in one pane. It controls the other: pushing schematics, checklists, measurement tables, images, and live logs. You can interact back — check items off, input readings, click buttons. The AI sees your responses.
```
┌───────────────────────────┬──────────────────────────────────┐
│ AI CLI │ Kitty-Workbench display │
│ │ │
│ │ ┌────────┬───────────────────┐ │
│ > diagnose the HV │ │ CRT │ ☑ Check R412 │ │
│ focus circuit │ │ Focus │ ☐ Check R413 │ │
│ │ │ Schem │ ☐ Check C201 │ │
│ Measuring R412... │ │ │ │ │
│ Value: 1.05M ohm │ │ │ Voltage: [4.72] │ │
│ Expected: 910K │ │ │ [Record] │ │
│ Drifted +16.7% — FAIL │ ├────────┴───────────────────┤ │
│ │ │ 14:32 R412: 1.05M — FAIL │ │
│ │ │ 14:33 Replacing R412... │ │
└───────────────────────────┴──────────────────────────────────┘
```
## Setup
Give your AI the repo URL and tell it to set you up:
```
Clone https://github.com/yourorg/kitty-workbench and set it up for me
```
Or if you've already cloned it, open your AI CLI in the repo and say:
```
Read INSTALL.md and set me up
```
The AI clones the repo (if needed), installs the package, detects your terminal environment, walks you through your options, configures the MCP server, and writes a personalized `START.md` for future sessions.
That's it. The AI handles everything.
## What It Does
Kitty-Workbench is an [MCP server](https://modelcontextprotocol.io). Once configured, your AI CLI has access to these tools:
| Tool | What it does |
|------|-------------|
| `kitt_open` | Creates a project and opens the display pane |
| `kitt_display` | Pushes content — markdown, tables, checklists, buttons, text inputs |
| `kitt_image` | Displays images (schematics, photos, diagrams) |
| `kitt_layout` | Configures multi-region layouts (main + sidebar + log) |
| `kitt_log` | Records session log entries (saved to disk + shown in display) |
| `kitt_events` | Reads your interactions (checkbox toggles, button clicks, text input) |
| `kitt_close` | Closes the display pane and ends the session |
The AI generates all content. Kitty-Workbench is just the plumbing.
## Terminal Support
Kitty-Workbench auto-detects your terminal environment and adapts:
| Backend | Pane splitting | Image quality |
|---------|---------------|---------------|
| **kitty** | Native splits | Best (kitty graphics protocol) |
| **tmux** | tmux splits | Good (sixel, if your terminal supports it) |
| **Other** | Separate window | Good (sixel) or basic (ASCII art) |
You don't need kitty. You don't need tmux. Kitty-Workbench works in any terminal — it just gets better with more capable ones. The AI-guided setup explains your options and what each gives you.
### Remote Access
How you're connected to the machine running the AI matters:
| Access method | What works | What doesn't |
|---------------|-----------|-------------|
| **Native** (sitting at the machine) | Everything | — |
| **Remote desktop** (RDP, VNC, etc.) | Everything — same as native | — |
| **SSH** | **tmux only** | kitty splits, new windows |
**If you work over SSH, use tmux.** Here's why:
- **tmux works** because it runs on the same host as the AI. The AI calls `tmux split-window` and tmux creates the pane on the remote host. You see it through your SSH session. No special setup.
- **kitty splits don't work** because `kitty @` controls the kitty instance via a local Unix socket. When the AI runs on a remote host, it can't reach your local kitty. (Workaround: `kitten ssh` forwards the control protocol, but requires users to connect with `kitten ssh` instead of `ssh`.)
- **The "plain" backend can't open a window** on your local machine from a remote host. You'd have to manually open a second terminal and run the TUI yourself.
**Images over SSH:** Sixel and kitty graphics sequences are just terminal escape codes — they travel over SSH like any other output. Most modern terminals and SSH clients pass them through fine. If images don't render, your local terminal may not support the protocol, or your SSH config may be stripping escape sequences.
### Image Rendering
| Protocol | Quality | Terminals |
|----------|---------|-----------|
| Kitty graphics | Full color, alpha, animation | Kitty |
| Sixel | 256+ colors | GNOME Terminal 46+, Windows Terminal, iTerm2, foot, WezTerm, xterm |
| ASCII art | Block characters | Everything |
## Usage
Once set up, just talk to your AI. It decides when to use the display.
**Hardware diagnostic:**
> "I need to troubleshoot the focus circuit on this oscilloscope. Here's the service manual."
**Guided procedure:**
> "Walk me through replacing the capacitors on this PCB. Show me the layout and a checklist."
**Data collection:**
> "I need to measure and record voltages at 12 test points on this power supply."
The AI opens the display pane, builds the interface, and updates it as you work. Session logs are saved to disk in human-readable markdown — anyone can follow what happened without AI access.
## Project Data
Each session creates a project in `~/Kitty-Workbench/`:
```
~/Kitty-Workbench/
START.md # Personalized startup guide (created during setup)
io102/
session.md # Human-readable session log
session.jsonl # Machine-readable log (for AI session resume)
cost-log.jsonl # Session tracking
assets/ # Images, manuals, datasheets
```
Projects persist. Ask the AI to reopen one and it picks up where you left off.
## Requirements
- Python 3.10+
- Any terminal
- Any MCP-compatible AI CLI
## Platform Support
| Platform | Status |
|----------|--------|
| Linux | Full support |
| macOS | Full support |
| Windows | Via WSL2 |
## FAQ
**Do I need kitty installed?**
No. It works in any terminal. Kitty gives the best image quality, but tmux with sixel is excellent. The setup process will explain your options.
**Does it work in tmux?**
Yes. The TUI framework (Textual) works fully in tmux. Images render via sixel if your underlying terminal supports it.
**I SSH into a remote machine to work. What do I need?**
Use tmux on the remote host. It's the only backend that works reliably over SSH. The AI and tmux are on the same machine, so pane splitting works. Images travel over SSH as escape codes — if your local terminal supports sixel, you'll see them.
**Can I use kitty on my local machine to control a remote AI?**
Not easily. `kitty @` uses a local socket that the remote host can't reach. You can work around this with `kitten ssh` (which forwards kitty's control protocol), but tmux is simpler.
**Can I resume a previous session?**
Yes. Projects persist on disk. Ask the AI to open an existing project — it reads the session log to catch up.
**What AI CLIs work with this?**
Any that supports the [Model Context Protocol](https://modelcontextprotocol.io) (MCP) over stdio transport.
## License
MIT
+2 -2
View File
@@ -1,6 +1,6 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
requires = ["setuptools>=68.0", "setuptools-scm"]
build-backend = "setuptools.build_meta"
[project]
name = "kitty-workbench"
+31
View File
@@ -0,0 +1,31 @@
"""Terminal backend abstraction — detect and adapt to kitty, tmux, or plain terminal."""
from __future__ import annotations
import os
from abc import ABC, abstractmethod
from typing import Union
class Backend(ABC):
@abstractmethod
def launch_pane(self, command: list[str], title: str) -> Union[int, str]:
"""Split the terminal and run command in new pane. Returns pane ID."""
@abstractmethod
def close_pane(self, pane_id: Union[int, str]) -> None:
"""Close the pane."""
@abstractmethod
def image_protocol(self) -> str:
"""Return supported image protocol: 'kitty', 'sixel', or 'none'."""
@property
@abstractmethod
def name(self) -> str:
"""Backend name for display/logging."""
def detect_backend() -> Backend:
if os.environ.get("KITTY_PID"):
from kitty_workbench.backends.kitty import KittyBackend
return KittyBackend()
elif os.environ.get("TMUX"):
from kitty_workbench.backends.tmux import TmuxBackend
return TmuxBackend()
else:
from kitty_workbench.backends.plain import PlainBackend
return PlainBackend()
+18
View File
@@ -0,0 +1,18 @@
"""Kitty terminal backend — native splits via kitty @ remote control."""
from __future__ import annotations
import subprocess
from typing import Union
from kitty_workbench.backends import Backend
class KittyBackend(Backend):
name = "kitty"
def launch_pane(self, command: list[str], title: str) -> int:
result = subprocess.run(
["kitty", "@", "launch", "--location=vsplit", "--title", title] + command,
capture_output=True, text=True,
)
return int(result.stdout.strip())
def close_pane(self, pane_id: Union[int, str]) -> None:
subprocess.run(["kitty", "@", "close-window", f"--match=id:{pane_id}"], capture_output=True)
def image_protocol(self) -> str:
return "kitty"
+38
View File
@@ -0,0 +1,38 @@
"""Plain terminal backend — opens a new terminal window."""
from __future__ import annotations
import os
import platform
import shutil
import subprocess
from typing import Union
from kitty_workbench.backends import Backend
class PlainBackend(Backend):
name = "plain"
def launch_pane(self, command: list[str], title: str) -> str:
terminal = os.environ.get("TERMINAL")
if terminal and shutil.which(terminal):
proc = subprocess.Popen([terminal, "-e"] + command)
return str(proc.pid)
system = platform.system()
if system == "Darwin":
proc = subprocess.Popen(["open", "-a", "Terminal.app", command[0], "--args"] + command[1:])
return str(proc.pid)
for term_cmd in ["x-terminal-emulator", "gnome-terminal", "konsole", "xterm"]:
if shutil.which(term_cmd):
if term_cmd == "gnome-terminal":
proc = subprocess.Popen([term_cmd, "--", *command])
else:
proc = subprocess.Popen([term_cmd, "-e", *command])
return str(proc.pid)
raise RuntimeError(f"Could not find a terminal emulator. Please run manually:\n {' '.join(command)}")
def close_pane(self, pane_id: Union[int, str]) -> None:
pass
def image_protocol(self) -> str:
term = os.environ.get("TERM_PROGRAM", "")
if term in {"iTerm.app", "iTerm2", "WezTerm", "foot", "contour", "xterm", "mlterm", "mintty"}:
return "sixel"
vte = os.environ.get("VTE_VERSION", "")
if vte and int(vte) >= 7600:
return "sixel"
return "none"
+27
View File
@@ -0,0 +1,27 @@
"""Tmux backend — pane splits via tmux split-window."""
from __future__ import annotations
import os
import subprocess
from typing import Union
from kitty_workbench.backends import Backend
SIXEL_TERMINALS = {"iTerm.app", "iTerm2", "WezTerm", "foot", "contour", "xterm", "mlterm", "mintty"}
class TmuxBackend(Backend):
name = "tmux"
def launch_pane(self, command: list[str], title: str) -> str:
result = subprocess.run(
["tmux", "split-window", "-h", "-d", "-P", "-F", "#{pane_id}"] + command,
capture_output=True, text=True,
)
return result.stdout.strip()
def close_pane(self, pane_id: Union[int, str]) -> None:
subprocess.run(["tmux", "kill-pane", "-t", str(pane_id)], capture_output=True)
def image_protocol(self) -> str:
term = os.environ.get("TERM_PROGRAM", "")
if term in SIXEL_TERMINALS:
return "sixel"
vte = os.environ.get("VTE_VERSION", "")
if vte and int(vte) >= 7600:
return "sixel"
return "none"
+88
View File
@@ -0,0 +1,88 @@
"""CLI entry point for kitty-workbench."""
from __future__ import annotations
import argparse
import sys
from kitty_workbench import __version__
def cmd_mcp(args):
"""Start the MCP server on stdio."""
from kitty_workbench.server import create_mcp_server
mcp = create_mcp_server()
mcp.run(transport="stdio")
def cmd_tui(args):
"""Launch the Textual TUI app (called by backends, not typically user-facing)."""
from kitty_workbench.tui import KittWorkbenchApp
app = KittWorkbenchApp(socket_path=args.socket)
app.run()
def cmd_list(args):
"""List all projects."""
from kitty_workbench.project import list_projects
projects = list_projects()
if not projects:
print("No projects found in ~/Kitty-Workbench/")
return
for p in projects:
print(f" {p['name']}")
def cmd_open(args):
"""Open a project display (standalone, no MCP)."""
import asyncio
from kitty_workbench.server import KittWorkbenchServer
srv = KittWorkbenchServer()
async def run():
await srv.kitt_open(args.name, args.name)
try:
while True:
await asyncio.sleep(1)
except (KeyboardInterrupt, asyncio.CancelledError):
await srv.kitt_close(args.name)
asyncio.run(run())
def main():
parser = argparse.ArgumentParser(
prog="kitty-workbench",
description="MCP server for AI-driven terminal display panels",
)
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
sub = parser.add_subparsers(dest="command")
sub.add_parser("mcp", help="Start MCP server (stdio transport)")
tui_parser = sub.add_parser("tui", help="Launch TUI app (internal)")
tui_parser.add_argument("name", help="Project name")
tui_parser.add_argument("--socket", required=True, help="Unix socket path")
sub.add_parser("list", help="List all projects")
open_parser = sub.add_parser("open", help="Open display for existing project")
open_parser.add_argument("name", help="Project name")
sub.add_parser("help", help="Show help")
args = parser.parse_args()
commands = {
"mcp": cmd_mcp,
"tui": cmd_tui,
"list": cmd_list,
"open": cmd_open,
"help": lambda a: parser.print_help(),
}
if args.command is None:
parser.print_help()
sys.exit(0)
commands[args.command](args)
+76
View File
@@ -0,0 +1,76 @@
"""Image rendering abstraction — kitty graphics, sixel, or ASCII art."""
from __future__ import annotations
import base64
import shutil
import subprocess
import sys
from pathlib import Path
def render_image_kitty(path: str) -> str:
"""Return kitty graphics protocol escape sequence for the image."""
data = Path(path).read_bytes()
b64 = base64.standard_b64encode(data).decode()
chunks = [b64[i:i + 4096] for i in range(0, len(b64), 4096)]
escape = ""
for i, chunk in enumerate(chunks):
m = 1 if i < len(chunks) - 1 else 0
if i == 0:
escape += f"\033_Ga=T,f=100,m={m};{chunk}\033\\"
else:
escape += f"\033_Gm={m};{chunk}\033\\"
return escape
def render_image_sixel(path: str) -> str:
"""Convert image to sixel using chafa or img2sixel."""
if shutil.which("chafa"):
result = subprocess.run(
["chafa", "--format=sixel", "--size=80x40", str(path)],
capture_output=True, text=True,
)
if result.returncode == 0:
return result.stdout
if shutil.which("img2sixel"):
result = subprocess.run(
["img2sixel", "-w", "640", str(path)],
capture_output=True, text=True,
)
if result.returncode == 0:
return result.stdout
return f"[Sixel unavailable — install chafa or libsixel. Image: {path}]"
def render_image_ascii(path: str) -> str:
"""Convert image to ASCII/Unicode block art using chafa."""
if shutil.which("chafa"):
result = subprocess.run(
["chafa", "--size=60x30", str(path)],
capture_output=True, text=True,
)
if result.returncode == 0:
return result.stdout
return f"[Image: {path}]"
def render_image(path: str, protocol: str) -> str:
"""Render an image using the specified protocol. Returns terminal-ready string."""
p = Path(path)
if p.suffix.lower() == ".svg":
try:
import cairosvg
png_path = p.with_suffix(".png")
cairosvg.svg2png(url=str(p), write_to=str(png_path))
path = str(png_path)
except ImportError:
return f"[SVG display requires cairosvg: pip install cairosvg. File: {path}]"
if protocol == "kitty":
return render_image_kitty(path)
elif protocol == "sixel":
return render_image_sixel(path)
else:
return render_image_ascii(path)
+111
View File
@@ -0,0 +1,111 @@
"""Project directory management — create, log, read, list."""
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
DEFAULT_WORKBENCH_DIR = Path.home() / "Kitty-Workbench"
def _now_iso() -> str:
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
def project_path(name: str, workbench_dir: Path = DEFAULT_WORKBENCH_DIR) -> Path:
return workbench_dir / name
def project_exists(name: str, workbench_dir: Path = DEFAULT_WORKBENCH_DIR) -> bool:
return project_path(name, workbench_dir).is_dir()
def create_project(
name: str, title: str, workbench_dir: Path = DEFAULT_WORKBENCH_DIR
) -> Path:
"""Create a project directory with log files. Idempotent — won't overwrite existing logs."""
pdir = project_path(name, workbench_dir)
pdir.mkdir(parents=True, exist_ok=True)
(pdir / "assets").mkdir(exist_ok=True)
md_path = pdir / "session.md"
if not md_path.exists():
md_path.write_text(f"# {title} — Session Log\n\nStarted: {_now_iso()}\n\n")
jsonl_path = pdir / "session.jsonl"
if not jsonl_path.exists():
jsonl_path.write_text("")
cost_path = pdir / "cost-log.jsonl"
if not cost_path.exists():
cost_path.write_text("")
return pdir
def append_log(
name: str,
entry: str,
data: Optional[dict] = None,
level: str = "info",
workbench_dir: Path = DEFAULT_WORKBENCH_DIR,
) -> dict:
"""Append a log entry to session.md and session.jsonl. Returns the entry dict."""
pdir = project_path(name, workbench_dir)
ts = _now_iso()
with open(pdir / "session.md", "a") as f:
f.write(f"\n### {ts}\n{entry}\n")
obj = {"ts": ts, "entry": entry, "level": level}
if data:
obj.update(data)
with open(pdir / "session.jsonl", "a") as f:
f.write(json.dumps(obj) + "\n")
return obj
def read_log(
name: str, tail: int = 20, workbench_dir: Path = DEFAULT_WORKBENCH_DIR
) -> list[dict]:
"""Read recent log entries from session.jsonl."""
jsonl_path = project_path(name, workbench_dir) / "session.jsonl"
if not jsonl_path.exists():
return []
lines = jsonl_path.read_text().strip().split("\n")
lines = [l for l in lines if l.strip()]
recent = lines[-tail:] if len(lines) > tail else lines
entries = []
for line in recent:
try:
entries.append(json.loads(line))
except json.JSONDecodeError:
entries.append({"raw": line})
return entries
def list_projects(workbench_dir: Path = DEFAULT_WORKBENCH_DIR) -> list[dict]:
"""List all project directories."""
if not workbench_dir.exists():
return []
projects = []
for d in sorted(workbench_dir.iterdir()):
if d.is_dir() and not d.name.startswith(".") and d.name != "START.md":
projects.append({"name": d.name})
return projects
def log_session_event(
name: str, event: str, workbench_dir: Path = DEFAULT_WORKBENCH_DIR, **extra
) -> None:
"""Append to cost-log.jsonl."""
pdir = project_path(name, workbench_dir)
obj = {"ts": _now_iso(), "event": event, "project": name}
obj.update(extra)
with open(pdir / "cost-log.jsonl", "a") as f:
f.write(json.dumps(obj) + "\n")
+135
View File
@@ -0,0 +1,135 @@
"""JSON-lines protocol for server ↔ TUI communication."""
from __future__ import annotations
import json
from dataclasses import dataclass, field, asdict
from typing import Optional
# --- Server → TUI Commands ---
@dataclass
class InitCmd:
project: str
title: str
image_protocol: str
description: str = ""
cmd: str = field(default="init", init=False)
@dataclass
class DisplayCmd:
widget: str
pane: str = "main"
clear: bool = False
content: Optional[str] = None
items: Optional[list] = None
id: Optional[str] = None
label: Optional[str] = None
placeholder: Optional[str] = None
cmd: str = field(default="display", init=False)
@dataclass
class ImageCmd:
path: str
pane: str = "main"
clear: bool = True
cmd: str = field(default="image", init=False)
@dataclass
class LogCmd:
entry: str
level: str = "info"
cmd: str = field(default="log", init=False)
@dataclass
class ClearCmd:
pane: str = "main"
cmd: str = field(default="clear", init=False)
@dataclass
class LayoutCmd:
panes: dict = field(default_factory=dict)
cmd: str = field(default="layout", init=False)
@dataclass
class NotifyCmd:
message: str
level: str = "info"
cmd: str = field(default="notify", init=False)
@dataclass
class ShutdownCmd:
cmd: str = field(default="shutdown", init=False)
# --- TUI → Server Events ---
@dataclass
class ReadyEvent:
event: str = field(default="ready", init=False)
@dataclass
class ChecklistToggleEvent:
pane: str
index: int
label: str
checked: bool
event: str = field(default="checklist_toggle", init=False)
@dataclass
class ButtonClickEvent:
pane: str
id: str
event: str = field(default="button_click", init=False)
@dataclass
class InputSubmitEvent:
pane: str
id: str
value: str
event: str = field(default="input_submit", init=False)
# --- Registry for decoding ---
_CMD_TYPES = {
"init": InitCmd,
"display": DisplayCmd,
"image": ImageCmd,
"log": LogCmd,
"clear": ClearCmd,
"layout": LayoutCmd,
"notify": NotifyCmd,
"shutdown": ShutdownCmd,
}
_EVENT_TYPES = {
"ready": ReadyEvent,
"checklist_toggle": ChecklistToggleEvent,
"button_click": ButtonClickEvent,
"input_submit": InputSubmitEvent,
}
def encode_message(msg) -> str:
"""Serialize a command or event dataclass to a JSON line (no trailing newline)."""
return json.dumps(asdict(msg), separators=(",", ":"))
def decode_message(line: str):
"""Deserialize a JSON line to a command or event dataclass. Returns None if unknown."""
data = json.loads(line)
if "cmd" in data:
cls = _CMD_TYPES.get(data["cmd"])
if cls is None:
return None
kwargs = {k: v for k, v in data.items() if k != "cmd"}
return cls(**kwargs)
elif "event" in data:
cls = _EVENT_TYPES.get(data["event"])
if cls is None:
return None
kwargs = {k: v for k, v in data.items() if k != "event"}
return cls(**kwargs)
return None
+258
View File
@@ -0,0 +1,258 @@
"""MCP server — exposes kitt_* tools, manages socket connections to TUI apps."""
from __future__ import annotations
import asyncio
import json
import os
import sys
from pathlib import Path
from typing import Optional
from mcp.server.fastmcp import FastMCP
from kitty_workbench.backends import detect_backend, Backend
from kitty_workbench.project import (
create_project, project_exists, append_log, read_log,
list_projects, log_session_event, project_path,
)
from kitty_workbench.protocol import (
InitCmd, DisplayCmd, ImageCmd, LogCmd, ClearCmd, LayoutCmd,
ShutdownCmd, encode_message, decode_message, ReadyEvent,
)
DEFAULT_WORKBENCH_DIR = Path.home() / "Kitty-Workbench"
DEFAULT_SOCKET_DIR = "/tmp"
class KittWorkbenchServer:
"""Core server logic — testable without MCP transport."""
def __init__(self, workbench_dir: Path = DEFAULT_WORKBENCH_DIR, socket_dir: str = DEFAULT_SOCKET_DIR):
self.workbench_dir = Path(workbench_dir)
self.socket_dir = socket_dir
self.backend: Optional[Backend] = None
self._connections: dict[str, asyncio.StreamWriter] = {}
self._pane_ids: dict[str, object] = {}
self._event_queues: dict[str, list] = {}
self._socket_servers: dict[str, asyncio.AbstractServer] = {}
def _socket_path(self, name: str) -> str:
return os.path.join(self.socket_dir, f"kitt-{name}.sock")
async def _launch_tui(self, name: str, title: str) -> None:
"""Start socket server, launch TUI pane, wait for ready."""
if self.backend is None:
self.backend = detect_backend()
sock_path = self._socket_path(name)
if os.path.exists(sock_path):
os.unlink(sock_path)
ready_event = asyncio.Event()
async def handle_tui_connection(reader, writer):
self._connections[name] = writer
while True:
try:
line = await reader.readline()
if not line:
break
msg = decode_message(line.decode().strip())
if msg is None:
continue
if isinstance(msg, ReadyEvent):
init_cmd = InitCmd(
project=name, title=title,
image_protocol=self.backend.image_protocol(),
)
writer.write((encode_message(init_cmd) + "\n").encode())
await writer.drain()
ready_event.set()
else:
if name not in self._event_queues:
self._event_queues[name] = []
from dataclasses import asdict
self._event_queues[name].append(asdict(msg))
except (ConnectionResetError, asyncio.IncompleteReadError):
break
server = await asyncio.start_unix_server(handle_tui_connection, path=sock_path)
self._socket_servers[name] = server
command = [sys.executable, "-m", "kitty_workbench", "tui", name, "--socket", sock_path]
pane_id = self.backend.launch_pane(command, title)
self._pane_ids[name] = pane_id
try:
await asyncio.wait_for(ready_event.wait(), timeout=10.0)
except asyncio.TimeoutError:
raise RuntimeError(f"TUI did not connect within 10 seconds for project '{name}'")
async def _send_cmd(self, name: str, cmd) -> None:
writer = self._connections.get(name)
if writer is None:
return
writer.write((encode_message(cmd) + "\n").encode())
await writer.drain()
async def kitt_open(self, name: str, title: str, description: str = "") -> str:
if name in self._connections:
return json.dumps({
"project": name,
"backend": self.backend.name if self.backend else "unknown",
"status": "already_open",
})
create_project(name, title, workbench_dir=self.workbench_dir)
log_session_event(name, "session_start", workbench_dir=self.workbench_dir)
self._event_queues[name] = []
await self._launch_tui(name, title)
return json.dumps({
"project": name,
"backend": self.backend.name if self.backend else "unknown",
"image_support": self.backend.image_protocol() if self.backend else "none",
"status": "ready",
})
async def kitt_display(self, project: str, widget: str, content: str = "",
items: str = "", id: str = "", label: str = "",
placeholder: str = "", pane: str = "main", clear: bool = False) -> str:
if project not in self._connections:
return json.dumps({"error": f"Project '{project}' not open. Call kitt_open first."})
cmd = DisplayCmd(
widget=widget, pane=pane, clear=clear,
content=content or None,
items=json.loads(items) if items else None,
id=id or None, label=label or None, placeholder=placeholder or None,
)
await self._send_cmd(project, cmd)
return json.dumps({"ok": True})
async def kitt_image(self, project: str, path: str, pane: str = "main", clear: bool = True) -> str:
if project not in self._connections:
return json.dumps({"error": f"Project '{project}' not open."})
p = Path(path)
if not p.is_absolute():
p = project_path(project, self.workbench_dir) / "assets" / path
if not p.exists():
return json.dumps({"error": f"Image not found: {p}"})
cmd = ImageCmd(path=str(p), pane=pane, clear=clear)
await self._send_cmd(project, cmd)
return json.dumps({"ok": True, "path": str(p), "protocol": self.backend.image_protocol() if self.backend else "none"})
async def kitt_layout(self, project: str, panes: str) -> str:
if project not in self._connections:
return json.dumps({"error": f"Project '{project}' not open."})
panes_dict = json.loads(panes)
cmd = LayoutCmd(panes=panes_dict)
await self._send_cmd(project, cmd)
return json.dumps({"ok": True, "panes": list(panes_dict.keys())})
async def kitt_log(self, project: str, entry: str, data: str = "{}", level: str = "info") -> str:
if not project_exists(project, workbench_dir=self.workbench_dir):
return json.dumps({"error": f"Project '{project}' not found."})
data_dict = json.loads(data) if data and data != "{}" else None
append_log(project, entry, data=data_dict, level=level, workbench_dir=self.workbench_dir)
if project in self._connections:
cmd = LogCmd(entry=entry, level=level)
await self._send_cmd(project, cmd)
return json.dumps({"ok": True})
async def kitt_events(self, project: str) -> str:
events = self._event_queues.get(project, [])
self._event_queues[project] = []
return json.dumps({"events": events})
async def kitt_read_log(self, project: str, tail: int = 20) -> str:
if not project_exists(project, workbench_dir=self.workbench_dir):
return json.dumps({"error": f"Project '{project}' not found."})
entries = read_log(project, tail=tail, workbench_dir=self.workbench_dir)
return json.dumps({"entries": entries})
async def kitt_list(self) -> str:
projects = list_projects(workbench_dir=self.workbench_dir)
for p in projects:
p["active"] = p["name"] in self._connections
return json.dumps({"projects": projects})
async def kitt_close(self, project: str) -> str:
if project not in self._connections and project not in self._pane_ids:
return json.dumps({"error": f"Project '{project}' is not open."})
if project_exists(project, workbench_dir=self.workbench_dir):
entries = read_log(project, tail=999999, workbench_dir=self.workbench_dir)
log_session_event(project, "session_end", workbench_dir=self.workbench_dir, log_entries=len(entries))
if project in self._connections:
try:
await self._send_cmd(project, ShutdownCmd())
except Exception:
pass
del self._connections[project]
if project in self._pane_ids and self.backend:
try:
self.backend.close_pane(self._pane_ids[project])
except Exception:
pass
del self._pane_ids[project]
if project in self._socket_servers:
self._socket_servers[project].close()
del self._socket_servers[project]
sock_path = self._socket_path(project)
if os.path.exists(sock_path):
os.unlink(sock_path)
self._event_queues.pop(project, None)
return json.dumps({"ok": True})
def create_mcp_server(workbench_dir: Path = DEFAULT_WORKBENCH_DIR, socket_dir: str = DEFAULT_SOCKET_DIR) -> FastMCP:
"""Create the FastMCP instance with all kitt_* tools registered."""
srv = KittWorkbenchServer(workbench_dir=workbench_dir, socket_dir=socket_dir)
mcp = FastMCP("kitty-workbench", instructions="Kitty-Workbench — interactive terminal display panel for AI-driven diagnostics. Call kitt_open first to start.")
@mcp.tool()
async def kitt_open(name: str, title: str, description: str = "") -> str:
"""Create a new Kitty-Workbench project and open the interactive display pane. Call this first before using any other kitt_ tools. A new pane appears next to the AI CLI — as a split in kitty or tmux, or as a separate terminal window."""
return await srv.kitt_open(name, title, description)
@mcp.tool()
async def kitt_display(project: str, widget: str, content: str = "", items: str = "",
id: str = "", label: str = "", placeholder: str = "",
pane: str = "main", clear: bool = False) -> str:
"""Push content to the display pane. Widget types: 'markdown' (rendered text), 'table' (columns+rows as JSON in content), 'checklist' (items as JSON array), 'button' (needs id+label), 'input' (needs id+placeholder). Use kitt_events to read user interactions."""
return await srv.kitt_display(project, widget, content, items, id, label, placeholder, pane, clear)
@mcp.tool()
async def kitt_image(project: str, path: str, pane: str = "main", clear: bool = True) -> str:
"""Display an image in the display pane. Supports PNG, JPG, GIF. Use absolute path or relative to project assets/ dir. Image quality depends on terminal (kitty > sixel > ASCII art)."""
return await srv.kitt_image(project, path, pane, clear)
@mcp.tool()
async def kitt_layout(project: str, panes: str) -> str:
"""Change the display layout. Pass a JSON object with region names and options. Example: {"main": {"ratio": 2}, "sidebar": {"ratio": 1, "position": "right"}, "log": {"ratio": 1, "position": "bottom"}}"""
return await srv.kitt_layout(project, panes)
@mcp.tool()
async def kitt_log(project: str, entry: str, data: str = "{}", level: str = "info") -> str:
"""Record a diagnostic log entry. Saved to disk and shown in log pane if it exists. Levels: info, warning, error, success."""
return await srv.kitt_log(project, entry, data, level)
@mcp.tool()
async def kitt_events(project: str) -> str:
"""Read user interactions from the display pane — checklist toggles, button clicks, text input submissions. Returns all events since last call, then clears the queue."""
return await srv.kitt_events(project)
@mcp.tool()
async def kitt_read_log(project: str, tail: int = 20) -> str:
"""Read recent session log entries from disk. Use this to resume a previous session."""
return await srv.kitt_read_log(project, tail)
@mcp.tool()
async def kitt_list() -> str:
"""List all Kitty-Workbench projects and whether their display pane is currently open."""
return await srv.kitt_list()
@mcp.tool()
async def kitt_close(project: str) -> str:
"""Close the display pane and end the session. Logs session end to cost-log.jsonl."""
return await srv.kitt_close(project)
return mcp
+242
View File
@@ -0,0 +1,242 @@
"""Textual TUI application — the display pane."""
from __future__ import annotations
import asyncio
import json
import sys
from pathlib import Path
from textual.app import App, ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import (
Static, Markdown, DataTable, Button, Input, Header, Footer,
Checkbox, ListView, ListItem, RichLog, Label,
)
from textual.css.query import NoMatches
from kitty_workbench.protocol import (
decode_message, encode_message, ReadyEvent,
DisplayCmd, ImageCmd, LogCmd, ClearCmd, LayoutCmd, InitCmd,
NotifyCmd, ShutdownCmd,
ChecklistToggleEvent, ButtonClickEvent, InputSubmitEvent,
)
class KittWorkbenchApp(App):
"""Kitty-Workbench display TUI."""
CSS = """
Screen {
layout: grid;
grid-size: 1 1;
}
#main {
overflow-y: auto;
}
#sidebar {
overflow-y: auto;
display: none;
}
#log {
display: none;
border-top: solid $accent;
}
#status-bar {
dock: bottom;
height: 1;
background: $surface;
color: $text-muted;
padding: 0 1;
}
.log-info { color: $text; }
.log-warning { color: yellow; }
.log-error { color: red; }
.log-success { color: green; }
"""
def __init__(self, socket_path: str, **kwargs):
super().__init__(**kwargs)
self.socket_path = socket_path
self._writer: asyncio.StreamWriter | None = None
self._project = ""
self._image_protocol = "none"
def compose(self) -> ComposeResult:
yield Container(id="main")
yield Container(id="sidebar")
yield RichLog(id="log", wrap=True, markup=True)
yield Static("Connecting...", id="status-bar")
async def on_mount(self) -> None:
self.run_worker(self._connect_socket(), exclusive=True)
async def _connect_socket(self) -> None:
try:
reader, writer = await asyncio.open_unix_connection(self.socket_path)
self._writer = writer
writer.write((encode_message(ReadyEvent()) + "\n").encode())
await writer.drain()
self.query_one("#status-bar", Static).update("Connected")
while True:
line = await reader.readline()
if not line:
break
msg = decode_message(line.decode().strip())
if msg is not None:
self.handle_command(msg)
except Exception as e:
self.query_one("#status-bar", Static).update(f"Error: {e}")
def handle_command(self, cmd) -> None:
if isinstance(cmd, InitCmd):
self._project = cmd.project
self._image_protocol = cmd.image_protocol
self.title = cmd.title
self.query_one("#status-bar", Static).update(
f"{self._project}{self._image_protocol}"
)
elif isinstance(cmd, DisplayCmd):
self._handle_display(cmd)
elif isinstance(cmd, ImageCmd):
self._handle_image(cmd)
elif isinstance(cmd, LogCmd):
self._handle_log(cmd)
elif isinstance(cmd, ClearCmd):
self._handle_clear(cmd)
elif isinstance(cmd, LayoutCmd):
self._handle_layout(cmd)
elif isinstance(cmd, NotifyCmd):
self.notify(cmd.message, severity=cmd.level)
elif isinstance(cmd, ShutdownCmd):
self.exit()
def _get_pane(self, pane_name: str) -> Container | RichLog:
try:
return self.query_one(f"#{pane_name}")
except NoMatches:
return self.query_one("#main")
def _handle_display(self, cmd: DisplayCmd) -> None:
pane = self._get_pane(cmd.pane)
if cmd.clear and not isinstance(pane, RichLog):
pane.remove_children()
if cmd.widget == "markdown":
pane.mount(Markdown(cmd.content or ""))
elif cmd.widget == "table":
data = json.loads(cmd.content) if cmd.content else {"columns": [], "rows": []}
table = DataTable()
for col in data.get("columns", []):
table.add_column(col, key=col)
for row in data.get("rows", []):
table.add_row(*row)
pane.mount(table)
elif cmd.widget == "checklist":
items = cmd.items or []
lv = ListView(id=f"checklist-{cmd.pane}")
for i, item in enumerate(items):
cb = Checkbox(item["label"], value=item.get("checked", False), id=f"check-{cmd.pane}-{i}")
lv.append(ListItem(cb))
pane.mount(lv)
elif cmd.widget == "button":
btn = Button(cmd.label or cmd.id or "Button", id=f"btn-{cmd.id}")
pane.mount(btn)
elif cmd.widget == "input":
inp = Input(placeholder=cmd.placeholder or "", id=f"input-{cmd.id}")
pane.mount(inp)
def _handle_image(self, cmd: ImageCmd) -> None:
pane = self._get_pane(cmd.pane)
if cmd.clear and not isinstance(pane, RichLog):
pane.remove_children()
from kitty_workbench.image_renderer import render_image
rendered = render_image(cmd.path, self._image_protocol)
pane.mount(Static(rendered, markup=False))
def _handle_log(self, cmd: LogCmd) -> None:
try:
log_widget = self.query_one("#log", RichLog)
except NoMatches:
return
log_widget.write(f"[{cmd.level.upper()}] {cmd.entry}")
def _handle_clear(self, cmd: ClearCmd) -> None:
pane = self._get_pane(cmd.pane)
if isinstance(pane, RichLog):
pane.clear()
else:
pane.remove_children()
def _handle_layout(self, cmd: LayoutCmd) -> None:
pane_names = set(cmd.panes.keys())
for name in ("main", "sidebar", "log"):
try:
widget = self.query_one(f"#{name}")
if name in pane_names:
widget.styles.display = "block"
else:
widget.styles.display = "none"
except NoMatches:
pass
has_sidebar = "sidebar" in pane_names
has_log = "log" in pane_names
cols = 2 if has_sidebar else 1
rows = 2 if has_log else 1
self.screen.styles.grid_size_columns = cols
self.screen.styles.grid_size_rows = rows
if has_sidebar:
main_ratio = cmd.panes.get("main", {}).get("ratio", 2)
side_ratio = cmd.panes.get("sidebar", {}).get("ratio", 1)
self.screen.styles.grid_columns = f"{main_ratio}fr {side_ratio}fr"
if has_log:
content_ratio = 3
log_ratio = cmd.panes.get("log", {}).get("ratio", 1)
self.screen.styles.grid_rows = f"{content_ratio}fr {log_ratio}fr"
async def _send_event(self, evt) -> None:
if self._writer:
self._writer.write((encode_message(evt) + "\n").encode())
await self._writer.drain()
def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
cb = event.checkbox
parts = cb.id.split("-") if cb.id else []
if len(parts) >= 3:
pane = parts[1]
index = int(parts[2])
evt = ChecklistToggleEvent(pane=pane, index=index, label=str(cb.label), checked=cb.value)
self.run_worker(self._send_event(evt))
def on_button_pressed(self, event: Button.Pressed) -> None:
btn = event.button
btn_id = btn.id.removeprefix("btn-") if btn.id else ""
pane = "main"
for parent in btn.ancestors:
if hasattr(parent, "id") and parent.id in ("main", "sidebar", "log"):
pane = parent.id
break
evt = ButtonClickEvent(pane=pane, id=btn_id)
self.run_worker(self._send_event(evt))
def on_input_submitted(self, event: Input.Submitted) -> None:
inp = event.input
inp_id = inp.id.removeprefix("input-") if inp.id else ""
pane = "main"
for parent in inp.ancestors:
if hasattr(parent, "id") and parent.id in ("main", "sidebar", "log"):
pane = parent.id
break
evt = InputSubmitEvent(pane=pane, id=inp_id, value=event.value)
self.run_worker(self._send_event(evt))
+13
View File
@@ -0,0 +1,13 @@
import pytest
@pytest.fixture
def tmp_workbench(tmp_path):
"""Provide a temporary ~/Kitty-Workbench directory."""
wb = tmp_path / "Kitty-Workbench"
wb.mkdir()
return wb
@pytest.fixture
def socket_path(tmp_path):
"""Provide a temporary socket path."""
return str(tmp_path / "test.sock")
+61
View File
@@ -0,0 +1,61 @@
import os
from unittest.mock import patch, MagicMock
from kitty_workbench.backends import detect_backend, Backend
from kitty_workbench.backends.kitty import KittyBackend
from kitty_workbench.backends.tmux import TmuxBackend
from kitty_workbench.backends.plain import PlainBackend
def test_detect_kitty():
with patch.dict(os.environ, {"KITTY_PID": "12345"}, clear=False):
backend = detect_backend()
assert isinstance(backend, KittyBackend)
def test_detect_tmux():
env = os.environ.copy()
env.pop("KITTY_PID", None)
env["TMUX"] = "/tmp/tmux-1000/default,12345,0"
with patch.dict(os.environ, env, clear=True):
backend = detect_backend()
assert isinstance(backend, TmuxBackend)
def test_detect_plain():
env = os.environ.copy()
env.pop("KITTY_PID", None)
env.pop("TMUX", None)
with patch.dict(os.environ, env, clear=True):
backend = detect_backend()
assert isinstance(backend, PlainBackend)
def test_kitty_image_protocol():
b = KittyBackend()
assert b.image_protocol() == "kitty"
def test_tmux_image_protocol():
b = TmuxBackend()
with patch.dict(os.environ, {"TERM_PROGRAM": "unknown"}, clear=False):
assert b.image_protocol() in ("sixel", "none")
def test_plain_image_protocol():
b = PlainBackend()
assert b.image_protocol() in ("sixel", "none")
def test_kitty_launch_pane():
b = KittyBackend()
with patch("kitty_workbench.backends.kitty.subprocess") as mock_sp:
mock_sp.run.return_value = MagicMock(stdout="42", returncode=0)
pane_id = b.launch_pane(["kitty-workbench", "tui", "test"], "Test")
assert pane_id == 42
call_args = mock_sp.run.call_args[0][0]
assert "kitty" in call_args[0]
assert "--location=vsplit" in call_args
def test_tmux_launch_pane():
b = TmuxBackend()
with patch("kitty_workbench.backends.tmux.subprocess") as mock_sp:
mock_sp.run.return_value = MagicMock(stdout="%5", returncode=0)
pane_id = b.launch_pane(["kitty-workbench", "tui", "test"], "Test")
assert pane_id == "%5"
call_args = mock_sp.run.call_args[0][0]
assert "tmux" in call_args[0]
assert "split-window" in call_args
+120
View File
@@ -0,0 +1,120 @@
import asyncio
import json
import pytest
from kitty_workbench.server import KittWorkbenchServer
from kitty_workbench.protocol import (
encode_message, decode_message, ReadyEvent, ChecklistToggleEvent,
InitCmd, DisplayCmd, LogCmd,
)
@pytest.mark.asyncio
async def test_full_session_flow(tmp_workbench):
"""End-to-end: open → display → log → events → close."""
srv = KittWorkbenchServer(
workbench_dir=tmp_workbench,
socket_dir=str(tmp_workbench),
)
# Mock the backend so it doesn't actually launch a pane
from unittest.mock import MagicMock
mock_backend = MagicMock()
mock_backend.name = "test"
mock_backend.image_protocol.return_value = "none"
mock_backend.launch_pane.return_value = 0
srv.backend = mock_backend
# Override _launch_tui to start socket server and connect as fake TUI
async def fake_launch(name, title):
import os
sock_path = srv._socket_path(name)
if os.path.exists(sock_path):
os.unlink(sock_path)
ready_event = asyncio.Event()
async def handle(reader, writer):
srv._connections[name] = writer
while True:
line = await reader.readline()
if not line:
break
msg = decode_message(line.decode().strip())
if msg is None:
continue
if isinstance(msg, ReadyEvent):
init_cmd = InitCmd(project=name, title=title, image_protocol="none")
writer.write((encode_message(init_cmd) + "\n").encode())
await writer.drain()
ready_event.set()
else:
from dataclasses import asdict
if name not in srv._event_queues:
srv._event_queues[name] = []
srv._event_queues[name].append(asdict(msg))
server = await asyncio.start_unix_server(handle, path=sock_path)
srv._socket_servers[name] = server
srv._pane_ids[name] = 0
# Connect as fake TUI
reader, writer = await asyncio.open_unix_connection(sock_path)
writer.write((encode_message(ReadyEvent()) + "\n").encode())
await writer.drain()
await asyncio.wait_for(ready_event.wait(), timeout=5.0)
# Read init command
line = await reader.readline()
init = decode_message(line.decode().strip())
assert isinstance(init, InitCmd)
assert init.project == name
srv._test_reader = reader
srv._test_writer = writer
srv._launch_tui = fake_launch
# --- Open ---
result = json.loads(await srv.kitt_open("test-proj", "Integration Test"))
assert result["status"] == "ready"
assert result["project"] == "test-proj"
# --- Display ---
result = json.loads(await srv.kitt_display("test-proj", "markdown", content="# Hello"))
assert result["ok"] is True
line = await srv._test_reader.readline()
cmd = decode_message(line.decode().strip())
assert isinstance(cmd, DisplayCmd)
assert cmd.content == "# Hello"
# --- Log ---
result = json.loads(await srv.kitt_log("test-proj", "Test entry", level="info"))
assert result["ok"] is True
entries = json.loads(await srv.kitt_read_log("test-proj"))["entries"]
assert len(entries) == 1
assert entries[0]["entry"] == "Test entry"
# --- Events ---
evt = ChecklistToggleEvent(pane="sidebar", index=0, label="Test", checked=True)
srv._test_writer.write((encode_message(evt) + "\n").encode())
await srv._test_writer.drain()
await asyncio.sleep(0.1)
events = json.loads(await srv.kitt_events("test-proj"))["events"]
assert len(events) == 1
assert events[0]["checked"] is True
# --- List ---
projects = json.loads(await srv.kitt_list())["projects"]
assert any(p["name"] == "test-proj" and p["active"] for p in projects)
# --- Close ---
result = json.loads(await srv.kitt_close("test-proj"))
assert result["ok"] is True
# Cleanup
srv._test_writer.close()
+67
View File
@@ -0,0 +1,67 @@
import json
from kitty_workbench.project import (
create_project, project_exists, project_path,
append_log, read_log, list_projects, log_session_event,
)
def test_create_project(tmp_workbench):
path = create_project("io102", "Heathkit IO-102", workbench_dir=tmp_workbench)
assert path.exists()
assert (path / "session.md").exists()
assert (path / "session.jsonl").exists()
assert (path / "cost-log.jsonl").exists()
assert (path / "assets").is_dir()
md = (path / "session.md").read_text()
assert "Heathkit IO-102" in md
def test_create_project_idempotent(tmp_workbench):
create_project("io102", "First", workbench_dir=tmp_workbench)
append_log("io102", "test entry", workbench_dir=tmp_workbench)
create_project("io102", "Second", workbench_dir=tmp_workbench)
entries = read_log("io102", workbench_dir=tmp_workbench)
assert len(entries) == 1
def test_project_exists(tmp_workbench):
assert not project_exists("io102", workbench_dir=tmp_workbench)
create_project("io102", "Test", workbench_dir=tmp_workbench)
assert project_exists("io102", workbench_dir=tmp_workbench)
def test_append_and_read_log(tmp_workbench):
create_project("io102", "Test", workbench_dir=tmp_workbench)
append_log("io102", "R412 measured 1.05M", data={"ohms": 1050000}, workbench_dir=tmp_workbench)
append_log("io102", "R413 OK", workbench_dir=tmp_workbench)
entries = read_log("io102", tail=10, workbench_dir=tmp_workbench)
assert len(entries) == 2
assert entries[0]["entry"] == "R412 measured 1.05M"
assert entries[0]["ohms"] == 1050000
assert entries[1]["entry"] == "R413 OK"
def test_read_log_tail(tmp_workbench):
create_project("io102", "Test", workbench_dir=tmp_workbench)
for i in range(30):
append_log("io102", f"Entry {i}", workbench_dir=tmp_workbench)
entries = read_log("io102", tail=5, workbench_dir=tmp_workbench)
assert len(entries) == 5
assert entries[0]["entry"] == "Entry 25"
def test_list_projects(tmp_workbench):
assert list_projects(workbench_dir=tmp_workbench) == []
create_project("io102", "First", workbench_dir=tmp_workbench)
create_project("psu-rebuild", "Second", workbench_dir=tmp_workbench)
projects = list_projects(workbench_dir=tmp_workbench)
assert [p["name"] for p in projects] == ["io102", "psu-rebuild"]
def test_log_session_event(tmp_workbench):
create_project("io102", "Test", workbench_dir=tmp_workbench)
log_session_event("io102", "session_start", workbench_dir=tmp_workbench)
cost_log = (tmp_workbench / "io102" / "cost-log.jsonl").read_text().strip()
entry = json.loads(cost_log.split("\n")[-1])
assert entry["event"] == "session_start"
assert entry["project"] == "io102"
+48
View File
@@ -0,0 +1,48 @@
import json
from kitty_workbench.protocol import (
DisplayCmd, ImageCmd, LogCmd, ClearCmd, LayoutCmd, InitCmd, ShutdownCmd,
ReadyEvent, ChecklistToggleEvent, ButtonClickEvent, InputSubmitEvent,
encode_message, decode_message,
)
def test_display_cmd_round_trip():
cmd = DisplayCmd(widget="markdown", content="# Hello", pane="main", clear=False)
line = encode_message(cmd)
decoded = decode_message(line)
assert decoded == cmd
def test_init_cmd_round_trip():
cmd = InitCmd(project="io102", title="Test Project", image_protocol="sixel")
line = encode_message(cmd)
decoded = decode_message(line)
assert decoded == cmd
assert decoded.project == "io102"
def test_checklist_event_round_trip():
evt = ChecklistToggleEvent(pane="sidebar", index=2, label="Check R412", checked=True)
line = encode_message(evt)
decoded = decode_message(line)
assert decoded == evt
assert decoded.checked is True
def test_ready_event_round_trip():
evt = ReadyEvent()
line = encode_message(evt)
decoded = decode_message(line)
assert isinstance(decoded, ReadyEvent)
def test_encode_produces_single_line():
cmd = LogCmd(entry="test entry", level="info")
line = encode_message(cmd)
assert "\n" not in line
assert json.loads(line)["cmd"] == "log"
def test_decode_unknown_message_returns_none():
result = decode_message('{"cmd": "unknown_thing", "data": 123}')
assert result is None
+57
View File
@@ -0,0 +1,57 @@
import asyncio
import json
from pathlib import Path
from unittest.mock import patch, AsyncMock
import pytest
from kitty_workbench.server import KittWorkbenchServer
from kitty_workbench.protocol import encode_message, decode_message, ReadyEvent, ChecklistToggleEvent
@pytest.fixture
def server(tmp_workbench, socket_path):
return KittWorkbenchServer(workbench_dir=tmp_workbench, socket_dir=str(Path(socket_path).parent))
@pytest.mark.asyncio
async def test_server_open_creates_project(server, tmp_workbench):
with patch.object(server, "_launch_tui", new_callable=AsyncMock):
result = await server.kitt_open("test-proj", "Test Project")
result_data = json.loads(result)
assert result_data["project"] == "test-proj"
assert (tmp_workbench / "test-proj" / "session.md").exists()
@pytest.mark.asyncio
async def test_server_list_empty(server):
result = await server.kitt_list()
data = json.loads(result)
assert data["projects"] == []
@pytest.mark.asyncio
async def test_server_log_writes_to_disk(server, tmp_workbench):
with patch.object(server, "_launch_tui", new_callable=AsyncMock):
await server.kitt_open("test-proj", "Test")
server._connections["test-proj"] = AsyncMock()
result = await server.kitt_log("test-proj", "R412 measured 1.05M", level="warning")
data = json.loads(result)
assert data["ok"] is True
jsonl = (tmp_workbench / "test-proj" / "session.jsonl").read_text().strip()
entry = json.loads(jsonl)
assert entry["entry"] == "R412 measured 1.05M"
@pytest.mark.asyncio
async def test_server_events_queue(server):
server._event_queues["test-proj"] = []
server._event_queues["test-proj"].append(
{"event": "checklist_toggle", "index": 0, "label": "Test", "checked": True, "pane": "sidebar"}
)
result = await server.kitt_events("test-proj")
data = json.loads(result)
assert len(data["events"]) == 1
assert data["events"][0]["checked"] is True
result2 = await server.kitt_events("test-proj")
assert json.loads(result2)["events"] == []
+57
View File
@@ -0,0 +1,57 @@
import asyncio
import json
import pytest
from textual.app import App
from kitty_workbench.tui import KittWorkbenchApp
from kitty_workbench.protocol import (
encode_message, InitCmd, DisplayCmd, LayoutCmd, LogCmd, ClearCmd, ShutdownCmd,
)
@pytest.mark.asyncio
async def test_app_instantiates():
app = KittWorkbenchApp(socket_path="/tmp/nonexistent.sock")
assert isinstance(app, App)
@pytest.mark.asyncio
async def test_app_handles_markdown_display():
app = KittWorkbenchApp(socket_path="/tmp/nonexistent.sock")
async with app.run_test(size=(80, 24)) as pilot:
app.handle_command(DisplayCmd(
widget="markdown",
content="# Hello World\n\nThis is a test.",
pane="main",
))
await pilot.pause()
assert app.is_running
@pytest.mark.asyncio
async def test_app_handles_layout():
app = KittWorkbenchApp(socket_path="/tmp/nonexistent.sock")
async with app.run_test(size=(80, 24)) as pilot:
app.handle_command(LayoutCmd(panes={
"main": {"ratio": 2},
"sidebar": {"ratio": 1, "position": "right"},
"log": {"ratio": 1, "position": "bottom"},
}))
await pilot.pause()
assert app.query_one("#sidebar") is not None
assert app.query_one("#log") is not None
@pytest.mark.asyncio
async def test_app_handles_log():
app = KittWorkbenchApp(socket_path="/tmp/nonexistent.sock")
async with app.run_test(size=(80, 24)) as pilot:
app.handle_command(LayoutCmd(panes={
"main": {"ratio": 2},
"log": {"ratio": 1, "position": "bottom"},
}))
await pilot.pause()
app.handle_command(LogCmd(entry="R412 measured 1.05M — FAIL", level="warning"))
await pilot.pause()
assert app.is_running