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.
Files changed (59) hide show
  1. claudechic/__init__.py +3 -1
  2. claudechic/__main__.py +12 -1
  3. claudechic/agent.py +60 -19
  4. claudechic/agent_manager.py +8 -2
  5. claudechic/analytics.py +62 -0
  6. claudechic/app.py +267 -158
  7. claudechic/commands.py +120 -6
  8. claudechic/config.py +80 -0
  9. claudechic/features/worktree/commands.py +70 -1
  10. claudechic/help_data.py +200 -0
  11. claudechic/messages.py +0 -17
  12. claudechic/processes.py +120 -0
  13. claudechic/profiling.py +18 -1
  14. claudechic/protocols.py +1 -1
  15. claudechic/remote.py +249 -0
  16. claudechic/sessions.py +60 -50
  17. claudechic/styles.tcss +19 -18
  18. claudechic/widgets/__init__.py +112 -41
  19. claudechic/widgets/base/__init__.py +20 -0
  20. claudechic/widgets/base/clickable.py +23 -0
  21. claudechic/widgets/base/copyable.py +55 -0
  22. claudechic/{cursor.py → widgets/base/cursor.py} +9 -28
  23. claudechic/widgets/base/tool_protocol.py +30 -0
  24. claudechic/widgets/content/__init__.py +41 -0
  25. claudechic/widgets/{diff.py → content/diff.py} +11 -65
  26. claudechic/widgets/{chat.py → content/message.py} +25 -76
  27. claudechic/widgets/{tools.py → content/tools.py} +12 -24
  28. claudechic/widgets/input/__init__.py +9 -0
  29. claudechic/widgets/layout/__init__.py +51 -0
  30. claudechic/widgets/{chat_view.py → layout/chat_view.py} +92 -43
  31. claudechic/widgets/{footer.py → layout/footer.py} +17 -7
  32. claudechic/widgets/{indicators.py → layout/indicators.py} +55 -7
  33. claudechic/widgets/layout/processes.py +68 -0
  34. claudechic/widgets/{agents.py → layout/sidebar.py} +163 -82
  35. claudechic/widgets/modals/__init__.py +9 -0
  36. claudechic/widgets/modals/process_modal.py +121 -0
  37. claudechic/widgets/{profile_modal.py → modals/profile.py} +2 -1
  38. claudechic/widgets/primitives/__init__.py +13 -0
  39. claudechic/widgets/{button.py → primitives/button.py} +1 -1
  40. claudechic/widgets/{collapsible.py → primitives/collapsible.py} +5 -1
  41. claudechic/widgets/{scroll.py → primitives/scroll.py} +2 -0
  42. claudechic/widgets/primitives/spinner.py +57 -0
  43. claudechic/widgets/prompts.py +146 -17
  44. claudechic/widgets/reports/__init__.py +10 -0
  45. claudechic-0.3.1.dist-info/METADATA +88 -0
  46. claudechic-0.3.1.dist-info/RECORD +71 -0
  47. {claudechic-0.2.2.dist-info → claudechic-0.3.1.dist-info}/WHEEL +1 -1
  48. claudechic-0.3.1.dist-info/licenses/LICENSE +21 -0
  49. claudechic/features/worktree/prompts.py +0 -101
  50. claudechic/widgets/model_prompt.py +0 -56
  51. claudechic-0.2.2.dist-info/METADATA +0 -58
  52. claudechic-0.2.2.dist-info/RECORD +0 -54
  53. /claudechic/widgets/{todo.py → content/todo.py} +0 -0
  54. /claudechic/widgets/{autocomplete.py → input/autocomplete.py} +0 -0
  55. /claudechic/widgets/{history_search.py → input/history_search.py} +0 -0
  56. /claudechic/widgets/{context_report.py → reports/context.py} +0 -0
  57. /claudechic/widgets/{usage.py → reports/usage.py} +0 -0
  58. {claudechic-0.2.2.dist-info → claudechic-0.3.1.dist-info}/entry_points.txt +0 -0
  59. {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
- table = Table(box=None, padding=(0, 4), collapse_padding=True, show_header=True)
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 _extract_preview_from_chunk(chunk: bytes) -> str | None:
51
- """Extract first user message preview from a chunk of session data."""
52
- for line in chunk.split(b"\n"):
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") == "user" and not d.get("isMeta"):
58
- content = d.get("message", {}).get("content", "")
59
- if isinstance(content, str) and not content.startswith("<"):
60
- text = content.replace("\n", " ")
61
- return text[:200] + "…" if len(text) > 200 else text
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 None
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 content
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, preview, mtime, msg_count) tuples,
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
- # Phase 1: Quick stat() to get files sorted by mtime (sync, fast)
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, stat.st_size))
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, size) in enumerate(candidates[:check_limit]):
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
- chunk = await fh.read(chunk_size)
124
-
125
- preview = _extract_preview_from_chunk(chunk)
126
- if not preview:
127
- continue
128
-
129
- # For search, check if preview matches (simplified - only checks preview, not full content)
130
- if search and search_lower not in preview.lower():
131
- continue
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
- # msg_count is now approximate (we don't read whole file)
134
- sessions.append((f.stem, preview, mtime, 1))
145
+ if msg_count == 0:
146
+ continue
147
+ if search and search_lower not in title.lower():
148
+ continue
135
149
 
136
- # Early exit if we have enough and not searching
137
- if not search and len(sessions) >= limit:
138
- break
150
+ sessions.append((f.stem, title, effective_time, msg_count))
139
151
 
140
- except (IOError, OSError):
141
- continue
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
- session_id: str, limit: int = 10, cwd: Path | None = None
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[-limit:]
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
- * Widgets use DEFAULT_CSS for co-located styles (Tailwind-style).
6
- * This file contains app-level layout and shared styles.
7
- * Use theme variables ($primary, $surface, etc.) - avoid hardcoded hex colors.
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, ToolUseWidget.hovered {
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, ShellOutputWidget.hovered {
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 - overlaid, hidden until hover */
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; /* Hidden by default via transparent color */
231
+ color: transparent;
223
232
  }
224
233
 
225
- ChatMessage:hover .copy-btn,
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 0 0;
426
+ margin: 1 0 1 0;
426
427
  }
427
428
 
428
429
  MarkdownTableContent > .header {