soothe-cli 1.0.0__tar.gz → 1.0.3__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 (145) hide show
  1. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/PKG-INFO +1 -1
  2. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/commands/binding.py +3 -3
  3. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/app/_execution.py +4 -0
  4. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/app/_messages_mixin.py +21 -28
  5. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/widgets/context_data.py +1 -1
  6. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/widgets/messages/cognition_goal_tree.py +5 -5
  7. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/widgets/messages/cognition_step.py +3 -3
  8. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/widgets/messages/structured_ask_user.py +118 -57
  9. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/widgets/plan_quick_view_overlay.py +1 -1
  10. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/.gitignore +0 -0
  11. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/README.md +0 -0
  12. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/pyproject.toml +0 -0
  13. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/__init__.py +0 -0
  14. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/_cli_context.py +0 -0
  15. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/_env_vars.py +0 -0
  16. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/_version.py +0 -0
  17. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/card_wire.py +0 -0
  18. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/cli/__init__.py +0 -0
  19. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/cli/commands/__init__.py +0 -0
  20. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/cli/commands/autopilot_cmd.py +0 -0
  21. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/cli/commands/config_cmd.py +0 -0
  22. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/cli/commands/cron_cmd.py +0 -0
  23. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/cli/commands/loop_cmd.py +0 -0
  24. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
  25. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/cli/commands/status_cmd.py +0 -0
  26. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/cli/execution/__init__.py +0 -0
  27. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/cli/execution/daemon.py +0 -0
  28. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/cli/execution/daemon_errors.py +0 -0
  29. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/cli/execution/headless.py +0 -0
  30. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/cli/execution/headless_renderer.py +0 -0
  31. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/cli/execution/launcher.py +0 -0
  32. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/cli/main.py +0 -0
  33. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/cli/soothecli.py +0 -0
  34. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/commands/__init__.py +0 -0
  35. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/commands/command_registry.py +0 -0
  36. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/commands/command_router.py +0 -0
  37. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/commands/slash_commands.py +0 -0
  38. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/commands/subagent_routing.py +0 -0
  39. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/config/__init__.py +0 -0
  40. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/config/cli_config.py +0 -0
  41. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/config/loader.py +0 -0
  42. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/config/logging_setup.py +0 -0
  43. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/display/__init__.py +0 -0
  44. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/display/markdown_theme.py +0 -0
  45. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/display/media_utils.py +0 -0
  46. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/display/mermaid_render.py +0 -0
  47. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/display/path_utils.py +0 -0
  48. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/display/preview_limits.py +0 -0
  49. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/display/shell_color.py +0 -0
  50. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/display/spinner_labels.py +0 -0
  51. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/display/theme.py +0 -0
  52. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/display/tips.py +0 -0
  53. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/display/tool_display.py +0 -0
  54. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/display/unicode_security.py +0 -0
  55. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/loops/__init__.py +0 -0
  56. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/loops/sessions.py +0 -0
  57. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/model_config.py +0 -0
  58. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/project_utils.py +0 -0
  59. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/runtime/__init__.py +0 -0
  60. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/runtime/headless/processor.py +0 -0
  61. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/runtime/headless/processor_state.py +0 -0
  62. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/runtime/parse/tool_call_resolution.py +0 -0
  63. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/runtime/policy/tui_trace_log.py +0 -0
  64. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/runtime/presentation/duration_format.py +0 -0
  65. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/runtime/presentation/engine.py +0 -0
  66. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/runtime/presentation/id_format.py +0 -0
  67. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/runtime/presentation/renderer_base.py +0 -0
  68. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/runtime/presentation/renderer_protocol.py +0 -0
  69. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/runtime/presentation/step_id_format.py +0 -0
  70. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/runtime/presentation/subagent_task_display.py +0 -0
  71. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/runtime/state/file_tracker.py +0 -0
  72. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/runtime/state/session_stats.py +0 -0
  73. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/runtime/state/step_router.py +0 -0
  74. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/runtime/state/stream_accumulator.py +0 -0
  75. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/runtime/token_events_debug.py +0 -0
  76. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/runtime/token_usage.py +0 -0
  77. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/runtime/transport/session.py +0 -0
  78. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/runtime/turn/prepare.py +0 -0
  79. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/runtime/wire/chunk_filter.py +0 -0
  80. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/runtime/wire/message_text.py +0 -0
  81. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/runtime/wire/messages.py +0 -0
  82. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/settings/__init__.py +0 -0
  83. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/settings/_console_impl.py +0 -0
  84. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/settings/bootstrap.py +0 -0
  85. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/settings/core.py +0 -0
  86. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/settings/glyphs.py +0 -0
  87. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/settings/provider.py +0 -0
  88. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/settings/shell_allow.py +0 -0
  89. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/settings/skills_dirs.py +0 -0
  90. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/settings/stream_config.py +0 -0
  91. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/__init__.py +0 -0
  92. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/app/__init__.py +0 -0
  93. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/app/_app.py +0 -0
  94. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/app/_clarification.py +0 -0
  95. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/app/_entrypoints.py +0 -0
  96. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/app/_history.py +0 -0
  97. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/app/_model.py +0 -0
  98. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/app/_model_params.py +0 -0
  99. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/app/_startup.py +0 -0
  100. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/app/_terminal.py +0 -0
  101. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/app/_theme_prefs.py +0 -0
  102. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/app/_types.py +0 -0
  103. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/app/_ui.py +0 -0
  104. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/app/app.tcss +0 -0
  105. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/composer_mode.py +0 -0
  106. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/file_change_notify.py +0 -0
  107. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/file_change_renderers.py +0 -0
  108. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/input.py +0 -0
  109. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/skills/__init__.py +0 -0
  110. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/skills/invocation.py +0 -0
  111. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/skills/load.py +0 -0
  112. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/textual_adapter.py +0 -0
  113. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/widgets/__init__.py +0 -0
  114. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/widgets/_links.py +0 -0
  115. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/widgets/autocomplete.py +0 -0
  116. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/widgets/chat_input.py +0 -0
  117. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/widgets/clipboard.py +0 -0
  118. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/widgets/context_viewer.py +0 -0
  119. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/widgets/diff.py +0 -0
  120. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/widgets/editor.py +0 -0
  121. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/widgets/file_change_preview.py +0 -0
  122. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/widgets/help_screen.py +0 -0
  123. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/widgets/history.py +0 -0
  124. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/widgets/loading.py +0 -0
  125. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/widgets/loop_selector.py +0 -0
  126. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
  127. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/widgets/message_store.py +0 -0
  128. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/widgets/messages/__init__.py +0 -0
  129. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/widgets/messages/_helpers.py +0 -0
  130. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/widgets/messages/app.py +0 -0
  131. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/widgets/messages/assistant.py +0 -0
  132. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/widgets/messages/cognition_reason.py +0 -0
  133. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/widgets/messages/cognition_step_activity.py +0 -0
  134. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/widgets/messages/diff_message.py +0 -0
  135. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/widgets/messages/error.py +0 -0
  136. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/widgets/messages/skill.py +0 -0
  137. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/widgets/messages/user.py +0 -0
  138. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
  139. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
  140. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/widgets/pinned_goal_bar.py +0 -0
  141. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/widgets/router_profile_selector.py +0 -0
  142. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/widgets/status.py +0 -0
  143. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
  144. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/tui/widgets/welcome.py +0 -0
  145. {soothe_cli-1.0.0 → soothe_cli-1.0.3}/src/soothe_cli/update_check.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: soothe-cli
3
- Version: 1.0.0
3
+ Version: 1.0.3
4
4
  Summary: Soothe CLI client - communicates with daemon via WebSocket
5
5
  Project-URL: Homepage, https://github.com/mirasoth/soothe
6
6
  Project-URL: Documentation, https://soothe.readthedocs.io
@@ -155,14 +155,14 @@ def message_to_widget(data: MessageData) -> Widget:
155
155
  is_tool_approval = origin == "tool_approval"
156
156
  refine_label = "Edit" if is_tool_approval else "Refine"
157
157
  question_spec = {
158
- "question": "Action for this plan: Approve, Refine, or Reject?"
158
+ "question": "Action for this plan: Approve, Reject, or Refine?"
159
159
  if not is_tool_approval
160
160
  else "Approve this tool call?",
161
161
  "header": "Plan review" if not is_tool_approval else "Tool approval",
162
162
  "options": [
163
163
  {"label": "Approve", "description": "Accept and proceed."},
164
- {"label": refine_label, "description": "Request changes."},
165
164
  {"label": "Reject", "description": "Reject."},
165
+ {"label": refine_label, "description": "Request changes."},
166
166
  ],
167
167
  }
168
168
  w = StructuredAskUserWidget(
@@ -261,7 +261,7 @@ def message_from_widget(widget: Widget) -> MessageData:
261
261
  # Awaiting state for HITL origins.
262
262
  if is_hitl and not widget._submitted: # noqa: SLF001
263
263
  if is_plan_review:
264
- content = "Plan review: awaiting Approve / Refine / Reject"
264
+ content = "Plan review: awaiting Approve / Reject / Refine"
265
265
  else:
266
266
  content = "Tool approval: awaiting Approve / Edit / Reject"
267
267
  return MessageData(type=MessageType.APP, content=content, id=widget_id)
@@ -341,6 +341,10 @@ class _ExecutionMixin:
341
341
  # next pauses, which freezes scrolling and chat-input focus.
342
342
  await self._send_to_agent(payload_text)
343
343
 
344
+ # Return focus to the chat input so the user can type immediately.
345
+ with suppress(Exception):
346
+ self.focus_primary_input()
347
+
344
348
  async def _resolve_default_clarification_mode(self) -> str:
345
349
  """Fetch the daemon's default clarification mode for plan approval.
346
350
 
@@ -793,20 +793,22 @@ class _MessagesMixin:
793
793
  def action_quit_or_interrupt(self) -> None:
794
794
  """Handle Ctrl+C — clear input, interrupt work, or exit on double-press.
795
795
 
796
- Priority order when a task is running (agent/shell):
797
- 1. If input has pending text, clear it (first Ctrl+C).
798
- 2. If input is empty, interrupt the running task.
799
- Interrupting a task resets the idle double-press exit state.
800
-
801
- When idle, Ctrl+C arms a double-press exit:
802
- 1. First press clears any pending draft and shows a hint.
803
- 2. Second press within the timeout window exits the TUI.
804
-
805
- Note: Copying selected text is bound to Ctrl+Y (`action_copy_selection`)
806
- so Ctrl+C is reserved for interrupt/exit behavior.
796
+ Priority order: HITL review card (abandon + focus chat) → running
797
+ task (clear input, then interrupt) → idle double-press exit.
798
+ Ctrl+Y copies selection, so Ctrl+C stays reserved for this path.
807
799
  """
808
800
  current_time = time.monotonic()
809
801
 
802
+ # HITL review card (plan/tool): abandon and return focus to chat.
803
+ if self._has_active_qa_widget():
804
+ widget = self._active_plan_review_widget()
805
+ if widget is not None and not getattr(widget, "_submitted", False):
806
+ widget._abandon()
807
+ self._ctrl_c_pressed_time = None
808
+ if self._chat_input:
809
+ self._chat_input.focus_input()
810
+ return
811
+
810
812
  # Check if input has pending content (text, mode, or completion).
811
813
  has_pending_input = self._has_pending_chat_input()
812
814
 
@@ -1072,25 +1074,21 @@ class _MessagesMixin:
1072
1074
  inputs.append(inp)
1073
1075
  return inputs
1074
1076
 
1075
- def _active_plan_review_action_focus(self) -> Widget | None:
1076
- """Prefer the Approve button when a plan-review or tool-approval card is active."""
1077
+ def _active_plan_review_widget(self) -> Widget | None:
1078
+ """Return the active unsubmitted HITL review card, if any."""
1077
1079
  adapter = getattr(self, "_ui_adapter", None)
1078
1080
  if adapter is None:
1079
1081
  return None
1080
1082
  by_step = getattr(adapter, "_clarification_input_by_step", None) or {}
1081
- # Both option-selector origins render Approve / Refine(Edit) / Reject buttons.
1082
1083
  for message in by_step.values():
1083
1084
  if getattr(message, "_submitted", False):
1084
1085
  continue
1085
1086
  # String compare — avoid MagicMock truthiness on property access in tests.
1086
- if getattr(message, "_origin_node", None) not in ("plan_mode_review", "tool_approval"):
1087
- continue
1088
- buttons = getattr(message, "_action_buttons", None) or {}
1089
- if not isinstance(buttons, dict):
1090
- continue
1091
- approve = buttons.get("approve")
1092
- if approve is not None and not getattr(approve, "disabled", False):
1093
- return approve
1087
+ if getattr(message, "_origin_node", None) in (
1088
+ "plan_mode_review",
1089
+ "tool_approval",
1090
+ ):
1091
+ return message
1094
1092
  return None
1095
1093
 
1096
1094
  def _non_chat_focusable_inputs(self) -> list[Input]:
@@ -1107,18 +1105,13 @@ class _MessagesMixin:
1107
1105
  """Return the single input that should receive typing focus, if unambiguous.
1108
1106
 
1109
1107
  When an inline clarification card is active, its answer field takes
1110
- precedence over the bottom chat prompt. Planner-subagent review
1111
- focuses the Approve action. On modal screens, a lone
1108
+ precedence over the bottom chat prompt. On modal screens, a lone
1112
1109
  filter box is focused automatically.
1113
1110
  """
1114
1111
  clar_inputs = self._active_clarification_inputs()
1115
1112
  if clar_inputs:
1116
1113
  return clar_inputs[0]
1117
1114
 
1118
- plan_action = self._active_plan_review_action_focus()
1119
- if plan_action is not None:
1120
- return plan_action
1121
-
1122
1115
  non_chat = self._non_chat_focusable_inputs()
1123
1116
  if len(non_chat) == 1:
1124
1117
  return non_chat[0]
@@ -138,7 +138,7 @@ def format_token_usage(snapshot: TokenUsageSnapshot) -> str:
138
138
  formatted = format_token_count(count)
139
139
  usage = f"{formatted}{suffix} tokens used this loop"
140
140
  if in_count > 0 or out_count > 0:
141
- usage += f" (in: {format_token_count(in_count)} · out: {format_token_count(out_count)})"
141
+ usage += f" (↑{format_token_count(in_count)} · ↓{format_token_count(out_count)})"
142
142
 
143
143
  msg = f"{usage} · {model_name}" if model_name else usage
144
144
 
@@ -331,7 +331,7 @@ class CognitionGoalTreeMessage(Vertical):
331
331
  self._goal_out_tokens += out_t
332
332
 
333
333
  def goal_token_suffix(self) -> str:
334
- """Compact `in:1.2K out:345` suffix for the plan panel title.
334
+ """Compact `↑1.2K ↓345` suffix for the plan panel title.
335
335
 
336
336
  Returns an empty string when no step has recorded any tokens.
337
337
  """
@@ -340,7 +340,7 @@ class CognitionGoalTreeMessage(Vertical):
340
340
  return ""
341
341
  from soothe_cli.runtime.state.session_stats import format_token_count
342
342
 
343
- return f"in:{format_token_count(total_in)} out:{format_token_count(total_out)}"
343
+ return f"↑{format_token_count(total_in)} ↓{format_token_count(total_out)}"
344
344
 
345
345
  def mark_loop_started(self, started_at: float | None = None) -> None:
346
346
  """Anchor plan-level elapsed time (matches thinking-row turn start)."""
@@ -390,14 +390,14 @@ class CognitionGoalTreeMessage(Vertical):
390
390
 
391
391
  @staticmethod
392
392
  def _step_token_parts(st: _StepLineState) -> list[str]:
393
- """In/out token labels for a step row, e.g. `in:1.2K out:345`."""
393
+ """Arrow token labels for a step row, e.g. `↑1.2K ↓345`."""
394
394
  if not (st.input_tokens or st.output_tokens):
395
395
  return []
396
396
  from soothe_cli.runtime.state.session_stats import format_token_count
397
397
 
398
398
  return [
399
- f"in:{format_token_count(st.input_tokens)}",
400
- f"out:{format_token_count(st.output_tokens)}",
399
+ f"↑{format_token_count(st.input_tokens)}",
400
+ f"↓{format_token_count(st.output_tokens)}",
401
401
  ]
402
402
 
403
403
  def _step_summary_suffix(self, st: _StepLineState) -> str:
@@ -251,14 +251,14 @@ class CognitionStepMessage(Vertical):
251
251
  self._sync_step_card_surface()
252
252
 
253
253
  def _token_budget_suffix(self) -> str:
254
- """Token budget suffix for status lines, e.g. `in:1.2K out:345`."""
254
+ """Token budget suffix for status lines, e.g. `↑1.2K ↓345`."""
255
255
  if not (self._input_tokens or self._output_tokens):
256
256
  return ""
257
257
  from soothe_cli.runtime.state.session_stats import format_token_count
258
258
 
259
259
  parts: list[str] = []
260
- parts.append(f"in:{format_token_count(self._input_tokens)}")
261
- parts.append(f"out:{format_token_count(self._output_tokens)}")
260
+ parts.append(f"↑{format_token_count(self._input_tokens)}")
261
+ parts.append(f"↓{format_token_count(self._output_tokens)}")
262
262
  return " · " + " ".join(parts)
263
263
 
264
264
  def _compact_title_meta_suffix(self) -> str:
@@ -2,7 +2,7 @@
2
2
 
3
3
  Unifies the generic ``ask_user`` (execute origin) and the HITL plan-review /
4
4
  tool-approval origins into one widget. HITL origins differ only in that their
5
- options are system-prefilled (Approve / Refine or Edit / Reject), the ``Other``
5
+ options are system-prefilled (Approve / Reject / Refine or Edit), the ``Other``
6
6
  free-text row is suppressed, and the Refine/Edit option shows a comment input.
7
7
  """
8
8
 
@@ -214,17 +214,21 @@ class StructuredAskUserWidget(Vertical):
214
214
  display: none;
215
215
  }
216
216
 
217
- StructuredAskUserWidget .saq-custom-input {
218
- margin: 0 0 0 2;
217
+ /* ── Inline option inputs (shared by custom + comment) ────────── */
218
+
219
+ /* `!important` beats Textual's default Input `:focus` border + height. */
220
+ StructuredAskUserWidget .saq-inline-input {
221
+ margin: 0;
219
222
  width: 1fr;
220
- height: 1;
221
- padding: 0 1;
222
- background: $surface;
223
- border: none;
223
+ height: auto !important;
224
+ padding: 0 !important;
225
+ border: none !important;
226
+ background: transparent !important;
227
+ color: $foreground !important;
224
228
  }
225
229
 
226
- StructuredAskUserWidget .saq-custom-input:disabled {
227
- opacity: 0.5;
230
+ StructuredAskUserWidget .saq-inline-input:disabled {
231
+ color: $text-muted;
228
232
  }
229
233
 
230
234
  StructuredAskUserWidget .saq-custom-hint {
@@ -351,7 +355,8 @@ class StructuredAskUserWidget(Vertical):
351
355
  StructuredAskUserWidget.is-submitted .saq-footer,
352
356
  StructuredAskUserWidget.is-submitted .saq-hint,
353
357
  StructuredAskUserWidget.is-submitted .saq-recap,
354
- StructuredAskUserWidget.is-submitted .saq-separator {
358
+ StructuredAskUserWidget.is-submitted .saq-separator,
359
+ StructuredAskUserWidget.is-submitted .saq-body-path {
355
360
  display: none;
356
361
  }
357
362
 
@@ -414,17 +419,19 @@ class StructuredAskUserWidget(Vertical):
414
419
  min-width: 0;
415
420
  }
416
421
 
417
- StructuredAskUserWidget .saq-comment-input {
418
- margin: 0;
419
- width: 1fr;
422
+ /* `❯` prefix before the comment input — same line height as the row.
423
+ Hidden unless the Refine/Edit option is highlighted. */
424
+ StructuredAskUserWidget .saq-comment-prefix {
425
+ width: auto;
420
426
  height: 1;
421
- padding: 0 1;
422
- background: $surface;
423
- border: none;
427
+ padding: 0;
428
+ margin: 0 1 0 1;
429
+ color: $primary;
430
+ visibility: hidden;
424
431
  }
425
432
 
426
- StructuredAskUserWidget .saq-comment-input:disabled {
427
- opacity: 0.5;
433
+ StructuredAskUserWidget .saq-comment-prefix.is-visible {
434
+ visibility: visible;
428
435
  }
429
436
 
430
437
  /* ── HITL: expand/collapse plan body in answered view ──────────── */
@@ -437,6 +444,11 @@ class StructuredAskUserWidget(Vertical):
437
444
  margin: 0;
438
445
  }
439
446
 
447
+ /* After submit, hide the plan body preview unless expanded via toggle. */
448
+ StructuredAskUserWidget.is-submitted .saq-body-box {
449
+ display: none;
450
+ }
451
+
440
452
  StructuredAskUserWidget.is-submitted .saq-body-box.is-expanded {
441
453
  display: block;
442
454
  }
@@ -767,13 +779,19 @@ class StructuredAskUserWidget(Vertical):
767
779
  classes="saq-option-row",
768
780
  markup=False,
769
781
  )
782
+ yield Static(
783
+ "❯",
784
+ id="saq-comment-prefix",
785
+ classes="saq-comment-prefix",
786
+ markup=False,
787
+ )
770
788
  comment_placeholder = (
771
789
  "Revised args…" if self._is_tool_approval else "Comments…"
772
790
  )
773
791
  self._comment_input = Input(
774
792
  placeholder=comment_placeholder,
775
793
  id="saq-comment-input",
776
- classes="saq-comment-input",
794
+ classes="saq-inline-input",
777
795
  disabled=True,
778
796
  )
779
797
  yield self._comment_input
@@ -804,7 +822,7 @@ class StructuredAskUserWidget(Vertical):
804
822
  self._custom_input = Input(
805
823
  placeholder="Type a custom answer…",
806
824
  id="saq-custom-input",
807
- classes="saq-custom-input",
825
+ classes="saq-inline-input",
808
826
  disabled=_has_renderable_opts,
809
827
  )
810
828
  yield self._custom_input
@@ -962,8 +980,6 @@ class StructuredAskUserWidget(Vertical):
962
980
  def _start_focus_guard(self) -> None:
963
981
  """Poll every 200 ms and recapture focus if it drifted from the widget.
964
982
 
965
- Scrollbar clicks, hydration re-layouts, and lifecycle events can
966
- move focus to the chat input, the scroll container, or ``None``.
967
983
  Stopped on submit/abandon.
968
984
  """
969
985
 
@@ -974,16 +990,9 @@ class StructuredAskUserWidget(Vertical):
974
990
  focused = self.app.focused
975
991
  except Exception: # noqa: BLE001
976
992
  return
977
- # Recapture whenever focus has left the widget and its
978
- # descendants. The chat input is the most common culprit, but
979
- # scrollbar clicks, hydration re-layouts, and other events can
980
- # also move focus to None or the scroll container.
981
993
  if not self._focus_drifted(focused):
982
994
  return
983
- try:
984
- self.app.set_focus(self)
985
- except Exception: # noqa: BLE001
986
- pass
995
+ self._refocus_self_no_scroll()
987
996
 
988
997
  try:
989
998
  self._focus_guard_timer = self.set_interval(0.2, _tick)
@@ -1001,12 +1010,7 @@ class StructuredAskUserWidget(Vertical):
1001
1010
  self._focus_guard_timer = None
1002
1011
 
1003
1012
  def _schedule_robust_focus(self) -> None:
1004
- """Pin focus to the widget for ~600 ms after mount.
1005
-
1006
- Textual's post-mount layout can re-focus the chat input after
1007
- `call_after_refresh` fires; staggered re-focus calls win that race
1008
- so arrow keys / Enter work immediately on appear.
1009
- """
1013
+ """Pin focus to the widget for ~600 ms after mount."""
1010
1014
 
1011
1015
  def _refocus() -> None:
1012
1016
  try:
@@ -1017,10 +1021,7 @@ class StructuredAskUserWidget(Vertical):
1017
1021
  return
1018
1022
  except Exception: # noqa: BLE001
1019
1023
  pass
1020
- try:
1021
- self.app.set_focus(self)
1022
- except Exception: # noqa: BLE001
1023
- pass
1024
+ self._refocus_self_no_scroll()
1024
1025
 
1025
1026
  try:
1026
1027
  self.set_timer(0.05, _refocus)
@@ -1030,6 +1031,13 @@ class StructuredAskUserWidget(Vertical):
1030
1031
  except Exception: # noqa: BLE001
1031
1032
  pass
1032
1033
 
1034
+ def _refocus_self_no_scroll(self) -> None:
1035
+ """Focus the container without scrolling (prevents plan-header jump)."""
1036
+ try:
1037
+ self.app.set_focus(self, scroll_visible=False)
1038
+ except Exception: # noqa: BLE001
1039
+ pass
1040
+
1033
1041
  def _focus_drifted(self, focused: Any) -> bool:
1034
1042
  """Return True if focus left the widget and its descendants.
1035
1043
 
@@ -1075,16 +1083,12 @@ class StructuredAskUserWidget(Vertical):
1075
1083
  self._toggle_body_expanded()
1076
1084
 
1077
1085
  def on_descendant_focus(self, event: DescendantFocus) -> None:
1078
- """Guard: the HITL comment input may only hold focus when the
1079
- Refine/Edit option is the selected action."""
1080
- if (
1081
- self._comment_input is not None
1082
- and event.widget is self._comment_input
1083
- and self._comment_option_index is not None
1084
- ):
1085
- sel = self._selected.get(self._current_q)
1086
- if sel != self._comment_option_index:
1087
- self.screen.set_focus(self)
1086
+ """An inline input holds focus only while its option is highlighted."""
1087
+ allowed = self._inline_input_for_highlight()
1088
+ if allowed is not None and event.widget is allowed:
1089
+ return
1090
+ if event.widget in self._inline_inputs():
1091
+ self.screen.set_focus(self)
1088
1092
 
1089
1093
  # ── Rendering updates ──────────────────────────────────────────
1090
1094
 
@@ -1197,6 +1201,15 @@ class StructuredAskUserWidget(Vertical):
1197
1201
  # HITL: enable/disable comment input based on the selected option.
1198
1202
  if self._comment_input is not None and self._comment_option_index is not None:
1199
1203
  self._update_comment_input_state()
1204
+ # Show the `❯` prefix only when the comment option is highlighted.
1205
+ try:
1206
+ prefix = self.query_one("#saq-comment-prefix", Static)
1207
+ if self._highlighted == self._comment_option_index:
1208
+ prefix.add_class("is-visible")
1209
+ else:
1210
+ prefix.remove_class("is-visible")
1211
+ except Exception: # noqa: BLE001
1212
+ pass
1200
1213
 
1201
1214
  def _update_custom_row_highlight(self, custom_idx: int) -> None:
1202
1215
  """Apply highlight/selected classes and refresh the custom-row text."""
@@ -1244,12 +1257,47 @@ class StructuredAskUserWidget(Vertical):
1244
1257
  pass
1245
1258
 
1246
1259
  def _update_comment_input_state(self) -> None:
1247
- """Enable/disable the HITL comment input based on the selected option."""
1260
+ """Enable the comment input when Refine/Edit is highlighted."""
1248
1261
  if self._comment_input is None or self._comment_option_index is None:
1249
1262
  return
1250
- sel = self._selected.get(self._current_q)
1251
- should_enable = sel is not None and sel == self._comment_option_index
1252
- self._comment_input.disabled = not should_enable
1263
+ self._comment_input.disabled = self._highlighted != self._comment_option_index
1264
+
1265
+ # ── Inline-input helpers (shared by custom + comment) ───────────
1266
+
1267
+ def _inline_inputs(self) -> tuple[Input, ...]:
1268
+ """Existing inline `Input`s attached to option rows."""
1269
+ return tuple(inp for inp in (self._custom_input, self._comment_input) if inp is not None)
1270
+
1271
+ def _inline_input_for_highlight(self) -> Input | None:
1272
+ """The inline input whose option is highlighted, else None."""
1273
+ if (
1274
+ self._comment_option_index is not None
1275
+ and self._highlighted == self._comment_option_index
1276
+ ):
1277
+ return self._comment_input
1278
+ if self._allow_custom:
1279
+ custom_idx = len(self._question_options(self._current_q))
1280
+ if self._highlighted == custom_idx:
1281
+ return self._custom_input
1282
+ return None
1283
+
1284
+ def _focus_inline_input_or_container(self) -> None:
1285
+ """Focus the highlighted option's inline input, or the container.
1286
+
1287
+ When leaving an inline input (Refine→Approve/Reject), the input is
1288
+ disabled by `_update_comment_input_state`, which makes Textual drop
1289
+ focus to None. Proactively re-focus the container without scrolling
1290
+ so the 200 ms guard doesn't recapture and scroll to the plan header.
1291
+ """
1292
+ target = self._inline_input_for_highlight()
1293
+ if target is not None and not target.disabled:
1294
+ try:
1295
+ self.app.set_focus(target)
1296
+ return
1297
+ except Exception: # noqa: BLE001
1298
+ pass
1299
+ # No inline input — keep focus on the container without scrolling.
1300
+ self._refocus_self_no_scroll()
1253
1301
 
1254
1302
  def _update_tab_highlight(self) -> None:
1255
1303
  """Update tab labels to reflect current question and answered state."""
@@ -1444,6 +1492,7 @@ class StructuredAskUserWidget(Vertical):
1444
1492
  total = num_opts + (1 if self._allow_custom else 0)
1445
1493
  self._highlighted = (self._highlighted - 1) % total
1446
1494
  self._update_option_highlight()
1495
+ self._focus_inline_input_or_container()
1447
1496
 
1448
1497
  def action_next_option(self) -> None:
1449
1498
  if self._degraded or self._submitted or self._submit_review_open:
@@ -1452,6 +1501,7 @@ class StructuredAskUserWidget(Vertical):
1452
1501
  total = num_opts + (1 if self._allow_custom else 0)
1453
1502
  self._highlighted = (self._highlighted + 1) % total
1454
1503
  self._update_option_highlight()
1504
+ self._focus_inline_input_or_container()
1455
1505
 
1456
1506
  def action_confirm(self) -> None:
1457
1507
  """Enter: select highlighted option, or finalize submit review."""
@@ -1471,7 +1521,10 @@ class StructuredAskUserWidget(Vertical):
1471
1521
  if self._allow_custom and self._highlighted == custom_idx:
1472
1522
  # Custom row — focus the input if not yet selected
1473
1523
  if self._custom_input is not None and not self._custom_input.disabled:
1474
- self._schedule_focus(self._custom_input)
1524
+ try:
1525
+ self.app.set_focus(self._custom_input)
1526
+ except Exception: # noqa: BLE001
1527
+ self._schedule_focus(self._custom_input)
1475
1528
  return
1476
1529
  # Select the highlighted option
1477
1530
  self._selected[self._current_q] = self._highlighted
@@ -1484,7 +1537,10 @@ class StructuredAskUserWidget(Vertical):
1484
1537
  and self._highlighted == self._comment_option_index
1485
1538
  and self._comment_input is not None
1486
1539
  ):
1487
- self._schedule_focus(self._comment_input)
1540
+ try:
1541
+ self.app.set_focus(self._comment_input)
1542
+ except Exception: # noqa: BLE001
1543
+ self._schedule_focus(self._comment_input)
1488
1544
  return
1489
1545
  # HITL: immediate submit on action selection (Approve/Reject).
1490
1546
  if self._is_hitl:
@@ -1600,7 +1656,12 @@ class StructuredAskUserWidget(Vertical):
1600
1656
  # HITL: comment input — Enter submits with the comment.
1601
1657
  if event.input is self._comment_input:
1602
1658
  event.stop()
1603
- self._comment_text = str(event.input.value or "").strip()
1659
+ text = str(event.input.value or "").strip()
1660
+ if not text:
1661
+ return
1662
+ self._comment_text = text
1663
+ # Select Refine/Edit so _answers_collected appends the comment.
1664
+ self._selected[self._current_q] = self._comment_option_index
1604
1665
  self._finalize()
1605
1666
  return
1606
1667
  if self._degraded:
@@ -37,7 +37,7 @@ def _plan_quick_view_title(
37
37
  elapsed: str | None = None,
38
38
  tokens: str | None = None,
39
39
  ) -> str:
40
- """Compose the panel title: `Orchestrating [8d26] · complex · 37s · in:1.2K out:345`."""
40
+ """Compose the panel title: `Orchestrating [8d26] · complex · 37s · ↑1.2K ↓345`."""
41
41
  title = "Orchestrating"
42
42
  short_id = compact_id_suffix(loop_id or "")
43
43
  if short_id:
File without changes
File without changes
File without changes