Compare commits
19 Commits
79f1fd3f75
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| bc0827a3be | |||
| dce4c93cff | |||
| 705d53f957 | |||
| ea9b11faa4 | |||
| edebdcbab0 | |||
| 26c9d2cd77 | |||
| f5dd2a6f58 | |||
| ef34625104 | |||
| 679846e877 | |||
| f6a1cc4ebd | |||
| e37b907326 | |||
| 382e083471 | |||
| 707e8b7e31 | |||
| f16f16ef77 | |||
| e9c0cd7f15 | |||
| 5a509cbbbb | |||
| dc910d442d | |||
| d376e52908 | |||
| 8fd01c1275 |
+326
@@ -0,0 +1,326 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
# SSH usage — does the user SSH to remote machines for work?
|
||||||
|
ls ~/.ssh/config 2>/dev/null && echo "ssh config: found" || echo "ssh config: not found"
|
||||||
|
grep -l "ControlMaster\|ControlPath" ~/.ssh/config 2>/dev/null && echo "ssh multiplexing: configured" || echo "ssh multiplexing: not configured"
|
||||||
|
ls ~/.ssh/sockets/ 2>/dev/null && echo "ssh sockets dir: exists" || echo "ssh sockets dir: missing"
|
||||||
|
ls ~/.ssh/known_hosts 2>/dev/null && wc -l < ~/.ssh/known_hosts 2>/dev/null && echo "known hosts (suggests SSH usage)" || 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:**
|
||||||
|
tmux works immediately for pane splitting, but images are limited to sixel or ASCII art. Present the user with options:
|
||||||
|
|
||||||
|
> "You're connected via SSH. Here are your options:
|
||||||
|
>
|
||||||
|
> **Option A: tmux (works now)** — I can split panes via tmux. Images render via sixel if your local terminal supports it, otherwise ASCII art. No setup needed.
|
||||||
|
>
|
||||||
|
> **Option B: Install kitty on the remote host + use a remote desktop** — If you can access this machine via a remote desktop protocol (Chrome Remote Desktop, RDP, VNC, etc.), you could install kitty on the remote host and use it through the remote desktop session. This gives you the full experience: native splits, best image quality. Is remote desktop an option for you?
|
||||||
|
>
|
||||||
|
> **Option C: Both** — Set up tmux now for SSH sessions, and also install kitty on the remote host for when you connect via remote desktop. Best of both worlds."
|
||||||
|
|
||||||
|
If the user chooses B or C, install kitty on the remote host:
|
||||||
|
- Linux: `curl -L https://sw.kovidgoyal.net/kitty/installer.sh | sh /dev/stdin`
|
||||||
|
- After install: add `allow_remote_control yes` to `~/.config/kitty/kitty.conf`
|
||||||
|
- Remind them: kitty's display features only work through a remote desktop session, not over SSH
|
||||||
|
|
||||||
|
If the user chooses A or C, set up tmux as normal (Step 4 handles mouse config).
|
||||||
|
|
||||||
|
**If native/remote desktop (sitting at the machine or RDP/VNC):**
|
||||||
|
**Recommend kitty** — it gives the best experience (native splits, best image quality via kitty graphics protocol). Present it as the primary recommendation, with tmux as the quick-start alternative if they don't want to install anything new.
|
||||||
|
|
||||||
|
However, warn about this caveat:
|
||||||
|
> **Note:** If you install kitty and use it as your terminal, be aware that some AI CLIs (like `gemini`) may prompt for passwords or authentication tokens during setup. Kitty uses its own keyboard protocol which can interfere with password prompts in some CLI tools. If you hit issues with password/auth prompts not working in kitty, switch to your regular terminal for that step, then come back to kitty.
|
||||||
|
|
||||||
|
Installation:
|
||||||
|
- Linux: `curl -L https://sw.kovidgoyal.net/kitty/installer.sh | sh /dev/stdin`
|
||||||
|
- macOS: `brew install --cask kitty`
|
||||||
|
- After install: add `allow_remote_control yes` to `~/.config/kitty/kitty.conf`
|
||||||
|
- Launch your AI CLI from within kitty to get the full experience
|
||||||
|
|
||||||
|
**If already in kitty:**
|
||||||
|
Great — they get the best experience. Just need `allow_remote_control yes` in kitty.conf. Note: existing kitty windows must be restarted for config changes to take effect.
|
||||||
|
|
||||||
|
**If no tmux and no kitty:**
|
||||||
|
Recommend kitty (best experience) or tmux (simplest install). Plain backend works but has no split panes — the TUI opens in a separate window.
|
||||||
|
|
||||||
|
## 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. **If tmux backend chosen**, enable mouse support so the user can click widgets in the TUI pane:
|
||||||
|
```bash
|
||||||
|
# Check current setting
|
||||||
|
tmux show -g mouse 2>/dev/null
|
||||||
|
```
|
||||||
|
If mouse is off or not set:
|
||||||
|
```bash
|
||||||
|
tmux set -g mouse on
|
||||||
|
```
|
||||||
|
To make it permanent, add to `~/.tmux.conf`:
|
||||||
|
```bash
|
||||||
|
grep -q "set -g mouse on" ~/.tmux.conf 2>/dev/null || echo "set -g mouse on" >> ~/.tmux.conf
|
||||||
|
```
|
||||||
|
Without this, tmux intercepts mouse clicks and the user cannot interact with checkboxes, buttons, or inputs in the display pane.
|
||||||
|
|
||||||
|
4. **If the user SSHes to remote machines** (detected by known_hosts having entries, or the user mentions remote work), **set up SSH ControlMaster** so the AI CLI can reuse the user's authenticated SSH connections without needing to re-enter passwords or touch physical keys:
|
||||||
|
|
||||||
|
Ask the user: "Do you SSH into remote machines as part of your work? If so, I can configure SSH connection multiplexing — this lets you authenticate once, and my SSH commands piggyback on your open connection without needing a password."
|
||||||
|
|
||||||
|
If yes:
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.ssh/sockets
|
||||||
|
chmod 700 ~/.ssh/sockets
|
||||||
|
```
|
||||||
|
|
||||||
|
Check if ControlMaster is already configured:
|
||||||
|
```bash
|
||||||
|
grep -q "ControlMaster" ~/.ssh/config 2>/dev/null && echo "Already configured" || echo "Not configured"
|
||||||
|
```
|
||||||
|
|
||||||
|
If not configured, add to `~/.ssh/config` (create if needed):
|
||||||
|
```bash
|
||||||
|
touch ~/.ssh/config
|
||||||
|
chmod 600 ~/.ssh/config
|
||||||
|
cat >> ~/.ssh/config << 'SSHEOF'
|
||||||
|
|
||||||
|
# Kitty-Workbench: SSH connection multiplexing
|
||||||
|
# First connection authenticates normally (password, key, etc.)
|
||||||
|
# Subsequent connections reuse the tunnel — no re-auth needed
|
||||||
|
Host *
|
||||||
|
ControlMaster auto
|
||||||
|
ControlPath ~/.ssh/sockets/%r@%h-%p
|
||||||
|
ControlPersist yes
|
||||||
|
SSHEOF
|
||||||
|
```
|
||||||
|
|
||||||
|
Explain to the user how it works:
|
||||||
|
> **How this works:** When you SSH into a remote machine, the connection stays open in the background for 10 minutes (`ControlPersist yes`). During that time, any other SSH command to the same host — including ones I run — reuses your authenticated tunnel. No password prompt, no key tap. The tunnel stays open until the original session closes or the connection drops — your key/auth provider handles its own timeout. Just open an SSH session to your target machine before asking me to work on it.
|
||||||
|
|
||||||
|
If the user's `~/.ssh/config` already has Host-specific blocks, add the ControlMaster settings under a `Host *` block at the **end** of the file so it acts as a default without overriding specific host configs.
|
||||||
|
|
||||||
|
5. **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.
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# Kitty-Workbench
|
||||||
|
|
||||||
|
**Open source and safe for work.**
|
||||||
|
|
||||||
|
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://git.sethpc.xyz/Seth/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
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Kitty-Workbench Demo
|
||||||
|
====================
|
||||||
|
Run this in a kitty terminal to see the interactive display panel.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 demo.py
|
||||||
|
|
||||||
|
What it does:
|
||||||
|
1. Opens a Unix socket server
|
||||||
|
2. Splits your kitty window and launches the TUI in the right pane
|
||||||
|
3. Pushes a diagnostic scenario (Heathkit IO-102 oscilloscope focus repair)
|
||||||
|
4. Leaves the TUI running so you can interact with the checklist
|
||||||
|
|
||||||
|
Press Ctrl+C to close.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Ensure kitty_workbench is importable
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent / "src"))
|
||||||
|
|
||||||
|
from kitty_workbench.protocol import (
|
||||||
|
encode_message, decode_message, ReadyEvent, InitCmd,
|
||||||
|
DisplayCmd, LayoutCmd, LogCmd, ShutdownCmd,
|
||||||
|
)
|
||||||
|
from kitty_workbench.project import create_project
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
project_name = "demo-io102"
|
||||||
|
title = "Heathkit IO-102 Focus Diagnostic"
|
||||||
|
sock_path = f"/tmp/kitt-{project_name}.sock"
|
||||||
|
|
||||||
|
# Clean up stale socket
|
||||||
|
if os.path.exists(sock_path):
|
||||||
|
os.unlink(sock_path)
|
||||||
|
|
||||||
|
# Ensure project dir exists
|
||||||
|
create_project(project_name, title)
|
||||||
|
|
||||||
|
# -- Socket server --
|
||||||
|
ready = asyncio.Event()
|
||||||
|
tui_writer = None
|
||||||
|
|
||||||
|
async def on_connect(reader, writer):
|
||||||
|
nonlocal tui_writer
|
||||||
|
tui_writer = writer
|
||||||
|
while True:
|
||||||
|
line = await reader.readline()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
msg = decode_message(line.decode().strip())
|
||||||
|
if isinstance(msg, ReadyEvent):
|
||||||
|
ready.set()
|
||||||
|
elif msg is not None:
|
||||||
|
# Print user events to this terminal
|
||||||
|
from dataclasses import asdict
|
||||||
|
print(f" [event] {asdict(msg)}")
|
||||||
|
|
||||||
|
server = await asyncio.start_unix_server(on_connect, path=sock_path)
|
||||||
|
|
||||||
|
# -- Launch TUI in a kitty split --
|
||||||
|
print(f"Launching Kitty-Workbench TUI...")
|
||||||
|
|
||||||
|
tui_cmd = [
|
||||||
|
sys.executable, "-m", "kitty_workbench",
|
||||||
|
"tui", project_name, "--socket", sock_path,
|
||||||
|
]
|
||||||
|
|
||||||
|
# Try kitty split first, fall back to tmux, then a new window
|
||||||
|
launched = False
|
||||||
|
|
||||||
|
if os.environ.get("KITTY_PID") or os.environ.get("KITTY_WINDOW_ID"):
|
||||||
|
# We're inside kitty — use native split
|
||||||
|
import subprocess
|
||||||
|
result = subprocess.run(
|
||||||
|
["kitty", "@", "launch", "--location=vsplit",
|
||||||
|
"--title", title] + tui_cmd,
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
print(f" Opened kitty split pane (id: {result.stdout.strip()})")
|
||||||
|
launched = True
|
||||||
|
|
||||||
|
if not launched and os.environ.get("TMUX"):
|
||||||
|
import subprocess
|
||||||
|
result = subprocess.run(
|
||||||
|
["tmux", "split-window", "-h", "-d", "-P", "-F", "#{pane_id}"] + tui_cmd,
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
print(f" Opened tmux split pane ({result.stdout.strip()})")
|
||||||
|
launched = True
|
||||||
|
|
||||||
|
if not launched:
|
||||||
|
# Last resort: try kitty @ anyway (might work with allow_remote_control)
|
||||||
|
import subprocess
|
||||||
|
result = subprocess.run(
|
||||||
|
["kitty", "@", "launch", "--location=vsplit",
|
||||||
|
"--title", title] + tui_cmd,
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
print(f" Opened kitty split pane (id: {result.stdout.strip()})")
|
||||||
|
launched = True
|
||||||
|
else:
|
||||||
|
print(f" Could not auto-split. Run this in another terminal:")
|
||||||
|
print(f" {' '.join(tui_cmd)}")
|
||||||
|
print(f" Waiting for TUI to connect...")
|
||||||
|
|
||||||
|
# Wait for TUI to connect
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(ready.wait(), timeout=15)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
print("TUI did not connect within 15s. Is kitty remote control enabled?")
|
||||||
|
print("Add to ~/.config/kitty/kitty.conf:")
|
||||||
|
print(" allow_remote_control yes")
|
||||||
|
server.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
print(" TUI connected!\n")
|
||||||
|
|
||||||
|
async def send(cmd):
|
||||||
|
tui_writer.write((encode_message(cmd) + "\n").encode())
|
||||||
|
await tui_writer.drain()
|
||||||
|
await asyncio.sleep(0.4)
|
||||||
|
|
||||||
|
# -- Push demo content --
|
||||||
|
print("Pushing diagnostic scenario...")
|
||||||
|
|
||||||
|
await send(InitCmd(
|
||||||
|
project=project_name,
|
||||||
|
title=title,
|
||||||
|
image_protocol="none",
|
||||||
|
))
|
||||||
|
|
||||||
|
await send(LayoutCmd(panes={
|
||||||
|
"main": {"ratio": 2},
|
||||||
|
"sidebar": {"ratio": 1, "position": "right"},
|
||||||
|
"log": {"ratio": 1, "position": "bottom"},
|
||||||
|
}))
|
||||||
|
|
||||||
|
await send(DisplayCmd(
|
||||||
|
widget="markdown",
|
||||||
|
content="""# HV Focus Circuit Diagnostic
|
||||||
|
|
||||||
|
## CRT Focus Voltage Divider
|
||||||
|
|
||||||
|
The focus voltage is derived from the HV supply through a resistive divider:
|
||||||
|
|
||||||
|
- **R412** (910K) + **R413** (2.2M) + **R414** (1M)
|
||||||
|
- Expected focus voltage: ~2.1kV at CRT pin 6
|
||||||
|
- Measured: **1.8kV — low by 300V**
|
||||||
|
|
||||||
|
## Probable Cause
|
||||||
|
|
||||||
|
Carbon composition resistors R412-R414 have drifted with age.
|
||||||
|
R412 shows **+16.7% drift** — replacing with metal film.
|
||||||
|
|
||||||
|
## Circuit
|
||||||
|
|
||||||
|
```
|
||||||
|
HV Supply (5.2kV)
|
||||||
|
│
|
||||||
|
[R414] 1M
|
||||||
|
│
|
||||||
|
├──── Focus pin (CRT pin 6)
|
||||||
|
│
|
||||||
|
[R413] 2.2M
|
||||||
|
│
|
||||||
|
[R412] 910K ◄── DRIFTED to 1.05M
|
||||||
|
│
|
||||||
|
GND
|
||||||
|
```
|
||||||
|
""",
|
||||||
|
pane="main",
|
||||||
|
clear=True,
|
||||||
|
))
|
||||||
|
|
||||||
|
await send(DisplayCmd(
|
||||||
|
widget="checklist",
|
||||||
|
items=[
|
||||||
|
{"label": "Measure R412 (910K)", "checked": True},
|
||||||
|
{"label": "Measure R413 (2.2M)", "checked": True},
|
||||||
|
{"label": "Measure C201 ESR", "checked": True},
|
||||||
|
{"label": "Replace R412", "checked": False},
|
||||||
|
{"label": "Re-measure focus voltage", "checked": False},
|
||||||
|
{"label": "Verify CRT focus", "checked": False},
|
||||||
|
],
|
||||||
|
pane="sidebar",
|
||||||
|
clear=True,
|
||||||
|
))
|
||||||
|
|
||||||
|
await send(LogCmd(entry="R412: 1.05M (expected 910K) — FAIL +16.7%", level="warning"))
|
||||||
|
await send(LogCmd(entry="R413: 2.18M (expected 2.2M) — PASS", level="success"))
|
||||||
|
await send(LogCmd(entry="C201 ESR: 0.3Ω — PASS", level="success"))
|
||||||
|
await send(LogCmd(entry="Replacing R412 with 910K 1% metal film", level="info"))
|
||||||
|
|
||||||
|
print("Demo loaded! Interact with the checklist in the TUI pane.")
|
||||||
|
print("Events from the TUI will appear below.\n")
|
||||||
|
print("Press Ctrl+C to close.\n")
|
||||||
|
|
||||||
|
# Keep running and print events
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
except (KeyboardInterrupt, asyncio.CancelledError):
|
||||||
|
print("\nShutting down...")
|
||||||
|
try:
|
||||||
|
await send(ShutdownCmd())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
server.close()
|
||||||
|
if os.path.exists(sock_path):
|
||||||
|
os.unlink(sock_path)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["setuptools>=68.0"]
|
||||||
build-backend = "hatchling.build"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "kitty-workbench"
|
name = "kitty-workbench"
|
||||||
|
|||||||
@@ -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()
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -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 = True) -> 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 = True) -> str:
|
||||||
|
"""Push content to the display pane. Clears the target pane first by default (set clear=false to append instead). Widget types: 'markdown' (rendered text), 'table' (columns+rows as JSON in content), 'checklist' (items as JSON array), 'button' (needs id+label), 'input' (text field — needs id+placeholder, user must press Enter to submit). Tell the user to press Enter to submit text inputs. 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
|
||||||
@@ -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))
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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"] == []
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user