claudechic 0.2.2__py3-none-any.whl → 0.3.1__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.
- claudechic/__init__.py +3 -1
- claudechic/__main__.py +12 -1
- claudechic/agent.py +60 -19
- claudechic/agent_manager.py +8 -2
- claudechic/analytics.py +62 -0
- claudechic/app.py +267 -158
- claudechic/commands.py +120 -6
- claudechic/config.py +80 -0
- claudechic/features/worktree/commands.py +70 -1
- claudechic/help_data.py +200 -0
- claudechic/messages.py +0 -17
- claudechic/processes.py +120 -0
- claudechic/profiling.py +18 -1
- claudechic/protocols.py +1 -1
- claudechic/remote.py +249 -0
- claudechic/sessions.py +60 -50
- claudechic/styles.tcss +19 -18
- claudechic/widgets/__init__.py +112 -41
- claudechic/widgets/base/__init__.py +20 -0
- claudechic/widgets/base/clickable.py +23 -0
- claudechic/widgets/base/copyable.py +55 -0
- claudechic/{cursor.py → widgets/base/cursor.py} +9 -28
- claudechic/widgets/base/tool_protocol.py +30 -0
- claudechic/widgets/content/__init__.py +41 -0
- claudechic/widgets/{diff.py → content/diff.py} +11 -65
- claudechic/widgets/{chat.py → content/message.py} +25 -76
- claudechic/widgets/{tools.py → content/tools.py} +12 -24
- claudechic/widgets/input/__init__.py +9 -0
- claudechic/widgets/layout/__init__.py +51 -0
- claudechic/widgets/{chat_view.py → layout/chat_view.py} +92 -43
- claudechic/widgets/{footer.py → layout/footer.py} +17 -7
- claudechic/widgets/{indicators.py → layout/indicators.py} +55 -7
- claudechic/widgets/layout/processes.py +68 -0
- claudechic/widgets/{agents.py → layout/sidebar.py} +163 -82
- claudechic/widgets/modals/__init__.py +9 -0
- claudechic/widgets/modals/process_modal.py +121 -0
- claudechic/widgets/{profile_modal.py → modals/profile.py} +2 -1
- claudechic/widgets/primitives/__init__.py +13 -0
- claudechic/widgets/{button.py → primitives/button.py} +1 -1
- claudechic/widgets/{collapsible.py → primitives/collapsible.py} +5 -1
- claudechic/widgets/{scroll.py → primitives/scroll.py} +2 -0
- claudechic/widgets/primitives/spinner.py +57 -0
- claudechic/widgets/prompts.py +146 -17
- claudechic/widgets/reports/__init__.py +10 -0
- claudechic-0.3.1.dist-info/METADATA +88 -0
- claudechic-0.3.1.dist-info/RECORD +71 -0
- {claudechic-0.2.2.dist-info → claudechic-0.3.1.dist-info}/WHEEL +1 -1
- claudechic-0.3.1.dist-info/licenses/LICENSE +21 -0
- claudechic/features/worktree/prompts.py +0 -101
- claudechic/widgets/model_prompt.py +0 -56
- claudechic-0.2.2.dist-info/METADATA +0 -58
- claudechic-0.2.2.dist-info/RECORD +0 -54
- /claudechic/widgets/{todo.py → content/todo.py} +0 -0
- /claudechic/widgets/{autocomplete.py → input/autocomplete.py} +0 -0
- /claudechic/widgets/{history_search.py → input/history_search.py} +0 -0
- /claudechic/widgets/{context_report.py → reports/context.py} +0 -0
- /claudechic/widgets/{usage.py → reports/usage.py} +0 -0
- {claudechic-0.2.2.dist-info → claudechic-0.3.1.dist-info}/entry_points.txt +0 -0
- {claudechic-0.2.2.dist-info → claudechic-0.3.1.dist-info}/top_level.txt +0 -0
claudechic/profiling.py
CHANGED
|
@@ -9,6 +9,7 @@ from contextlib import contextmanager
|
|
|
9
9
|
|
|
10
10
|
_enabled = os.environ.get("CHIC_PROFILE", "true").lower() != "false"
|
|
11
11
|
_stats: dict[str, dict] = defaultdict(lambda: {"count": 0, "total": 0.0, "max": 0.0})
|
|
12
|
+
_start_time = time.perf_counter()
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
@contextmanager
|
|
@@ -60,7 +61,15 @@ def get_stats_table():
|
|
|
60
61
|
"""Get statistics as a Rich Table (borderless, compact)."""
|
|
61
62
|
from rich.table import Table
|
|
62
63
|
|
|
63
|
-
|
|
64
|
+
duration = get_session_duration()
|
|
65
|
+
table = Table(
|
|
66
|
+
box=None,
|
|
67
|
+
padding=(0, 4),
|
|
68
|
+
collapse_padding=True,
|
|
69
|
+
show_header=True,
|
|
70
|
+
title=f"[dim]Session duration: {duration:.1f}s[/]",
|
|
71
|
+
title_justify="left",
|
|
72
|
+
)
|
|
64
73
|
table.add_column("Function", style="dim")
|
|
65
74
|
table.add_column("Calls", justify="right")
|
|
66
75
|
table.add_column("Total", justify="right")
|
|
@@ -79,12 +88,20 @@ def get_stats_table():
|
|
|
79
88
|
return table
|
|
80
89
|
|
|
81
90
|
|
|
91
|
+
def get_session_duration() -> float:
|
|
92
|
+
"""Get session duration in seconds."""
|
|
93
|
+
return time.perf_counter() - _start_time
|
|
94
|
+
|
|
95
|
+
|
|
82
96
|
def get_stats_text() -> str:
|
|
83
97
|
"""Get statistics as plain text for copying."""
|
|
84
98
|
if not _stats:
|
|
85
99
|
return "No profiling data collected."
|
|
86
100
|
|
|
101
|
+
duration = get_session_duration()
|
|
87
102
|
lines = [
|
|
103
|
+
f"Session duration: {duration:.1f}s",
|
|
104
|
+
"",
|
|
88
105
|
f"{'Function':<45} {'Calls':>8} {'Total':>10} {'Avg':>10} {'Max':>10}",
|
|
89
106
|
"-" * 85,
|
|
90
107
|
]
|
claudechic/protocols.py
CHANGED
|
@@ -22,7 +22,7 @@ class AgentManagerObserver(Protocol):
|
|
|
22
22
|
"""Called when the active agent changes."""
|
|
23
23
|
...
|
|
24
24
|
|
|
25
|
-
def on_agent_closed(self, agent_id: str) -> None:
|
|
25
|
+
def on_agent_closed(self, agent_id: str, message_count: int) -> None:
|
|
26
26
|
"""Called when an agent is closed."""
|
|
27
27
|
...
|
|
28
28
|
|
claudechic/remote.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""HTTP server for remote control of claudechic.
|
|
2
|
+
|
|
3
|
+
Enables external processes (like Claude in another terminal) to:
|
|
4
|
+
- Take screenshots (SVG or PNG)
|
|
5
|
+
- Send messages to the active agent
|
|
6
|
+
- Wait for agent idle
|
|
7
|
+
- Get screen content as text
|
|
8
|
+
- Exit the app (for restart)
|
|
9
|
+
|
|
10
|
+
Start with --remote-port flag or CLAUDECHIC_REMOTE_PORT env var.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import io
|
|
17
|
+
import logging
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from rich.console import Console
|
|
21
|
+
from typing import TYPE_CHECKING
|
|
22
|
+
|
|
23
|
+
from aiohttp import web
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from claudechic.app import ChatApp
|
|
27
|
+
|
|
28
|
+
log = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
_app: ChatApp | None = None
|
|
31
|
+
_server: web.AppRunner | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def handle_screenshot(request: web.Request) -> web.Response:
|
|
35
|
+
"""Save screenshot. Query params: ?path=/tmp/shot.svg&format=svg|png
|
|
36
|
+
|
|
37
|
+
For PNG, uses macOS qlmanage for conversion (falls back to SVG if unavailable).
|
|
38
|
+
"""
|
|
39
|
+
if _app is None:
|
|
40
|
+
return web.json_response({"error": "App not initialized"}, status=500)
|
|
41
|
+
|
|
42
|
+
fmt = request.query.get("format", "svg")
|
|
43
|
+
default_path = f"/tmp/claudechic-screenshot.{fmt}"
|
|
44
|
+
path = request.query.get("path", default_path)
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
# Always save SVG first
|
|
48
|
+
svg_path = path if fmt == "svg" else path.replace(".png", ".svg")
|
|
49
|
+
result_path = _app.save_screenshot(
|
|
50
|
+
filename=Path(svg_path).name, path=str(Path(svg_path).parent)
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
if fmt == "png":
|
|
54
|
+
# Convert SVG to PNG using macOS qlmanage
|
|
55
|
+
import subprocess
|
|
56
|
+
|
|
57
|
+
png_path = path if path.endswith(".png") else f"{path}.png"
|
|
58
|
+
proc = await asyncio.create_subprocess_exec(
|
|
59
|
+
"qlmanage",
|
|
60
|
+
"-t",
|
|
61
|
+
"-s",
|
|
62
|
+
"1200",
|
|
63
|
+
"-o",
|
|
64
|
+
str(Path(png_path).parent),
|
|
65
|
+
result_path,
|
|
66
|
+
stdout=subprocess.DEVNULL,
|
|
67
|
+
stderr=subprocess.DEVNULL,
|
|
68
|
+
)
|
|
69
|
+
await proc.wait()
|
|
70
|
+
# qlmanage adds .png to the filename
|
|
71
|
+
actual_png = f"{result_path}.png"
|
|
72
|
+
if Path(actual_png).exists():
|
|
73
|
+
# Rename to requested path
|
|
74
|
+
Path(actual_png).rename(png_path)
|
|
75
|
+
result_path = png_path
|
|
76
|
+
|
|
77
|
+
return web.json_response({"path": result_path, "format": fmt})
|
|
78
|
+
except Exception as e:
|
|
79
|
+
return web.json_response({"error": str(e)}, status=500)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def handle_send(request: web.Request) -> web.Response:
|
|
83
|
+
"""Send a message or command to the active agent. Body: {"text": "message"}
|
|
84
|
+
|
|
85
|
+
If text starts with / or !, it's treated as a command.
|
|
86
|
+
Otherwise it's sent to the agent as a prompt.
|
|
87
|
+
"""
|
|
88
|
+
if _app is None:
|
|
89
|
+
return web.json_response({"error": "App not initialized"}, status=500)
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
data = await request.json()
|
|
93
|
+
text = data.get("text", "")
|
|
94
|
+
except Exception:
|
|
95
|
+
# Plain text body
|
|
96
|
+
text = await request.text()
|
|
97
|
+
|
|
98
|
+
if not text:
|
|
99
|
+
return web.json_response({"error": "No text provided"}, status=400)
|
|
100
|
+
|
|
101
|
+
# Check for slash/bang commands
|
|
102
|
+
stripped = text.strip()
|
|
103
|
+
if stripped.startswith("/") or stripped.startswith("!"):
|
|
104
|
+
from claudechic.commands import handle_command as do_command
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
handled = do_command(_app, text)
|
|
108
|
+
return web.json_response(
|
|
109
|
+
{"status": "executed" if handled else "not_handled", "command": text}
|
|
110
|
+
)
|
|
111
|
+
except Exception as e:
|
|
112
|
+
return web.json_response({"error": str(e)}, status=500)
|
|
113
|
+
|
|
114
|
+
# Send to active agent
|
|
115
|
+
agent = _app._agent
|
|
116
|
+
if agent is None:
|
|
117
|
+
return web.json_response({"error": "No active agent"}, status=400)
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
_app._send_to_active_agent(text)
|
|
121
|
+
return web.json_response({"status": "sent", "text": text[:100]})
|
|
122
|
+
except Exception as e:
|
|
123
|
+
return web.json_response({"error": str(e)}, status=500)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
async def handle_screen_text(request: web.Request) -> web.Response:
|
|
127
|
+
"""Get current screen content as plain text.
|
|
128
|
+
|
|
129
|
+
Returns the full screen rendered as text, preserving 2D layout.
|
|
130
|
+
Uses the same rendering pipeline as export_screenshot but outputs plain text.
|
|
131
|
+
|
|
132
|
+
Query params:
|
|
133
|
+
compact: If "false", include blank lines (default: true, removes blank lines)
|
|
134
|
+
"""
|
|
135
|
+
compact = request.query.get("compact", "true").lower() != "false"
|
|
136
|
+
if _app is None:
|
|
137
|
+
return web.json_response({"error": "App not initialized"}, status=500)
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
width, height = _app.size
|
|
141
|
+
console = Console(
|
|
142
|
+
width=width,
|
|
143
|
+
height=height,
|
|
144
|
+
file=io.StringIO(),
|
|
145
|
+
force_terminal=True,
|
|
146
|
+
color_system="truecolor",
|
|
147
|
+
record=True,
|
|
148
|
+
legacy_windows=False,
|
|
149
|
+
safe_box=False,
|
|
150
|
+
)
|
|
151
|
+
screen_render = _app.screen._compositor.render_update(
|
|
152
|
+
full=True, screen_stack=_app._background_screens
|
|
153
|
+
)
|
|
154
|
+
console.print(screen_render)
|
|
155
|
+
text = console.export_text(clear=True, styles=False)
|
|
156
|
+
if compact:
|
|
157
|
+
text = "\n".join(line for line in text.splitlines() if line.strip())
|
|
158
|
+
return web.json_response({"text": text})
|
|
159
|
+
except Exception as e:
|
|
160
|
+
return web.json_response({"error": str(e)}, status=500)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
async def handle_wait_idle(request: web.Request) -> web.Response:
|
|
164
|
+
"""Wait until active agent is idle. Query param: ?timeout=30"""
|
|
165
|
+
if _app is None:
|
|
166
|
+
return web.json_response({"error": "App not initialized"}, status=500)
|
|
167
|
+
|
|
168
|
+
timeout = float(request.query.get("timeout", "30"))
|
|
169
|
+
agent = _app._agent
|
|
170
|
+
if agent is None:
|
|
171
|
+
return web.json_response({"error": "No active agent"}, status=400)
|
|
172
|
+
|
|
173
|
+
from claudechic.enums import AgentStatus
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
start = asyncio.get_event_loop().time()
|
|
177
|
+
while agent.status != AgentStatus.IDLE:
|
|
178
|
+
if asyncio.get_event_loop().time() - start > timeout:
|
|
179
|
+
return web.json_response(
|
|
180
|
+
{"error": "Timeout waiting for idle"}, status=408
|
|
181
|
+
)
|
|
182
|
+
await asyncio.sleep(0.1)
|
|
183
|
+
return web.json_response({"status": "idle"})
|
|
184
|
+
except Exception as e:
|
|
185
|
+
return web.json_response({"error": str(e)}, status=500)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
async def handle_status(request: web.Request) -> web.Response: # noqa: ARG001
|
|
189
|
+
"""Get app/agent status."""
|
|
190
|
+
if _app is None:
|
|
191
|
+
return web.json_response({"error": "App not initialized"}, status=500)
|
|
192
|
+
|
|
193
|
+
agent = _app._agent
|
|
194
|
+
agents = []
|
|
195
|
+
if _app.agent_mgr:
|
|
196
|
+
for a in _app.agent_mgr:
|
|
197
|
+
agents.append(
|
|
198
|
+
{
|
|
199
|
+
"name": a.name,
|
|
200
|
+
"id": a.id,
|
|
201
|
+
"status": str(a.status),
|
|
202
|
+
"cwd": str(a.cwd),
|
|
203
|
+
"active": a.id == _app.agent_mgr.active_id,
|
|
204
|
+
}
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
return web.json_response(
|
|
208
|
+
{
|
|
209
|
+
"agents": agents,
|
|
210
|
+
"active_agent": agent.name if agent else None,
|
|
211
|
+
}
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
async def handle_exit(request: web.Request) -> web.Response: # noqa: ARG001
|
|
216
|
+
"""Exit the app cleanly. Use this before restarting."""
|
|
217
|
+
if _app is None:
|
|
218
|
+
return web.json_response({"error": "App not initialized"}, status=500)
|
|
219
|
+
|
|
220
|
+
# Schedule exit after response is sent
|
|
221
|
+
async def do_exit():
|
|
222
|
+
await asyncio.sleep(0.1) # Let response complete
|
|
223
|
+
if _app:
|
|
224
|
+
await _app._cleanup_and_exit()
|
|
225
|
+
|
|
226
|
+
asyncio.create_task(do_exit())
|
|
227
|
+
return web.json_response({"status": "exiting"})
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
async def start_server(app: ChatApp, port: int) -> None:
|
|
231
|
+
"""Start the remote control HTTP server."""
|
|
232
|
+
global _app, _server
|
|
233
|
+
_app = app
|
|
234
|
+
|
|
235
|
+
webapp = web.Application()
|
|
236
|
+
webapp.router.add_get("/screenshot", handle_screenshot)
|
|
237
|
+
webapp.router.add_post("/send", handle_send)
|
|
238
|
+
webapp.router.add_get("/screen_text", handle_screen_text)
|
|
239
|
+
webapp.router.add_get("/wait_idle", handle_wait_idle)
|
|
240
|
+
webapp.router.add_get("/status", handle_status)
|
|
241
|
+
webapp.router.add_post("/exit", handle_exit)
|
|
242
|
+
|
|
243
|
+
runner = web.AppRunner(webapp)
|
|
244
|
+
await runner.setup()
|
|
245
|
+
_server = runner
|
|
246
|
+
|
|
247
|
+
site = web.TCPSite(runner, "localhost", port)
|
|
248
|
+
await site.start()
|
|
249
|
+
log.info(f"Remote control server started on http://localhost:{port}")
|
claudechic/sessions.py
CHANGED
|
@@ -47,22 +47,48 @@ def _get_session_file(
|
|
|
47
47
|
return session_file if session_file.exists() else None
|
|
48
48
|
|
|
49
49
|
|
|
50
|
-
def
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
def _extract_session_info(
|
|
51
|
+
content: bytes,
|
|
52
|
+
) -> tuple[str | None, str | None, int, float | None]:
|
|
53
|
+
"""Extract summary, first message, count, and last timestamp from session content.
|
|
54
|
+
|
|
55
|
+
Returns (summary, first_user_message, msg_count, last_timestamp_unix).
|
|
56
|
+
"""
|
|
57
|
+
from datetime import datetime
|
|
58
|
+
|
|
59
|
+
summary = None
|
|
60
|
+
first_user_msg = None
|
|
61
|
+
msg_count = 0
|
|
62
|
+
last_timestamp: float | None = None
|
|
63
|
+
|
|
64
|
+
for line in content.split(b"\n"):
|
|
53
65
|
if not line.strip():
|
|
54
66
|
continue
|
|
55
67
|
try:
|
|
56
68
|
d = json.loads(line)
|
|
57
|
-
if d.get("type") == "
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
69
|
+
if d.get("type") == "summary":
|
|
70
|
+
summary = d.get("summary")
|
|
71
|
+
elif d.get("type") == "user" and not d.get("isMeta"):
|
|
72
|
+
msg_count += 1
|
|
73
|
+
# Capture first user message as fallback title
|
|
74
|
+
if first_user_msg is None:
|
|
75
|
+
text = d.get("message", {}).get("content", "")
|
|
76
|
+
if (
|
|
77
|
+
isinstance(text, str)
|
|
78
|
+
and text.strip()
|
|
79
|
+
and not text.startswith("<")
|
|
80
|
+
):
|
|
81
|
+
first_user_msg = text.replace("\n", " ")[:100]
|
|
82
|
+
# Track timestamp from any entry
|
|
83
|
+
if ts := d.get("timestamp"):
|
|
84
|
+
try:
|
|
85
|
+
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
86
|
+
last_timestamp = dt.timestamp()
|
|
87
|
+
except ValueError:
|
|
88
|
+
pass
|
|
62
89
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
63
|
-
# Skip lines that fail to parse (partial line at chunk boundary)
|
|
64
90
|
continue
|
|
65
|
-
return
|
|
91
|
+
return summary, first_user_msg, msg_count, last_timestamp
|
|
66
92
|
|
|
67
93
|
|
|
68
94
|
async def get_recent_sessions(
|
|
@@ -70,25 +96,20 @@ async def get_recent_sessions(
|
|
|
70
96
|
) -> list[tuple[str, str, float, int]]:
|
|
71
97
|
"""Get recent sessions from a project.
|
|
72
98
|
|
|
73
|
-
Optimized for responsiveness:
|
|
74
|
-
- Reads only first 16KB of each file for preview (not entire file)
|
|
75
|
-
- Sorts by mtime first, then only reads files needed
|
|
76
|
-
- Yields to event loop periodically
|
|
77
|
-
|
|
78
99
|
Args:
|
|
79
100
|
limit: Maximum number of sessions to return
|
|
80
|
-
search: Optional text to filter sessions by
|
|
101
|
+
search: Optional text to filter sessions by title
|
|
81
102
|
cwd: Project directory. If None, uses current working directory.
|
|
82
103
|
|
|
83
104
|
Returns:
|
|
84
|
-
List of (session_id,
|
|
105
|
+
List of (session_id, title, mtime, msg_count) tuples,
|
|
85
106
|
sorted by modification time descending.
|
|
86
107
|
"""
|
|
87
108
|
sessions_dir = get_project_sessions_dir(cwd)
|
|
88
109
|
if not sessions_dir:
|
|
89
110
|
return []
|
|
90
111
|
|
|
91
|
-
#
|
|
112
|
+
# Get files sorted by mtime
|
|
92
113
|
candidates = []
|
|
93
114
|
for f in sessions_dir.glob("*.jsonl"):
|
|
94
115
|
if not is_valid_uuid(f.stem):
|
|
@@ -96,57 +117,46 @@ async def get_recent_sessions(
|
|
|
96
117
|
try:
|
|
97
118
|
stat = f.stat()
|
|
98
119
|
if stat.st_size > 0:
|
|
99
|
-
candidates.append((f, stat.st_mtime
|
|
120
|
+
candidates.append((f, stat.st_mtime))
|
|
100
121
|
except OSError:
|
|
101
122
|
continue
|
|
102
123
|
|
|
103
124
|
candidates.sort(key=lambda x: x[1], reverse=True)
|
|
104
125
|
|
|
105
|
-
# Phase 2: Read previews from top candidates only
|
|
106
|
-
# If no search, we only need `limit` files
|
|
107
|
-
# If searching, we need to check more but can stop early
|
|
108
126
|
search_lower = search.lower()
|
|
109
127
|
sessions = []
|
|
110
|
-
check_limit = (
|
|
111
|
-
len(candidates) if search else limit * 2
|
|
112
|
-
) # read a few extra in case some fail
|
|
113
128
|
|
|
114
|
-
for i, (f, mtime
|
|
115
|
-
# Yield to event loop every 10 files to stay responsive
|
|
129
|
+
for i, (f, mtime) in enumerate(candidates):
|
|
116
130
|
if i > 0 and i % 10 == 0:
|
|
117
131
|
await asyncio.sleep(0)
|
|
118
132
|
|
|
119
133
|
try:
|
|
120
|
-
# Read only first 16KB - enough to find first user message
|
|
121
|
-
chunk_size = min(16384, size)
|
|
122
134
|
async with aiofiles.open(f, mode="rb") as fh:
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
135
|
+
content = await fh.read()
|
|
136
|
+
summary, first_msg, msg_count, last_ts = _extract_session_info(content)
|
|
137
|
+
title = summary or first_msg or f.stem[:8]
|
|
138
|
+
# Prefer timestamp from file content over file mtime
|
|
139
|
+
effective_time = last_ts or mtime
|
|
140
|
+
except (IOError, OSError):
|
|
141
|
+
title = f.stem[:8]
|
|
142
|
+
msg_count = 0
|
|
143
|
+
effective_time = mtime
|
|
132
144
|
|
|
133
|
-
|
|
134
|
-
|
|
145
|
+
if msg_count == 0:
|
|
146
|
+
continue
|
|
147
|
+
if search and search_lower not in title.lower():
|
|
148
|
+
continue
|
|
135
149
|
|
|
136
|
-
|
|
137
|
-
if not search and len(sessions) >= limit:
|
|
138
|
-
break
|
|
150
|
+
sessions.append((f.stem, title, effective_time, msg_count))
|
|
139
151
|
|
|
140
|
-
|
|
141
|
-
|
|
152
|
+
if not search and len(sessions) >= limit:
|
|
153
|
+
break
|
|
142
154
|
|
|
143
155
|
return sessions[:limit]
|
|
144
156
|
|
|
145
157
|
|
|
146
|
-
async def load_session_messages(
|
|
147
|
-
|
|
148
|
-
) -> list[dict]:
|
|
149
|
-
"""Load recent messages from a session file.
|
|
158
|
+
async def load_session_messages(session_id: str, cwd: Path | None = None) -> list[dict]:
|
|
159
|
+
"""Load all messages from a session file.
|
|
150
160
|
|
|
151
161
|
Returns list of message dicts with 'type' key:
|
|
152
162
|
- user: {'type': 'user', 'content': str}
|
|
@@ -194,7 +204,7 @@ async def load_session_messages(
|
|
|
194
204
|
except (json.JSONDecodeError, IOError):
|
|
195
205
|
pass
|
|
196
206
|
|
|
197
|
-
return messages
|
|
207
|
+
return messages
|
|
198
208
|
|
|
199
209
|
|
|
200
210
|
async def get_plan_path_for_session(
|
claudechic/styles.tcss
CHANGED
|
@@ -2,9 +2,18 @@
|
|
|
2
2
|
*
|
|
3
3
|
* CSS POLICY:
|
|
4
4
|
* -----------
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* DEFAULT_CSS (inline): Use for structural styles that are essential to the widget
|
|
6
|
+
* - Dimensions (width, height, min-width)
|
|
7
|
+
* - Layout properties (padding, margin for structure)
|
|
8
|
+
* - Display modes (display: none for hidden states)
|
|
9
|
+
*
|
|
10
|
+
* styles.tcss (this file): Use for visual/thematic styles
|
|
11
|
+
* - Colors (use theme variables: $primary, $surface, etc.)
|
|
12
|
+
* - Borders for visual grouping
|
|
13
|
+
* - Hover effects and interactive states
|
|
14
|
+
* - App-level layout coordination
|
|
15
|
+
*
|
|
16
|
+
* Avoid hardcoded hex colors - use theme variables.
|
|
8
17
|
*
|
|
9
18
|
* Visual language: All content blocks use left border bars to indicate type.
|
|
10
19
|
* - User messages: vibrant orange border ($primary)
|
|
@@ -158,7 +167,7 @@ ToolUseWidget {
|
|
|
158
167
|
margin: 0;
|
|
159
168
|
}
|
|
160
169
|
|
|
161
|
-
ToolUseWidget:hover
|
|
170
|
+
ToolUseWidget:hover {
|
|
162
171
|
border-left: wide $panel-lighten-2;
|
|
163
172
|
background: $surface;
|
|
164
173
|
}
|
|
@@ -186,7 +195,7 @@ ShellOutputWidget {
|
|
|
186
195
|
margin: 1 0;
|
|
187
196
|
}
|
|
188
197
|
|
|
189
|
-
ShellOutputWidget:hover
|
|
198
|
+
ShellOutputWidget:hover {
|
|
190
199
|
border-left: wide $warning-lighten-1;
|
|
191
200
|
background: $surface;
|
|
192
201
|
}
|
|
@@ -208,7 +217,7 @@ ShellOutputWidget #shell-output {
|
|
|
208
217
|
}
|
|
209
218
|
|
|
210
219
|
|
|
211
|
-
/* Copy buttons -
|
|
220
|
+
/* Copy buttons - hidden until hovered */
|
|
212
221
|
.copy-btn {
|
|
213
222
|
layer: above;
|
|
214
223
|
dock: right;
|
|
@@ -219,19 +228,10 @@ ShellOutputWidget #shell-output {
|
|
|
219
228
|
text-align: center;
|
|
220
229
|
background: transparent;
|
|
221
230
|
border: none;
|
|
222
|
-
color: transparent;
|
|
231
|
+
color: transparent;
|
|
223
232
|
}
|
|
224
233
|
|
|
225
|
-
|
|
226
|
-
ChatMessage.hovered .copy-btn,
|
|
227
|
-
ToolUseWidget:hover .copy-btn,
|
|
228
|
-
ToolUseWidget.hovered .copy-btn,
|
|
229
|
-
ShellOutputWidget:hover .copy-btn,
|
|
230
|
-
ShellOutputWidget.hovered .copy-btn {
|
|
231
|
-
color: $text-muted; /* Visible on hover */
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
Button.copy-btn:hover {
|
|
234
|
+
.copy-btn:hover {
|
|
235
235
|
color: $primary !important;
|
|
236
236
|
background: transparent !important;
|
|
237
237
|
}
|
|
@@ -256,6 +256,7 @@ Button.copy-btn:hover {
|
|
|
256
256
|
|
|
257
257
|
/* Chat views - both initial and dynamically created */
|
|
258
258
|
#chat-view, .chat-view {
|
|
259
|
+
layout: stream;
|
|
259
260
|
width: 1fr;
|
|
260
261
|
max-width: 100;
|
|
261
262
|
padding: 1 1;
|
|
@@ -422,7 +423,7 @@ MarkdownH2 {
|
|
|
422
423
|
MarkdownH3 {
|
|
423
424
|
color: $text;
|
|
424
425
|
text-style: bold;
|
|
425
|
-
margin: 1 0
|
|
426
|
+
margin: 1 0 1 0;
|
|
426
427
|
}
|
|
427
428
|
|
|
428
429
|
MarkdownTableContent > .header {
|