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/__init__.py CHANGED
@@ -1,5 +1,7 @@
1
1
  """Claude Chic - A stylish terminal UI for Claude Code."""
2
2
 
3
+ from importlib.metadata import version
4
+
3
5
  from claudechic.app import ChatApp
4
6
  from claudechic.theme import CHIC_THEME
5
7
  from claudechic.protocols import AgentManagerObserver, AgentObserver, PermissionHandler
@@ -11,4 +13,4 @@ __all__ = [
11
13
  "AgentObserver",
12
14
  "PermissionHandler",
13
15
  ]
14
- __version__ = "0.1.0"
16
+ __version__ = version("claudechic")
claudechic/__main__.py CHANGED
@@ -1,6 +1,7 @@
1
1
  """Entry point for claudechic CLI."""
2
2
 
3
3
  import argparse
4
+ import os
4
5
  from importlib.metadata import version
5
6
 
6
7
  from claudechic.app import ChatApp
@@ -24,6 +25,12 @@ def main():
24
25
  parser.add_argument(
25
26
  "--session", "-s", type=str, help="Resume a specific session ID"
26
27
  )
28
+ parser.add_argument(
29
+ "--remote-port",
30
+ type=int,
31
+ default=int(os.environ.get("CLAUDECHIC_REMOTE_PORT", "0")),
32
+ help="Start HTTP server for remote control on this port",
33
+ )
27
34
  parser.add_argument("prompt", nargs="*", help="Initial prompt to send")
28
35
  args = parser.parse_args()
29
36
 
@@ -42,7 +49,11 @@ def main():
42
49
  Console().control(Control.title(f"Claude Chic · {Path.cwd().name}"))
43
50
 
44
51
  try:
45
- app = ChatApp(resume_session_id=resume_id, initial_prompt=initial_prompt)
52
+ app = ChatApp(
53
+ resume_session_id=resume_id,
54
+ initial_prompt=initial_prompt,
55
+ remote_port=args.remote_port,
56
+ )
46
57
  app.run()
47
58
  except (KeyboardInterrupt, SystemExit):
48
59
  pass
claudechic/agent.py CHANGED
@@ -75,12 +75,22 @@ class ToolUse:
75
75
  is_error: bool = False
76
76
 
77
77
 
78
+ @dataclass
79
+ class TextBlock:
80
+ """A text block within an assistant turn."""
81
+
82
+ text: str
83
+
84
+
78
85
  @dataclass
79
86
  class AssistantContent:
80
- """An assistant message in chat history."""
87
+ """An assistant message in chat history.
81
88
 
82
- text: str = ""
83
- tool_uses: list[ToolUse] = field(default_factory=list)
89
+ Contains an ordered list of blocks (TextBlock or ToolUse) to preserve
90
+ the original interleaving of text and tool uses.
91
+ """
92
+
93
+ blocks: list[TextBlock | ToolUse] = field(default_factory=list)
84
94
 
85
95
 
86
96
  @dataclass
@@ -159,6 +169,7 @@ class Agent:
159
169
  self.auto_approve_edits: bool = False
160
170
  self.session_allowed_tools: set[str] = set() # Tools allowed for this session
161
171
  self._pending_followup: str | None = None # Auto-send after current response
172
+ self.model: str | None = None # Model override (None = SDK default)
162
173
 
163
174
  # Worktree finish state (for /worktree finish flow)
164
175
  self.finish_state: Any = None
@@ -173,6 +184,9 @@ class Agent:
173
184
  self.observer: AgentObserver | None = None
174
185
  self.permission_handler: PermissionHandler | None = None
175
186
 
187
+ # Background process tracking (PID of claude binary)
188
+ self._claude_pid: int | None = None
189
+
176
190
  # -----------------------------------------------------------------------
177
191
  # Lifecycle
178
192
  # -----------------------------------------------------------------------
@@ -194,6 +208,11 @@ class Agent:
194
208
  self.client = ClaudeSDKClient(options)
195
209
  await self.client.connect()
196
210
 
211
+ # Capture the claude process PID for background process tracking
212
+ from claudechic.processes import get_claude_pid_from_client
213
+
214
+ self._claude_pid = get_claude_pid_from_client(self.client)
215
+
197
216
  if resume:
198
217
  self.session_id = resume
199
218
 
@@ -217,7 +236,7 @@ class Agent:
217
236
  pass
218
237
  self.client = None
219
238
 
220
- async def load_history(self, limit: int = 50, cwd: Path | None = None) -> None:
239
+ async def load_history(self, cwd: Path | None = None) -> None:
221
240
  """Load message history from session file into self.messages.
222
241
 
223
242
  This populates Agent.messages from the persisted session,
@@ -225,7 +244,6 @@ class Agent:
225
244
  Call ChatView._render_full() after this to update UI.
226
245
 
227
246
  Args:
228
- limit: Maximum number of messages to load
229
247
  cwd: Working directory for session lookup (defaults to self.cwd)
230
248
  """
231
249
  from claudechic.sessions import load_session_messages
@@ -234,9 +252,7 @@ class Agent:
234
252
  return
235
253
 
236
254
  self.messages.clear()
237
- raw_messages = await load_session_messages(
238
- self.session_id, limit=limit, cwd=cwd or self.cwd
239
- )
255
+ raw_messages = await load_session_messages(self.session_id, cwd=cwd or self.cwd)
240
256
 
241
257
  current_assistant: AssistantContent | None = None
242
258
 
@@ -253,17 +269,15 @@ class Agent:
253
269
  ChatItem(role="user", content=UserContent(text=m["content"]))
254
270
  )
255
271
  elif m["type"] == "assistant":
256
- # Start or continue assistant content
272
+ # Add text block to current assistant content (preserving order)
257
273
  if current_assistant is None:
258
- current_assistant = AssistantContent(text=m["content"])
259
- else:
260
- # Append to existing (shouldn't happen often with current parser)
261
- current_assistant.text += "\n" + m["content"]
274
+ current_assistant = AssistantContent()
275
+ current_assistant.blocks.append(TextBlock(text=m["content"]))
262
276
  elif m["type"] == "tool_use":
263
- # Add tool use to current assistant content
277
+ # Add tool use to current assistant content (preserving order)
264
278
  if current_assistant is None:
265
279
  current_assistant = AssistantContent()
266
- current_assistant.tool_uses.append(
280
+ current_assistant.blocks.append(
267
281
  ToolUse(
268
282
  id=m.get("id", ""),
269
283
  name=m["name"],
@@ -475,7 +489,8 @@ class Agent:
475
489
  )
476
490
 
477
491
  self._current_text_buffer += text
478
- self._current_assistant.text = self._current_text_buffer
492
+ # Update the current TextBlock in-place for live streaming display
493
+ self._update_current_text_block()
479
494
  if self.observer:
480
495
  self.observer.on_message_updated(self)
481
496
  self.observer.on_text_chunk(self, text, new_message, parent_tool_use_id)
@@ -496,10 +511,24 @@ class Agent:
496
511
  self._needs_new_message = False
497
512
  self._handle_text_chunk(text, new_msg, parent_id)
498
513
 
514
+ def _update_current_text_block(self) -> None:
515
+ """Update the current TextBlock with accumulated text (for streaming)."""
516
+ if not self._current_assistant or not self._current_text_buffer:
517
+ return
518
+ # Find or create the trailing TextBlock
519
+ if self._current_assistant.blocks and isinstance(
520
+ self._current_assistant.blocks[-1], TextBlock
521
+ ):
522
+ self._current_assistant.blocks[-1].text = self._current_text_buffer
523
+ else:
524
+ self._current_assistant.blocks.append(
525
+ TextBlock(text=self._current_text_buffer)
526
+ )
527
+
499
528
  def _flush_current_text(self) -> None:
500
- """Flush accumulated text to current assistant message."""
529
+ """Flush accumulated text to current assistant message and reset buffer."""
501
530
  if self._current_assistant and self._current_text_buffer:
502
- self._current_assistant.text = self._current_text_buffer
531
+ self._update_current_text_block()
503
532
  self._current_text_buffer = ""
504
533
  if self.observer:
505
534
  self.observer.on_message_updated(self)
@@ -544,7 +573,7 @@ class Agent:
544
573
  self.messages.append(
545
574
  ChatItem(role="assistant", content=self._current_assistant)
546
575
  )
547
- self._current_assistant.tool_uses.append(tool)
576
+ self._current_assistant.blocks.append(tool)
548
577
  if self.observer:
549
578
  self.observer.on_message_updated(self)
550
579
  self.observer.on_tool_use(self, tool)
@@ -710,3 +739,15 @@ class Agent:
710
739
  "message": {"role": "user", "content": content},
711
740
  "parent_tool_use_id": None,
712
741
  }
742
+
743
+ def get_background_processes(self) -> list:
744
+ """Get list of background processes for this agent.
745
+
746
+ Returns:
747
+ List of BackgroundProcess objects
748
+ """
749
+ if not self._claude_pid:
750
+ return []
751
+ from claudechic.processes import get_child_processes
752
+
753
+ return get_child_processes(self._claude_pid)
@@ -115,6 +115,7 @@ class AgentManager:
115
115
  worktree: str | None = None,
116
116
  resume: str | None = None,
117
117
  switch_to: bool = True,
118
+ model: str | None = None,
118
119
  ) -> Agent:
119
120
  """Create and connect a new agent.
120
121
 
@@ -124,17 +125,21 @@ class AgentManager:
124
125
  worktree: Git worktree branch name if applicable
125
126
  resume: Session ID to resume
126
127
  switch_to: Whether to make this the active agent
128
+ model: Model override (None = SDK default)
127
129
 
128
130
  Returns:
129
131
  The created agent (connected and ready)
130
132
  """
131
133
  agent = Agent(name=name, cwd=cwd, worktree=worktree)
134
+ agent.model = model
132
135
 
133
136
  # Wire callbacks
134
137
  self._wire_agent_callbacks(agent)
135
138
 
136
139
  # Create options and connect
137
- options = self._options_factory(cwd=cwd, resume=resume, agent_name=agent.name)
140
+ options = self._options_factory(
141
+ cwd=cwd, resume=resume, agent_name=agent.name, model=model
142
+ )
138
143
  await agent.connect(options, resume=resume)
139
144
 
140
145
  # Register agent
@@ -192,13 +197,14 @@ class AgentManager:
192
197
 
193
198
  name = agent.name
194
199
  was_active = agent_id == self.active_id
200
+ message_count = len(agent.messages)
195
201
 
196
202
  # Disconnect
197
203
  await agent.disconnect()
198
204
  log.info(f"Closed agent '{name}' (id={agent_id})")
199
205
 
200
206
  if self.manager_observer:
201
- self.manager_observer.on_agent_closed(agent_id)
207
+ self.manager_observer.on_agent_closed(agent_id, message_count)
202
208
 
203
209
  # Switch to another agent if we closed the active one
204
210
  if was_active and self.agents:
@@ -0,0 +1,62 @@
1
+ """PostHog analytics for claudechic - fire-and-forget event tracking."""
2
+
3
+ import os
4
+ import platform
5
+ import shutil
6
+ import uuid as uuid_mod
7
+ from datetime import datetime, timezone
8
+
9
+ import httpx
10
+
11
+ from claudechic.config import get_analytics_enabled, get_analytics_id
12
+
13
+ VERSION = "0.1.0" # Keep in sync with __init__.py
14
+ SESSION_ID = str(uuid_mod.uuid4()) # Unique per process
15
+
16
+ POSTHOG_HOST = "https://us.i.posthog.com"
17
+ POSTHOG_API_KEY = "phc_M0LMkbSaDsaXi5LeYE5A95Kz8hTHgsJ4POlqucehsse"
18
+
19
+
20
+ async def capture(event: str, **properties: str | int | float | bool) -> None:
21
+ """Capture an analytics event to PostHog.
22
+
23
+ Fire-and-forget: failures are silently ignored.
24
+ Respects analytics opt-out setting.
25
+ """
26
+ if not get_analytics_enabled():
27
+ return
28
+
29
+ # Build properties - session_id on all events, context only on app_started
30
+ props: dict = {"$session_id": SESSION_ID, **properties}
31
+
32
+ if event in ("app_started", "app_installed"):
33
+ # Include version and environment context on session start and install
34
+ props["claudechic_version"] = VERSION
35
+ try:
36
+ term_size = os.get_terminal_size()
37
+ props["term_width"] = term_size.columns
38
+ props["term_height"] = term_size.lines
39
+ except OSError:
40
+ pass
41
+ props["term_program"] = os.environ.get("TERM_PROGRAM", "unknown")
42
+ props["os"] = platform.system()
43
+ props["has_uv"] = shutil.which("uv") is not None
44
+ props["has_conda"] = shutil.which("conda") is not None
45
+
46
+ payload = {
47
+ "api_key": POSTHOG_API_KEY,
48
+ "event": event,
49
+ "distinct_id": get_analytics_id(),
50
+ "timestamp": datetime.now(timezone.utc).isoformat(),
51
+ "properties": props,
52
+ }
53
+
54
+ try:
55
+ async with httpx.AsyncClient() as client:
56
+ await client.post(
57
+ f"{POSTHOG_HOST}/capture/",
58
+ json=payload,
59
+ timeout=5.0,
60
+ )
61
+ except Exception:
62
+ pass # Silent failure - analytics should never impact user experience