Document current coding-workflow stack state

Snapshot of where opencode + Qwen3-Coder + MCPs + Kimi-Linear + voice
  + Phoenix tracing land today, plus in-flight (oc-tree, kimi-linear
  context ramp) and next (ComfyUI) items with pointers to per-project
  NEXT_STEPS.md guides.
This commit is contained in:
2026-05-10 21:14:43 -04:00
parent 228fe8d1ac
commit a29793032d
35 changed files with 2067 additions and 37 deletions

1
oc-tree/.python-version Normal file
View File

@@ -0,0 +1 @@
3.11

120
oc-tree/NEXT_STEPS.md Normal file
View File

@@ -0,0 +1,120 @@
# oc-tree — resumption guide
Open this file first when picking the work back up.
## What this project is
A Python TUI sidecar that subscribes to `opencode serve`'s SSE event
stream and renders a live tree of sessions → messages → tool calls in a
terminal pane next to the opencode TUI. Subagents nest under their
parent via `session.created.info.parentID`.
Roadmap entry: `localgenai/Roadmap.md` → "Layer 0: Cross-cutting
capabilities" → "Observability — partial" + prioritized step #12.
Phoenix (`opencode/.opencode/plugin/phoenix-bridge.js`) already handles
the *external* deep-trace store. oc-tree is the glanceable "what's it
doing right now" pane that lives in-harness (well, in tmux next to it).
## Where we are
- Plan agreed: Python + textual, lives at `localgenai/oc-tree/`.
- Phases: M0 → M1 → M2 → M3 → M4. See descriptions in this repo's task
list (`TaskList`) or the roadmap entry.
- **M0 — DONE** (skeleton). `uv sync` installs cleanly; `oc-tree` and
`oc-tree-probe` entry points resolve. Imports verified.
- **M0 — AWAITING USER ACTION** (schema verification). Probe is built
but hasn't been run against a live `opencode serve`. Three open
schema questions still unanswered.
## What blocks progress
User needs to run the probe against a real opencode session and report
back. Without these answers, M2's reducer design is guesswork.
Run in a tmux pane while `opencode serve` is up:
```sh
cd ~/Documents/obsidian/localgenai/oc-tree
uv run oc-tree-probe
```
Drive opencode through a session that hits **all three** triggers:
- Spawn a Task-tool subagent
- Trigger at least one permission prompt
- Make at least one regular tool call (Read/Bash/etc.)
Ctrl-C the probe. Then run:
```sh
# Q1: does session.created.info.parentID populate for subagents?
jq -r 'select(.type=="session.created") | .raw.properties.info.parentID' \
/tmp/oc-tree-probe.jsonl
# Q2: does message.part.updated carry full part or delta?
jq -c 'select(.type=="message.part.updated") | .raw.properties.part' \
/tmp/oc-tree-probe.jsonl | head
# Q3: what permission.* events actually fire?
jq -r '.type' /tmp/oc-tree-probe.jsonl | grep -i permission | sort -u
```
Paste the output (or the JSONL file path) into the next session.
## What happens next
Once probe answers are in:
1. Mark M0 complete, start **M1 (flat session list)** — textual app,
live-updating list of sessions with status, no nesting yet. Proves
the reducer + render loop. Independent of the schema answers, so
could start in parallel.
2. **M2 (tree view)** — needs probe answers to know:
- Whether to nest by `parentID` directly (Q1 yes) or fall back to
inferring subagents from `Task` tool-part response payloads.
- Whether the part-update reducer replaces by `partID` (Q2 = full
part) or merges a delta (Q2 = delta).
- What permission events to render (Q3).
3. **M3 (reconnect + state rebuild)** — heartbeat watchdog, REST replay
on disconnect. Driven by sst/opencode#15149/#22198 known leaks.
4. **M4 (polish)** — keybindings, theme, tmux layout doc.
## File layout
```
localgenai/oc-tree/
├── pyproject.toml uv project (textual, httpx, httpx-sse)
├── README.md user-facing readme
├── NEXT_STEPS.md this file
├── .python-version 3.11
└── src/oc_tree/
├── client.py OpenCodeClient: REST + SSE
├── probe.py schema-verification CLI
├── __main__.py stub for `oc-tree` (real TUI in M1)
└── widgets/ empty (populated in M1+)
```
## Key references
- opencode server docs: <https://opencode.ai/docs/server/>
- Authoritative schema: `GET /doc` on a running `opencode serve` (do
not hardcode — fetch per-version).
- sst/opencode#7451 — no per-session SSE endpoint; we filter `/event`
client-side.
- sst/opencode#6573 — Task subagent over `opencode serve` may have
bugs; this is what Q1 verifies.
- sst/opencode#11424`message.part.updated` sometimes replays full
state; this is what Q2 verifies.
- sst/opencode#15149, #22198 — SSE disconnect leaks; informs M3
shutdown discipline.
## Decisions worth not relitigating
- **Python + textual** chosen over Go+Bubbletea (faster iteration,
matches stack — uvx already in use) and Node+ink (worse SSE/UI
ergonomics; phoenix-bridge.js doesn't justify matching).
- **Read-only v1.** No sending messages, no editing. Just visibility.
- **Lives in `localgenai/oc-tree/`** rather than its own repo; can be
extracted later if it warrants a standalone release.
- **State rebuild via REST on every (re)connect** rather than trusting
SSE catchup or `Last-Event-ID` (server doesn't honor it).

72
oc-tree/README.md Normal file
View File

@@ -0,0 +1,72 @@
# oc-tree
Live tree-view sidecar for [opencode](https://opencode.ai). Subscribes
to the `opencode serve` SSE event stream and renders a live hierarchy of
sessions → messages → tool calls in a terminal pane next to the opencode
TUI.
Phoenix (`opencode/.opencode/plugin/phoenix-bridge.js`) handles
after-the-fact deep traces; oc-tree is the glanceable "what's it doing
right now" pane.
## Status
- **M0** — skeleton + schema probe (current).
- M1 — flat session list (textual).
- M2 — tree view with subagent nesting via `parentID`.
- M3 — heartbeat watchdog + REST replay on reconnect.
- M4 — polish (keybindings, theme, tmux layout doc).
Picking the work back up: read [`NEXT_STEPS.md`](./NEXT_STEPS.md) first.
## Requirements
- Python 3.11+
- `uv`
- A running `opencode serve` (default `127.0.0.1:4096`)
## Quickstart
```sh
cd ~/Documents/obsidian/localgenai/oc-tree
uv sync
uv run oc-tree-probe
```
Drive opencode in another terminal. Probe writes JSONL frames to
`/tmp/oc-tree-probe.jsonl` and a live counter to stdout. Ctrl-C to stop;
event-type counts print on exit.
### M0 verification queries
After driving a session that includes a Task subagent, a permission
prompt, and a tool call:
```sh
# 1. Does session.created.info.parentID populate for subagents?
jq -r 'select(.type=="session.created") | .raw.properties.info.parentID' \
/tmp/oc-tree-probe.jsonl
# 2. Does message.part.updated carry full parts or deltas?
jq -c 'select(.type=="message.part.updated") | .raw.properties.part' \
/tmp/oc-tree-probe.jsonl | head
# 3. Which permission.* events actually fire?
jq -r '.type' /tmp/oc-tree-probe.jsonl | grep -i permission | sort -u
```
## Configuration
| Env var | Default |
| --------------------------- | ------------------------ |
| `OPENCODE_URL` | `http://127.0.0.1:4096` |
| `OPENCODE_SERVER_USERNAME` | `opencode` (if pw set) |
| `OPENCODE_SERVER_PASSWORD` | _(unset → no auth)_ |
## References
- [opencode server docs](https://opencode.ai/docs/server/)
- [sst/opencode#7451](https://github.com/sst/opencode/issues/7451) — no per-session SSE endpoint
- [sst/opencode#6573](https://github.com/sst/opencode/issues/6573) — Task subagent over `opencode serve`
- [sst/opencode#11424](https://github.com/sst/opencode/issues/11424) — replayed message.part.updated frames
- [sst/opencode#15149](https://github.com/sst/opencode/issues/15149) — SSE disconnect leaves server hung

21
oc-tree/pyproject.toml Normal file
View File

@@ -0,0 +1,21 @@
[project]
name = "oc-tree"
version = "0.0.1"
description = "Live tree-view sidecar for opencode SSE events"
requires-python = ">=3.11"
dependencies = [
"textual>=0.80",
"httpx>=0.27",
"httpx-sse>=0.4",
]
[project.scripts]
oc-tree = "oc_tree.__main__:main"
oc-tree-probe = "oc_tree.probe:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/oc_tree"]

View File

View File

@@ -0,0 +1,20 @@
"""Entry point. M0: stub that points users at the probe.
The textual app lands in M1+. Until then, this command exists so the
script registration in pyproject.toml resolves.
"""
from __future__ import annotations
def main() -> int:
print(
"oc-tree TUI ships in M1.\n"
"For now, run the schema probe:\n"
" uv run oc-tree-probe\n"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,93 @@
"""Thin async client for opencode's HTTP + SSE API.
Talks to `opencode serve` (default 127.0.0.1:4096). Auth is off unless
OPENCODE_SERVER_PASSWORD is set, matching upstream defaults.
The single SSE endpoint is `GET /event`; per-session streams don't exist
(sst/opencode#7451), so callers filter by sessionID client-side.
"""
from __future__ import annotations
import base64
import os
from collections.abc import AsyncIterator
from dataclasses import dataclass
from typing import Any
import httpx
from httpx_sse import aconnect_sse
@dataclass(frozen=True)
class Event:
type: str
properties: dict[str, Any]
raw: dict[str, Any]
def _auth_header() -> dict[str, str]:
pw = os.environ.get("OPENCODE_SERVER_PASSWORD", "")
if not pw:
return {}
user = os.environ.get("OPENCODE_SERVER_USERNAME", "opencode")
token = base64.b64encode(f"{user}:{pw}".encode()).decode()
return {"Authorization": f"Basic {token}"}
class OpenCodeClient:
def __init__(
self,
base_url: str | None = None,
*,
timeout: float = 30.0,
) -> None:
self.base_url = (
base_url
or os.environ.get("OPENCODE_URL")
or "http://127.0.0.1:4096"
).rstrip("/")
# SSE needs no read timeout; REST calls cap at `timeout`.
self._sse_timeout = httpx.Timeout(timeout, read=None)
self._rest_timeout = httpx.Timeout(timeout)
self._headers = _auth_header()
async def list_sessions(
self, *, scope: str = "project", limit: int = 50
) -> list[dict[str, Any]]:
async with httpx.AsyncClient(
base_url=self.base_url,
headers=self._headers,
timeout=self._rest_timeout,
) as c:
r = await c.get("/session", params={"scope": scope, "limit": limit})
r.raise_for_status()
return r.json()
async def get_session_messages(self, session_id: str) -> list[dict[str, Any]]:
async with httpx.AsyncClient(
base_url=self.base_url,
headers=self._headers,
timeout=self._rest_timeout,
) as c:
r = await c.get(f"/session/{session_id}/message")
r.raise_for_status()
return r.json()
async def stream_events(self) -> AsyncIterator[Event]:
"""Yield events from /event. Caller handles reconnect."""
async with httpx.AsyncClient(
base_url=self.base_url,
headers=self._headers,
timeout=self._sse_timeout,
) as c:
async with aconnect_sse(c, "GET", "/event") as src:
async for sse in src.aiter_sse():
if not sse.data:
continue
payload = sse.json()
yield Event(
type=payload.get("type", ""),
properties=payload.get("properties", {}) or {},
raw=payload,
)

View File

@@ -0,0 +1,100 @@
"""Schema probe: dump raw /event frames to JSONL for inspection.
Used in M0 to verify three open questions before building the UI:
1. Does session.created.info.parentID get populated for Task subagents?
(sst/opencode#6573 — may be broken over `opencode serve`.)
2. Does message.part.updated carry the full part or a delta?
(sst/opencode#11424 — frames sometimes replay full state.)
3. What permission.* event names actually fire? (Undocumented.)
Run, drive opencode through a session that includes a Task-tool
subagent + a permission prompt + at least one tool call, then grep the
output JSONL for `parentID`, `permission`, and successive part frames.
Usage:
uv run oc-tree-probe # writes /tmp/oc-tree-probe.jsonl
uv run oc-tree-probe --out file.jsonl
"""
from __future__ import annotations
import argparse
import asyncio
import json
import signal
import sys
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from .client import OpenCodeClient
async def _run(out_path: Path) -> int:
client = OpenCodeClient()
counts: Counter[str] = Counter()
print(f"oc-tree probe → {out_path}")
print(f" base_url={client.base_url}")
print(" drive opencode now; ctrl-c to stop and print summary\n")
stop = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, stop.set)
with out_path.open("a", encoding="utf-8") as f:
try:
stream = client.stream_events()
stream_task = asyncio.create_task(_consume(stream, f, counts))
stop_task = asyncio.create_task(stop.wait())
done, pending = await asyncio.wait(
{stream_task, stop_task},
return_when=asyncio.FIRST_COMPLETED,
)
for t in pending:
t.cancel()
for t in done:
exc = t.exception()
if exc and not isinstance(exc, asyncio.CancelledError):
print(f"\nstream error: {exc!r}", file=sys.stderr)
except KeyboardInterrupt:
pass
print("\n--- event counts ---")
for t, n in counts.most_common():
print(f" {n:>5} {t}")
return 0
async def _consume(stream, f, counts: Counter[str]) -> None:
async for event in stream:
counts[event.type] += 1
line = json.dumps(
{
"ts": datetime.now(timezone.utc).isoformat(),
"type": event.type,
"raw": event.raw,
},
ensure_ascii=False,
)
f.write(line + "\n")
f.flush()
# Live tick so the operator knows it's working.
sys.stdout.write(f"\r{sum(counts.values()):>6} events last={event.type:<40}")
sys.stdout.flush()
def main() -> int:
ap = argparse.ArgumentParser(description="opencode SSE schema probe")
ap.add_argument(
"--out",
type=Path,
default=Path("/tmp/oc-tree-probe.jsonl"),
help="output JSONL file (appended)",
)
args = ap.parse_args()
return asyncio.run(_run(args.out))
if __name__ == "__main__":
raise SystemExit(main())

View File

212
oc-tree/uv.lock generated Normal file
View File

@@ -0,0 +1,212 @@
version = 1
requires-python = ">=3.11"
[[package]]
name = "anyio"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353 },
]
[[package]]
name = "certifi"
version = "2026.4.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707 },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 },
]
[[package]]
name = "httpx-sse"
version = "0.4.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960 },
]
[[package]]
name = "idna"
version = "3.13"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629 },
]
[[package]]
name = "linkify-it-py"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "uc-micro-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878 },
]
[[package]]
name = "markdown-it-py"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687 },
]
[package.optional-dependencies]
linkify = [
{ name = "linkify-it-py" },
]
[[package]]
name = "mdit-py-plugins"
version = "0.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d8/3d/e0e8d9d1cee04f758120915e2b2a3a07eb41f8cf4654b4734788a522bcd1/mdit_py_plugins-0.6.0.tar.gz", hash = "sha256:2436f14a7295837ac9228a36feeabda867c4abc488c8d019ad5c0bda88eee040", size = 56025 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/d6/48f5b9e44e2e760855d7b489b1317cd7620e82dcb73197961e5cc1391348/mdit_py_plugins-0.6.0-py3-none-any.whl", hash = "sha256:f7e7a25d8b616fee99cb1e330da73451d11a8061baf39bb9663ab9ce0e005b90", size = 66655 },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 },
]
[[package]]
name = "oc-tree"
version = "0.0.1"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
{ name = "httpx-sse" },
{ name = "textual" },
]
[package.metadata]
requires-dist = [
{ name = "httpx", specifier = ">=0.27" },
{ name = "httpx-sse", specifier = ">=0.4" },
{ name = "textual", specifier = ">=0.80" },
]
[[package]]
name = "platformdirs"
version = "4.9.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348 },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 },
]
[[package]]
name = "rich"
version = "15.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654 },
]
[[package]]
name = "textual"
version = "8.2.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py", extra = ["linkify"] },
{ name = "mdit-py-plugins" },
{ name = "platformdirs" },
{ name = "pygments" },
{ name = "rich" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/62/1e/1eedc5bac184d00aaa5f9a99095f7e266af3ec46fa926c1051be5d358da1/textual-8.2.5.tar.gz", hash = "sha256:6c894e65a879dadb4f6cf46ddcfedb0173ff7e0cb1fe605ff7b357a597bdbc90", size = 1851596 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cd/01/c4555f9c8a692ff83d84930150540f743ce94c89234f9e9a15ff4baba3a8/textual-8.2.5-py3-none-any.whl", hash = "sha256:247d2aa2faf222749c321f88a736247f37ee2c023604079c7490bfacddfcd4b2", size = 727050 },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 },
]
[[package]]
name = "uc-micro-py"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383 },
]