deepy-cli 0.2.24__tar.gz → 0.2.25__tar.gz

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 (111) hide show
  1. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/PKG-INFO +1 -1
  2. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/pyproject.toml +1 -1
  3. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/__init__.py +1 -1
  4. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/tools/agents.py +29 -3
  5. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/tui/app.py +58 -1
  6. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/tui/screens.py +183 -1
  7. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/tui/widgets.py +42 -5
  8. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/ui/file_mentions.py +1 -2
  9. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/ui/terminal.py +5 -2
  10. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/README.md +0 -0
  11. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/__main__.py +0 -0
  12. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/audit.py +0 -0
  13. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/background_tasks.py +0 -0
  14. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/cli.py +0 -0
  15. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/config/__init__.py +0 -0
  16. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/config/settings.py +0 -0
  17. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/data/__init__.py +0 -0
  18. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/data/skills/skill-creator/SKILL.md +0 -0
  19. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/data/skills/skill-installer/SKILL.md +0 -0
  20. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/data/tools/AskUserQuestion.md +0 -0
  21. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/data/tools/Read.md +0 -0
  22. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/data/tools/Search.md +0 -0
  23. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/data/tools/Update.md +0 -0
  24. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/data/tools/WebFetch.md +0 -0
  25. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/data/tools/WebSearch.md +0 -0
  26. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/data/tools/Write.md +0 -0
  27. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/data/tools/__init__.py +0 -0
  28. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/data/tools/shell.md +0 -0
  29. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/data/tools/task_list.md +0 -0
  30. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/data/tools/task_output.md +0 -0
  31. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/data/tools/task_stop.md +0 -0
  32. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/data/tools/test_shell.md +0 -0
  33. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/data/tools/todo_write.md +0 -0
  34. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/errors.py +0 -0
  35. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/input_suggestions.py +0 -0
  36. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/llm/__init__.py +0 -0
  37. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/llm/agent.py +0 -0
  38. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/llm/cache_context.py +0 -0
  39. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/llm/compaction.py +0 -0
  40. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/llm/context.py +0 -0
  41. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/llm/events.py +0 -0
  42. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/llm/model_capabilities.py +0 -0
  43. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/llm/provider.py +0 -0
  44. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/llm/replay.py +0 -0
  45. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/llm/runner.py +0 -0
  46. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/llm/thinking.py +0 -0
  47. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/mcp.py +0 -0
  48. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/prompts/__init__.py +0 -0
  49. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/prompts/compact.py +0 -0
  50. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/prompts/init_agents.py +0 -0
  51. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/prompts/rules.py +0 -0
  52. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/prompts/runtime_context.py +0 -0
  53. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/prompts/system.py +0 -0
  54. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/prompts/tool_docs.py +0 -0
  55. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/session_cost.py +0 -0
  56. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/sessions/__init__.py +0 -0
  57. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/sessions/index.py +0 -0
  58. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/sessions/manager.py +0 -0
  59. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/sessions/session.py +0 -0
  60. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/sessions/store_helpers.py +0 -0
  61. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/skill_market.py +0 -0
  62. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/skills.py +0 -0
  63. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/status.py +0 -0
  64. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/subagents.py +0 -0
  65. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/todos.py +0 -0
  66. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/tools/__init__.py +0 -0
  67. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/tools/builtin.py +0 -0
  68. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/tools/file_state.py +0 -0
  69. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/tools/result.py +0 -0
  70. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/tools/search.py +0 -0
  71. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/tools/shell_output.py +0 -0
  72. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/tools/shell_utils.py +0 -0
  73. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/tools/test_shell.py +0 -0
  74. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/tui/__init__.py +0 -0
  75. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/tui/commands.py +0 -0
  76. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/tui/compat.py +0 -0
  77. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/tui/diff.py +0 -0
  78. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/tui/runner.py +0 -0
  79. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/tui/state.py +0 -0
  80. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/types/__init__.py +0 -0
  81. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/types/sdk.py +0 -0
  82. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/types/tool_payloads.py +0 -0
  83. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/ui/__init__.py +0 -0
  84. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/ui/app.py +0 -0
  85. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/ui/ask_user_question.py +0 -0
  86. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/ui/audit_approval_panel.py +0 -0
  87. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/ui/audit_approval_picker.py +0 -0
  88. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/ui/exit_summary.py +0 -0
  89. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/ui/loading_text.py +0 -0
  90. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/ui/local_command.py +0 -0
  91. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/ui/markdown.py +0 -0
  92. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/ui/message_view.py +0 -0
  93. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/ui/model_picker.py +0 -0
  94. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/ui/prompt_buffer.py +0 -0
  95. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/ui/prompt_input.py +0 -0
  96. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/ui/session_list.py +0 -0
  97. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/ui/session_picker.py +0 -0
  98. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/ui/skill_picker.py +0 -0
  99. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/ui/slash_commands.py +0 -0
  100. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/ui/status_footer.py +0 -0
  101. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/ui/styles.py +0 -0
  102. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/ui/theme_picker.py +0 -0
  103. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/ui/thinking_state.py +0 -0
  104. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/ui/welcome.py +0 -0
  105. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/update_check.py +0 -0
  106. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/usage.py +0 -0
  107. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/utils/__init__.py +0 -0
  108. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/utils/debug_logger.py +0 -0
  109. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/utils/error_logger.py +0 -0
  110. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/utils/json.py +0 -0
  111. {deepy_cli-0.2.24 → deepy_cli-0.2.25}/src/deepy/utils/notify.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: deepy-cli
3
- Version: 0.2.24
3
+ Version: 0.2.25
4
4
  Summary: Deepy - Vibe coding for DeepSeek models in your terminal
5
5
  Keywords: deepseek,coding-agent,terminal,cli,agents
6
6
  Author: kirineko
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "deepy-cli"
3
- version = "0.2.24"
3
+ version = "0.2.25"
4
4
  description = "Deepy - Vibe coding for DeepSeek models in your terminal"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- __version__ = "0.2.24"
3
+ __version__ = "0.2.25"
4
4
 
5
5
 
6
6
  def main() -> None:
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import re
4
5
  from copy import deepcopy
5
6
  from typing import TYPE_CHECKING, Any
6
7
 
@@ -283,7 +284,10 @@ def build_function_tools(
283
284
  name="Read",
284
285
  description=(
285
286
  "Read one or more project files or directories. Use files=[...] to read "
286
- "multiple targets in one call; use range/head/tail/offset/limit for slices."
287
+ 'multiple targets in one call; use quoted range strings like {"path": '
288
+ '"src/app.py", "range": "80-120"} or {"files": [{"path": '
289
+ '"src/app.py", "range": "80-120"}]}. Use head/tail/offset/limit '
290
+ "for other slices."
287
291
  ),
288
292
  params_json_schema=READ_SCHEMA,
289
293
  on_invoke_tool=invoke_read,
@@ -406,7 +410,7 @@ def _tool_args(
406
410
  try:
407
411
  parsed = json_utils.loads(raw_input or "{}")
408
412
  except json_utils.JSONDecodeError as exc:
409
- repaired, repair_metadata = _repair_tool_arguments(raw_input or "")
413
+ repaired, repair_metadata = _repair_tool_arguments(raw_input or "", tool_name=tool_name)
410
414
  if repaired is not None:
411
415
  try:
412
416
  parsed = json_utils.loads(repaired)
@@ -460,11 +464,15 @@ def _invalid_tool_arguments_result(
460
464
  ).to_json()
461
465
 
462
466
 
463
- def _repair_tool_arguments(raw_input: str) -> tuple[str | None, dict[str, Any]]:
467
+ def _repair_tool_arguments(raw_input: str, *, tool_name: str) -> tuple[str | None, dict[str, Any]]:
464
468
  repaired = raw_input.strip()
465
469
  if not repaired:
466
470
  return None, {}
467
471
  operations: list[str] = []
472
+ if tool_name == "Read":
473
+ repaired, changed = _quote_unquoted_read_ranges(repaired)
474
+ if changed:
475
+ operations.append("read_range_string")
468
476
  repaired, changed = _replace_unquoted_python_literals(repaired)
469
477
  if changed:
470
478
  operations.append("json_literals")
@@ -481,6 +489,24 @@ def _repair_tool_arguments(raw_input: str) -> tuple[str | None, dict[str, Any]]:
481
489
  }
482
490
 
483
491
 
492
+ def _quote_unquoted_read_ranges(value: str) -> tuple[str, bool]:
493
+ pattern = re.compile(
494
+ r'(?P<lead>(?:^|[,{]\s*))(?P<key>"range"|range)\s*:\s*'
495
+ r'(?P<start>[1-9]\d*)\s*-\s*(?P<end>[1-9]\d*)'
496
+ r'(?P<trail>\s*(?=[,}\]]))'
497
+ )
498
+
499
+ def replace(match: re.Match[str]) -> str:
500
+ return (
501
+ f'{match.group("lead")}"range": '
502
+ f'"{match.group("start")}-{match.group("end")}"'
503
+ f'{match.group("trail")}'
504
+ )
505
+
506
+ repaired, count = pattern.subn(replace, value)
507
+ return repaired, count > 0
508
+
509
+
484
510
  def _replace_unquoted_python_literals(value: str) -> tuple[str, bool]:
485
511
  replacements = {"None": "null", "True": "true", "False": "false"}
486
512
  output: list[str] = []
@@ -18,6 +18,7 @@ from textual.reactive import var
18
18
  from textual.widget import MountError
19
19
  from textual.widgets import Footer, Header, Label, Static
20
20
 
21
+ from deepy.audit import ApprovalDecision, AuditModeState, PendingApproval
21
22
  from deepy.background_tasks import BackgroundTaskManager, BackgroundTaskSnapshot
22
23
  from deepy.config import (
23
24
  PROVIDER_CATALOG,
@@ -79,6 +80,8 @@ from deepy.tui.commands import (
79
80
  )
80
81
  from deepy.tui.diff import diff_view_from_tool_output
81
82
  from deepy.tui.screens import (
83
+ AUDIT_APPROVAL_APPROVE,
84
+ AuditApprovalScreen,
82
85
  Choice,
83
86
  ChoiceScreen,
84
87
  InfoScreen,
@@ -175,6 +178,7 @@ class DeepyTuiApp(App[None]):
175
178
  Binding("ctrl+d", "confirm_quit", "Quit", priority=True),
176
179
  Binding("escape", "interrupt_or_focus_prompt", "Interrupt"),
177
180
  Binding("ctrl+o", "toggle_help_panel", "Panel"),
181
+ Binding("shift+tab", "cycle_audit_mode", "Audit", priority=True),
178
182
  Binding("alt+up", "focus_previous_block", "Previous block"),
179
183
  Binding("alt+down", "focus_next_block", "Next block"),
180
184
  ]
@@ -398,6 +402,7 @@ class DeepyTuiApp(App[None]):
398
402
  self.run_once = run_once
399
403
  self.guide_missing_config = guide_missing_config
400
404
  self.controller = TuiController(settings=settings)
405
+ self.audit_state = AuditModeState(settings.audit.mode)
401
406
  self.input_suggestions = InputSuggestionController(
402
407
  enabled=settings.ui.input_suggestions_enabled
403
408
  )
@@ -622,6 +627,7 @@ class DeepyTuiApp(App[None]):
622
627
  "- **Ctrl+J** - insert newline\n"
623
628
  "- **Ctrl+P** - command palette\n"
624
629
  "- **Ctrl+O** - toggle side panel\n"
630
+ "- **Shift+Tab** - cycle audit mode\n"
625
631
  "- **Alt+Up / Alt+Down** - move between transcript blocks\n"
626
632
  "- **Ctrl+Up / Ctrl+Down** - prompt history\n"
627
633
  "- **Ctrl+D twice** - exit",
@@ -651,6 +657,7 @@ class DeepyTuiApp(App[None]):
651
657
  f"- Session: `{self.state.session_id or 'new'}`",
652
658
  f"- Theme: `{self.settings.ui.theme}`",
653
659
  f"- View: `{self.settings.ui.view_mode}`",
660
+ f"- Audit: `{_format_tui_audit_mode(self.audit_state, self.settings)}`",
654
661
  f"- Input suggestions: `{'enabled' if self.settings.ui.input_suggestions_enabled else 'disabled'}`",
655
662
  f"- Loaded skills: `{', '.join(self.controller.loaded_skill_names) or 'none'}`",
656
663
  f"- Sessions: `{report.session_count}`",
@@ -1357,6 +1364,8 @@ class DeepyTuiApp(App[None]):
1357
1364
  session_id=self.state.session_id,
1358
1365
  skill_names=skill_names,
1359
1366
  background_tasks=self.background_tasks,
1367
+ audit_mode=self.audit_state,
1368
+ approval_resolver=self._tui_approval_resolver,
1360
1369
  )
1361
1370
  except Exception as exc:
1362
1371
  self.post_message(TurnFailedMessage(exc))
@@ -1393,6 +1402,27 @@ class DeepyTuiApp(App[None]):
1393
1402
  await self._append_block(ErrorBlock(str(message.error)))
1394
1403
  self._update_status("Error")
1395
1404
 
1405
+ async def _tui_approval_resolver(self, pending: list[PendingApproval]) -> list[ApprovalDecision]:
1406
+ decisions: list[ApprovalDecision] = []
1407
+ for item in pending:
1408
+ choice = await self.push_screen_wait(
1409
+ AuditApprovalScreen(
1410
+ item,
1411
+ project_root=self.project_root,
1412
+ width=max(40, self.size.width - 6),
1413
+ )
1414
+ )
1415
+ approved = choice == AUDIT_APPROVAL_APPROVE
1416
+ decisions.append(
1417
+ ApprovalDecision(
1418
+ outcome="approve" if approved else "reject",
1419
+ rejection_message=None
1420
+ if approved
1421
+ else "Tool execution was rejected by the user audit approval decision.",
1422
+ )
1423
+ )
1424
+ return decisions
1425
+
1396
1426
  @on(QuestionBlock.Answered)
1397
1427
  async def on_question_answered(self, message: QuestionBlock.Answered) -> None:
1398
1428
  message.stop()
@@ -1686,6 +1716,7 @@ class DeepyTuiApp(App[None]):
1686
1716
  self.state.session_id,
1687
1717
  self.controller.loaded_skill_names,
1688
1718
  self._todo_text,
1719
+ audit_state=self.audit_state,
1689
1720
  )
1690
1721
  )
1691
1722
 
@@ -1707,8 +1738,13 @@ class DeepyTuiApp(App[None]):
1707
1738
  project_root=self.project_root,
1708
1739
  settings=self.settings,
1709
1740
  background_tasks=self.background_tasks,
1741
+ audit_state=self.audit_state,
1710
1742
  )
1711
1743
 
1744
+ def action_cycle_audit_mode(self) -> None:
1745
+ mode = self.audit_state.cycle()
1746
+ self._update_status(f"Audit {mode.value}")
1747
+
1712
1748
  def action_confirm_quit(self) -> None:
1713
1749
  if self.state.quit_confirm_pending:
1714
1750
  self._exit_with_summary()
@@ -1810,7 +1846,9 @@ class DeepyTuiApp(App[None]):
1810
1846
  self.state = request_interrupt(self.state)
1811
1847
  self._update_status("Interrupt requested")
1812
1848
  return
1813
- self.query_one("#prompt-input", PromptTextArea).focus()
1849
+ prompt = self.query_one("#prompt-input", PromptTextArea)
1850
+ prompt.prepare_clear_on_next_delete()
1851
+ prompt.focus()
1814
1852
 
1815
1853
  def action_focus_next_block(self) -> None:
1816
1854
  blocks = list(self.query(".transcript-block"))
@@ -2103,11 +2141,13 @@ def _build_tui_status_context(
2103
2141
  project_root: Path,
2104
2142
  settings: Settings,
2105
2143
  background_tasks: BackgroundTaskManager | None = None,
2144
+ audit_state: AuditModeState | None = None,
2106
2145
  ) -> str:
2107
2146
  segments = [
2108
2147
  f"provider {settings.model.provider}",
2109
2148
  f"model {settings.model.name}[{settings.model.reasoning_mode}]",
2110
2149
  f"cwd {format_home_relative_path(project_root)}",
2150
+ f"audit {_active_tui_audit_mode(audit_state, settings)}",
2111
2151
  ]
2112
2152
  if has_agents_instructions(project_root):
2113
2153
  segments.append("[AGENTS.md]")
@@ -2145,6 +2185,8 @@ def _format_tui_side_status(
2145
2185
  session_id: str | None,
2146
2186
  loaded_skill_names: list[str],
2147
2187
  todo_text: str,
2188
+ *,
2189
+ audit_state: AuditModeState | None = None,
2148
2190
  ) -> str:
2149
2191
  session_entry = _tui_session_entry(project_root, session_id)
2150
2192
  lines = [
@@ -2152,6 +2194,7 @@ def _format_tui_side_status(
2152
2194
  f"Provider: {settings.model.provider}",
2153
2195
  f"Model: {settings.model.name}",
2154
2196
  f"Thinking: {settings.model.reasoning_mode}",
2197
+ f"Audit: {_format_tui_audit_mode(audit_state, settings)}",
2155
2198
  f"Session: {session_id or 'new'}",
2156
2199
  f"Cache: {_format_tui_cache_status(session_entry)}",
2157
2200
  f"Skills: {', '.join(loaded_skill_names) or 'none'}",
@@ -2161,6 +2204,20 @@ def _format_tui_side_status(
2161
2204
  return "\n".join(lines)
2162
2205
 
2163
2206
 
2207
+ def _active_tui_audit_mode(audit_state: AuditModeState | None, settings: Settings) -> str:
2208
+ if audit_state is not None:
2209
+ return audit_state.mode.value
2210
+ return settings.audit.mode.value
2211
+
2212
+
2213
+ def _format_tui_audit_mode(audit_state: AuditModeState | None, settings: Settings) -> str:
2214
+ active = _active_tui_audit_mode(audit_state, settings)
2215
+ configured = settings.audit.mode.value
2216
+ if active == configured:
2217
+ return active
2218
+ return f"{active} (runtime, config {configured})"
2219
+
2220
+
2164
2221
  def _format_tui_cache_status(session_entry: Any | None) -> str:
2165
2222
  if session_entry is None:
2166
2223
  return "unknown"
@@ -1,16 +1,25 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
+ from pathlib import Path
4
5
  from typing import Literal
5
6
 
6
7
  from textual import on
7
8
  from textual.app import ComposeResult
8
9
  from textual.binding import Binding
9
- from textual.containers import Vertical
10
+ from textual.containers import Vertical, VerticalScroll
10
11
  from textual.screen import ModalScreen
11
12
  from textual.widgets import Footer, Input, Label, Markdown, OptionList, Static
12
13
  from textual.widgets.option_list import Option
13
14
 
15
+ from deepy.audit import PendingApproval
16
+ from deepy.ui.audit_approval_panel import build_approval_view
17
+ from deepy.ui.styles import DARK_PALETTE, UiPalette
18
+
19
+
20
+ AUDIT_APPROVAL_APPROVE = "approve"
21
+ AUDIT_APPROVAL_REJECT = "reject"
22
+
14
23
 
15
24
  class InfoScreen(ModalScreen[None]):
16
25
  BINDINGS = [
@@ -55,6 +64,179 @@ class InfoScreen(ModalScreen[None]):
55
64
  self.dismiss(None)
56
65
 
57
66
 
67
+ class AuditApprovalScreen(ModalScreen[str]):
68
+ BINDINGS = [
69
+ Binding("escape", "dismiss", "Reject"),
70
+ Binding("y", "ignore_letter_shortcut", show=False),
71
+ Binding("a", "ignore_letter_shortcut", show=False),
72
+ Binding("n", "ignore_letter_shortcut", show=False),
73
+ Binding("r", "ignore_letter_shortcut", show=False),
74
+ ]
75
+
76
+ CSS = """
77
+ AuditApprovalScreen {
78
+ align: center middle;
79
+ }
80
+
81
+ AuditApprovalScreen > Vertical {
82
+ width: 112;
83
+ max-width: 98%;
84
+ height: auto;
85
+ max-height: 92%;
86
+ border: round $warning;
87
+ background: $surface;
88
+ padding: 1 2;
89
+ }
90
+
91
+ AuditApprovalScreen > Vertical.-has-preview {
92
+ height: 92%;
93
+ }
94
+
95
+ AuditApprovalScreen .approval-summary {
96
+ margin-top: 1;
97
+ }
98
+
99
+ AuditApprovalScreen .approval-preview {
100
+ height: 1fr;
101
+ max-height: 1fr;
102
+ margin-top: 1;
103
+ border: tall $warning;
104
+ padding: 0 1;
105
+ }
106
+
107
+ AuditApprovalScreen OptionList {
108
+ height: 4;
109
+ max-height: 4;
110
+ margin-top: 1;
111
+ }
112
+
113
+ AuditApprovalScreen .screen-help {
114
+ color: $text-muted;
115
+ margin: 1 0 0 0;
116
+ }
117
+ """
118
+
119
+ def __init__(
120
+ self,
121
+ item: PendingApproval,
122
+ *,
123
+ project_root: str | Path | None = None,
124
+ palette: UiPalette | None = None,
125
+ width: int | None = None,
126
+ ) -> None:
127
+ super().__init__()
128
+ self.item = item
129
+ self.project_root = project_root
130
+ self.palette = palette or DARK_PALETTE
131
+ self.width = width
132
+ self._title_label: Label | None = None
133
+ self._summary: Static | None = None
134
+ self._container: Vertical | None = None
135
+ self._preview_container: VerticalScroll | None = None
136
+ self._preview: Static | None = None
137
+ self._options: OptionList | None = None
138
+
139
+ def compose(self) -> ComposeResult:
140
+ self._container = Vertical()
141
+ with self._container:
142
+ self._title_label = Label("", id="approval-title", classes="block-title")
143
+ self._summary = Static("", id="approval-summary", classes="approval-summary")
144
+ self._options = OptionList(id="approval-options")
145
+ yield self._title_label
146
+ yield self._summary
147
+ self._preview_container = VerticalScroll(id="approval-preview", classes="approval-preview")
148
+ with self._preview_container:
149
+ self._preview = Static("", id="approval-preview-content")
150
+ yield self._preview
151
+ yield self._options
152
+ yield Static("Use Up/Down to select, Enter to activate, Esc to reject.", classes="screen-help")
153
+
154
+ def on_mount(self) -> None:
155
+ self._refresh_view()
156
+ self._approval_options().focus()
157
+
158
+ @on(OptionList.OptionSelected, "#approval-options")
159
+ def on_option_selected(self, event: OptionList.OptionSelected) -> None:
160
+ event.stop()
161
+ option_id = str(event.option_id or "")
162
+ if option_id == AUDIT_APPROVAL_APPROVE:
163
+ self.dismiss(AUDIT_APPROVAL_APPROVE)
164
+ return
165
+ if option_id == AUDIT_APPROVAL_REJECT:
166
+ self.dismiss(AUDIT_APPROVAL_REJECT)
167
+
168
+ def action_ignore_letter_shortcut(self) -> None:
169
+ return
170
+
171
+ async def action_dismiss(self, result: str | None = None) -> None:
172
+ self.dismiss(AUDIT_APPROVAL_REJECT)
173
+
174
+ def _refresh_view(self) -> None:
175
+ view = build_approval_view(
176
+ self.item,
177
+ palette=self.palette,
178
+ project_root=self.project_root,
179
+ expanded=True,
180
+ width=self.width,
181
+ )
182
+ self._approval_title().update(view.title)
183
+ summary = f"{view.target_label}: {view.target or '-'}"
184
+ if view.metadata:
185
+ summary += "\n" + "\n".join(f"{label}: {value}" for label, value in view.metadata)
186
+ self._approval_summary().update(summary)
187
+ preview = self._approval_preview()
188
+ preview_container = self._approval_preview_container()
189
+ container = self._approval_container()
190
+ if view.preview is None:
191
+ preview.update("")
192
+ preview_container.display = False
193
+ container.set_class(False, "-has-preview")
194
+ else:
195
+ preview.update(view.preview)
196
+ preview_container.display = True
197
+ container.set_class(True, "-has-preview")
198
+ options = self._approval_options()
199
+ options.clear_options()
200
+ options.add_options(
201
+ [
202
+ Option("Approve", id=AUDIT_APPROVAL_APPROVE),
203
+ Option("Reject", id=AUDIT_APPROVAL_REJECT),
204
+ ]
205
+ )
206
+ options.highlighted = 0
207
+ self.call_after_refresh(options.refresh)
208
+
209
+ def _approval_title(self) -> Label:
210
+ if self._title_label is None:
211
+ raise RuntimeError("Approval title is not mounted.")
212
+ return self._title_label
213
+
214
+ def _approval_summary(self) -> Static:
215
+ if self._summary is None:
216
+ raise RuntimeError("Approval summary is not mounted.")
217
+ return self._summary
218
+
219
+ def _approval_container(self) -> Vertical:
220
+ if self._container is None:
221
+ raise RuntimeError("Approval container is not mounted.")
222
+ return self._container
223
+
224
+ def _approval_preview(self) -> Static:
225
+ if self._preview is None:
226
+ raise RuntimeError("Approval preview is not mounted.")
227
+ return self._preview
228
+
229
+ def _approval_preview_container(self) -> VerticalScroll:
230
+ if self._preview_container is None:
231
+ raise RuntimeError("Approval preview container is not mounted.")
232
+ return self._preview_container
233
+
234
+ def _approval_options(self) -> OptionList:
235
+ if self._options is None:
236
+ raise RuntimeError("Approval option list is not mounted.")
237
+ return self._options
238
+
239
+
58
240
  @dataclass(frozen=True)
59
241
  class Choice:
60
242
  label: str
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import re
4
+ import time
4
5
  from pathlib import Path
5
6
  from typing import cast
6
7
 
@@ -95,6 +96,10 @@ class _KeyboardProtocolTextMixin:
95
96
 
96
97
 
97
98
  class PromptTextArea(_KeyboardProtocolTextMixin, TextArea):
99
+ _clear_draft_delete_deadline: float | None = None
100
+ _CLEAR_DRAFT_DELETE_WINDOW_SECONDS = 2.0
101
+ _clock = staticmethod(time.monotonic)
102
+
98
103
  BINDINGS = [
99
104
  Binding("enter", "submit", "Send", priority=True),
100
105
  Binding("ctrl+j", "newline", "Newline", priority=True),
@@ -119,9 +124,26 @@ class PromptTextArea(_KeyboardProtocolTextMixin, TextArea):
119
124
 
120
125
  @on(TextArea.Changed)
121
126
  def on_keyboard_protocol_text_changed(self, event: TextArea.Changed) -> None:
127
+ if self._clear_draft_delete_deadline is not None and not self._normalizing_keyboard_protocol_text:
128
+ self._clear_draft_delete_deadline = None
122
129
  if self._normalize_keyboard_protocol_text():
123
130
  event.stop()
124
131
 
132
+ def prepare_clear_on_next_delete(self) -> None:
133
+ self._clear_draft_delete_deadline = (
134
+ self._clock() + self._CLEAR_DRAFT_DELETE_WINDOW_SECONDS
135
+ )
136
+
137
+ def action_delete_left(self) -> None:
138
+ if self._clear_draft_if_pending():
139
+ return
140
+ super().action_delete_left()
141
+
142
+ def action_delete_right(self) -> None:
143
+ if self._clear_draft_if_pending():
144
+ return
145
+ super().action_delete_right()
146
+
125
147
  def action_submit(self) -> None:
126
148
  panel = self.parent
127
149
  if isinstance(panel, PromptPanel) and panel.accept_selected_suggestion():
@@ -173,6 +195,18 @@ class PromptTextArea(_KeyboardProtocolTextMixin, TextArea):
173
195
  return
174
196
  super().action_cursor_down(select)
175
197
 
198
+ def _clear_draft_if_pending(self) -> bool:
199
+ deadline = self._clear_draft_delete_deadline
200
+ if deadline is None:
201
+ return False
202
+ self._clear_draft_delete_deadline = None
203
+ if self._clock() > deadline:
204
+ return False
205
+ if not self.text:
206
+ return False
207
+ self.clear()
208
+ return True
209
+
176
210
 
177
211
  class QuestionTextArea(_KeyboardProtocolTextMixin, TextArea):
178
212
  BINDINGS = [
@@ -322,11 +356,14 @@ class PromptPanel(Vertical):
322
356
  mention = extract_file_mention_fragment(text)
323
357
  if mention is None:
324
358
  return []
325
- paths = (
326
- self.discovery.top_level_paths()
327
- if "/" not in mention.fragment
328
- else self.discovery.deep_paths(mention.fragment.rsplit("/", 1)[0])
329
- )
359
+ if "/" not in mention.fragment:
360
+ paths = (
361
+ self.discovery.top_level_paths()
362
+ if not mention.fragment
363
+ else self.discovery.deep_paths()
364
+ )
365
+ else:
366
+ paths = self.discovery.deep_paths(mention.fragment.rsplit("/", 1)[0])
330
367
  return [f"@{path}" for path in rank_file_mention_candidates(paths, mention.fragment)]
331
368
 
332
369
  def _apply_suggestion(self, text: str, suggestion: str) -> str:
@@ -14,7 +14,6 @@ from prompt_toolkit.document import Document
14
14
 
15
15
  DEFAULT_FILE_MENTION_LIMIT = 1000
16
16
  DEFAULT_FILE_MENTION_REFRESH_INTERVAL = 2.0
17
- DEEP_SEARCH_MIN_FRAGMENT_LENGTH = 3
18
17
 
19
18
  _IGNORED_NAMES = frozenset(
20
19
  {
@@ -292,7 +291,7 @@ class FileMentionCompleter(Completer):
292
291
  )
293
292
 
294
293
  def _paths_for_fragment(self, fragment: str) -> list[str]:
295
- if "/" not in fragment and len(fragment) < DEEP_SEARCH_MIN_FRAGMENT_LENGTH:
294
+ if "/" not in fragment and not fragment:
296
295
  return self._discovery.top_level_paths()
297
296
  scope = fragment.rsplit("/", 1)[0] if "/" in fragment else None
298
297
  return self._discovery.deep_paths(scope)
@@ -104,6 +104,7 @@ from deepy.ui.local_command import (
104
104
  shell_tool_result_json,
105
105
  )
106
106
  from deepy.ui.message_view import (
107
+ ToolOutputView,
107
108
  format_tool_display_name,
108
109
  format_tool_display_label,
109
110
  format_tool_call_summary,
@@ -3981,7 +3982,7 @@ def _print_stream_event(
3981
3982
  call_summary = call.summary if call is not None else ""
3982
3983
  summary = (
3983
3984
  _audit_rejection_tool_summary(call.name if call is not None else view.name)
3984
- if _is_audit_rejection_tool_output(event.text)
3985
+ if _is_audit_rejection_tool_output(event.text, view)
3985
3986
  else format_tool_progress_summary(call_summary, event.text)
3986
3987
  )
3987
3988
  diff = render_tool_diff_preview(
@@ -4021,7 +4022,9 @@ def _stream_event_writes_terminal(event: DeepyStreamEvent) -> bool:
4021
4022
  return event.kind in {"tool_output", "status"}
4022
4023
 
4023
4024
 
4024
- def _is_audit_rejection_tool_output(output: str) -> bool:
4025
+ def _is_audit_rejection_tool_output(output: str, view: ToolOutputView) -> bool:
4026
+ if view.ok is True:
4027
+ return False
4025
4028
  normalized = output.strip().lower()
4026
4029
  return "audit approval" in normalized and "reject" in normalized
4027
4030
 
File without changes
File without changes
File without changes