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/commands.py CHANGED
@@ -54,8 +54,22 @@ def handle_command(app: "ChatApp", prompt: str) -> bool:
54
54
  app._handle_usage_command()
55
55
  return True
56
56
 
57
- if cmd == "/model":
58
- app._handle_model_prompt()
57
+ if cmd == "/model" or cmd.startswith("/model "):
58
+ parts = cmd.split(maxsplit=1)
59
+ if len(parts) == 1:
60
+ # No argument - show prompt
61
+ app._handle_model_prompt()
62
+ else:
63
+ # Direct model selection: /model sonnet
64
+ model = parts[1].lower()
65
+ valid_models = {"opus", "sonnet", "haiku"}
66
+ if model not in valid_models:
67
+ app.notify(
68
+ f"Invalid model '{model}'. Use: opus, sonnet, haiku",
69
+ severity="error",
70
+ )
71
+ else:
72
+ app._set_agent_model(model)
59
73
  return True
60
74
 
61
75
  if cmd == "/exit":
@@ -65,6 +79,17 @@ def handle_command(app: "ChatApp", prompt: str) -> bool:
65
79
  if cmd == "/welcome":
66
80
  return _handle_welcome(app)
67
81
 
82
+ if cmd == "/help":
83
+ app.run_worker(_handle_help(app))
84
+ return True
85
+
86
+ if cmd == "/processes":
87
+ _handle_processes(app)
88
+ return True
89
+
90
+ if cmd.startswith("/analytics"):
91
+ return _handle_analytics(app, cmd)
92
+
68
93
  return False
69
94
 
70
95
 
@@ -91,7 +116,7 @@ def _handle_agent(app: "ChatApp", command: str) -> bool:
91
116
  # In narrow mode, open the sidebar overlay instead of listing
92
117
  width = app.size.width
93
118
  has_content = (
94
- len(app.agents) > 1 or app.agent_sidebar._worktrees or app.todo_panel.todos
119
+ len(app.agents) > 1 or app.agent_section._worktrees or app.todo_panel.todos
95
120
  )
96
121
  if width < app.SIDEBAR_MIN_WIDTH and has_content:
97
122
  app._sidebar_overlay_open = True
@@ -123,10 +148,37 @@ def _handle_agent(app: "ChatApp", command: str) -> bool:
123
148
  app._close_agent(target)
124
149
  return True
125
150
 
126
- # Create new agent
151
+ # Check if agent with this name exists - switch to it
127
152
  name = subcommand
128
- path = Path(parts[2]) if len(parts) > 2 else Path.cwd()
129
- app._create_new_agent(name, path)
153
+ existing = app.agent_mgr.find_by_name(name) if app.agent_mgr else None
154
+ if existing:
155
+ app._switch_to_agent(existing.id)
156
+ return True
157
+
158
+ # Create new agent - parse optional --model flag (supports --model=x or --model x)
159
+ cwd: Path | None = None
160
+ model = None
161
+ valid_models = {"opus", "sonnet", "haiku"}
162
+ args = parts[2:]
163
+ i = 0
164
+ while i < len(args):
165
+ part = args[i]
166
+ if part.startswith("--model="):
167
+ model = part[8:].lower()
168
+ elif part == "--model" and i + 1 < len(args):
169
+ model = args[i + 1].lower()
170
+ i += 1
171
+ elif not part.startswith("-") and cwd is None:
172
+ cwd = Path(part)
173
+ i += 1
174
+ if model and model not in valid_models:
175
+ app.notify(
176
+ f"Invalid model '{model}'. Use: opus, sonnet, haiku", severity="error"
177
+ )
178
+ return True
179
+ # Default to current agent's cwd, fallback to app's cwd
180
+ default_cwd = app._agent.cwd if app._agent else Path.cwd()
181
+ app._create_new_agent(name, cwd or default_cwd, model=model)
130
182
  return True
131
183
 
132
184
 
@@ -239,6 +291,11 @@ Claude Chic is open source and written in Python with Textual. It's easy to ext
239
291
 
240
292
  **Example:** Use simple quality of life features like shell support with `!ls`. or `!git diff`.
241
293
 
294
+ For more information, read
295
+ [the docs](https://matthewrocklin.com/claudechic),
296
+ [GitHub](https://github.com/mrocklin/claudechic),
297
+ or this [introductory video](https://www.youtube.com/watch?v=2HcORToX5sU).
298
+
242
299
  Enjoy!
243
300
 
244
301
  ---
@@ -299,3 +356,60 @@ def _handle_compactish(app: "ChatApp", command: str) -> bool:
299
356
  app.notify("Session compacted", timeout=3)
300
357
 
301
358
  return True
359
+
360
+
361
+ async def _handle_help(app: "ChatApp") -> None:
362
+ """Display help information."""
363
+ from claudechic.help_data import format_help
364
+ from claudechic.widgets import ChatMessage
365
+
366
+ agent = app._agent
367
+ help_text = await format_help(agent)
368
+
369
+ chat_view = app._chat_view
370
+ if chat_view:
371
+ msg = ChatMessage(help_text)
372
+ msg.add_class("system-message")
373
+ chat_view.mount(msg)
374
+ chat_view.scroll_if_tailing()
375
+
376
+
377
+ def _handle_processes(app: "ChatApp") -> None:
378
+ """Show process modal with current background processes."""
379
+ from claudechic.widgets.modals.process_modal import ProcessModal
380
+
381
+ agent = app._agent
382
+ if agent:
383
+ processes = agent.get_background_processes()
384
+ else:
385
+ processes = []
386
+ app.push_screen(ProcessModal(processes))
387
+
388
+
389
+ def _handle_analytics(app: "ChatApp", command: str) -> bool:
390
+ """Handle /analytics commands: opt-in, opt-out."""
391
+ from claudechic.config import (
392
+ get_analytics_enabled,
393
+ get_analytics_id,
394
+ set_analytics_enabled,
395
+ )
396
+
397
+ parts = command.split()
398
+ subcommand = parts[1] if len(parts) > 1 else ""
399
+
400
+ if subcommand == "opt-in":
401
+ set_analytics_enabled(True)
402
+ app.notify("Analytics enabled")
403
+ return True
404
+
405
+ if subcommand == "opt-out":
406
+ set_analytics_enabled(False)
407
+ app.notify("Analytics disabled")
408
+ return True
409
+
410
+ # Show current status
411
+ enabled = get_analytics_enabled()
412
+ user_id = get_analytics_id()
413
+ status = "enabled" if enabled else "disabled"
414
+ app.notify(f"Analytics {status}, ID: {user_id[:8]}...")
415
+ return True
claudechic/config.py ADDED
@@ -0,0 +1,80 @@
1
+ """Configuration management for claudechic via ~/.claude/claudechic.yaml."""
2
+
3
+ import uuid
4
+ from pathlib import Path
5
+
6
+ import yaml
7
+
8
+ CONFIG_PATH = Path.home() / ".claude" / "claudechic.yaml"
9
+
10
+ _config: dict = {}
11
+ _loaded: bool = False
12
+ _new_install: bool = False # True if analytics ID was just created
13
+
14
+
15
+ def _load_config() -> dict:
16
+ """Load config from disk, creating with defaults if missing."""
17
+ global _config, _loaded, _new_install
18
+ if _loaded:
19
+ return _config
20
+
21
+ if CONFIG_PATH.exists():
22
+ with open(CONFIG_PATH) as f:
23
+ _config = yaml.safe_load(f) or {}
24
+ else:
25
+ _config = {}
26
+
27
+ # Ensure analytics section with defaults
28
+ if "analytics" not in _config:
29
+ _config["analytics"] = {}
30
+ if "enabled" not in _config["analytics"]:
31
+ _config["analytics"]["enabled"] = True
32
+ if "id" not in _config["analytics"]:
33
+ _config["analytics"]["id"] = str(uuid.uuid4())
34
+ _new_install = True
35
+ _save_config()
36
+
37
+ _loaded = True
38
+ return _config
39
+
40
+
41
+ def _save_config() -> None:
42
+ """Write config to disk."""
43
+ if not _config:
44
+ return
45
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
46
+ with open(CONFIG_PATH, "w") as f:
47
+ yaml.dump(_config, f, default_flow_style=False)
48
+
49
+
50
+ def get_analytics_enabled() -> bool:
51
+ """Check if analytics collection is enabled."""
52
+ return _load_config()["analytics"]["enabled"]
53
+
54
+
55
+ def get_analytics_id() -> str:
56
+ """Get the anonymous analytics ID, generating if needed."""
57
+ return _load_config()["analytics"]["id"]
58
+
59
+
60
+ def set_analytics_enabled(enabled: bool) -> None:
61
+ """Enable or disable analytics collection."""
62
+ _load_config()["analytics"]["enabled"] = enabled
63
+ _save_config()
64
+
65
+
66
+ def get_theme() -> str | None:
67
+ """Get saved theme preference, or None if not set."""
68
+ return _load_config().get("theme")
69
+
70
+
71
+ def set_theme(theme: str) -> None:
72
+ """Save theme preference."""
73
+ _load_config()["theme"] = theme
74
+ _save_config()
75
+
76
+
77
+ def is_new_install() -> bool:
78
+ """Check if this is a new install (analytics ID was just created)."""
79
+ _load_config() # Ensure config is loaded
80
+ return _new_install
@@ -9,9 +9,11 @@ from textual.containers import Center
9
9
  from textual import work
10
10
 
11
11
  from claudechic.features.worktree.git import (
12
+ FinishInfo,
12
13
  FinishPhase,
13
14
  FinishState,
14
15
  ResolutionAction,
16
+ WorktreeInfo,
15
17
  WorktreeStatus,
16
18
  clean_gitignored_files,
17
19
  cleanup_worktrees,
@@ -27,7 +29,7 @@ from claudechic.features.worktree.git import (
27
29
  remove_worktree,
28
30
  start_worktree,
29
31
  )
30
- from claudechic.features.worktree.prompts import (
32
+ from claudechic.widgets.prompts import (
31
33
  UncommittedChangesPrompt,
32
34
  WorktreePrompt,
33
35
  )
@@ -59,6 +61,8 @@ def handle_worktree_command(app: "ChatApp", command: str) -> None:
59
61
  elif subcommand == "cleanup":
60
62
  branches = parts[2].split() if len(parts) > 2 else None
61
63
  _handle_cleanup(app, branches)
64
+ elif subcommand == "discard":
65
+ _handle_discard(app)
62
66
  else:
63
67
  _switch_or_create_worktree(app, subcommand)
64
68
 
@@ -331,6 +335,71 @@ def _close_agents_for_branches(app: "ChatApp", branches: list[str]) -> None:
331
335
  app._do_close_agent(agent.id)
332
336
 
333
337
 
338
+ def _handle_discard(app: "ChatApp") -> None:
339
+ """Handle /worktree discard command - discard current worktree entirely."""
340
+ success, message, info = get_finish_info(app.sdk_cwd)
341
+ if not success or info is None:
342
+ app.notify(message, severity="error")
343
+ return
344
+
345
+ status = diagnose_worktree(info)
346
+
347
+ # Check if there's anything to warn about
348
+ has_commits = status.commits_ahead > 0 and not status.is_merged
349
+ has_changes = status.has_uncommitted or status.untracked_other
350
+
351
+ if has_commits or has_changes:
352
+ _run_discard_prompt(app, info, status)
353
+ else:
354
+ _do_discard(app, info)
355
+
356
+
357
+ def _do_discard(app: "ChatApp", info: FinishInfo) -> None:
358
+ """Force remove worktree and branch."""
359
+ wt = WorktreeInfo(path=info.worktree_dir, branch=info.branch_name, is_main=False)
360
+ success, msg = remove_worktree(wt, force=True)
361
+ if success:
362
+ app.notify(f"Discarded {info.branch_name}")
363
+ _close_agents_for_branches(app, [info.branch_name])
364
+ else:
365
+ app.notify(msg, severity="error")
366
+
367
+
368
+ @work(group="discard_prompt", exclusive=True, exit_on_error=False)
369
+ async def _run_discard_prompt(
370
+ app: "ChatApp", info: FinishInfo, status: WorktreeStatus
371
+ ) -> None:
372
+ """Prompt user to confirm discarding a worktree with commits/changes."""
373
+ from claudechic.widgets import SelectionPrompt, ChatInput
374
+
375
+ warnings = []
376
+ if status.commits_ahead > 0 and not status.is_merged:
377
+ warnings.append(f"{status.commits_ahead} unmerged commits")
378
+ if status.has_uncommitted:
379
+ warnings.append(f"{len(status.uncommitted_files)} uncommitted changes")
380
+ if status.untracked_other:
381
+ warnings.append(f"{len(status.untracked_other)} untracked files")
382
+
383
+ warning_text = ", ".join(warnings)
384
+ options = [
385
+ ("discard", f"Discard anyway ({warning_text})"),
386
+ ("cancel", "Cancel"),
387
+ ]
388
+
389
+ async with app._show_prompt(
390
+ SelectionPrompt(f"Discard {info.branch_name}?", options)
391
+ ) as prompt:
392
+ prompt.focus()
393
+ selected = await prompt.wait()
394
+
395
+ app.query_one("#input", ChatInput).focus()
396
+
397
+ if selected == "discard":
398
+ _do_discard(app, info)
399
+ else:
400
+ app.notify("Discard cancelled")
401
+
402
+
334
403
  def _handle_cleanup(app: "ChatApp", branches: list[str] | None) -> None:
335
404
  """Handle /worktree cleanup command."""
336
405
  results = cleanup_worktrees(branches)
@@ -0,0 +1,200 @@
1
+ """Help data and formatting for /help command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from claudechic.agent import Agent
11
+
12
+ # Claudechic-specific commands
13
+ CHIC_COMMANDS = [
14
+ ("/clear", "Clear chat and start new session"),
15
+ ("/resume [id]", "Resume a previous session"),
16
+ ("/agent [name] [path]", "Create or list agents"),
17
+ ("/shell <cmd>", "Run shell command (or -i for interactive)"),
18
+ ("/worktree <name>", "Create git worktree with agent"),
19
+ ("/compactish [-n]", "Compact session to reduce context"),
20
+ ("/usage", "Show API rate limit usage"),
21
+ ("/model", "Change model"),
22
+ ("/theme", "Search themes"),
23
+ ("/welcome", "Show welcome message"),
24
+ ("/help", "Show this help"),
25
+ ("/exit", "Quit"),
26
+ ("!<cmd>", "Shell command alias"),
27
+ ]
28
+
29
+ # Keyboard shortcuts
30
+ SHORTCUTS = [
31
+ ("Ctrl+C (x2)", "Quit"),
32
+ ("Ctrl+L", "Clear chat"),
33
+ ("Ctrl+S", "Screenshot"),
34
+ ("Shift+Tab", "Toggle auto-edit mode"),
35
+ ("Escape", "Cancel current action"),
36
+ ("Ctrl+N", "New agent hint"),
37
+ ("Ctrl+R", "History search"),
38
+ ("Ctrl+1-9", "Switch to agent by position"),
39
+ ("Enter", "Send message"),
40
+ ("Ctrl+J", "Insert newline"),
41
+ ("Up/Down", "Navigate input history"),
42
+ ]
43
+
44
+ # MCP tools from claudechic (mcp.py)
45
+ MCP_TOOLS = [
46
+ ("spawn_agent", "Create new Claude agent"),
47
+ ("spawn_worktree", "Create git worktree with agent"),
48
+ ("ask_agent", "Send question to another agent"),
49
+ ("tell_agent", "Send message without expecting reply"),
50
+ ("list_agents", "List all running agents"),
51
+ ("close_agent", "Close an agent by name"),
52
+ ]
53
+
54
+
55
+ def _parse_skill_description(path: Path) -> str:
56
+ """Extract description from SKILL.md frontmatter."""
57
+ try:
58
+ content = path.read_text()
59
+ if content.startswith("---"):
60
+ end = content.find("---", 3)
61
+ if end > 0:
62
+ frontmatter = content[3:end]
63
+ for line in frontmatter.split("\n"):
64
+ if line.startswith("description:"):
65
+ return line.split(":", 1)[1].strip()
66
+ except Exception:
67
+ pass
68
+ return "No description"
69
+
70
+
71
+ def discover_skills() -> list[tuple[str, str]]:
72
+ """Discover enabled skills from plugins."""
73
+ skills = []
74
+
75
+ # Read settings for enabled plugins
76
+ settings_path = Path.home() / ".claude" / "settings.json"
77
+ if not settings_path.exists():
78
+ return skills
79
+
80
+ try:
81
+ settings = json.loads(settings_path.read_text())
82
+ except Exception:
83
+ return skills
84
+
85
+ enabled = settings.get("enabledPlugins", {})
86
+
87
+ # Read installed plugins
88
+ installed_path = Path.home() / ".claude" / "plugins" / "installed_plugins.json"
89
+ if not installed_path.exists():
90
+ return skills
91
+
92
+ try:
93
+ installed = json.loads(installed_path.read_text())
94
+ except Exception:
95
+ return skills
96
+
97
+ for plugin_id, is_enabled in enabled.items():
98
+ if not is_enabled:
99
+ continue
100
+ if plugin_id not in installed.get("plugins", {}):
101
+ continue
102
+
103
+ installs = installed["plugins"][plugin_id]
104
+ if not installs:
105
+ continue
106
+
107
+ install_path = Path(installs[0]["installPath"])
108
+ skills_dir = install_path / "skills"
109
+ if not skills_dir.exists():
110
+ continue
111
+
112
+ for skill_dir in skills_dir.iterdir():
113
+ if not skill_dir.is_dir():
114
+ continue
115
+ skill_md = skill_dir / "SKILL.md"
116
+ if skill_md.exists():
117
+ desc = _parse_skill_description(skill_md)
118
+ # Format: plugin:skill or just skill if plugin matches
119
+ plugin_name = plugin_id.split("@")[0]
120
+ skill_name = skill_dir.name
121
+ if plugin_name == skill_name:
122
+ skills.append((skill_name, desc))
123
+ else:
124
+ skills.append((f"{plugin_name}:{skill_name}", desc))
125
+
126
+ return skills
127
+
128
+
129
+ async def get_sdk_commands(agent: "Agent | None") -> list[tuple[str, str]]:
130
+ """Get commands from SDK server info."""
131
+ if not agent or not agent.client:
132
+ return []
133
+
134
+ try:
135
+ info = await agent.client.get_server_info()
136
+ if not info:
137
+ return []
138
+
139
+ return [
140
+ (f"/{cmd['name']}", cmd.get("description", ""))
141
+ for cmd in info.get("commands", [])
142
+ ]
143
+ except Exception:
144
+ return []
145
+
146
+
147
+ async def format_help(agent: "Agent | None") -> str:
148
+ """Format complete help text as markdown."""
149
+ lines = ["# Help\n"]
150
+
151
+ # Discover skills first so we can filter them from SDK commands
152
+ skills = discover_skills()
153
+ skill_names = {name.split(":")[0] for name, _ in skills} # e.g. "frontend-design"
154
+
155
+ # SDK commands (filter out skills which may appear here too)
156
+ sdk_cmds = await get_sdk_commands(agent)
157
+ sdk_cmds = [
158
+ (cmd, desc) for cmd, desc in sdk_cmds if cmd.lstrip("/") not in skill_names
159
+ ]
160
+ if sdk_cmds:
161
+ lines.append("## Claude Code Commands\n")
162
+ lines.append("| Command | Description |")
163
+ lines.append("|---------|-------------|")
164
+ for cmd, desc in sdk_cmds:
165
+ lines.append(f"| `{cmd}` | {desc} |")
166
+ lines.append("")
167
+
168
+ # Chic commands
169
+ lines.append("## Claudechic Commands\n")
170
+ lines.append("| Command | Description |")
171
+ lines.append("|---------|-------------|")
172
+ for cmd, desc in CHIC_COMMANDS:
173
+ lines.append(f"| `{cmd}` | {desc} |")
174
+ lines.append("")
175
+
176
+ # Skills (already discovered above for filtering)
177
+ if skills:
178
+ lines.append("## Skills\n")
179
+ lines.append("| Skill | Description |")
180
+ lines.append("|-------|-------------|")
181
+ for name, desc in skills:
182
+ lines.append(f"| `/{name}` | {desc} |")
183
+ lines.append("")
184
+
185
+ # MCP tools
186
+ lines.append("## MCP Tools (chic)\n")
187
+ lines.append("| Tool | Description |")
188
+ lines.append("|------|-------------|")
189
+ for name, desc in MCP_TOOLS:
190
+ lines.append(f"| `{name}` | {desc} |")
191
+ lines.append("")
192
+
193
+ # Shortcuts
194
+ lines.append("## Keyboard Shortcuts\n")
195
+ lines.append("| Key | Action |")
196
+ lines.append("|-----|--------|")
197
+ for key, action in SHORTCUTS:
198
+ lines.append(f"| `{key}` | {action} |")
199
+
200
+ return "\n".join(lines)
claudechic/messages.py CHANGED
@@ -10,23 +10,6 @@ from claude_agent_sdk import (
10
10
  )
11
11
 
12
12
 
13
- class StreamChunk(Message):
14
- """Message sent when a chunk of text is received from Claude."""
15
-
16
- def __init__(
17
- self,
18
- text: str,
19
- new_message: bool = False,
20
- parent_tool_use_id: str | None = None,
21
- agent_id: str | None = None,
22
- ) -> None:
23
- self.text = text
24
- self.new_message = new_message # Start a new ChatMessage widget
25
- self.parent_tool_use_id = parent_tool_use_id # If set, belongs to a Task
26
- self.agent_id = agent_id # Which agent this belongs to
27
- super().__init__()
28
-
29
-
30
13
  class ResponseComplete(Message):
31
14
  """Message sent when Claude's response is complete."""
32
15
 
@@ -0,0 +1,120 @@
1
+ """Background process tracking and detection for Claude agents."""
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from datetime import datetime
6
+
7
+ import psutil
8
+
9
+
10
+ @dataclass
11
+ class BackgroundProcess:
12
+ """A background process being tracked."""
13
+
14
+ pid: int
15
+ command: str # Short description of the command
16
+ start_time: datetime
17
+
18
+
19
+ def _extract_command(cmdline: list[str]) -> str | None:
20
+ """Extract the user command from a shell cmdline.
21
+
22
+ Claude wraps commands like:
23
+ ['/bin/zsh', '-c', '-l', "source ... && eval 'sleep 30' ..."]
24
+
25
+ We want to extract just 'sleep 30'.
26
+ """
27
+ # Find the argument containing the actual command (after -c and optional -l)
28
+ cmd_arg = None
29
+ for i, arg in enumerate(cmdline):
30
+ if arg == "-c" and i + 1 < len(cmdline):
31
+ # Next non-flag arg is the command
32
+ for j in range(i + 1, len(cmdline)):
33
+ if not cmdline[j].startswith("-"):
34
+ cmd_arg = cmdline[j]
35
+ break
36
+ break
37
+
38
+ if not cmd_arg:
39
+ return None
40
+
41
+ # Try to extract from eval '...' pattern
42
+ match = re.search(r"eval ['\"](.+?)['\"] \\< /dev/null", cmd_arg)
43
+ if match:
44
+ return match.group(1)
45
+
46
+ # Try simpler eval pattern
47
+ match = re.search(r"eval ['\"](.+?)['\"]", cmd_arg)
48
+ if match:
49
+ return match.group(1)
50
+
51
+ # Fall back to full command (truncated)
52
+ return cmd_arg[:50] if len(cmd_arg) > 50 else cmd_arg
53
+
54
+
55
+ def get_child_processes(claude_pid: int) -> list[BackgroundProcess]:
56
+ """Get background processes that are children of a claude process.
57
+
58
+ Args:
59
+ claude_pid: PID of the claude binary for an agent
60
+
61
+ Returns:
62
+ List of BackgroundProcess objects for active shell children
63
+ """
64
+ try:
65
+ claude_proc = psutil.Process(claude_pid)
66
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
67
+ return []
68
+
69
+ processes = []
70
+ for child in claude_proc.children(recursive=True):
71
+ try:
72
+ name = child.name()
73
+ # Only track shell processes (where commands run)
74
+ if name not in ("zsh", "bash", "sh"):
75
+ continue
76
+
77
+ status = child.status()
78
+ if status == psutil.STATUS_ZOMBIE:
79
+ continue
80
+
81
+ # Extract the command being run
82
+ cmdline = child.cmdline()
83
+ command = _extract_command(cmdline)
84
+ if not command:
85
+ continue
86
+
87
+ # Filter out our own monitoring commands
88
+ if command.startswith("ps "):
89
+ continue
90
+
91
+ # Get start time
92
+ create_time = datetime.fromtimestamp(child.create_time())
93
+
94
+ processes.append(
95
+ BackgroundProcess(
96
+ pid=child.pid, command=command, start_time=create_time
97
+ )
98
+ )
99
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
100
+ continue
101
+
102
+ return processes
103
+
104
+
105
+ def get_claude_pid_from_client(client) -> int | None:
106
+ """Extract the claude process PID from an SDK client.
107
+
108
+ Args:
109
+ client: ClaudeSDKClient instance
110
+
111
+ Returns:
112
+ PID of the claude subprocess, or None if not available
113
+ """
114
+ try:
115
+ transport = client._transport
116
+ if transport and hasattr(transport, "_process") and transport._process:
117
+ return transport._process.pid
118
+ except Exception:
119
+ pass
120
+ return None