diff --git a/CLAUDE.md b/CLAUDE.md
index b29f5871..32145753 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -100,7 +100,7 @@ Security relaxation (trusted environments only): `DISABLE_SECURITY_PATTERNS` (de
Multi-project topics: `ENABLE_PROJECT_THREADS` (default false), `PROJECT_THREADS_MODE` (`private`|`group`), `PROJECT_THREADS_CHAT_ID` (required for group mode), `PROJECTS_CONFIG_PATH` (path to YAML project registry), `PROJECT_THREADS_SYNC_ACTION_INTERVAL_SECONDS` (default `1.1`, set `0` to disable pacing). See `config/projects.example.yaml`.
-Output verbosity: `VERBOSE_LEVEL` (default 1, range 0-2). Controls how much of Claude's background activity is shown to the user in real-time. 0 = quiet (only final response, typing indicator still active), 1 = normal (tool names + reasoning snippets shown during execution), 2 = detailed (tool names with input summaries + longer reasoning text). Users can override per-session via `/verbose 0|1|2`. A persistent typing indicator is refreshed every ~2 seconds at all levels.
+Output verbosity: `VERBOSE_LEVEL` (default 1, range 0-3). Controls how much of Claude's background activity is shown to the user in real-time. 0 = quiet (only final response, typing indicator still active), 1 = normal (tool names + reasoning snippets shown during execution), 2 = detailed (tool names with input summaries + longer reasoning text), 3 = detailed **and** preserve the progress message after the final response (Stop button stripped, text kept as a read-only log). Users can override per-session via `/verbose 0|1|2|3`. A persistent typing indicator is refreshed every ~2 seconds at all levels.
Voice transcription: `ENABLE_VOICE_MESSAGES` (default true), `VOICE_PROVIDER` (`mistral`|`openai`|`local`, default `mistral`), `MISTRAL_API_KEY`, `OPENAI_API_KEY`, `VOICE_TRANSCRIPTION_MODEL`. For local provider: `WHISPER_CPP_BINARY_PATH`, `WHISPER_CPP_MODEL_PATH` (requires ffmpeg + whisper.cpp installed). Provider implementation is in `src/bot/features/voice_handler.py`.
diff --git a/README.md b/README.md
index e30bb05b..0962464b 100644
--- a/README.md
+++ b/README.md
@@ -121,13 +121,14 @@ You: /verbose 0
Bot: Verbosity set to 0 (quiet)
```
-Use `/verbose 0|1|2` to control how much background activity is shown:
+Use `/verbose 0|1|2|3` to control how much background activity is shown:
| Level | Shows |
|-------|-------|
| **0** (quiet) | Final response only (typing indicator stays active) |
| **1** (normal, default) | Tool names + reasoning snippets in real-time |
| **2** (detailed) | Tool names with inputs + longer reasoning text |
+| **3** (detailed + keep log) | Same as 2, but the progress log is **preserved** after the final response as a read-only audit trail |
#### GitHub Workflow
@@ -235,7 +236,7 @@ CLAUDE_TIMEOUT_SECONDS=300 # Operation timeout
# Mode
AGENTIC_MODE=true # Agentic (default) or classic mode
-VERBOSE_LEVEL=1 # 0=quiet, 1=normal (default), 2=detailed
+VERBOSE_LEVEL=1 # 0=quiet, 1=normal (default), 2=detailed, 3=detailed+keep-log
# Rate Limiting
RATE_LIMIT_REQUESTS=10 # Requests per window
diff --git a/docs/tools.md b/docs/tools.md
index 54010cc4..a3ecf599 100644
--- a/docs/tools.md
+++ b/docs/tools.md
@@ -83,6 +83,7 @@ Control verbosity with `/verbose`:
| `/verbose 0` | Final response only (typing indicator stays active) |
| `/verbose 1` | Tool names + reasoning snippets (default) |
| `/verbose 2` | Tool names with input details + longer reasoning text |
+| `/verbose 3` | Same as 2, and the progress message is kept visible after the final response (Stop button stripped, text preserved as a read-only log) |
## Configuration
diff --git a/src/bot/orchestrator.py b/src/bot/orchestrator.py
index 6d9719f0..430cec10 100644
--- a/src/bot/orchestrator.py
+++ b/src/bot/orchestrator.py
@@ -458,7 +458,7 @@ async def get_bot_commands(self) -> list: # type: ignore[type-arg]
BotCommand("start", "Start the bot"),
BotCommand("new", "Start a fresh session"),
BotCommand("status", "Show session status"),
- BotCommand("verbose", "Set output verbosity (0/1/2)"),
+ BotCommand("verbose", "Set output verbosity (0/1/2/3)"),
BotCommand("repo", "List repos / switch workspace"),
BotCommand("restart", "Restart the bot"),
]
@@ -587,36 +587,62 @@ def _get_verbose_level(self, context: ContextTypes.DEFAULT_TYPE) -> int:
return int(user_override)
return self.settings.verbose_level
+ async def _finalize_progress_msg(
+ self, progress_msg: Any, verbose_level: int
+ ) -> None:
+ """Delete the progress message, or keep it (sans Stop button) as a log.
+
+ At verbose_level 3 the progress text is preserved as a read-only
+ record of Claude's tool calls and reasoning (the Stop button is
+ stripped). At levels 0-2 the message is deleted (the original
+ behavior).
+ """
+ if verbose_level >= 3:
+ try:
+ await progress_msg.edit_reply_markup(reply_markup=None)
+ except Exception:
+ logger.debug("Failed to clear progress reply markup, ignoring")
+ return
+ try:
+ await progress_msg.delete()
+ except Exception:
+ logger.debug("Failed to delete progress message, ignoring")
+
async def agentic_verbose(
self, update: Update, context: ContextTypes.DEFAULT_TYPE
) -> None:
- """Set output verbosity: /verbose [0|1|2]."""
+ """Set output verbosity: /verbose [0|1|2|3]."""
args = update.message.text.split()[1:] if update.message.text else []
+ labels = {
+ 0: "quiet",
+ 1: "normal",
+ 2: "detailed",
+ 3: "detailed+keep-log",
+ }
if not args:
current = self._get_verbose_level(context)
- labels = {0: "quiet", 1: "normal", 2: "detailed"}
await update.message.reply_text(
f"Verbosity: {current} ({labels.get(current, '?')})\n\n"
- "Usage: /verbose 0|1|2\n"
+ "Usage: /verbose 0|1|2|3\n"
" 0 = quiet (final response only)\n"
" 1 = normal (tools + reasoning)\n"
- " 2 = detailed (tools with inputs + reasoning)",
+ " 2 = detailed (tools with inputs + reasoning)\n"
+ " 3 = detailed, keep progress log visible",
parse_mode="HTML",
)
return
try:
level = int(args[0])
- if level not in (0, 1, 2):
+ if level not in (0, 1, 2, 3):
raise ValueError
except ValueError:
await update.message.reply_text(
- "Please use: /verbose 0, /verbose 1, or /verbose 2"
+ "Please use: /verbose 0, /verbose 1, /verbose 2, or /verbose 3"
)
return
context.user_data["verbose_level"] = level
- labels = {0: "quiet", 1: "normal", 2: "detailed"}
await update.message.reply_text(
f"Verbosity set to {level} ({labels[level]})",
parse_mode="HTML",
@@ -1074,10 +1100,7 @@ async def agentic_text(
except Exception:
logger.debug("Draft flush failed in finally block", user_id=user_id)
- try:
- await progress_msg.delete()
- except Exception:
- logger.debug("Failed to delete progress message, ignoring")
+ await self._finalize_progress_msg(progress_msg, verbose_level)
# Use MCP-collected images (from send_image_to_user tool calls)
images: List[ImageAttachment] = mcp_images
@@ -1284,10 +1307,7 @@ async def agentic_document(
claude_response.content
)
- try:
- await progress_msg.delete()
- except Exception:
- logger.debug("Failed to delete progress message, ignoring")
+ await self._finalize_progress_msg(progress_msg, verbose_level)
# Use MCP-collected images (from send_image_to_user tool calls)
images: List[ImageAttachment] = mcp_images_doc
@@ -1494,10 +1514,7 @@ async def _handle_agentic_media_message(
formatter = ResponseFormatter(self.settings)
formatted_messages = formatter.format_claude_response(claude_response.content)
- try:
- await progress_msg.delete()
- except Exception:
- logger.debug("Failed to delete progress message, ignoring")
+ await self._finalize_progress_msg(progress_msg, verbose_level)
# Use MCP-collected images (from send_image_to_user tool calls).
images: List[ImageAttachment] = mcp_images_media
diff --git a/src/config/settings.py b/src/config/settings.py
index c4f7cb18..68ebc6f2 100644
--- a/src/config/settings.py
+++ b/src/config/settings.py
@@ -257,16 +257,19 @@ class Settings(BaseSettings):
),
)
- # Output verbosity (0=quiet, 1=normal, 2=detailed)
+ # Output verbosity (0=quiet, 1=normal, 2=detailed, 3=detailed+keep-log)
verbose_level: int = Field(
1,
description=(
"Bot output verbosity: 0=quiet (final response only), "
"1=normal (tool names + reasoning), "
- "2=detailed (tool inputs + longer reasoning)"
+ "2=detailed (tool inputs + longer reasoning), "
+ "3=detailed and keep the progress message visible after "
+ "the final response (Stop button stripped, text preserved "
+ "as a read-only log)"
),
ge=0,
- le=2,
+ le=3,
)
# Streaming drafts (Telegram sendMessageDraft)
diff --git a/tests/unit/test_orchestrator.py b/tests/unit/test_orchestrator.py
index ce5e419e..0b8175bb 100644
--- a/tests/unit/test_orchestrator.py
+++ b/tests/unit/test_orchestrator.py
@@ -322,6 +322,98 @@ async def test_agentic_text_calls_claude(agentic_settings, deps):
assert call.kwargs.get("reply_markup") is None
+async def test_verbose_level_3_preserves_log(tmp_dir, deps):
+ """At verbose_level=3 the progress message is kept (Stop button stripped)."""
+ settings = create_test_config(
+ approved_directory=str(tmp_dir),
+ agentic_mode=True,
+ verbose_level=3,
+ )
+ orchestrator = MessageOrchestrator(settings, deps)
+
+ mock_response = MagicMock()
+ mock_response.session_id = "session-abc"
+ mock_response.content = "Done!"
+ mock_response.tools_used = []
+
+ claude_integration = AsyncMock()
+ claude_integration.run_command = AsyncMock(return_value=mock_response)
+
+ update = MagicMock()
+ update.effective_user.id = 123
+ update.message.text = "hi"
+ update.message.message_id = 1
+ update.message.chat.send_action = AsyncMock()
+ update.message.reply_text = AsyncMock()
+
+ progress_msg = AsyncMock()
+ progress_msg.delete = AsyncMock()
+ progress_msg.edit_reply_markup = AsyncMock()
+ update.message.reply_text.return_value = progress_msg
+
+ context = MagicMock()
+ context.user_data = {}
+ context.bot_data = {
+ "settings": settings,
+ "claude_integration": claude_integration,
+ "storage": None,
+ "rate_limiter": None,
+ "audit_logger": None,
+ }
+
+ await orchestrator.agentic_text(update, context)
+
+ # Progress message was NOT deleted; Stop button was stripped.
+ progress_msg.delete.assert_not_called()
+ progress_msg.edit_reply_markup.assert_called_once_with(reply_markup=None)
+
+
+async def test_verbose_level_2_still_deletes_progress(tmp_dir, deps):
+ """At verbose_level<=2 the progress message is deleted (original behavior)."""
+ settings = create_test_config(
+ approved_directory=str(tmp_dir),
+ agentic_mode=True,
+ verbose_level=2,
+ )
+ orchestrator = MessageOrchestrator(settings, deps)
+
+ mock_response = MagicMock()
+ mock_response.session_id = "session-abc"
+ mock_response.content = "Done!"
+ mock_response.tools_used = []
+
+ claude_integration = AsyncMock()
+ claude_integration.run_command = AsyncMock(return_value=mock_response)
+
+ update = MagicMock()
+ update.effective_user.id = 123
+ update.message.text = "hi"
+ update.message.message_id = 1
+ update.message.chat.send_action = AsyncMock()
+ update.message.reply_text = AsyncMock()
+
+ progress_msg = AsyncMock()
+ progress_msg.delete = AsyncMock()
+ progress_msg.edit_reply_markup = AsyncMock()
+ update.message.reply_text.return_value = progress_msg
+
+ context = MagicMock()
+ context.user_data = {}
+ context.bot_data = {
+ "settings": settings,
+ "claude_integration": claude_integration,
+ "storage": None,
+ "rate_limiter": None,
+ "audit_logger": None,
+ }
+
+ await orchestrator.agentic_text(update, context)
+
+ # At verbose_level<3 the progress msg is deleted.
+ progress_msg.delete.assert_called_once()
+ progress_msg.edit_reply_markup.assert_not_called()
+
+
async def test_agentic_callback_scoped_to_cd_pattern(agentic_settings, deps):
"""Agentic callback handler is registered with cd: pattern filter."""
orchestrator = MessageOrchestrator(agentic_settings, deps)