Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3944056
[tau] Start on the coding agent demo
msullivan May 12, 2026
816e35d
[tau] Queue user input while a turn is streaming
msullivan May 12, 2026
b16295c
[tau] Extract chat_loop into a standalone function
msullivan May 12, 2026
e3eac3e
[tau] Don't yank scrolled-up readers down, and hide the scrollbar
msullivan May 12, 2026
9367e37
[tau] Implement pi's seven tools with approval-gated mutations
msullivan May 12, 2026
23c7012
[tau] Approval prompt as a focusable widget above the composer
msullivan May 13, 2026
ca90d55
[tau] Stop gating write and edit behind approval
msullivan May 13, 2026
11b06f5
[tau] Add TAU_ADVERTISE=1 flag to include co-author trailer in commit…
msullivan May 13, 2026
fa28f07
[tau] Add session history with persist/resume support
msullivan May 13, 2026
31efff8
[tau] Add ruff config, lint & format
msullivan May 13, 2026
93e8f50
[tau] Show cumulative token usage in footer bar
msullivan May 13, 2026
99ac2fd
[tau] Show approximate context size in usage footer
msullivan May 13, 2026
c0a33e4
[tau] Enable gateway caching and improve usage display
msullivan May 13, 2026
e5afb6b
[tau] Refactor chat_loop: extract _run_turn for single-turn logic
msullivan May 13, 2026
2b50da0
[tau] Add ApprovalTracker with per-command and global auto-approve
msullivan May 13, 2026
40bd43b
[tau] Auto-approve file tools under cwd, prompt for external paths
msullivan May 13, 2026
14ac685
[tau] Ring terminal bell on turn completion and approval prompts
msullivan May 13, 2026
be58130
[tau] Render assistant messages as markdown via Rich
msullivan May 13, 2026
9cfecb9
[tau] ESC to interrupt running turn
msullivan May 13, 2026
a889f16
[tau] Remove ctrl+d quit binding
msullivan May 13, 2026
401da53
[tau] Consistent scroll-follow behavior for all event types
msullivan May 13, 2026
0cc6651
[tau] Make bash tool a StreamingTextTool
msullivan May 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -216,3 +216,4 @@ __marimo__/

.claude/
.codex
.tau/
5 changes: 5 additions & 0 deletions examples/check-examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@
REPO / "examples" / "multiagent-textual",
["fastapi", "textual", "websockets"],
),
(
"tau-agent",
REPO / "examples" / "tau-agent",
["textual"],
),
(
"temporal-direct",
REPO / "examples" / "temporal-direct",
Expand Down
13 changes: 13 additions & 0 deletions examples/tau-agent/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
dist/

# Environment
.env
.env*.local

# Tau session history
.tau/
41 changes: 41 additions & 0 deletions examples/tau-agent/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# tau-agent

`tau` is a coding-agent demo built on the `ai` library. Single
process, Textual TUI, streaming replies, pi-style tool surface:

- **`read`** — read files; offset/limit pagination with continuation hints
- **`write`** — create / overwrite a file
- **`edit`** — exact-match str_replace, multiple disjoint edits per call
- **`bash`** — run a shell command in cwd, output truncated to the last 50KB / 2000 lines *(requires approval)*
- **`grep`** — regex search (skips `.git`, `node_modules`, etc.)
- **`find`** — glob match
- **`ls`** — directory listing

Approval-gated tools fire a `ToolApproval` hook; the composer turns
into a `[y/n]` prompt mid-turn. Unrelated text typed during a
pending approval falls through to the message queue — the hook stays
pending until you give it a y or n.

No workspace jail. The approval gate is the safety mechanism;
everything else relies on you watching the prompts.

## Setup

```bash
uv sync
```

## Running

```bash
uv run python tau.py
```

Type a message, hit enter. `ctrl+c` to quit.

## Environment

| Variable | Description | Default |
|----------|-------------|---------|
| `AI_GATEWAY_API_KEY` | Vercel AI Gateway API key | — |
| `TAU_MODEL` | Model id passed to `ai.ai_gateway(...)` | `anthropic/claude-sonnet-4.5` |
23 changes: 23 additions & 0 deletions examples/tau-agent/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
[project]
name = "tau-agent"
version = "0.1.0"
description = "Tau — a coding-agent chat bot demo built with the ai library and Textual"
requires-python = ">=3.12"
dependencies = [
"vercel-ai-sdk",
"textual>=3.0",
]

[tool.uv.sources]
vercel-ai-sdk = { path = "../..", editable = true }

[tool.ruff]
target-version = "py313"

[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]

[dependency-groups]
dev = [
"ruff>=0.15.12",
]
182 changes: 182 additions & 0 deletions examples/tau-agent/session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
"""Session history — persist and resume conversations.

Sessions are stored as JSONL files under ``.tau/sessions/``. Each line
is a JSON-serialised ``ai.messages.Message``. The first line is always
a metadata object (not a Message) carrying session-level info:

{"meta": true, "session_id": "...", "model": "...", "cwd": "...", "created": "..."}

Usage:
# New session (default)
uv run python tau.py

# Resume the most recent session
uv run python tau.py --resume

# Resume a specific session by ID (or prefix)
uv run python tau.py --session 20250101-120000

# List saved sessions
uv run python tau.py --list
"""

from __future__ import annotations

import json
import os
import pathlib
from datetime import UTC, datetime
from typing import Any

import ai

# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------

SESSIONS_DIR = pathlib.Path(".tau") / "sessions"


def _ensure_dir() -> None:
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)


# ---------------------------------------------------------------------------
# Session ID
# ---------------------------------------------------------------------------


def new_session_id() -> str:
"""Timestamp-based, human-readable session ID."""
return datetime.now(UTC).strftime("%Y%m%d-%H%M%S")


# ---------------------------------------------------------------------------
# Metadata
# ---------------------------------------------------------------------------


def _meta_line(session_id: str, model: str) -> str:
return json.dumps(
{
"meta": True,
"session_id": session_id,
"model": model,
"cwd": os.getcwd(),
"created": datetime.now(UTC).isoformat(),
},
ensure_ascii=False,
)


def _read_meta(path: pathlib.Path) -> dict[str, Any] | None:
try:
with path.open("r", encoding="utf-8") as f:
first = f.readline().strip()
if first:
obj = json.loads(first)
if isinstance(obj, dict) and obj.get("meta"):
return obj
except (OSError, json.JSONDecodeError):
pass
return None


# ---------------------------------------------------------------------------
# Writing
# ---------------------------------------------------------------------------


def create_session(session_id: str, model: str) -> pathlib.Path:
"""Create a new JSONL session file and write the metadata header."""
_ensure_dir()
path = SESSIONS_DIR / f"{session_id}.jsonl"
with path.open("w", encoding="utf-8") as f:
f.write(_meta_line(session_id, model) + "\n")
return path


def append_messages(
path: pathlib.Path,
messages: list[ai.messages.Message],
*,
after: int = 0,
) -> int:
"""Append new messages to the session file.

``after`` is the count of messages already written (excluding the
metadata line). Only messages from ``messages[after:]`` are
appended. Returns the new total written count.
"""
new = messages[after:]
if not new:
return after
with path.open("a", encoding="utf-8") as f:
for msg in new:
f.write(msg.model_dump_json() + "\n")
return after + len(new)


# ---------------------------------------------------------------------------
# Reading / resuming
# ---------------------------------------------------------------------------


def list_sessions() -> list[dict[str, Any]]:
"""Return metadata dicts for all sessions, newest first."""
_ensure_dir()
sessions: list[dict[str, Any]] = []
for p in sorted(SESSIONS_DIR.glob("*.jsonl"), reverse=True):
meta = _read_meta(p)
if meta is not None:
meta["_path"] = str(p)
sessions.append(meta)
return sessions


def resolve_session(session_id: str | None) -> pathlib.Path | None:
"""Find a session file.

- ``None`` → most recent session
- exact match → that session
- prefix match → first match (newest first)
"""
_ensure_dir()
files = sorted(SESSIONS_DIR.glob("*.jsonl"), reverse=True)
if not files:
return None
if session_id is None:
return files[0]
# Exact
exact = SESSIONS_DIR / f"{session_id}.jsonl"
if exact.exists():
return exact
# Prefix
for f in files:
if f.stem.startswith(session_id):
return f
return None


def load_messages(
path: pathlib.Path,
) -> tuple[dict[str, Any], list[ai.messages.Message]]:
"""Load session metadata + messages from a JSONL file.

Returns ``(meta_dict, messages_list)``. The system message is
included in the list (it's persisted like any other message).
"""
meta: dict[str, Any] = {}
messages: list[ai.messages.Message] = []
with path.open("r", encoding="utf-8") as f:
for lineno, line in enumerate(f, 1):
line = line.strip()
if not line:
continue
if lineno == 1:
obj = json.loads(line)
if isinstance(obj, dict) and obj.get("meta"):
meta = obj
continue
messages.append(ai.messages.Message.model_validate_json(line))
return meta, messages
Loading
Loading