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/app.py CHANGED
@@ -30,7 +30,6 @@ from claude_agent_sdk import (
30
30
  ResultMessage,
31
31
  )
32
32
  from claudechic.messages import (
33
- StreamChunk,
34
33
  ResponseComplete,
35
34
  SystemNotification,
36
35
  ToolUseMessage,
@@ -48,6 +47,8 @@ from claudechic.features.worktree.commands import on_response_complete_finish
48
47
  from claudechic.permissions import PermissionRequest, PermissionResponse
49
48
  from claudechic.agent import Agent, ImageAttachment, ToolUse
50
49
  from claudechic.agent_manager import AgentManager
50
+ from claudechic.analytics import capture
51
+ from claudechic.config import get_theme, set_theme, is_new_install
51
52
  from claudechic.enums import AgentStatus, PermissionChoice, ToolName
52
53
  from claudechic.mcp import set_app, create_chic_server
53
54
  from claudechic.file_index import FileIndex
@@ -61,21 +62,23 @@ from claudechic.widgets import (
61
62
  AgentToolWidget,
62
63
  TodoWidget,
63
64
  TodoPanel,
65
+ ProcessPanel,
64
66
  SelectionPrompt,
65
67
  QuestionPrompt,
66
68
  SessionItem,
67
69
  TextAreaAutoComplete,
68
70
  HistorySearch,
69
- AgentSidebar,
71
+ AgentSection,
70
72
  AgentItem,
71
73
  WorktreeItem,
72
74
  ChatView,
73
- PlanButton,
75
+ PlanItem,
76
+ PlanSection,
74
77
  HamburgerButton,
75
78
  EditPlanRequested,
76
79
  )
77
- from claudechic.widgets.footer import AutoEditLabel, ModelLabel, StatusFooter
78
- from claudechic.widgets.model_prompt import ModelPrompt
80
+ from claudechic.widgets.layout.footer import AutoEditLabel, ModelLabel, StatusFooter
81
+ from claudechic.widgets.prompts import ModelPrompt
79
82
  from claudechic.errors import setup_logging # noqa: F401 - used at startup
80
83
  from claudechic.profiling import profile
81
84
  from claudechic.sampling import start_sampler
@@ -125,7 +128,6 @@ class ChatApp(App):
125
128
  "shift+tab", "cycle_permission_mode", "Auto-edit", priority=True, show=False
126
129
  ),
127
130
  Binding("escape", "escape", "Cancel", show=False),
128
- Binding("ctrl+n", "new_agent", "New Agent", priority=True, show=False),
129
131
  Binding("ctrl+r", "history_search", "History", priority=True, show=False),
130
132
  # Agent switching: ctrl+1 through ctrl+9
131
133
  *[
@@ -148,23 +150,32 @@ class ChatApp(App):
148
150
  CENTERED_SIDEBAR_WIDTH = 140 # Above this, center chat while showing sidebar
149
151
 
150
152
  def __init__(
151
- self, resume_session_id: str | None = None, initial_prompt: str | None = None
153
+ self,
154
+ resume_session_id: str | None = None,
155
+ initial_prompt: str | None = None,
156
+ remote_port: int = 0,
152
157
  ) -> None:
153
158
  super().__init__()
159
+ self.scroll_sensitivity_y = 1.0 # Smoother scrolling (default is 2.0)
154
160
  # AgentManager is the single source of truth for agents
155
161
  self.agent_mgr: AgentManager | None = None
156
162
 
157
163
  self._resume_on_start = resume_session_id
158
164
  self._initial_prompt = initial_prompt
165
+ self._remote_port = remote_port
159
166
  self._session_picker_active = False
160
167
  # Event queues for testing
161
168
  self.interactions: asyncio.Queue[PermissionRequest] = asyncio.Queue()
162
169
  self.completions: asyncio.Queue[ResponseComplete] = asyncio.Queue()
170
+ # Permission UI serialization - only show one prompt at a time
171
+ self._permission_lock = asyncio.Lock()
163
172
  # File index for fuzzy file search
164
173
  self.file_index: FileIndex | None = None
165
174
  # Cached widget references (initialized lazily)
166
- self._agent_sidebar: AgentSidebar | None = None
175
+ self._agent_section: AgentSection | None = None
176
+ self._plan_section: PlanSection | None = None
167
177
  self._todo_panel: TodoPanel | None = None
178
+ self._process_panel: ProcessPanel | None = None
168
179
  self._context_bar: ContextBar | None = None
169
180
  self._right_sidebar: Vertical | None = None
170
181
  self._input_container: Vertical | None = None
@@ -174,14 +185,15 @@ class ChatApp(App):
174
185
  self._shell_process: asyncio.subprocess.Process | None = None
175
186
  # Agent-to-UI mappings (Agent has no UI references)
176
187
  self._chat_views: dict[str, ChatView] = {} # agent_id -> ChatView
188
+ self._agent_metadata: dict[
189
+ str, dict
190
+ ] = {} # agent_id -> {created_at, same_directory}
177
191
  self._active_prompts: dict[
178
192
  str, Any
179
193
  ] = {} # agent_id -> SelectionPrompt/QuestionPrompt
180
194
  # Sidebar overlay state (for narrow screens)
181
195
  self._sidebar_overlay_open = False
182
196
  self._hamburger_btn: HamburgerButton | None = None
183
- # Selected model (None = SDK default)
184
- self.selected_model: str | None = None
185
197
  # Available models from SDK (populated in _update_slash_commands)
186
198
  self._available_models: list[dict] = []
187
199
 
@@ -255,10 +267,16 @@ class ChatApp(App):
255
267
 
256
268
  # Cached widget accessors (lazy init on first access)
257
269
  @property
258
- def agent_sidebar(self) -> AgentSidebar:
259
- if self._agent_sidebar is None:
260
- self._agent_sidebar = self.query_one("#agent-sidebar", AgentSidebar)
261
- return self._agent_sidebar
270
+ def agent_section(self) -> AgentSection:
271
+ if self._agent_section is None:
272
+ self._agent_section = self.query_one("#agent-section", AgentSection)
273
+ return self._agent_section
274
+
275
+ @property
276
+ def plan_section(self) -> PlanSection:
277
+ if self._plan_section is None:
278
+ self._plan_section = self.query_one("#plan-section", PlanSection)
279
+ return self._plan_section
262
280
 
263
281
  @property
264
282
  def todo_panel(self) -> TodoPanel:
@@ -266,6 +284,12 @@ class ChatApp(App):
266
284
  self._todo_panel = self.query_one("#todo-panel", TodoPanel)
267
285
  return self._todo_panel
268
286
 
287
+ @property
288
+ def process_panel(self) -> ProcessPanel:
289
+ if self._process_panel is None:
290
+ self._process_panel = self.query_one("#process-panel", ProcessPanel)
291
+ return self._process_panel
292
+
269
293
  @property
270
294
  def context_bar(self) -> ContextBar:
271
295
  if self._context_bar is None:
@@ -311,7 +335,7 @@ class ChatApp(App):
311
335
  return
312
336
  agent.status = status
313
337
  try:
314
- self.agent_sidebar.update_status(agent.id, status)
338
+ self.agent_section.update_status(agent.id, status)
315
339
  except Exception:
316
340
  pass # Sidebar not mounted yet
317
341
 
@@ -434,7 +458,9 @@ class ChatApp(App):
434
458
  "/compactish",
435
459
  "/usage",
436
460
  "/model",
461
+ "/processes",
437
462
  "/welcome",
463
+ "/help",
438
464
  ]
439
465
 
440
466
  def compose(self) -> ComposeResult:
@@ -443,8 +469,10 @@ class ChatApp(App):
443
469
  yield ListView(id="session-picker", classes="hidden")
444
470
  yield ChatView(id="chat-view")
445
471
  with Vertical(id="right-sidebar", classes="hidden"):
446
- yield AgentSidebar(id="agent-sidebar")
472
+ yield AgentSection(id="agent-section")
473
+ yield PlanSection(id="plan-section", classes="hidden")
447
474
  yield TodoPanel(id="todo-panel")
475
+ yield ProcessPanel(id="process-panel", classes="hidden")
448
476
  with Horizontal(id="input-wrapper"):
449
477
  with Vertical(id="input-container"):
450
478
  yield ImageAttachments(id="image-attachments", classes="hidden")
@@ -468,6 +496,7 @@ class ChatApp(App):
468
496
  cwd: Path | None = None,
469
497
  resume: str | None = None,
470
498
  agent_name: str | None = None,
499
+ model: str | None = None,
471
500
  ) -> ClaudeAgentOptions:
472
501
  """Create SDK options with common settings.
473
502
 
@@ -480,22 +509,37 @@ class ChatApp(App):
480
509
  setting_sources=["user", "project", "local"],
481
510
  cwd=cwd,
482
511
  resume=resume,
483
- model=self.selected_model,
512
+ model=model,
484
513
  mcp_servers={"chic": create_chic_server(caller_name=agent_name)},
485
514
  include_partial_messages=True,
486
515
  stderr=self._handle_sdk_stderr,
487
516
  )
488
517
 
489
518
  async def on_mount(self) -> None:
519
+ # Track app start (and install if new user)
520
+ self._app_start_time = time.time()
521
+ if is_new_install():
522
+ self.run_worker(capture("app_installed"))
523
+ self.run_worker(capture("app_started", resumed=bool(self._resume_on_start)))
524
+
490
525
  # Start CPU sampling profiler
491
526
  start_sampler()
492
527
 
528
+ # Start background process polling
529
+ self.set_interval(2.0, self._poll_background_processes)
530
+
493
531
  # Register app for MCP tools
494
532
  set_app(self)
495
533
 
496
- # Register and activate custom theme
534
+ # Start remote control server if requested
535
+ if self._remote_port:
536
+ from claudechic.remote import start_server
537
+
538
+ await start_server(self, self._remote_port)
539
+
540
+ # Register and activate custom theme (use saved preference or default to chic)
497
541
  self.register_theme(CHIC_THEME)
498
- self.theme = "chic"
542
+ self.theme = get_theme() or "chic"
499
543
 
500
544
  # Initialize AgentManager (new architecture)
501
545
  self.agent_mgr = AgentManager(self._make_options)
@@ -518,6 +562,10 @@ class ChatApp(App):
518
562
  # Connect SDK in background - UI renders while this happens
519
563
  self._connect_initial_client()
520
564
 
565
+ def watch_theme(self, theme: str) -> None:
566
+ """Save theme preference when changed."""
567
+ set_theme(theme)
568
+
521
569
  @work(exclusive=True, group="connect")
522
570
  async def _connect_initial_client(self) -> None:
523
571
  """Connect SDK for the initial agent."""
@@ -537,7 +585,7 @@ class ChatApp(App):
537
585
 
538
586
  # Connect the agent to SDK
539
587
  options = self._make_options(
540
- cwd=agent.cwd, resume=resume, agent_name=agent.name
588
+ cwd=agent.cwd, resume=resume, agent_name=agent.name, model=agent.model
541
589
  )
542
590
  try:
543
591
  await agent.connect(options, resume=resume)
@@ -575,27 +623,9 @@ class ChatApp(App):
575
623
  models = info["models"]
576
624
  if isinstance(models, list) and models:
577
625
  self._available_models = models
578
- # Find active model - either user-selected or SDK default
579
- active = models[0]
580
- for m in models:
581
- # If user selected a model, find it
582
- if (
583
- self.selected_model
584
- and m.get("value") == self.selected_model
585
- ):
586
- active = m
587
- break
588
- # Otherwise use the one marked 'default' by SDK
589
- if m.get("value") == "default":
590
- active = m
591
- # Extract short name from description like "Opus 4.5 · ..."
592
- desc = active.get("description", "")
593
- model_name = (
594
- desc.split("·")[0].strip()
595
- if "·" in desc
596
- else active.get("displayName", "")
597
- )
598
- self.status_footer.model = model_name
626
+ # Update footer with current agent's model
627
+ agent = self._agent
628
+ self._update_footer_model(agent.model if agent else None)
599
629
  except Exception as e:
600
630
  log.warning(f"Failed to fetch SDK commands: {e}")
601
631
  self.refresh_context()
@@ -606,6 +636,15 @@ class ChatApp(App):
606
636
  if self.file_index:
607
637
  await self.file_index.refresh()
608
638
 
639
+ def _poll_background_processes(self) -> None:
640
+ """Poll for background processes and update the panel and footer."""
641
+ agent = self._agent
642
+ if not agent:
643
+ return
644
+ processes = agent.get_background_processes()
645
+ self.process_panel.update_processes(processes)
646
+ self.status_footer.update_processes(processes)
647
+
609
648
  async def _load_and_display_history(
610
649
  self, session_id: str, cwd: Path | None = None
611
650
  ) -> None:
@@ -619,7 +658,7 @@ class ChatApp(App):
619
658
 
620
659
  # Set session_id and load history
621
660
  agent.session_id = session_id
622
- await agent.load_history(limit=50, cwd=cwd)
661
+ await agent.load_history(cwd=cwd)
623
662
 
624
663
  # Re-render ChatView from Agent.messages
625
664
  chat_view = self._chat_views.get(agent.id)
@@ -726,14 +765,6 @@ class ChatApp(App):
726
765
  except Exception:
727
766
  pass # OK to fail during shutdown
728
767
 
729
- @profile
730
- def on_stream_chunk(self, event: StreamChunk) -> None:
731
- chat_view = self._get_chat_view(event.agent_id)
732
- if not chat_view:
733
- return
734
-
735
- chat_view.append_text(event.text, event.new_message, event.parent_tool_use_id)
736
-
737
768
  @profile
738
769
  def on_tool_use_message(self, event: ToolUseMessage) -> None:
739
770
  agent = self._get_agent(event.agent_id)
@@ -741,21 +772,6 @@ class ChatApp(App):
741
772
  if not agent or not chat_view:
742
773
  return
743
774
 
744
- # TodoWrite gets special handling - update sidebar panel and/or inline widget
745
- if event.block.name == "TodoWrite":
746
- todos = event.block.input.get("todos", [])
747
- agent.todos = todos # Store on agent for switching
748
- self.todo_panel.update_todos(todos)
749
- self._position_right_sidebar()
750
- # Also update inline widget if exists, or create if narrow
751
- existing = self.query(TodoWidget)
752
- if existing:
753
- existing[0].update_todos(todos)
754
- elif self.size.width < self.SIDEBAR_MIN_WIDTH:
755
- chat_view.mount(TodoWidget(todos))
756
- self._show_thinking(event.agent_id)
757
- return
758
-
759
775
  # Create ToolUse data object for ChatView
760
776
  tool = ToolUse(
761
777
  id=event.block.id, name=event.block.name, input=event.block.input
@@ -850,7 +866,7 @@ class ChatApp(App):
850
866
  # Show sidebar when wide enough and we have multiple agents, worktrees, or todos
851
867
  agent_count = len(self.agent_mgr) if self.agent_mgr else 0
852
868
  has_content = (
853
- agent_count > 1 or self.agent_sidebar._worktrees or self.todo_panel.todos
869
+ agent_count > 1 or self.agent_section._worktrees or self.todo_panel.todos
854
870
  )
855
871
  width = self.size.width
856
872
  main = self.query_one("#main", Horizontal)
@@ -949,7 +965,7 @@ class ChatApp(App):
949
965
 
950
966
  # Use custom widget for context reports
951
967
  if "## Context Usage" in event.content:
952
- from claudechic.widgets.context_report import ContextReport
968
+ from claudechic.widgets.reports.context import ContextReport
953
969
 
954
970
  widget = ContextReport(event.content)
955
971
  else:
@@ -1080,15 +1096,19 @@ class ChatApp(App):
1080
1096
 
1081
1097
  async def _cleanup_and_exit(self) -> None:
1082
1098
  """Disconnect all agents and exit."""
1099
+ # Track app close with session duration
1100
+ duration = time.time() - getattr(self, "_app_start_time", time.time())
1101
+ await capture("app_closed", duration_seconds=int(duration))
1102
+
1083
1103
  for agent in self.agents.values():
1084
1104
  if agent.client:
1085
1105
  try:
1086
- await agent.client.interrupt()
1106
+ # disconnect() terminates the subprocess and waits for it to finish,
1107
+ # allowing it to flush session files before we exit
1108
+ await agent.client.disconnect()
1087
1109
  except Exception:
1088
1110
  pass # Best-effort cleanup during shutdown
1089
1111
  agent.client = None
1090
- # Brief delay to let SDK hooks complete before stream closes
1091
- await asyncio.sleep(0.1)
1092
1112
  # Suppress SDK stderr noise during exit (stream closed errors)
1093
1113
  sys.stderr = open(os.devnull, "w")
1094
1114
  self.exit()
@@ -1099,7 +1119,7 @@ class ChatApp(App):
1099
1119
  """Run a shell command async with PTY for color support."""
1100
1120
  from claudechic.shell_runner import run_in_pty
1101
1121
  from claudechic.widgets import ShellOutputWidget
1102
- from claudechic.widgets.chat import Spinner
1122
+ from claudechic.widgets.content.message import Spinner
1103
1123
 
1104
1124
  chat_view = self._chat_view
1105
1125
  if not chat_view:
@@ -1166,8 +1186,8 @@ class ChatApp(App):
1166
1186
  picker = self.query_one("#session-picker", ListView)
1167
1187
  picker.clear()
1168
1188
  sessions = await get_recent_sessions(search=search)
1169
- for session_id, preview, _, msg_count in sessions:
1170
- picker.append(SessionItem(session_id, preview, msg_count))
1189
+ for session_id, title, mtime, msg_count in sessions:
1190
+ picker.append(SessionItem(session_id, title, mtime, msg_count))
1171
1191
  # Select first item and focus for keyboard nav
1172
1192
  if sessions:
1173
1193
  self.call_after_refresh(
@@ -1195,7 +1215,12 @@ class ChatApp(App):
1195
1215
  resume_id = sessions[0][0] if sessions else None
1196
1216
 
1197
1217
  await self._replace_client(
1198
- self._make_options(cwd=new_cwd, resume=resume_id, agent_name=agent.name)
1218
+ self._make_options(
1219
+ cwd=new_cwd,
1220
+ resume=resume_id,
1221
+ agent_name=agent.name,
1222
+ model=agent.model,
1223
+ )
1199
1224
  )
1200
1225
 
1201
1226
  # Clear ChatView state
@@ -1222,7 +1247,7 @@ class ChatApp(App):
1222
1247
  agent.plan_path = plan_path
1223
1248
  # Update sidebar if this is still the active agent
1224
1249
  if self._agent and self._agent.id == agent.id:
1225
- self.agent_sidebar.set_plan(plan_path)
1250
+ self.plan_section.set_plan(plan_path)
1226
1251
 
1227
1252
  def action_escape(self) -> None:
1228
1253
  """Handle Escape: cancel picker, dismiss prompts, close overlay, or interrupt agent."""
@@ -1288,22 +1313,26 @@ class ChatApp(App):
1288
1313
  event.branch, event.path, worktree=event.branch, auto_resume=True
1289
1314
  )
1290
1315
 
1291
- def on_plan_button_clicked(self, event: PlanButton.Clicked) -> None:
1292
- """Handle plan button click - open plan file in editor."""
1316
+ def on_plan_item_plan_requested(self, event: PlanItem.PlanRequested) -> None:
1317
+ """Handle plan item click - open plan file in editor."""
1293
1318
  editor = os.environ.get("EDITOR", "vi")
1294
1319
  handle_command(self, f"/shell -i {editor} {event.plan_path}")
1295
1320
 
1296
- def on_hamburger_button_clicked(self, event: HamburgerButton.Clicked) -> None:
1297
- """Handle hamburger button click - toggle sidebar overlay."""
1321
+ def on_hamburger_button_sidebar_toggled(
1322
+ self, event: HamburgerButton.SidebarToggled
1323
+ ) -> None:
1324
+ """Handle hamburger button press - toggle sidebar overlay."""
1298
1325
  self._sidebar_overlay_open = not self._sidebar_overlay_open
1299
1326
  self._position_right_sidebar()
1300
1327
 
1301
1328
  def on_auto_edit_label_toggled(self, event: AutoEditLabel.Toggled) -> None:
1302
- """Handle auto-edit label click - toggle auto-edit mode."""
1329
+ """Handle auto-edit label press - toggle auto-edit mode."""
1303
1330
  self.action_cycle_permission_mode()
1304
1331
 
1305
- def on_model_label_clicked(self, event: ModelLabel.Clicked) -> None:
1306
- """Handle model label click - open model selector."""
1332
+ def on_model_label_model_change_requested(
1333
+ self, event: ModelLabel.ModelChangeRequested
1334
+ ) -> None:
1335
+ """Handle model label press - open model selector."""
1307
1336
  self._handle_model_prompt()
1308
1337
 
1309
1338
  def _close_sidebar_overlay(self) -> None:
@@ -1343,7 +1372,7 @@ class ChatApp(App):
1343
1372
  continue # Skip main worktree
1344
1373
  if wt.branch in agent_names:
1345
1374
  continue # Already have an agent
1346
- self.agent_sidebar.add_worktree(wt.branch, wt.path)
1375
+ self.agent_section.add_worktree(wt.branch, wt.path)
1347
1376
 
1348
1377
  def on_agent_item_close_requested(self, event: AgentItem.CloseRequested) -> None:
1349
1378
  """Handle close button click on agent item."""
@@ -1357,52 +1386,58 @@ class ChatApp(App):
1357
1386
  if agent_id not in self.agents:
1358
1387
  return
1359
1388
  old_agent = self._agent
1360
- # Save current input and hide old agent's UI
1361
- if old_agent:
1362
- old_agent.pending_input = self.chat_input.text
1363
- old_chat_view = self._chat_views.get(old_agent.id)
1364
- if old_chat_view:
1365
- old_chat_view.add_class("hidden")
1366
- old_prompt = self._active_prompts.get(old_agent.id)
1367
- if old_prompt:
1368
- old_prompt.add_class("hidden")
1369
- # Switch active agent (setter syncs to AgentManager)
1370
- self.active_agent_id = agent_id
1371
- agent = self._agent
1372
- chat_view = self._chat_views.get(agent_id)
1373
- if chat_view:
1374
- chat_view.remove_class("hidden")
1375
- # Restore new agent's input
1376
- if agent:
1377
- self.chat_input.text = agent.pending_input
1378
- # Show new agent's prompt if it has one, otherwise show input
1379
- active_prompt = self._active_prompts.get(agent_id)
1380
- if active_prompt:
1381
- active_prompt.remove_class("hidden")
1382
- self.input_container.add_class("hidden")
1383
- else:
1384
- self.input_container.remove_class("hidden")
1385
- # Update sidebar selection
1386
- self.agent_sidebar.set_active(agent_id)
1387
- # Update footer branch for new agent's cwd (async, non-blocking)
1389
+
1390
+ # Batch all class changes to trigger single CSS recalculation
1391
+ with self.batch_update():
1392
+ # Save current input and hide old agent's UI
1393
+ if old_agent:
1394
+ old_agent.pending_input = self.chat_input.text
1395
+ old_chat_view = self._chat_views.get(old_agent.id)
1396
+ if old_chat_view:
1397
+ old_chat_view.add_class("hidden")
1398
+ old_prompt = self._active_prompts.get(old_agent.id)
1399
+ if old_prompt:
1400
+ old_prompt.add_class("hidden")
1401
+ # Switch active agent (setter syncs to AgentManager)
1402
+ self.active_agent_id = agent_id
1403
+ agent = self._agent
1404
+ chat_view = self._chat_views.get(agent_id)
1405
+ if chat_view:
1406
+ chat_view.remove_class("hidden")
1407
+ # Restore new agent's input
1408
+ if agent:
1409
+ self.chat_input.text = agent.pending_input
1410
+ # Show new agent's prompt if it has one, otherwise show input
1411
+ active_prompt = self._active_prompts.get(agent_id)
1412
+ if active_prompt:
1413
+ active_prompt.remove_class("hidden")
1414
+ self.input_container.add_class("hidden")
1415
+ else:
1416
+ self.input_container.remove_class("hidden")
1417
+ # Update sidebar selection
1418
+ self.agent_section.set_active(agent_id)
1419
+ # Update footer
1420
+ self.status_footer.auto_edit = agent.auto_approve_edits if agent else False
1421
+ self._update_footer_model(agent.model if agent else None)
1422
+ # Update todo panel for new agent
1423
+ self.todo_panel.update_todos(agent.todos if agent else [])
1424
+ # Update context bar for new agent
1425
+ self.refresh_context()
1426
+ # Update plan button for new agent (use cached plan_path)
1427
+ self.plan_section.set_plan(agent.plan_path if agent else None)
1428
+ self._position_right_sidebar()
1429
+
1430
+ # These happen outside batch (async/focus)
1388
1431
  asyncio.create_task(
1389
1432
  self.status_footer.refresh_branch(str(agent.cwd) if agent else None)
1390
1433
  )
1391
- self.status_footer.auto_edit = agent.auto_approve_edits if agent else False
1392
- # Update todo panel for new agent
1393
- self.todo_panel.update_todos(agent.todos if agent else [])
1394
- # Update context bar for new agent
1395
- self.refresh_context()
1396
- # Update plan button for new agent (use cached plan_path)
1397
- self.agent_sidebar.set_plan(agent.plan_path if agent else None)
1398
- self._position_right_sidebar()
1399
1434
  self.chat_input.focus()
1400
1435
 
1401
1436
  async def _reconnect_agent(self, agent: "Agent", session_id: str) -> None:
1402
1437
  """Disconnect and reconnect an agent to reload its session."""
1403
1438
  await agent.disconnect()
1404
1439
  options = self._make_options(
1405
- cwd=agent.cwd, resume=session_id, agent_name=agent.name
1440
+ cwd=agent.cwd, resume=session_id, agent_name=agent.name, model=agent.model
1406
1441
  )
1407
1442
  await agent.connect(options, resume=session_id)
1408
1443
 
@@ -1416,7 +1451,9 @@ class ChatApp(App):
1416
1451
  if chat_view:
1417
1452
  chat_view.clear()
1418
1453
  await agent.disconnect()
1419
- options = self._make_options(cwd=agent.cwd, agent_name=agent.name)
1454
+ options = self._make_options(
1455
+ cwd=agent.cwd, agent_name=agent.name, model=agent.model
1456
+ )
1420
1457
  await agent.connect(options)
1421
1458
  self.refresh_context()
1422
1459
  self.notify("New session started")
@@ -1425,7 +1462,7 @@ class ChatApp(App):
1425
1462
  async def _handle_usage_command(self) -> None:
1426
1463
  """Handle /usage command - show API usage limits."""
1427
1464
  from claudechic.usage import fetch_usage
1428
- from claudechic.widgets.usage import UsageReport
1465
+ from claudechic.widgets.reports.usage import UsageReport
1429
1466
 
1430
1467
  chat_view = self._chat_view
1431
1468
  if not chat_view:
@@ -1436,16 +1473,40 @@ class ChatApp(App):
1436
1473
  chat_view.mount(widget)
1437
1474
  chat_view.scroll_if_tailing()
1438
1475
 
1476
+ @work(group="model_switch", exclusive=True, exit_on_error=False)
1477
+ async def _set_agent_model(self, model: str) -> None:
1478
+ """Set model for active agent and reconnect."""
1479
+ agent = self._agent
1480
+ if not agent:
1481
+ self.notify("No active agent", severity="warning")
1482
+ return
1483
+ if model == agent.model:
1484
+ return
1485
+ agent.model = model
1486
+ self._update_footer_model(model)
1487
+ if agent.client:
1488
+ self.notify(f"Switching to {model}...")
1489
+ await agent.disconnect()
1490
+ options = self._make_options(
1491
+ cwd=agent.cwd, agent_name=agent.name, model=model
1492
+ )
1493
+ await agent.connect(options)
1494
+
1439
1495
  @work(group="model_prompt", exclusive=True, exit_on_error=False)
1440
1496
  async def _handle_model_prompt(self) -> None:
1441
- """Show model selection prompt and handle result."""
1497
+ """Show model selection prompt and handle result for active agent."""
1442
1498
  from textual.containers import Center
1443
1499
 
1500
+ agent = self._agent
1501
+ if not agent:
1502
+ self.notify("No active agent", severity="warning")
1503
+ return
1504
+
1444
1505
  if not self._available_models:
1445
1506
  self.notify("No models available", severity="warning")
1446
1507
  return
1447
1508
 
1448
- prompt = ModelPrompt(self._available_models, current_value=self.selected_model)
1509
+ prompt = ModelPrompt(self._available_models, current_value=agent.model)
1449
1510
  container = Center(prompt, id="model-modal")
1450
1511
  self.mount(container)
1451
1512
 
@@ -1454,17 +1515,8 @@ class ChatApp(App):
1454
1515
  finally:
1455
1516
  container.remove()
1456
1517
 
1457
- if result and result != self.selected_model:
1458
- self.selected_model = result
1459
- # Reconnect active agent with new model
1460
- agent = self._agent
1461
- if agent and agent.client:
1462
- self.notify(f"Switching to {result}...")
1463
- await agent.disconnect()
1464
- options = self._make_options(cwd=agent.cwd, agent_name=agent.name)
1465
- await agent.connect(options)
1466
- # Refresh model display
1467
- await self._update_slash_commands()
1518
+ if result and result != agent.model:
1519
+ self._set_agent_model(result)
1468
1520
 
1469
1521
  @work(group="new_agent", exclusive=True, exit_on_error=False)
1470
1522
  async def _create_new_agent(
@@ -1474,6 +1526,7 @@ class ChatApp(App):
1474
1526
  worktree: str | None = None,
1475
1527
  auto_resume: bool = False,
1476
1528
  switch_to: bool = True,
1529
+ model: str | None = None,
1477
1530
  ) -> None:
1478
1531
  """Create a new agent via AgentManager.
1479
1532
 
@@ -1483,6 +1536,7 @@ class ChatApp(App):
1483
1536
  worktree: Git worktree branch name if applicable
1484
1537
  auto_resume: Try to resume session with most messages in cwd
1485
1538
  switch_to: Whether to switch to the new agent (default True)
1539
+ model: Model override (None = SDK default)
1486
1540
  """
1487
1541
  if self.agent_mgr is None:
1488
1542
  self.notify("Agent manager not initialized", severity="error")
@@ -1505,6 +1559,7 @@ class ChatApp(App):
1505
1559
  worktree=worktree,
1506
1560
  resume=resume_id,
1507
1561
  switch_to=switch_to,
1562
+ model=model,
1508
1563
  )
1509
1564
  except Exception as e:
1510
1565
  self.show_error(f"Failed to create agent '{name}'", e)
@@ -1636,6 +1691,14 @@ class ChatApp(App):
1636
1691
  """Handle new agent creation from AgentManager."""
1637
1692
  log.info(f"New agent created: {agent.name} (id={agent.id})")
1638
1693
 
1694
+ # Track analytics (same_directory = agent cwd matches app starting cwd)
1695
+ same_directory = agent.cwd == Path.cwd()
1696
+ self._agent_metadata[agent.id] = {
1697
+ "created_at": time.time(),
1698
+ "same_directory": same_directory,
1699
+ }
1700
+ self.run_worker(capture("agent_created", same_directory=same_directory))
1701
+
1639
1702
  try:
1640
1703
  # Create chat view for the agent
1641
1704
  is_first_agent = len(self.agent_mgr.agents) == 1 if self.agent_mgr else True
@@ -1648,7 +1711,8 @@ class ChatApp(App):
1648
1711
  else:
1649
1712
  # Additional agents get new chat views
1650
1713
  chat_view = ChatView(
1651
- id=f"chat-view-{agent.id}", classes="chat-view hidden"
1714
+ id=f"chat-view-{agent.id.replace('/', '-')}",
1715
+ classes="chat-view hidden",
1652
1716
  )
1653
1717
  main = self.query_one("#main", Horizontal)
1654
1718
  main.mount(chat_view, after=self.query_one("#session-picker"))
@@ -1659,7 +1723,7 @@ class ChatApp(App):
1659
1723
 
1660
1724
  # Add to sidebar
1661
1725
  try:
1662
- self.agent_sidebar.add_agent(agent.id, agent.name)
1726
+ self.agent_section.add_agent(agent.id, agent.name)
1663
1727
  except Exception:
1664
1728
  log.debug(f"Sidebar not mounted for agent {agent.id}")
1665
1729
 
@@ -1685,24 +1749,39 @@ class ChatApp(App):
1685
1749
 
1686
1750
  # Update sidebar
1687
1751
  try:
1688
- self.agent_sidebar.set_active(new_agent.id)
1752
+ self.agent_section.set_active(new_agent.id)
1689
1753
  except Exception:
1690
1754
  pass
1691
1755
 
1692
1756
  # Update footer
1693
1757
  self._update_footer_auto_edit()
1694
1758
  self._update_footer_cwd(new_agent.cwd)
1759
+ self._update_footer_model(new_agent.model)
1695
1760
 
1696
1761
  # Update todo panel
1697
1762
  self.todo_panel.update_todos(new_agent.todos)
1698
1763
  self.refresh_context()
1699
1764
  self._position_right_sidebar()
1700
1765
 
1701
- def on_agent_closed(self, agent_id: str) -> None:
1766
+ def on_agent_closed(self, agent_id: str, message_count: int = 0) -> None:
1702
1767
  """Handle agent closure from AgentManager."""
1703
1768
  log.info(f"Agent closed: {agent_id}")
1769
+
1770
+ # Track analytics
1771
+ metadata = self._agent_metadata.pop(agent_id, {})
1772
+ duration = time.time() - metadata.get("created_at", time.time())
1773
+ same_directory = metadata.get("same_directory", True)
1774
+ self.run_worker(
1775
+ capture(
1776
+ "agent_closed",
1777
+ duration_seconds=int(duration),
1778
+ same_directory=same_directory,
1779
+ message_count=message_count,
1780
+ )
1781
+ )
1782
+
1704
1783
  try:
1705
- self.agent_sidebar.remove_agent(agent_id)
1784
+ self.agent_section.remove_agent(agent_id)
1706
1785
  except Exception:
1707
1786
  pass
1708
1787
  self._position_right_sidebar()
@@ -1710,7 +1789,7 @@ class ChatApp(App):
1710
1789
  def on_status_changed(self, agent: Agent) -> None:
1711
1790
  """Handle agent status change."""
1712
1791
  try:
1713
- self.agent_sidebar.update_status(agent.id, agent.status)
1792
+ self.agent_section.update_status(agent.id, agent.status)
1714
1793
  # Update hamburger color if any agent needs attention
1715
1794
  self._update_hamburger_attention()
1716
1795
  except Exception:
@@ -1764,23 +1843,21 @@ class ChatApp(App):
1764
1843
  def on_todos_updated(self, agent: Agent) -> None:
1765
1844
  """Handle agent todos update."""
1766
1845
  if self.agent_mgr and agent.id == self.agent_mgr.active_id:
1767
- try:
1768
- self.todo_panel.update_todos(agent.todos)
1769
- except Exception:
1770
- pass
1846
+ self.todo_panel.update_todos(agent.todos)
1847
+ self._position_right_sidebar()
1848
+ # Add inline widget to chat stream
1849
+ chat_view = self._get_chat_view(agent.id)
1850
+ if chat_view:
1851
+ chat_view.mount(TodoWidget(agent.todos))
1852
+ chat_view.scroll_if_tailing()
1771
1853
 
1772
1854
  def on_text_chunk(
1773
1855
  self, agent: Agent, text: str, new_message: bool, parent_tool_use_id: str | None
1774
1856
  ) -> None:
1775
- """Handle text chunk from agent - post Textual Message for UI."""
1776
- self.post_message(
1777
- StreamChunk(
1778
- text,
1779
- new_message=new_message,
1780
- parent_tool_use_id=parent_tool_use_id,
1781
- agent_id=agent.id,
1782
- )
1783
- )
1857
+ """Handle text chunk from agent - update UI directly (bypasses message queue)."""
1858
+ chat_view = self._chat_views.get(agent.id)
1859
+ if chat_view:
1860
+ chat_view.append_text(text, new_message, parent_tool_use_id)
1784
1861
 
1785
1862
  def on_tool_use(self, agent: Agent, tool: ToolUse) -> None:
1786
1863
  """Handle tool use from agent - post Textual Message for UI."""
@@ -1833,15 +1910,25 @@ class ChatApp(App):
1833
1910
 
1834
1911
  This is called by Agent when it needs user input for a permission.
1835
1912
  Returns a PermissionResponse with choice and optional alternative message.
1913
+
1914
+ Uses a lock to serialize permission prompts - only one shown at a time.
1836
1915
  """
1837
1916
  # Put in interactions queue for testing
1838
1917
  await self.interactions.put(request)
1839
1918
 
1840
- # Wait until this agent is active before showing prompt (multi-agent only)
1841
- if len(self.agents) > 1:
1842
- while agent.id != self.active_agent_id:
1843
- await asyncio.sleep(0.1)
1919
+ # Serialize permission prompts - acquire lock before showing UI
1920
+ async with self._permission_lock:
1921
+ # Wait until this agent is active before showing prompt (multi-agent only)
1922
+ if len(self.agents) > 1:
1923
+ while agent.id != self.active_agent_id:
1924
+ await asyncio.sleep(0.1)
1844
1925
 
1926
+ return await self._show_permission_prompt(agent, request)
1927
+
1928
+ async def _show_permission_prompt(
1929
+ self, agent: Agent, request: PermissionRequest
1930
+ ) -> PermissionResponse:
1931
+ """Show the permission prompt UI (called under _permission_lock)."""
1845
1932
  if request.tool_name == ToolName.ASK_USER_QUESTION:
1846
1933
  # Handle question prompts
1847
1934
  questions = request.tool_input.get("questions", [])
@@ -1910,3 +1997,25 @@ class ChatApp(App):
1910
1997
  asyncio.create_task(self.status_footer.refresh_branch(str(cwd)))
1911
1998
  except Exception:
1912
1999
  pass
2000
+
2001
+ def _update_footer_model(self, model: str | None) -> None:
2002
+ """Update footer to show agent's model."""
2003
+ if not self._available_models:
2004
+ # No model info yet - show raw value or empty
2005
+ self.status_footer.model = model.capitalize() if model else ""
2006
+ return
2007
+ # Find matching model, or default if model is None
2008
+ active = self._available_models[0]
2009
+ for m in self._available_models:
2010
+ if model and m.get("value") == model:
2011
+ active = m
2012
+ break
2013
+ if not model and m.get("value") == "default":
2014
+ active = m
2015
+ break
2016
+ # Extract short name from description like "Opus 4.5 · ..."
2017
+ desc = active.get("description", "")
2018
+ model_name = (
2019
+ desc.split("·")[0].strip() if "·" in desc else active.get("displayName", "")
2020
+ )
2021
+ self.status_footer.model = model_name