coding-agent-wrapper 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- caw/__init__.py +88 -0
- caw/agent.py +578 -0
- caw/auth/README.md +118 -0
- caw/auth/__init__.py +23 -0
- caw/auth/cli.py +68 -0
- caw/auth/collector.py +324 -0
- caw/auth/linker.py +174 -0
- caw/auth/manifest.py +77 -0
- caw/auth/providers.py +433 -0
- caw/auth/status.py +241 -0
- caw/cli.py +50 -0
- caw/display.py +223 -0
- caw/faststats.py +298 -0
- caw/mcp.py +602 -0
- caw/models.py +385 -0
- caw/pricing.json +15 -0
- caw/pricing.py +33 -0
- caw/provider.py +135 -0
- caw/providers/__init__.py +0 -0
- caw/providers/claude_code.py +648 -0
- caw/providers/codex.py +564 -0
- caw/py.typed +0 -0
- caw/storage.py +184 -0
- caw/toolkit.py +198 -0
- caw/viewer/__init__.py +149 -0
- caw/viewer/static/index.html +847 -0
- coding_agent_wrapper-0.1.0.dist-info/METADATA +213 -0
- coding_agent_wrapper-0.1.0.dist-info/RECORD +31 -0
- coding_agent_wrapper-0.1.0.dist-info/WHEEL +4 -0
- coding_agent_wrapper-0.1.0.dist-info/entry_points.txt +2 -0
- coding_agent_wrapper-0.1.0.dist-info/licenses/LICENSE +202 -0
caw/auth/status.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Display status of auth files — symlink state, token expiry, last modified."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
from .manifest import Manifest
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
AUTH_DIR = Path.home() / ".caw" / "auth"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class AuthFileStatus:
|
|
23
|
+
"""Status of a single managed auth file."""
|
|
24
|
+
|
|
25
|
+
agent: str
|
|
26
|
+
file: str # host_original relative path
|
|
27
|
+
type: str # "credential" or "config"
|
|
28
|
+
strategy: str # "symlink" or "copy"
|
|
29
|
+
symlink_state: str # "linked", "wrong_target", "not_linked", "missing", "n/a"
|
|
30
|
+
exists: bool # whether the canonical file exists in auth dir
|
|
31
|
+
token_expiry: str | None # human-readable token info, or None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _check_token_expiry(auth_dir: Path, agent_name: str) -> str | None:
|
|
35
|
+
"""Check token expiry for known agents. Returns human-readable status or None."""
|
|
36
|
+
try:
|
|
37
|
+
if agent_name == "claude":
|
|
38
|
+
cred_path = auth_dir / "claude" / "credentials.json"
|
|
39
|
+
if cred_path.exists():
|
|
40
|
+
with open(cred_path) as f:
|
|
41
|
+
creds = json.load(f)
|
|
42
|
+
expires_at = creds.get("claudeAiOauth", {}).get("expiresAt")
|
|
43
|
+
if expires_at:
|
|
44
|
+
dt = datetime.fromtimestamp(expires_at / 1000, tz=timezone.utc)
|
|
45
|
+
now = datetime.now(timezone.utc)
|
|
46
|
+
if dt < now:
|
|
47
|
+
delta = now - dt
|
|
48
|
+
return f"EXPIRED ({_format_delta(delta)} ago)"
|
|
49
|
+
else:
|
|
50
|
+
delta = dt - now
|
|
51
|
+
return f"valid ({_format_delta(delta)} remaining)"
|
|
52
|
+
except Exception:
|
|
53
|
+
pass
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _format_delta(delta) -> str:
|
|
58
|
+
"""Format a timedelta to a human-readable string."""
|
|
59
|
+
total_seconds = int(delta.total_seconds())
|
|
60
|
+
if total_seconds < 60:
|
|
61
|
+
return f"{total_seconds}s"
|
|
62
|
+
elif total_seconds < 3600:
|
|
63
|
+
return f"{total_seconds // 60}m"
|
|
64
|
+
elif total_seconds < 86400:
|
|
65
|
+
return f"{total_seconds // 3600}h {(total_seconds % 3600) // 60}m"
|
|
66
|
+
else:
|
|
67
|
+
return f"{total_seconds // 86400}d {(total_seconds % 86400) // 3600}h"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _format_mtime(path: Path) -> str:
|
|
71
|
+
"""Format last modified time of a file."""
|
|
72
|
+
try:
|
|
73
|
+
# Resolve symlinks to get the actual file's mtime
|
|
74
|
+
real_path = path.resolve()
|
|
75
|
+
mtime = real_path.stat().st_mtime
|
|
76
|
+
dt = datetime.fromtimestamp(mtime, tz=timezone.utc)
|
|
77
|
+
now = datetime.now(timezone.utc)
|
|
78
|
+
delta = now - dt
|
|
79
|
+
return f"{_format_delta(delta)} ago"
|
|
80
|
+
except Exception:
|
|
81
|
+
return "unknown"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_status(
|
|
85
|
+
agents: list[str] | None = None,
|
|
86
|
+
auth_dir: str | Path | None = None,
|
|
87
|
+
) -> list[AuthFileStatus]:
|
|
88
|
+
"""Return structured status of all managed auth files.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
agents: Agent names to include, or None for all.
|
|
92
|
+
auth_dir: Custom auth directory. Defaults to ~/.caw/auth/.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
List of AuthFileStatus for each managed file.
|
|
96
|
+
|
|
97
|
+
Raises:
|
|
98
|
+
FileNotFoundError: If the manifest.json doesn't exist in auth_dir.
|
|
99
|
+
"""
|
|
100
|
+
resolved_dir = Path(auth_dir) if auth_dir else AUTH_DIR
|
|
101
|
+
manifest_path = resolved_dir / "manifest.json"
|
|
102
|
+
if not manifest_path.exists():
|
|
103
|
+
raise FileNotFoundError(f"No manifest.json found at {manifest_path}")
|
|
104
|
+
|
|
105
|
+
manifest = Manifest.load(manifest_path)
|
|
106
|
+
host_home = Path(manifest.host_home)
|
|
107
|
+
agent_names = set(agents) if agents and "all" not in agents else set(manifest.agents.keys())
|
|
108
|
+
|
|
109
|
+
results: list[AuthFileStatus] = []
|
|
110
|
+
for agent_name, agent_manifest in manifest.agents.items():
|
|
111
|
+
if agent_name not in agent_names:
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
token_info = _check_token_expiry(resolved_dir, agent_name)
|
|
115
|
+
|
|
116
|
+
for mf in agent_manifest.files:
|
|
117
|
+
canonical = resolved_dir / mf.src
|
|
118
|
+
original = host_home / mf.host_original
|
|
119
|
+
|
|
120
|
+
# Determine symlink state
|
|
121
|
+
if mf.strategy == "symlink":
|
|
122
|
+
if original.is_symlink():
|
|
123
|
+
if original.resolve() == canonical.resolve():
|
|
124
|
+
symlink_state = "linked"
|
|
125
|
+
else:
|
|
126
|
+
symlink_state = "wrong_target"
|
|
127
|
+
elif original.exists():
|
|
128
|
+
symlink_state = "not_linked"
|
|
129
|
+
else:
|
|
130
|
+
symlink_state = "missing"
|
|
131
|
+
else:
|
|
132
|
+
symlink_state = "n/a"
|
|
133
|
+
|
|
134
|
+
results.append(
|
|
135
|
+
AuthFileStatus(
|
|
136
|
+
agent=agent_name,
|
|
137
|
+
file=mf.host_original,
|
|
138
|
+
type=mf.type,
|
|
139
|
+
strategy=mf.strategy,
|
|
140
|
+
symlink_state=symlink_state,
|
|
141
|
+
exists=canonical.exists(),
|
|
142
|
+
token_expiry=token_info if mf.type == "credential" else None,
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
return results
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def get_docker_flags(auth_dir: str | Path | None = None) -> str:
|
|
150
|
+
"""Return the Docker ``-v`` flag for mounting the auth directory.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
auth_dir: Custom auth directory. Defaults to ~/.caw/auth/.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
A string like ``-v /path/to/auth:/tmp/caw_auth:rw``.
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
FileNotFoundError: If the manifest.json doesn't exist in auth_dir.
|
|
160
|
+
"""
|
|
161
|
+
resolved_dir = Path(auth_dir) if auth_dir else AUTH_DIR
|
|
162
|
+
manifest_path = resolved_dir / "manifest.json"
|
|
163
|
+
if not manifest_path.exists():
|
|
164
|
+
raise FileNotFoundError(f"No manifest.json found at {manifest_path}")
|
|
165
|
+
|
|
166
|
+
manifest = Manifest.load(manifest_path)
|
|
167
|
+
return f"-v {resolved_dir}:{manifest.mount_point}:rw"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def status(agents: list[str] | None = None, auth_dir: str | Path | None = None) -> None:
|
|
171
|
+
"""Show status of all managed auth files.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
agents: Agent names to show, or None for all.
|
|
175
|
+
auth_dir: Custom auth directory. Defaults to ~/.caw/auth/.
|
|
176
|
+
"""
|
|
177
|
+
resolved_dir = Path(auth_dir) if auth_dir else AUTH_DIR
|
|
178
|
+
manifest_path = resolved_dir / "manifest.json"
|
|
179
|
+
if not manifest_path.exists():
|
|
180
|
+
console.print("[yellow]No auth directory found.[/yellow] Run `caw auth setup` first.")
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
manifest = Manifest.load(manifest_path)
|
|
184
|
+
host_home = Path(manifest.host_home)
|
|
185
|
+
|
|
186
|
+
agent_names = set(agents) if agents and "all" not in agents else set(manifest.agents.keys())
|
|
187
|
+
|
|
188
|
+
table = Table(title="caw auth status", show_lines=True)
|
|
189
|
+
table.add_column("Agent", style="bold")
|
|
190
|
+
table.add_column("File", style="dim")
|
|
191
|
+
table.add_column("Type")
|
|
192
|
+
table.add_column("Strategy")
|
|
193
|
+
table.add_column("Symlink State")
|
|
194
|
+
table.add_column("Last Modified")
|
|
195
|
+
table.add_column("Token")
|
|
196
|
+
|
|
197
|
+
for agent_name, agent_manifest in manifest.agents.items():
|
|
198
|
+
if agent_name not in agent_names:
|
|
199
|
+
continue
|
|
200
|
+
|
|
201
|
+
token_info = _check_token_expiry(resolved_dir, agent_name)
|
|
202
|
+
|
|
203
|
+
for i, mf in enumerate(agent_manifest.files):
|
|
204
|
+
canonical = resolved_dir / mf.src
|
|
205
|
+
original = host_home / mf.host_original
|
|
206
|
+
|
|
207
|
+
# Check symlink state
|
|
208
|
+
if mf.strategy == "symlink":
|
|
209
|
+
if original.is_symlink():
|
|
210
|
+
target = os.readlink(str(original))
|
|
211
|
+
if original.resolve() == canonical.resolve():
|
|
212
|
+
symlink_state = "[green]linked[/green]"
|
|
213
|
+
else:
|
|
214
|
+
symlink_state = f"[yellow]wrong target[/yellow] ({target})"
|
|
215
|
+
elif original.exists():
|
|
216
|
+
symlink_state = "[yellow]not linked[/yellow] (regular file)"
|
|
217
|
+
else:
|
|
218
|
+
symlink_state = "[red]missing[/red]"
|
|
219
|
+
else:
|
|
220
|
+
symlink_state = "[dim]n/a (copy)[/dim]"
|
|
221
|
+
|
|
222
|
+
# Last modified
|
|
223
|
+
mtime = _format_mtime(canonical) if canonical.exists() else "[red]missing[/red]"
|
|
224
|
+
|
|
225
|
+
# Token info only on first row per agent
|
|
226
|
+
token_col = token_info if (i == 0 and token_info) else ""
|
|
227
|
+
|
|
228
|
+
table.add_row(
|
|
229
|
+
agent_name if i == 0 else "",
|
|
230
|
+
mf.host_original,
|
|
231
|
+
mf.type,
|
|
232
|
+
mf.strategy,
|
|
233
|
+
symlink_state,
|
|
234
|
+
mtime,
|
|
235
|
+
token_col,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
console.print(table)
|
|
239
|
+
|
|
240
|
+
# Docker flags hint
|
|
241
|
+
console.print(f"\n[dim]Docker mount flag: -v {resolved_dir}:{manifest.mount_point}:rw[/dim]")
|
caw/cli.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""caw CLI — main entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import signal
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from caw.auth.cli import app as auth_app
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(
|
|
13
|
+
name="caw",
|
|
14
|
+
help="Coding Agent Wrapper — tools for managing coding agents.",
|
|
15
|
+
no_args_is_help=True,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
app.add_typer(auth_app, name="auth")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.command()
|
|
22
|
+
def viewer(
|
|
23
|
+
host: str = typer.Option("0.0.0.0", "--host", "-h", help="Host to bind to."),
|
|
24
|
+
port: int = typer.Option(0, "--port", "-p", help="Port to bind to (0 = auto)."),
|
|
25
|
+
):
|
|
26
|
+
"""Launch the trajectory viewer web UI."""
|
|
27
|
+
from caw.viewer import start_viewer_server
|
|
28
|
+
|
|
29
|
+
server = start_viewer_server(
|
|
30
|
+
host=host,
|
|
31
|
+
port=port or None,
|
|
32
|
+
)
|
|
33
|
+
typer.echo(f"Trajectory viewer running at {server.url}")
|
|
34
|
+
typer.echo("Press Ctrl+C to stop.")
|
|
35
|
+
|
|
36
|
+
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
|
|
37
|
+
try:
|
|
38
|
+
signal.pause()
|
|
39
|
+
except KeyboardInterrupt:
|
|
40
|
+
pass
|
|
41
|
+
finally:
|
|
42
|
+
server.stop()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def main():
|
|
46
|
+
app()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
if __name__ == "__main__":
|
|
50
|
+
main()
|
caw/display.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Rich console display for live agent output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import threading
|
|
8
|
+
from enum import Enum
|
|
9
|
+
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from caw.models import TextBlock, ThinkingBlock, ToolUse, UsageStats
|
|
15
|
+
|
|
16
|
+
LOG_ENV_VAR = "CAW_LOG"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DisplayMode(str, Enum):
|
|
20
|
+
"""Print modes for agent output."""
|
|
21
|
+
|
|
22
|
+
FULL = "full"
|
|
23
|
+
SHORT = "short"
|
|
24
|
+
RESULT = "result"
|
|
25
|
+
OFF = "off"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _truncate(text: str, max_len: int = 40) -> str:
|
|
29
|
+
"""Truncate text to max_len chars, adding ellipsis if needed."""
|
|
30
|
+
text = text.replace("\n", " ").strip()
|
|
31
|
+
if len(text) <= max_len:
|
|
32
|
+
return text
|
|
33
|
+
return text[: max_len - 1] + "…"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _first_n_lines(text: str, n: int = 3) -> str:
|
|
37
|
+
"""Return the first n lines of text."""
|
|
38
|
+
lines = text.splitlines()
|
|
39
|
+
if len(lines) <= n:
|
|
40
|
+
return text
|
|
41
|
+
return "\n".join(lines[:n]) + f"\n… ({len(lines) - n} more lines)"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Display:
|
|
45
|
+
"""Mode-aware console display for agent events.
|
|
46
|
+
|
|
47
|
+
Uses ``rich.text.Text`` objects (not markup strings) to avoid
|
|
48
|
+
escaping issues with model output containing ``[brackets]``.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, mode: DisplayMode | str = DisplayMode.SHORT) -> None:
|
|
52
|
+
if isinstance(mode, str):
|
|
53
|
+
mode = DisplayMode(mode)
|
|
54
|
+
self.mode = mode
|
|
55
|
+
self.console = Console()
|
|
56
|
+
self._lock = threading.RLock()
|
|
57
|
+
self._last_result_text: str = ""
|
|
58
|
+
self._pending_text: TextBlock | None = None
|
|
59
|
+
|
|
60
|
+
# ------------------------------------------------------------------
|
|
61
|
+
# Event handlers
|
|
62
|
+
# ------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
def on_metadata(self, **kwargs: str) -> None:
|
|
65
|
+
"""Print metadata key-value pairs (agent, model, session, etc.)."""
|
|
66
|
+
with self._lock:
|
|
67
|
+
if self.mode == DisplayMode.OFF:
|
|
68
|
+
return
|
|
69
|
+
pairs = [f"{k}={v}" for k, v in kwargs.items() if v]
|
|
70
|
+
if not pairs:
|
|
71
|
+
return
|
|
72
|
+
line = Text()
|
|
73
|
+
line.append("[Metadata] ", style="dim bold")
|
|
74
|
+
line.append(" ".join(pairs), style="dim")
|
|
75
|
+
self.console.print(line)
|
|
76
|
+
|
|
77
|
+
def _flush_pending_text(self, bold: bool = False) -> None:
|
|
78
|
+
"""Print any buffered text block. Called with bold=True for the final one."""
|
|
79
|
+
if self._pending_text is None:
|
|
80
|
+
return
|
|
81
|
+
block = self._pending_text
|
|
82
|
+
self._pending_text = None
|
|
83
|
+
|
|
84
|
+
style = "bold" if bold else ""
|
|
85
|
+
line = Text()
|
|
86
|
+
line.append("[Assistant] ", style="bold blue")
|
|
87
|
+
if self.mode == DisplayMode.FULL:
|
|
88
|
+
line.append(block.text, style=style)
|
|
89
|
+
else:
|
|
90
|
+
line.append(_truncate(block.text), style=style)
|
|
91
|
+
self.console.print(line)
|
|
92
|
+
|
|
93
|
+
def on_user_message(self, message: str) -> None:
|
|
94
|
+
"""Print the user's message."""
|
|
95
|
+
with self._lock:
|
|
96
|
+
if self.mode in (DisplayMode.RESULT, DisplayMode.OFF):
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
line = Text()
|
|
100
|
+
line.append("[User] ", style="bold green")
|
|
101
|
+
if self.mode == DisplayMode.FULL:
|
|
102
|
+
line.append(message, style="bold")
|
|
103
|
+
else:
|
|
104
|
+
line.append(_truncate(message), style="bold")
|
|
105
|
+
self.console.print(line)
|
|
106
|
+
|
|
107
|
+
def on_text(self, block: TextBlock) -> None:
|
|
108
|
+
"""Buffer an assistant text block (printed on next event or at turn end)."""
|
|
109
|
+
with self._lock:
|
|
110
|
+
if self.mode == DisplayMode.OFF:
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
if self.mode == DisplayMode.RESULT:
|
|
114
|
+
self._last_result_text = block.text
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
# Flush any previous text block (not the final one, so not bold)
|
|
118
|
+
self._flush_pending_text(bold=False)
|
|
119
|
+
self._pending_text = block
|
|
120
|
+
|
|
121
|
+
def on_thinking(self, block: ThinkingBlock) -> None:
|
|
122
|
+
"""Print a thinking block."""
|
|
123
|
+
with self._lock:
|
|
124
|
+
if self.mode in (DisplayMode.RESULT, DisplayMode.OFF):
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
line = Text()
|
|
128
|
+
line.append("[Thinking] ", style="dim magenta")
|
|
129
|
+
if self.mode == DisplayMode.FULL:
|
|
130
|
+
line.append(block.text, style="dim")
|
|
131
|
+
else:
|
|
132
|
+
line.append(_truncate(block.text), style="dim")
|
|
133
|
+
self.console.print(line)
|
|
134
|
+
|
|
135
|
+
def on_tool_call(self, block: ToolUse) -> None:
|
|
136
|
+
"""Print a tool call (args only — result not yet known)."""
|
|
137
|
+
with self._lock:
|
|
138
|
+
if self.mode in (DisplayMode.RESULT, DisplayMode.OFF):
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
self._flush_pending_text(bold=False)
|
|
142
|
+
|
|
143
|
+
line = Text()
|
|
144
|
+
line.append("[Tool] ", style="bold yellow")
|
|
145
|
+
line.append(block.name, style="bold cyan")
|
|
146
|
+
line.append(" ")
|
|
147
|
+
|
|
148
|
+
if self.mode == DisplayMode.FULL:
|
|
149
|
+
args_str = json.dumps(block.arguments, indent=2)
|
|
150
|
+
line.append(args_str, style="dim")
|
|
151
|
+
else:
|
|
152
|
+
args_str = json.dumps(block.arguments, separators=(",", ":"))
|
|
153
|
+
line.append(_truncate(args_str), style="dim")
|
|
154
|
+
self.console.print(line)
|
|
155
|
+
|
|
156
|
+
def on_tool_result(self, block: ToolUse) -> None:
|
|
157
|
+
"""Print a tool result (output now available on the block)."""
|
|
158
|
+
with self._lock:
|
|
159
|
+
if self.mode in (DisplayMode.RESULT, DisplayMode.OFF):
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
tag_style = "bold red" if block.is_error else "bold yellow"
|
|
163
|
+
line = Text()
|
|
164
|
+
line.append("[Result] ", style=tag_style)
|
|
165
|
+
line.append(block.name, style="bold cyan")
|
|
166
|
+
|
|
167
|
+
output = block.output
|
|
168
|
+
if output:
|
|
169
|
+
line.append("\n")
|
|
170
|
+
text = self.mode == DisplayMode.FULL and output or _first_n_lines(output)
|
|
171
|
+
# Parse ANSI escapes so colorful tool output keeps its colors;
|
|
172
|
+
# uncolored portions fall back to the dim base style.
|
|
173
|
+
result_text = Text.from_ansi(text, style="dim")
|
|
174
|
+
line.append_text(result_text)
|
|
175
|
+
self.console.print(line)
|
|
176
|
+
|
|
177
|
+
def on_turn_end(self, result: str, usage: UsageStats, duration_ms: int) -> None:
|
|
178
|
+
"""Print end-of-turn stats or deferred result text."""
|
|
179
|
+
with self._lock:
|
|
180
|
+
if self.mode == DisplayMode.OFF:
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
if self.mode == DisplayMode.RESULT:
|
|
184
|
+
if self._last_result_text:
|
|
185
|
+
self.console.print(Panel(self._last_result_text, border_style="green", expand=False))
|
|
186
|
+
self._last_result_text = ""
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
# Flush the last text block as bold (it's the final assistant message)
|
|
190
|
+
self._flush_pending_text(bold=True)
|
|
191
|
+
|
|
192
|
+
# Stats as metadata
|
|
193
|
+
tokens = f"{usage.input_tokens}in/{usage.output_tokens}out"
|
|
194
|
+
meta: dict[str, str] = {
|
|
195
|
+
"duration": f"{duration_ms}ms",
|
|
196
|
+
"tokens": tokens,
|
|
197
|
+
}
|
|
198
|
+
if usage.cost_usd:
|
|
199
|
+
meta["cost"] = f"${usage.cost_usd:.4f}"
|
|
200
|
+
self.on_metadata(**meta)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# -- Global display singleton --------------------------------------------------
|
|
204
|
+
|
|
205
|
+
_global_display: Display | None = None
|
|
206
|
+
_global_display_resolved = False
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def get_global_display() -> Display | None:
|
|
210
|
+
"""Return the global Display. Falls back to CAW_LOG env var on first call."""
|
|
211
|
+
global _global_display, _global_display_resolved
|
|
212
|
+
if _global_display is None and not _global_display_resolved:
|
|
213
|
+
_global_display_resolved = True
|
|
214
|
+
env_mode = os.environ.get(LOG_ENV_VAR, "short")
|
|
215
|
+
_global_display = Display(mode=env_mode)
|
|
216
|
+
return _global_display
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def set_global_display(display: Display | None) -> None:
|
|
220
|
+
"""Set (or clear) the global Display instance."""
|
|
221
|
+
global _global_display, _global_display_resolved
|
|
222
|
+
_global_display = display
|
|
223
|
+
_global_display_resolved = True
|