@scemoon/cdh 1.0.0 → 1.0.4

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 (147) hide show
  1. package/cdh/__pycache__/cli.cpython-314.pyc +0 -0
  2. package/cdh/__pycache__/scaffold.cpython-314.pyc +0 -0
  3. package/cdh/cli.py +473 -87
  4. package/cdh/scaffold.py +386 -121
  5. package/onecode/__pycache__/cli.cpython-314.pyc +0 -0
  6. package/onecode/__pycache__/commands.cpython-314.pyc +0 -0
  7. package/onecode/__pycache__/config.cpython-314.pyc +0 -0
  8. package/onecode/agent/__pycache__/cdh_loader.cpython-314.pyc +0 -0
  9. package/onecode/agent/__pycache__/engine.cpython-314.pyc +0 -0
  10. package/onecode/agent/__pycache__/onecode_agent_acp.cpython-314.pyc +0 -0
  11. package/onecode/agent/__pycache__/session.cpython-314.pyc +0 -0
  12. package/onecode/agent/agents/__pycache__/types.cpython-314.pyc +0 -0
  13. package/onecode/agent/agents/types.py +123 -79
  14. package/onecode/agent/cdh_loader.py +26 -13
  15. package/onecode/agent/engine.py +426 -235
  16. package/onecode/agent/onecode_agent_acp.py +202 -75
  17. package/onecode/agent/session.py +8 -13
  18. package/onecode/agent/tools/__pycache__/agent_tools.cpython-314.pyc +0 -0
  19. package/onecode/agent/tools/__pycache__/todo_tools.cpython-314.pyc +0 -0
  20. package/onecode/agent/tools/agent_tools.py +4 -4
  21. package/onecode/agent/tools/todo_tools.py +188 -0
  22. package/onecode/cli.py +3 -3
  23. package/onecode/commands.py +439 -194
  24. package/onecode/config.py +3 -3
  25. package/onecode/config_screen.py +4 -2
  26. package/onecode/models/__pycache__/errors.cpython-314.pyc +0 -0
  27. package/onecode/models/__pycache__/messages.cpython-314.pyc +0 -0
  28. package/onecode/models/__pycache__/provider.cpython-314.pyc +0 -0
  29. package/onecode/models/errors.py +11 -1
  30. package/onecode/models/messages.py +35 -16
  31. package/onecode/models/provider.py +243 -12
  32. package/onecode/models/providers/__pycache__/anthropic_provider.cpython-314.pyc +0 -0
  33. package/onecode/models/providers/__pycache__/deepseek_provider.cpython-314.pyc +0 -0
  34. package/onecode/models/providers/__pycache__/glm_provider.cpython-314.pyc +0 -0
  35. package/onecode/models/providers/__pycache__/minimax_provider.cpython-314.pyc +0 -0
  36. package/onecode/models/providers/__pycache__/minimaxi_provider.cpython-314.pyc +0 -0
  37. package/onecode/models/providers/__pycache__/ollama_provider.cpython-314.pyc +0 -0
  38. package/onecode/models/providers/__pycache__/openai_provider.cpython-314.pyc +0 -0
  39. package/onecode/models/providers/anthropic_provider.py +4 -2
  40. package/onecode/models/providers/deepseek_provider.py +2 -0
  41. package/onecode/models/providers/glm_provider.py +2 -1
  42. package/onecode/models/providers/minimax_provider.py +2 -1
  43. package/onecode/models/providers/minimaxi_provider.py +76 -25
  44. package/onecode/models/providers/ollama_provider.py +2 -1
  45. package/onecode/models/providers/openai_provider.py +4 -0
  46. package/onecode/tasks/manager.py +1 -1
  47. package/package.json +1 -1
  48. package/pyproject.toml +10 -0
  49. package/run.js +24 -7
  50. package/tui/__pycache__/agents.cpython-314.pyc +0 -0
  51. package/tui/__pycache__/app.cpython-314.pyc +0 -0
  52. package/tui/__pycache__/db.cpython-314.pyc +0 -0
  53. package/tui/__pycache__/directory_watcher.cpython-314.pyc +0 -0
  54. package/tui/__pycache__/messages.cpython-314.pyc +0 -0
  55. package/tui/__pycache__/paths.cpython-314.pyc +0 -0
  56. package/tui/__pycache__/settings_schema.cpython-314.pyc +0 -0
  57. package/tui/acp/__pycache__/agent.cpython-314.pyc +0 -0
  58. package/tui/acp/__pycache__/api.cpython-314.pyc +0 -0
  59. package/tui/acp/__pycache__/messages.cpython-314.pyc +0 -0
  60. package/tui/acp/__pycache__/prompt.cpython-314.pyc +0 -0
  61. package/tui/acp/__pycache__/protocol.cpython-314.pyc +0 -0
  62. package/tui/acp/agent.py +60 -11
  63. package/tui/acp/api.py +6 -0
  64. package/tui/acp/messages.py +11 -0
  65. package/tui/acp/prompt.py +1 -1
  66. package/tui/acp/protocol.py +31 -0
  67. package/tui/agents.py +8 -7
  68. package/tui/app.py +60 -8
  69. package/tui/data/agents/ampcode.com.toml +1 -0
  70. package/tui/data/agents/augmentcode.com.toml +1 -0
  71. package/tui/data/agents/cdh.cloud-dev-harness.toml +3 -2
  72. package/tui/data/agents/crowai.dev.toml +1 -0
  73. package/tui/data/agents/deepagents.langchain.com +1 -0
  74. package/tui/data/agents/docker.com.toml +1 -0
  75. package/tui/data/agents/goose.ai.toml +1 -0
  76. package/tui/data/agents/inference.huggingface.co.toml +1 -1
  77. package/tui/data/agents/kiro.dev.toml +1 -0
  78. package/tui/data/agents/onecode.dev.toml +22 -0
  79. package/tui/data/agents/openclaw.ai.toml +1 -1
  80. package/tui/data/agents/openhands.dev.toml +1 -0
  81. package/tui/data/agents/stakpak.dev.toml +1 -0
  82. package/tui/data/agents/vibe.mistral.ai.toml +1 -0
  83. package/tui/data/agents/vtcode.dev.toml +1 -0
  84. package/tui/db.py +23 -0
  85. package/tui/directory_watcher.py +35 -1
  86. package/tui/messages.py +4 -1
  87. package/tui/paths.py +3 -3
  88. package/tui/screens/__pycache__/main.cpython-314.pyc +0 -0
  89. package/tui/screens/component_picker.py +148 -0
  90. package/tui/screens/main.py +47 -95
  91. package/tui/screens/projects.tcss +10 -0
  92. package/tui/screens/projects_app.py +33 -5
  93. package/tui/screens/projects_screen.py +171 -9
  94. package/tui/screens/store.py +1 -1
  95. package/tui/settings_schema.py +1 -1
  96. package/tui/widgets/__pycache__/ask_user.cpython-314.pyc +0 -0
  97. package/tui/widgets/__pycache__/conversation.cpython-314.pyc +0 -0
  98. package/tui/widgets/__pycache__/modified_files.cpython-314.pyc +0 -0
  99. package/tui/widgets/__pycache__/plan.cpython-314.pyc +0 -0
  100. package/tui/widgets/__pycache__/project_directory_tree.cpython-314.pyc +0 -0
  101. package/tui/widgets/__pycache__/question.cpython-314.pyc +0 -0
  102. package/tui/widgets/__pycache__/subagent.cpython-314.pyc +0 -0
  103. package/tui/widgets/__pycache__/tool_call.cpython-314.pyc +0 -0
  104. package/tui/widgets/ask_user.py +1 -1
  105. package/tui/widgets/conversation.py +99 -42
  106. package/tui/widgets/modified_files.py +191 -65
  107. package/tui/widgets/plan.py +22 -0
  108. package/tui/widgets/project_directory_tree.py +22 -10
  109. package/tui/widgets/project_grid_select.py +23 -1
  110. package/tui/widgets/question.py +1 -1
  111. package/tui/widgets/subagent.py +202 -36
  112. package/tui/widgets/subagent_screen.py +186 -0
  113. package/tui/widgets/tool_call.py +5 -8
  114. package/cdh/__pycache__/__init__.cpython-313.pyc +0 -0
  115. package/cdh/__pycache__/scaffold.cpython-313.pyc +0 -0
  116. package/onecode/__pycache__/config_screen.cpython-314.pyc +0 -0
  117. package/onecode/__pycache__/mock_data.cpython-314.pyc +0 -0
  118. package/onecode/agent/__pycache__/cdh_agent_acp.cpython-313.pyc +0 -0
  119. package/onecode/agent/__pycache__/cdh_agent_acp.cpython-314.pyc +0 -0
  120. package/onecode/agent/__pycache__/harness_skill.cpython-314.pyc +0 -0
  121. package/onecode/agent/tools/__pycache__/task_tools.cpython-314.pyc +0 -0
  122. package/onecode/agent/tools/task_tools.py +0 -249
  123. package/onecode/codebase/__pycache__/config.cpython-314.pyc +0 -0
  124. package/onecode/models/providers/__pycache__/mock_provider.cpython-314.pyc +0 -0
  125. package/onecode/skills/__pycache__/manager.cpython-314.pyc +0 -0
  126. package/tui/__pycache__/danger.cpython-314.pyc +0 -0
  127. package/tui/__pycache__/directory_suggester.cpython-314.pyc +0 -0
  128. package/tui/screens/__pycache__/log.cpython-314.pyc +0 -0
  129. package/tui/screens/__pycache__/projects_screen.cpython-314.pyc +0 -0
  130. package/tui/screens/__pycache__/sessions.cpython-314.pyc +0 -0
  131. package/tui/screens/__pycache__/settings.cpython-314.pyc +0 -0
  132. package/tui/screens/__pycache__/store.cpython-314.pyc +0 -0
  133. package/tui/widgets/__pycache__/acp_content.cpython-314.pyc +0 -0
  134. package/tui/widgets/__pycache__/conversation.cpython-313.pyc +0 -0
  135. package/tui/widgets/__pycache__/diff_view.cpython-314.pyc +0 -0
  136. package/tui/widgets/__pycache__/directory_input.cpython-314.pyc +0 -0
  137. package/tui/widgets/__pycache__/future_text.cpython-314.pyc +0 -0
  138. package/tui/widgets/__pycache__/grid_select.cpython-314.pyc +0 -0
  139. package/tui/widgets/__pycache__/mandelbrot.cpython-314.pyc +0 -0
  140. package/tui/widgets/__pycache__/markdown_note.cpython-314.pyc +0 -0
  141. package/tui/widgets/__pycache__/project_grid_select.cpython-314.pyc +0 -0
  142. package/tui/widgets/__pycache__/project_summary.cpython-314.pyc +0 -0
  143. package/tui/widgets/__pycache__/session_grid_select.cpython-314.pyc +0 -0
  144. package/tui/widgets/__pycache__/session_summary.cpython-314.pyc +0 -0
  145. package/tui/widgets/__pycache__/shell_result.cpython-314.pyc +0 -0
  146. package/tui/widgets/__pycache__/terminal.cpython-313.pyc +0 -0
  147. package/tui/widgets/__pycache__/tool_call.cpython-313.pyc +0 -0
@@ -267,6 +267,16 @@ This is a view of your conversation with the agent.
267
267
  pass
268
268
 
269
269
 
270
+ def _plan_entries_from_dicts(raw_entries):
271
+ """Deprecated thin wrapper around the shared ``entries_from_dicts`` helper.
272
+
273
+ Kept for backwards compatibility with any external callers; new code
274
+ should import ``entries_from_dicts`` directly from ``tui.widgets.plan``.
275
+ """
276
+ from tui.widgets.plan import entries_from_dicts
277
+ return entries_from_dicts(raw_entries)
278
+
279
+
270
280
  class Conversation(containers.Vertical):
271
281
  """Holds the agent conversation (input, output, and various controls / information)."""
272
282
 
@@ -378,6 +388,7 @@ class Conversation(containers.Vertical):
378
388
  self._agent_thought: AgentThought | None = None
379
389
  self._replay: bool = False
380
390
  self._replay_buffer: list[dict] = []
391
+ self._active_subagents: dict = {}
381
392
  self._last_escape_time: float = monotonic()
382
393
  self._agent_data = agent
383
394
  self._agent_session_id = agent_session_id
@@ -404,6 +415,7 @@ class Conversation(containers.Vertical):
404
415
 
405
416
  self._directory_changed = False
406
417
  self._directory_watcher: DirectoryWatcher | None = None
418
+ self._suppress_directory_changed = False
407
419
 
408
420
  self._initial_prompt = initial_prompt
409
421
 
@@ -559,7 +571,34 @@ class Conversation(containers.Vertical):
559
571
  @on(DirectoryChanged)
560
572
  def on_directory_changed(self, event: DirectoryChanged) -> None:
561
573
  event.stop()
562
- self.post_message(messages.ProjectDirectoryUpdated())
574
+ self._directory_changed = True
575
+ if self._suppress_directory_changed:
576
+ return
577
+ self._suppress_directory_changed = True
578
+ self.post_message(messages.ProjectDirectoryUpdated(project_dir=self.app.project_dir))
579
+ self.set_timer(0.5, self._release_directory_changed_suppress)
580
+
581
+ def _release_directory_changed_suppress(self) -> None:
582
+ self._suppress_directory_changed = False
583
+
584
+ def directory_watcher_swap(self, project_path: Path) -> None:
585
+ """Stop the current DirectoryWatcher (if any) and start a new one
586
+ rooted at *project_path*.
587
+
588
+ Used by ``MainScreen`` when the user switches projects so we don't
589
+ keep receiving events for the old tree.
590
+ """
591
+ if self._directory_watcher is not None:
592
+ self._directory_watcher.stop()
593
+ self._directory_watcher = None
594
+ if project_path is None:
595
+ return
596
+ try:
597
+ watcher = DirectoryWatcher(project_path, self)
598
+ except Exception:
599
+ return
600
+ watcher.start()
601
+ self._directory_watcher = watcher
563
602
 
564
603
  @on(Terminal.Finalized)
565
604
  def on_terminal_finalized(self, event: Terminal.Finalized) -> None:
@@ -572,7 +611,7 @@ class Conversation(containers.Vertical):
572
611
  if self._directory_changed or not self.is_watching_directory:
573
612
  self.prompt.project_directory_updated()
574
613
  self._directory_changed = False
575
- self.post_message(messages.ProjectDirectoryUpdated())
614
+ self.post_message(messages.ProjectDirectoryUpdated(project_dir=self.app.project_dir))
576
615
 
577
616
  @on(Terminal.LongRunning)
578
617
  def on_terminal_long_running(self, event: Terminal.LongRunning) -> None:
@@ -934,7 +973,7 @@ class Conversation(containers.Vertical):
934
973
 
935
974
  if self._directory_changed or not self.is_watching_directory:
936
975
  self._directory_changed = False
937
- self.post_message(messages.ProjectDirectoryUpdated())
976
+ self.post_message(messages.ProjectDirectoryUpdated(project_dir=self.app.project_dir))
938
977
  self.prompt.project_directory_updated()
939
978
 
940
979
  self._turn_count += 1
@@ -1102,12 +1141,12 @@ class Conversation(containers.Vertical):
1102
1141
  case "plan":
1103
1142
  current_thought = None
1104
1143
  self.new_block()
1105
- widgets.append(Plan(entry["entries"]))
1144
+ widgets.append(Plan(_plan_entries_from_dicts(entry["entries"])))
1106
1145
 
1107
1146
  case "subagent_start":
1108
1147
  current_thought = None
1109
1148
  self.new_block()
1110
- sa = SubAgent(entry["agent_type"], tool_id=entry["id"])
1149
+ sa = SubAgent(entry["agent_type"], tool_id=entry["id"], prompt=entry.get("prompt", ""))
1111
1150
  created_subagents[entry["id"]] = sa
1112
1151
  widgets.append(sa)
1113
1152
 
@@ -1116,10 +1155,18 @@ class Conversation(containers.Vertical):
1116
1155
  if sa is not None:
1117
1156
  sa.append_chunk(entry["text"])
1118
1157
 
1158
+ case "subagent_thinking":
1159
+ sa = created_subagents.get(entry["id"])
1160
+ if sa is not None:
1161
+ sa.append_thinking(entry["text"])
1162
+
1119
1163
  case "subagent_end":
1120
1164
  sa = created_subagents.get(entry["id"])
1121
1165
  if sa is not None:
1122
- sa.complete()
1166
+ sa.complete(
1167
+ entry.get("status", "completed"),
1168
+ entry.get("error", ""),
1169
+ )
1123
1170
 
1124
1171
  case "ask_user":
1125
1172
  current_thought = None
@@ -1192,14 +1239,7 @@ class Conversation(containers.Vertical):
1192
1239
  return
1193
1240
  from tui.widgets.plan import Plan
1194
1241
 
1195
- entries = [
1196
- Plan.Entry(
1197
- Content(entry["content"]),
1198
- entry.get("priority", "medium"),
1199
- entry.get("status", "pending"),
1200
- )
1201
- for entry in message.entries
1202
- ]
1242
+ entries = _plan_entries_from_dicts(message.entries)
1203
1243
 
1204
1244
  # Look up any existing Plan widget anywhere in the conversation
1205
1245
  # tree — checking only ``children[-1]`` would create duplicates if
@@ -1310,46 +1350,62 @@ class Conversation(containers.Vertical):
1310
1350
 
1311
1351
  message.stop()
1312
1352
  if self._replay:
1313
- self._replay_buffer.append({"kind": "subagent_start", "agent_type": message.agent_type, "id": message.subagent_id})
1353
+ self._replay_buffer.append({"kind": "subagent_start", "agent_type": message.agent_type, "id": message.subagent_id, "prompt": message.prompt})
1314
1354
  return
1315
- sa = SubAgent(message.agent_type, tool_id=message.subagent_id)
1355
+ sa = SubAgent(message.agent_type, tool_id=message.subagent_id, prompt=message.prompt)
1356
+ self._active_subagents[message.subagent_id] = sa
1316
1357
  await self.post(sa, new_block=True)
1317
1358
 
1318
- @on(acp_messages.SubAgentChunk)
1319
- async def on_acp_subagent_chunk(self, message: acp_messages.SubAgentChunk):
1359
+ def _get_subagent(self, subagent_id: str):
1320
1360
  from tui.widgets.subagent import SubAgent
1321
1361
 
1362
+ sa = self._active_subagents.get(subagent_id)
1363
+ if sa is not None:
1364
+ return sa
1365
+ try:
1366
+ return self.contents.get_child_by_id(subagent_id, SubAgent)
1367
+ except NoMatches:
1368
+ return None
1369
+
1370
+ @on(acp_messages.SubAgentChunk)
1371
+ async def on_acp_subagent_chunk(self, message: acp_messages.SubAgentChunk):
1322
1372
  message.stop()
1323
1373
  if self._replay:
1324
1374
  self._replay_buffer.append({"kind": "subagent_chunk", "id": message.subagent_id, "text": message.text})
1325
1375
  return
1326
- try:
1327
- sa: SubAgent | None = self.contents.get_child_by_id(
1328
- message.subagent_id, SubAgent
1329
- )
1330
- except NoMatches:
1376
+ sa = self._get_subagent(message.subagent_id)
1377
+ if sa is not None:
1378
+ sa.append_chunk(message.text)
1379
+
1380
+ @on(acp_messages.SubAgentThinking)
1381
+ async def on_acp_subagent_thinking(self, message: acp_messages.SubAgentThinking):
1382
+ message.stop()
1383
+ if self._replay:
1384
+ self._replay_buffer.append({
1385
+ "kind": "subagent_thinking",
1386
+ "id": message.subagent_id,
1387
+ "text": message.text,
1388
+ })
1331
1389
  return
1332
- else:
1333
- if sa is not None:
1334
- sa.append_chunk(message.text)
1390
+ sa = self._get_subagent(message.subagent_id)
1391
+ if sa is not None:
1392
+ sa.append_thinking(message.text)
1335
1393
 
1336
1394
  @on(acp_messages.SubAgentEnd)
1337
1395
  async def on_acp_subagent_end(self, message: acp_messages.SubAgentEnd):
1338
- from tui.widgets.subagent import SubAgent
1339
-
1340
1396
  message.stop()
1341
1397
  if self._replay:
1342
- self._replay_buffer.append({"kind": "subagent_end", "id": message.subagent_id})
1343
- return
1344
- try:
1345
- sa: SubAgent | None = self.contents.get_child_by_id(
1346
- message.subagent_id, SubAgent
1347
- )
1348
- except NoMatches:
1398
+ self._replay_buffer.append({
1399
+ "kind": "subagent_end",
1400
+ "id": message.subagent_id,
1401
+ "status": message.status,
1402
+ "error": message.error,
1403
+ })
1349
1404
  return
1350
- else:
1351
- if sa is not None:
1352
- sa.complete()
1405
+ sa = self._get_subagent(message.subagent_id)
1406
+ if sa is not None:
1407
+ sa.complete(message.status, message.error)
1408
+ self._active_subagents.pop(message.subagent_id, None)
1353
1409
 
1354
1410
  @on(acp_messages.AvailableCommandsUpdate)
1355
1411
  async def on_acp_available_commands_update(
@@ -1929,12 +1985,13 @@ class Conversation(containers.Vertical):
1929
1985
  self.cursor_offset = -1
1930
1986
  self.cursor.visible = False
1931
1987
  self.cursor.follow(None)
1932
- contents.refresh(layout=True)
1988
+ contents.refresh()
1933
1989
 
1934
1990
  if prune_children:
1935
1991
  await contents.remove_children(prune_children)
1936
1992
 
1937
- self.call_later(self.window.anchor)
1993
+ if self.window.scroll_y >= self.window.max_scroll_y:
1994
+ self.call_later(self.window.anchor)
1938
1995
 
1939
1996
  async def new_terminal(self) -> Terminal:
1940
1997
  """Create a new interactive Terminal.
@@ -2401,7 +2458,7 @@ class Conversation(containers.Vertical):
2401
2458
  save_config(cfg)
2402
2459
  new_project_dir = Path(project_path) if project_path else Path.cwd()
2403
2460
  self.app.project_dir = new_project_dir
2404
- self.post_message(messages.ProjectDirectoryUpdated())
2461
+ self.post_message(messages.ProjectDirectoryUpdated(project_dir=new_project_dir))
2405
2462
  self.flash(f"Switched to project: {name}", style="success")
2406
2463
  return True
2407
2464
  elif sub_cmd == "new":
@@ -2427,7 +2484,7 @@ class Conversation(containers.Vertical):
2427
2484
  cfg.current_project_path = str(ws)
2428
2485
  save_config(cfg)
2429
2486
  self.app.project_dir = ws
2430
- self.post_message(messages.ProjectDirectoryUpdated())
2487
+ self.post_message(messages.ProjectDirectoryUpdated(project_dir=ws))
2431
2488
  self.flash(f"Created and switched to project: {name}", style="success")
2432
2489
  return True
2433
2490
  else:
@@ -3,48 +3,74 @@ from __future__ import annotations
3
3
  from pathlib import Path
4
4
  import subprocess
5
5
 
6
+ from textual import work
6
7
  from textual.app import ComposeResult
7
8
  from textual.reactive import reactive
9
+ from textual.timer import Timer
8
10
  from textual.widgets import Static
9
11
  from textual import containers
10
12
 
11
13
 
12
- class ModifiedFiles(containers.Vertical):
13
- DEFAULT_CSS = """
14
- ModifiedFiles {
15
- height: auto;
16
- max-height: 15;
17
- overflow-y: auto;
14
+ CONTENT_WIDTH = 35
18
15
 
19
- Static {
20
- padding: 0 1;
21
- }
16
+ GIT_STATUS_TIMEOUT = 5.0
17
+ GIT_STATUS_DEBOUNCE = 0.2
22
18
 
23
- .file-added {
24
- color: $text-success;
25
- }
19
+ NOT_A_REPO_TEXT = "Not a git repository"
20
+ NO_CHANGES_TEXT = "No modified files"
21
+ LOADING_TEXT = "Checking…"
26
22
 
27
- .file-modified {
28
- color: $text-warning;
29
- }
23
+ IGNORED_DIRS = frozenset(
24
+ {
25
+ ".venv",
26
+ ".git",
27
+ ".pytest_cache",
28
+ "__pycache__",
29
+ ".mypy_cache",
30
+ "node_modules",
31
+ ".uv-cache",
32
+ "dist",
33
+ "build",
34
+ ".idea",
35
+ }
36
+ )
30
37
 
31
- .file-deleted {
32
- color: $text-error;
33
- }
38
+ STATUS_ICONS = {"?": "?", "M": "M", "A": "A", "D": "D", "R": "R", "C": "C"}
39
+ STATUS_COLORS = {"?": "green", "M": "yellow", "A": "green", "D": "red", "R": "#888888", "C": "green"}
40
+ DIFF_COLORS: dict[str, str] = {"add": "green", "del": "red", "mix": "yellow", "dim": "#555555"}
41
+
42
+
43
+ def _status_info(raw: str) -> tuple[str, str]:
44
+ ch = raw.strip()[:1] or "M"
45
+ return STATUS_ICONS.get(ch, "M"), STATUS_COLORS.get(ch, "yellow")
46
+
47
+
48
+ def _tag_color(added: int, deleted: int) -> str:
49
+ if added and deleted:
50
+ return DIFF_COLORS["mix"]
51
+ if added:
52
+ return DIFF_COLORS["add"]
53
+ if deleted:
54
+ return DIFF_COLORS["del"]
55
+ return DIFF_COLORS["dim"]
34
56
 
35
- .file-renamed {
36
- color: $text-secondary;
37
- }
38
57
 
39
- .no-changes {
40
- color: $text-secondary;
41
- text-style: italic;
58
+ class ModifiedFiles(containers.Vertical):
59
+ DEFAULT_CSS = """
60
+ ModifiedFiles {
61
+ height: auto;
62
+ overflow-y: auto;
63
+ padding: 0 0 0 0;
64
+
65
+ #mf-files {
66
+ height: auto;
67
+ padding: 0 0 0 1;
42
68
  }
43
69
  }
44
70
  """
45
71
 
46
72
  path: reactive[Path | None] = reactive(None)
47
- files: reactive[list[str]] = reactive([], recompose=True)
73
+ files: reactive[list[str]] = reactive(list, recompose=False)
48
74
 
49
75
  def __init__(
50
76
  self,
@@ -54,53 +80,153 @@ class ModifiedFiles(containers.Vertical):
54
80
  classes: str | None = None,
55
81
  ) -> None:
56
82
  super().__init__(name=name, id=id, classes=classes)
57
- # Initial value is set via data_bind in main.py on_mount.
58
- # Don't use set_reactive here — let _initialize_reactive trigger
59
- # watch_path properly on first reactive access.
83
+ self._debounce_timer: Timer | None = None
84
+ self._status_kind: str = "no-changes"
85
+ self._status_text: str = NO_CHANGES_TEXT
86
+ self._initial_path: Path | None = path
87
+
88
+ def on_mount(self) -> None:
89
+ initial = self._initial_path
90
+ self._initial_path = None
91
+ if initial is not None:
92
+ self.path = initial
60
93
 
61
94
  def watch_path(self, path: Path | None) -> None:
62
- """Run git status when path changes."""
95
+ self._cancel_debounce()
63
96
  if path is None:
64
- self.files = []
97
+ self._show_status("no-changes", NO_CHANGES_TEXT)
65
98
  return
99
+ self._show_status("-loading", LOADING_TEXT)
100
+ self._schedule_run(path)
101
+
102
+ def refresh_files(self) -> None:
103
+ if self.path is not None:
104
+ self._cancel_debounce()
105
+ self._show_status("-loading", LOADING_TEXT)
106
+ self._run_git_status(self.path)
107
+
108
+ def _schedule_run(self, path: Path) -> None:
109
+ timer = self.set_timer(
110
+ GIT_STATUS_DEBOUNCE,
111
+ lambda p=path: self._run_git_status(p),
112
+ )
113
+ self._debounce_timer = timer
114
+
115
+ def _cancel_debounce(self) -> None:
116
+ timer = self._debounce_timer
117
+ if timer is not None:
118
+ try:
119
+ timer.stop()
120
+ except Exception:
121
+ pass
122
+ self._debounce_timer = None
123
+
124
+ @work(exclusive=True, group="modified-files-git-status", thread=True)
125
+ def _run_git_status(self, path: Path) -> None:
126
+ app = self.app
127
+ outcome = self._do_git_status(path)
128
+ if outcome is None:
129
+ return
130
+ kind, text, lines = outcome
131
+ if lines is None:
132
+ app.call_from_thread(self._show_status, kind, text)
133
+ else:
134
+ app.call_from_thread(self._render_lines, lines)
135
+
136
+ def _do_git_status(
137
+ self, path: Path
138
+ ) -> tuple[str, str, list[str] | None] | None:
139
+ if any(part in IGNORED_DIRS for part in path.parts):
140
+ return ("not-a-repo", NOT_A_REPO_TEXT, None)
66
141
  try:
67
- result = subprocess.run(
142
+ status = subprocess.run(
68
143
  ["git", "-C", str(path), "status", "--porcelain"],
69
- capture_output=True,
70
- text=True,
71
- timeout=5,
144
+ capture_output=True, text=True,
145
+ timeout=GIT_STATUS_TIMEOUT,
72
146
  )
73
- if result.returncode == 0:
74
- self.files = [l for l in result.stdout.strip().split("\n") if l.strip()]
147
+ except subprocess.TimeoutExpired:
148
+ return ("no-changes", NO_CHANGES_TEXT, None)
149
+ except FileNotFoundError:
150
+ return ("not-a-repo", NOT_A_REPO_TEXT, None)
151
+ except Exception:
152
+ return ("no-changes", NO_CHANGES_TEXT, None)
153
+ if status.returncode != 0:
154
+ return ("not-a-repo", NOT_A_REPO_TEXT, None)
155
+
156
+ lines = [l for l in status.stdout.splitlines() if l.strip()]
157
+ if not lines:
158
+ return ("no-changes", NO_CHANGES_TEXT, ([], {}))
159
+
160
+ diffmap: dict[str, tuple[int, int]] = {}
161
+ for diff_cmd in (
162
+ ["git", "-C", str(path), "diff", "--numstat"],
163
+ ["git", "-C", str(path), "diff", "--cached", "--numstat"],
164
+ ):
165
+ try:
166
+ r = subprocess.run(
167
+ diff_cmd, capture_output=True, text=True,
168
+ timeout=GIT_STATUS_TIMEOUT,
169
+ )
170
+ except Exception:
171
+ continue
172
+ for dl in r.stdout.splitlines():
173
+ parts = dl.split("\t", 2)
174
+ if len(parts) == 3:
175
+ added, deleted = parts[0], parts[1]
176
+ if added != "-":
177
+ a, d = int(added), int(deleted)
178
+ fp = parts[2]
179
+ prev_a, prev_d = diffmap.get(fp, (0, 0))
180
+ diffmap[fp] = (prev_a + a, prev_d + d)
181
+
182
+ return ("file-modified", "", (lines, diffmap))
183
+
184
+ def _render_lines(self, data: tuple[list[str], dict[str, tuple[int, int]]]) -> None:
185
+ lines, diffmap = data
186
+ self.files = list(lines)
187
+ if not lines:
188
+ self._show_status("no-changes", NO_CHANGES_TEXT)
189
+ return
190
+ self._show_status("file-modified", "")
191
+
192
+ out: list[str] = []
193
+ for raw_line in lines:
194
+ filepath = raw_line[3:]
195
+ icon, sc = _status_info(raw_line[:2])
196
+
197
+ added, deleted = diffmap.get(filepath, (0, 0))
198
+ if "?" in raw_line[:2]:
199
+ tag = "new"
200
+ tc = DIFF_COLORS["dim"]
201
+ elif "D" in raw_line[:2]:
202
+ tag = "del"
203
+ tc = DIFF_COLORS["del"]
204
+ elif added or deleted:
205
+ tag = f"+{added}/-{deleted}"
206
+ tc = _tag_color(added, deleted)
75
207
  else:
76
- self.files = []
77
- except (subprocess.TimeoutExpired, FileNotFoundError):
78
- self.files = []
79
-
80
- def refresh_files(self) -> None:
81
- """Re-run git status manually (e.g. after file system changes)."""
82
- self.watch_path(self.path)
208
+ tag = ""
209
+ tc = ""
210
+
211
+ tag_s = f" [{tc}]{tag}[/]" if tag else ""
212
+ tag_len = len(tag) + 1 if tag else 0
213
+ max_fn = CONTENT_WIDTH - 2 - tag_len
214
+ if max_fn < 3:
215
+ max_fn = 3
216
+ disp = filepath[:max_fn - 1] + "…" if len(filepath) > max_fn else filepath
217
+ pad = CONTENT_WIDTH - 2 - len(disp) - tag_len
218
+ pad_s = " " * pad if pad > 0 else ""
219
+ out.append(f"[{sc}]{icon} {disp}[/]{pad_s}{tag_s}")
220
+
221
+ files_widget = self.query_one("#mf-files", Static)
222
+ files_widget.update("\n".join(out))
223
+
224
+ def _show_status(self, kind: str, text: str) -> None:
225
+ self._status_kind = kind
226
+ self._status_text = text
227
+ status = self.query_one("#mf-status", Static)
228
+ status.update(text)
83
229
 
84
230
  def compose(self) -> ComposeResult:
85
- if not self.files:
86
- yield Static("No modified files", classes="no-changes")
87
- return
88
- for line in self.files:
89
- status = line[:2]
90
- filepath = line[3:]
91
- cls = self._class_for_status(status)
92
- yield Static(f" {filepath}", classes=cls)
93
-
94
- @staticmethod
95
- def _class_for_status(status: str) -> str:
96
- if "?" in status:
97
- return "file-added"
98
- if "D" in status:
99
- return "file-deleted"
100
- if "A" in status or "C" in status:
101
- return "file-added"
102
- if "R" in status:
103
- return "file-renamed"
104
- if "M" in status:
105
- return "file-modified"
106
- return "file-modified"
231
+ yield Static(self._status_text, id="mf-status")
232
+ yield Static("", id="mf-files")
@@ -186,6 +186,28 @@ class Plan(containers.Grid):
186
186
  return Content()
187
187
 
188
188
 
189
+ def entries_from_dicts(raw_entries):
190
+ """Convert raw plan entries (dicts) into ``Plan.Entry`` dataclass instances.
191
+
192
+ Shared by ``MainScreen`` (sidebar) and ``Conversation`` (inline) so both
193
+ Plan widgets render the same data the same way. Filters entries with
194
+ empty content so the Plan widget never renders a blank row.
195
+ """
196
+ result = []
197
+ for entry in raw_entries:
198
+ content = (entry.get("content") or "").strip()
199
+ if not content:
200
+ continue
201
+ result.append(
202
+ Plan.Entry(
203
+ Content(content),
204
+ entry.get("priority", "medium"),
205
+ entry.get("status", "pending"),
206
+ )
207
+ )
208
+ return result
209
+
210
+
189
211
  if __name__ == "__main__":
190
212
  from textual.app import App
191
213
 
@@ -42,26 +42,37 @@ This shows the files in your project directory.
42
42
  disabled: bool = False,
43
43
  ) -> None:
44
44
  self.path_filter: PathFilter | None = None
45
+ self._reload_lock = asyncio.Lock()
45
46
  path = Path(path).resolve() if isinstance(path, str) else path.resolve()
46
47
  super().__init__(path, name=name, id=id, classes=classes, disabled=disabled)
47
48
 
49
+ async def _update_path_filter(self) -> None:
50
+ if self.path is None:
51
+ self.path_filter = None
52
+ return
53
+ path = Path(self.path) if isinstance(self.path, str) else self.path
54
+ path = await asyncio.to_thread(path.resolve)
55
+ self.path_filter = await asyncio.to_thread(PathFilter.from_git_root, path)
56
+
48
57
  async def watch_path(self) -> None:
49
58
  """Watch for changes to the `path` of the directory tree.
50
59
 
51
60
  If the path is changed the directory tree will be repopulated using
52
61
  the new value as the root.
53
62
  """
54
- has_cursor = self.cursor_node is not None
55
- self.reset_node(self.root, str(self.path), DirEntry(self.PATH(self.path)))
56
- await self.reload()
57
- if has_cursor:
58
- self.cursor_line = 0
59
- self.scroll_to(0, 0, animate=False)
63
+ if self.path is None:
64
+ return
65
+ async with self._reload_lock:
66
+ has_cursor = self.cursor_node is not None
67
+ self.reset_node(self.root, str(self.path), DirEntry(self.PATH(self.path)))
68
+ await self._update_path_filter()
69
+ await self.reload()
70
+ if has_cursor:
71
+ self.cursor_line = 0
72
+ self.scroll_to(0, 0, animate=False)
60
73
 
61
74
  async def on_mount(self) -> None:
62
- path = Path(self.path) if isinstance(self.path, str) else self.path
63
- path = await asyncio.to_thread(path.resolve)
64
- self.path_filter = await asyncio.to_thread(PathFilter.from_git_root, path)
75
+ await self._update_path_filter()
65
76
 
66
77
  def filter_paths(self, paths: Iterable[Path]) -> Iterable[Path]:
67
78
  """Filter the paths before adding them to the tree.
@@ -86,5 +97,6 @@ This shows the files in your project directory.
86
97
 
87
98
  @work
88
99
  async def action_refresh(self) -> None:
89
- await self.reload()
100
+ async with self._reload_lock:
101
+ await self.reload()
90
102
  self.notify("Project directory has been refreshed", title="Directory Tree")
@@ -1,5 +1,6 @@
1
1
  from pathlib import Path
2
2
 
3
+ import yaml
3
4
  from textual.app import ComposeResult
4
5
  from textual import getters
5
6
 
@@ -9,6 +10,24 @@ from tui.widgets.project_summary import ProjectSummary
9
10
  from onecode.config import CLOUD_DEV_HARNESS_DIR
10
11
 
11
12
 
13
+ def _read_project_path(pf: Path) -> str:
14
+ """Return the project directory path stored in ``pf``, or the
15
+ yaml/json file path as a fallback."""
16
+ try:
17
+ if pf.suffix == ".json":
18
+ import json
19
+
20
+ data = json.loads(pf.read_text(encoding="utf-8")) or {}
21
+ else:
22
+ data = yaml.safe_load(pf.read_text(encoding="utf-8")) or {}
23
+ path = data.get("path")
24
+ if isinstance(path, str) and path:
25
+ return path
26
+ except Exception:
27
+ pass
28
+ return str(pf)
29
+
30
+
12
31
  class ProjectGridSelect(GridSelect):
13
32
  FOCUS_ON_CLICK = True
14
33
  app: getters.app[A2TUIApp] = getters.app(A2TUIApp)
@@ -32,4 +51,7 @@ class ProjectGridSelect(GridSelect):
32
51
  projects_dir.mkdir(parents=True, exist_ok=True)
33
52
  project_files = list(projects_dir.glob("*.yaml")) + list(projects_dir.glob("*.json"))
34
53
  for pf in sorted(project_files):
35
- yield ProjectSummary(pf.stem, str(pf), id=pf.stem)
54
+ yield ProjectSummary(pf.stem, _read_project_path(pf), id=pf.stem)
55
+
56
+ def refresh(self, **kwargs) -> None:
57
+ super().refresh(**kwargs)