henchman-ai 0.1.10__py3-none-any.whl → 0.1.12__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
henchman/cli/app.py CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import asyncio
5
6
  import os
6
7
  from typing import TYPE_CHECKING
7
8
 
@@ -58,15 +59,18 @@ def _get_provider() -> ModelProvider:
58
59
  )
59
60
 
60
61
 
61
- def _run_interactive(output_format: str, plan_mode: bool = False) -> None:
62
+ def _run_interactive(output_format: str, plan_mode: bool = False, yes: bool = False) -> None:
62
63
  """Run interactive REPL mode.
63
64
 
64
65
  Args:
65
- output_format: Output format (text, json, stream-json).
66
+ output_format: Output format (text, json, stream-json, json-complete).
66
67
  plan_mode: Whether to start in plan mode.
68
+ yes: Auto-approve all tool executions.
67
69
  """
70
+ from pathlib import Path
68
71
  from henchman.cli.repl import Repl, ReplConfig
69
72
  from henchman.config import ContextLoader, load_settings
73
+ from henchman.core.session import SessionManager
70
74
  from henchman.rag import initialize_rag
71
75
 
72
76
  provider = _get_provider()
@@ -76,7 +80,7 @@ def _run_interactive(output_format: str, plan_mode: bool = False) -> None:
76
80
  context_loader = ContextLoader()
77
81
  system_prompt = context_loader.load()
78
82
 
79
- config = ReplConfig(system_prompt=system_prompt)
83
+ config = ReplConfig(system_prompt=system_prompt, auto_approve_tools=yes)
80
84
  repl = Repl(
81
85
  provider=provider,
82
86
  console=console,
@@ -84,8 +88,16 @@ def _run_interactive(output_format: str, plan_mode: bool = False) -> None:
84
88
  settings=settings
85
89
  )
86
90
 
91
+ # Initialize session management
92
+ session_manager = SessionManager()
93
+ project_hash = session_manager.compute_project_hash(Path.cwd())
94
+ session = session_manager.create_session(project_hash)
95
+
96
+ repl.session_manager = session_manager
97
+ repl.session = session
98
+
87
99
  # Initialize RAG system
88
- rag_system = initialize_rag(settings.rag, console=console)
100
+ rag_system = initialize_rag(settings.rag, console=console, index=False)
89
101
  if rag_system:
90
102
  repl.tool_registry.register(rag_system.search_tool)
91
103
  repl.rag_system = rag_system
@@ -108,18 +120,21 @@ def _run_interactive(output_format: str, plan_mode: bool = False) -> None:
108
120
  anyio.run(repl.run)
109
121
 
110
122
 
111
- def _run_headless(prompt: str, output_format: str, plan_mode: bool = False) -> None:
123
+ def _run_headless(prompt: str, output_format: str, plan_mode: bool = False, yes: bool = False) -> None:
112
124
  """Run headless mode with a single prompt.
113
125
 
114
126
  Args:
115
127
  prompt: The prompt to process.
116
- output_format: Output format (text, json, stream-json).
128
+ output_format: Output format (text, json, stream-json, json-complete).
117
129
  plan_mode: Whether to run in plan mode.
130
+ yes: Auto-approve all tool executions.
118
131
  """
132
+ from pathlib import Path
119
133
  from henchman.cli.json_output import JsonOutputRenderer
120
134
  from henchman.cli.repl import Repl, ReplConfig
121
135
  from henchman.config import ContextLoader, load_settings
122
136
  from henchman.core.events import EventType
137
+ from henchman.core.session import SessionManager
123
138
  from henchman.rag import initialize_rag
124
139
 
125
140
  provider = _get_provider()
@@ -134,11 +149,19 @@ def _run_headless(prompt: str, output_format: str, plan_mode: bool = False) -> N
134
149
  from henchman.cli.commands.plan import PLAN_MODE_PROMPT
135
150
  system_prompt += PLAN_MODE_PROMPT
136
151
 
137
- config = ReplConfig(system_prompt=system_prompt)
152
+ config = ReplConfig(system_prompt=system_prompt, auto_approve_tools=yes)
138
153
  repl = Repl(provider=provider, console=console, config=config, settings=settings)
139
154
 
155
+ # Initialize session management
156
+ session_manager = SessionManager()
157
+ project_hash = session_manager.compute_project_hash(Path.cwd())
158
+ session = session_manager.create_session(project_hash)
159
+
160
+ repl.session_manager = session_manager
161
+ repl.session = session
162
+
140
163
  # Initialize RAG system
141
- rag_system = initialize_rag(settings.rag) # No console output in headless
164
+ rag_system = initialize_rag(settings.rag, index=False) # No console output in headless
142
165
  if rag_system:
143
166
  repl.tool_registry.register(rag_system.search_tool)
144
167
  repl.rag_system = rag_system
@@ -150,23 +173,100 @@ def _run_headless(prompt: str, output_format: str, plan_mode: bool = False) -> N
150
173
 
151
174
  async def run_single_prompt_text() -> None: # pragma: no cover
152
175
  """Process a single prompt and exit with text output."""
153
- async for event in repl.agent.run(prompt):
154
- if event.type == EventType.CONTENT:
155
- console.print(event.data, end="")
156
- elif event.type == EventType.FINISHED:
157
- console.print()
176
+ if repl.rag_system:
177
+ asyncio.create_task(repl.rag_system.index_async())
178
+ await repl.process_input(prompt)
158
179
 
159
180
  async def run_single_prompt_json() -> None:
160
181
  """Process a single prompt and exit with JSON output."""
182
+ from henchman.core.events import AgentEvent, EventType
183
+ from henchman.providers.base import ToolCall
184
+ from collections.abc import AsyncIterator
185
+
186
+ if repl.rag_system:
187
+ asyncio.create_task(repl.rag_system.index_async())
188
+
161
189
  json_renderer = JsonOutputRenderer(console)
162
- async for event in repl.agent.run(prompt):
163
- json_renderer.render(event)
190
+
191
+ # We need to manually run the tool loop for JSON output
192
+ # to ensure all events are rendered as JSON
193
+ async def run_and_render(stream: AsyncIterator[AgentEvent]) -> None:
194
+ pending_tool_calls = []
195
+ async for event in stream:
196
+ json_renderer.render(event)
197
+ if event.type == EventType.TOOL_CALL_REQUEST:
198
+ pending_tool_calls.append(event.data)
199
+
200
+ if pending_tool_calls:
201
+ # Execute tool calls
202
+ for tool_call in pending_tool_calls:
203
+ result = await repl.tool_registry.execute(tool_call.name, tool_call.arguments)
204
+ repl.agent.submit_tool_result(tool_call.id, result.content)
205
+ # Emit tool result event
206
+ json_renderer.render(AgentEvent(type=EventType.TOOL_RESULT, data=result))
207
+
208
+ # Continue
209
+ await run_and_render(repl.agent.continue_with_tool_results())
210
+
211
+ await run_and_render(repl.agent.run(prompt))
164
212
 
165
213
  async def run_single_prompt_stream_json() -> None:
166
214
  """Process a single prompt and exit with streaming JSON output."""
215
+ from henchman.core.events import AgentEvent, EventType
216
+ from henchman.providers.base import ToolCall
217
+ from collections.abc import AsyncIterator
218
+
219
+ if repl.rag_system:
220
+ asyncio.create_task(repl.rag_system.index_async())
221
+
167
222
  json_renderer = JsonOutputRenderer(console)
168
- async for event in repl.agent.run(prompt):
169
- json_renderer.render_stream_json(event)
223
+
224
+ async def run_and_render_stream(stream: AsyncIterator[AgentEvent]) -> None:
225
+ pending_tool_calls = []
226
+ async for event in stream:
227
+ json_renderer.render_stream_json(event)
228
+ if event.type == EventType.TOOL_CALL_REQUEST:
229
+ pending_tool_calls.append(event.data)
230
+
231
+ if pending_tool_calls:
232
+ for tool_call in pending_tool_calls:
233
+ result = await repl.tool_registry.execute(tool_call.name, tool_call.arguments)
234
+ repl.agent.submit_tool_result(tool_call.id, result.content)
235
+ json_renderer.render_stream_json(AgentEvent(type=EventType.TOOL_RESULT, data=result))
236
+
237
+ await run_and_render_stream(repl.agent.continue_with_tool_results())
238
+
239
+ await run_and_render_stream(repl.agent.run(prompt))
240
+
241
+ async def run_single_prompt_json_complete() -> None:
242
+ """Process a single prompt and exit with complete JSON output."""
243
+ from henchman.core.events import AgentEvent, EventType
244
+ from henchman.providers.base import ToolCall
245
+ from collections.abc import AsyncIterator
246
+
247
+ if repl.rag_system:
248
+ asyncio.create_task(repl.rag_system.index_async())
249
+
250
+ json_renderer = JsonOutputRenderer(console)
251
+ all_events: list[AgentEvent] = []
252
+
253
+ async def run_and_collect(stream: AsyncIterator[AgentEvent]) -> None:
254
+ pending_tool_calls = []
255
+ async for event in stream:
256
+ all_events.append(event)
257
+ if event.type == EventType.TOOL_CALL_REQUEST:
258
+ pending_tool_calls.append(event.data)
259
+
260
+ if pending_tool_calls:
261
+ for tool_call in pending_tool_calls:
262
+ result = await repl.tool_registry.execute(tool_call.name, tool_call.arguments)
263
+ repl.agent.submit_tool_result(tool_call.id, result.content)
264
+ all_events.append(AgentEvent(type=EventType.TOOL_RESULT, data=result))
265
+
266
+ await run_and_collect(repl.agent.continue_with_tool_results())
267
+
268
+ await run_and_collect(repl.agent.run(prompt))
269
+ json_renderer.render_final_json(all_events)
170
270
 
171
271
  if output_format == "text":
172
272
  anyio.run(run_single_prompt_text)
@@ -174,16 +274,18 @@ def _run_headless(prompt: str, output_format: str, plan_mode: bool = False) -> N
174
274
  anyio.run(run_single_prompt_json)
175
275
  elif output_format == "stream-json":
176
276
  anyio.run(run_single_prompt_stream_json)
277
+ elif output_format == "json-complete":
278
+ anyio.run(run_single_prompt_json_complete)
177
279
  else: # pragma: no cover
178
280
  pass
179
281
 
180
282
 
181
283
  @click.command()
182
- @click.version_option(version=VERSION, prog_name="mlg")
284
+ @click.version_option(version=VERSION, prog_name="henchman")
183
285
  @click.option("-p", "--prompt", help="Run with a single prompt and exit")
184
286
  @click.option(
185
287
  "--output-format",
186
- type=click.Choice(["text", "json", "stream-json"]),
288
+ type=click.Choice(["text", "json", "stream-json", "json-complete"]),
187
289
  default="text",
188
290
  help="Output format for responses",
189
291
  )
@@ -193,15 +295,22 @@ def _run_headless(prompt: str, output_format: str, plan_mode: bool = False) -> N
193
295
  default=False,
194
296
  help="Start in plan mode (read-only)",
195
297
  )
196
- def cli(prompt: str | None, output_format: str, plan: bool) -> None:
298
+ @click.option(
299
+ "-y",
300
+ "--yes",
301
+ is_flag=True,
302
+ default=False,
303
+ help="Auto-approve all tool executions (non-interactive mode)",
304
+ )
305
+ def cli(prompt: str | None, output_format: str, plan: bool, yes: bool) -> None:
197
306
  """Henchman-AI: A model-agnostic AI agent CLI.
198
307
 
199
308
  Start an interactive session or run with --prompt for headless mode.
200
309
  """
201
310
  if prompt:
202
- _run_headless(prompt, output_format, plan)
311
+ _run_headless(prompt, output_format, plan, yes)
203
312
  else:
204
- _run_interactive(output_format, plan)
313
+ _run_interactive(output_format, plan, yes)
205
314
 
206
315
 
207
316
  def main() -> None: # pragma: no cover
@@ -49,6 +49,7 @@ class CommandContext:
49
49
  agent: Agent instance if available.
50
50
  tool_registry: ToolRegistry instance if available.
51
51
  session: Current Session if available.
52
+ repl: REPL instance if available.
52
53
  """
53
54
 
54
55
  console: Console
@@ -57,6 +58,7 @@ class CommandContext:
57
58
  agent: Agent | None = None
58
59
  tool_registry: ToolRegistry | None = None
59
60
  session: Session | None = None
61
+ repl: object | None = None
60
62
 
61
63
 
62
64
  class Command(ABC):
@@ -6,6 +6,8 @@ This module provides the default slash commands like /help, /quit, /clear, /tool
6
6
  from __future__ import annotations
7
7
 
8
8
  from henchman.cli.commands import Command, CommandContext
9
+ from henchman.cli.commands.chat import ChatCommand
10
+ from henchman.cli.commands.mcp import McpCommand
9
11
  from henchman.cli.commands.plan import PlanCommand
10
12
  from henchman.cli.commands.rag import RagCommand
11
13
  from henchman.cli.commands.skill import SkillCommand
@@ -53,6 +55,8 @@ class HelpCommand(Command):
53
55
  ctx.console.print(" /plan - Toggle Plan Mode (Read-Only)")
54
56
  ctx.console.print(" /rag - Manage semantic search index")
55
57
  ctx.console.print(" /skill - Manage and execute learned skills")
58
+ ctx.console.print(" /chat - Manage chat sessions (save, list, resume)")
59
+ ctx.console.print(" /mcp - Manage MCP server connections")
56
60
  ctx.console.print(" /quit - Exit the CLI")
57
61
  ctx.console.print(" /clear - Clear the screen")
58
62
  ctx.console.print(" /tools - List available tools")
@@ -206,6 +210,8 @@ def get_builtin_commands() -> list[Command]:
206
210
  QuitCommand(),
207
211
  ClearCommand(),
208
212
  ToolsCommand(),
213
+ ChatCommand(),
214
+ McpCommand(),
209
215
  PlanCommand(),
210
216
  RagCommand(),
211
217
  SkillCommand(),
@@ -67,9 +67,9 @@ class ChatCommand(Command):
67
67
  async def _show_help(self, ctx: CommandContext) -> None:
68
68
  """Show help for /chat command."""
69
69
  ctx.console.print("\n[bold blue]Chat Session Commands[/]\n")
70
- ctx.console.print(" /chat save [tag] - Save current session")
71
- ctx.console.print(" /chat list - List saved sessions")
72
- ctx.console.print(" /chat resume <tag> - Resume a saved session")
70
+ ctx.console.print(" /chat save [tag] - Save current session")
71
+ ctx.console.print(" /chat list - List saved sessions")
72
+ ctx.console.print(" /chat resume <tag|id> - Resume a saved session")
73
73
  ctx.console.print("")
74
74
 
75
75
  async def _save(self, ctx: CommandContext) -> None:
@@ -124,46 +124,60 @@ class ChatCommand(Command):
124
124
  return
125
125
 
126
126
  if len(ctx.args) < 2:
127
- ctx.console.print("[yellow]Usage: /chat resume <tag>[/]")
127
+ ctx.console.print("[yellow]Usage: /chat resume <tag|id>[/]")
128
128
  return
129
129
 
130
- tag = ctx.args[1]
130
+ tag_or_id = ctx.args[1]
131
131
  project_hash = getattr(ctx, "project_hash", None)
132
132
 
133
- session = manager.load_by_tag(tag, project_hash)
133
+ # Try loading by tag first
134
+ session = manager.load_by_tag(tag_or_id, project_hash)
135
+
136
+ # If that fails, try loading by ID
134
137
  if session is None:
135
- ctx.console.print(f"[red]Session not found: {tag}[/]")
138
+ try:
139
+ session = manager.load(tag_or_id)
140
+ except (ValueError, FileNotFoundError):
141
+ pass
142
+
143
+ if session is None:
144
+ ctx.console.print(f"[red]Session not found: {tag_or_id}[/]")
136
145
  return
137
146
 
138
- manager.set_current(session)
139
-
140
- # Restore session messages to agent history
141
- if ctx.agent is not None:
142
- # Clear agent history (keeping system prompt)
143
- ctx.agent.clear_history()
144
-
145
- # Convert SessionMessage objects to Message objects
146
- for session_msg in session.messages:
147
- # Convert tool_calls from dicts to ToolCall objects if present
148
- tool_calls = None
149
- if session_msg.tool_calls:
150
- tool_calls = [
151
- ToolCall(
152
- id=tc.get("id", ""),
153
- name=tc.get("name", ""),
154
- arguments=tc.get("arguments", {}),
155
- )
156
- for tc in session_msg.tool_calls
157
- ]
158
-
159
- msg = Message(
160
- role=session_msg.role,
161
- content=session_msg.content,
162
- tool_calls=tool_calls,
163
- tool_call_id=session_msg.tool_call_id,
164
- )
165
- ctx.agent.messages.append(msg)
147
+ # Use repl.set_session if available to handle all sync logic
148
+ if ctx.repl and hasattr(ctx.repl, "set_session"):
149
+ ctx.repl.set_session(session)
150
+ else:
151
+ # Fallback for when REPL isn't available (e.g. some tests)
152
+ manager.set_current(session)
153
+
154
+ # Restore session messages to agent history
155
+ if ctx.agent is not None:
156
+ # Clear agent history (keeping system prompt)
157
+ ctx.agent.clear_history()
158
+
159
+ # Convert SessionMessage objects to Message objects
160
+ for session_msg in session.messages:
161
+ # Convert tool_calls from dicts to ToolCall objects if present
162
+ tool_calls = None
163
+ if session_msg.tool_calls:
164
+ tool_calls = [
165
+ ToolCall(
166
+ id=tc.get("id", ""),
167
+ name=tc.get("name", ""),
168
+ arguments=tc.get("arguments", {}),
169
+ )
170
+ for tc in session_msg.tool_calls
171
+ ]
172
+
173
+ msg = Message(
174
+ role=session_msg.role,
175
+ content=session_msg.content,
176
+ tool_calls=tool_calls,
177
+ tool_call_id=session_msg.tool_call_id,
178
+ )
179
+ ctx.agent.messages.append(msg)
166
180
 
167
181
  ctx.console.print(
168
- f"[green]✓[/] Resumed session '{tag}' ({len(session.messages)} messages)"
182
+ f"[green]✓[/] Resumed session '{tag_or_id}' ({len(session.messages)} messages)"
169
183
  )
@@ -6,7 +6,6 @@ This module provides the /rag command for managing the RAG index.
6
6
  from __future__ import annotations
7
7
 
8
8
  import shutil
9
- from pathlib import Path
10
9
  from typing import TYPE_CHECKING
11
10
 
12
11
  from henchman.cli.commands import Command, CommandContext
@@ -126,12 +125,17 @@ class RagCommand(Command):
126
125
  )
127
126
  return
128
127
 
128
+ if getattr(rag_system, "is_indexing", False):
129
+ ctx.console.print("[yellow]RAG indexing is already in progress in the background.[/]")
130
+ return
131
+
129
132
  ctx.console.print("[dim]Forcing full reindex...[/]")
130
133
  stats = rag_system.index(console=ctx.console, force=True)
131
- ctx.console.print(
132
- f"[green]Reindex complete: {stats.files_added} files, "
133
- f"{stats.total_chunks} chunks[/]"
134
- )
134
+ if stats:
135
+ ctx.console.print(
136
+ f"[green]Reindex complete: {stats.files_added} files, "
137
+ f"{stats.total_chunks} chunks[/]"
138
+ )
135
139
 
136
140
  async def _clear(self, ctx: CommandContext) -> None:
137
141
  """Clear the RAG index."""
@@ -150,20 +154,22 @@ class RagCommand(Command):
150
154
  async def _clear_all(self, ctx: CommandContext) -> None:
151
155
  """Clear ALL RAG indices from the cache directory."""
152
156
  from henchman.rag.repo_id import get_rag_cache_dir
153
-
157
+
154
158
  cache_dir = get_rag_cache_dir()
155
-
159
+
156
160
  if not cache_dir.exists():
157
161
  ctx.console.print("[yellow]No RAG cache directory found[/]")
158
162
  return
159
-
160
- # Ask for confirmation
163
+
164
+ # Ask for confirmation using simple input
161
165
  ctx.console.print("[yellow]Warning: This will delete ALL RAG indices![/]")
162
- confirm = await ctx.repl.ask_user(
163
- "Are you sure you want to delete ALL RAG indices? (yes/no): "
164
- )
165
-
166
- if confirm and confirm.lower() in ("yes", "y"):
166
+ ctx.console.print("Type 'yes' to confirm: ", end="")
167
+ try:
168
+ confirm = input()
169
+ except (EOFError, KeyboardInterrupt):
170
+ confirm = ""
171
+
172
+ if confirm.lower() in ("yes", "y"):
167
173
  try:
168
174
  shutil.rmtree(cache_dir)
169
175
  ctx.console.print(f"[green]Cleared all RAG indices from {cache_dir}[/]")
@@ -175,32 +181,32 @@ class RagCommand(Command):
175
181
  async def _cleanup(self, ctx: CommandContext) -> None:
176
182
  """Clean up old project-based RAG indices."""
177
183
  from henchman.rag.system import find_git_root
178
-
184
+
179
185
  # Find git root if we're in a repository
180
186
  git_root = find_git_root()
181
187
  if not git_root:
182
188
  ctx.console.print("[yellow]Not in a git repository[/]")
183
189
  return
184
-
190
+
185
191
  old_index_dir = git_root / ".henchman" / "rag_index"
186
192
  old_manifest = git_root / ".henchman" / "rag_manifest.json"
187
-
193
+
188
194
  removed = []
189
-
195
+
190
196
  if old_index_dir.exists():
191
197
  try:
192
198
  shutil.rmtree(old_index_dir)
193
199
  removed.append(f"Index directory: {old_index_dir}")
194
200
  except Exception as e:
195
201
  ctx.console.print(f"[yellow]Error removing {old_index_dir}: {e}[/]")
196
-
202
+
197
203
  if old_manifest.exists():
198
204
  try:
199
205
  old_manifest.unlink()
200
206
  removed.append(f"Manifest file: {old_manifest}")
201
207
  except Exception as e:
202
208
  ctx.console.print(f"[yellow]Error removing {old_manifest}: {e}[/]")
203
-
209
+
204
210
  if removed:
205
211
  ctx.console.print("[green]Cleaned up old project-based RAG indices:[/]")
206
212
  for item in removed:
henchman/cli/console.py CHANGED
@@ -7,8 +7,10 @@ from __future__ import annotations
7
7
 
8
8
  from dataclasses import dataclass
9
9
 
10
+ import anyio
10
11
  from rich.console import Console
11
12
  from rich.markdown import Markdown
13
+ from rich.markup import escape
12
14
  from rich.syntax import Syntax
13
15
 
14
16
 
@@ -150,7 +152,7 @@ class OutputRenderer:
150
152
  Args:
151
153
  message: Success message text.
152
154
  """
153
- self.console.print(f"[{self.theme.success}]✓[/] {message}")
155
+ self.console.print(f"[{self.theme.success}]✓[/] {escape(message)}")
154
156
 
155
157
  def info(self, message: str) -> None:
156
158
  """Print an info message.
@@ -158,7 +160,7 @@ class OutputRenderer:
158
160
  Args:
159
161
  message: Info message text.
160
162
  """
161
- self.console.print(f"[{self.theme.primary}]ℹ[/] {message}")
163
+ self.console.print(f"[{self.theme.primary}]ℹ[/] {escape(message)}")
162
164
 
163
165
  def warning(self, message: str) -> None:
164
166
  """Print a warning message.
@@ -166,7 +168,7 @@ class OutputRenderer:
166
168
  Args:
167
169
  message: Warning message text.
168
170
  """
169
- self.console.print(f"[{self.theme.warning}]⚠[/] {message}")
171
+ self.console.print(f"[{self.theme.warning}]⚠[/] {escape(message)}")
170
172
 
171
173
  def error(self, message: str) -> None:
172
174
  """Print an error message.
@@ -174,7 +176,7 @@ class OutputRenderer:
174
176
  Args:
175
177
  message: Error message text.
176
178
  """
177
- self.console.print(f"[{self.theme.error}]✗[/] {message}")
179
+ self.console.print(f"[{self.theme.error}]✗[/] {escape(message)}")
178
180
 
179
181
  def muted(self, text: str) -> None:
180
182
  """Print muted/dim text.
@@ -190,7 +192,7 @@ class OutputRenderer:
190
192
  Args:
191
193
  text: Heading text.
192
194
  """
193
- self.console.print(f"\n[bold {self.theme.primary}]{text}[/]\n")
195
+ self.console.print(f"\n[bold {self.theme.primary}]{escape(text)}[/]\n")
194
196
 
195
197
  def markdown(self, content: str) -> None:
196
198
  """Render markdown content.
@@ -301,4 +303,7 @@ class OutputRenderer:
301
303
  True if confirmed.
302
304
  """
303
305
  from rich.prompt import Confirm
304
- return Confirm.ask(message, console=self.console)
306
+ import anyio
307
+ return await anyio.to_thread.run_sync(
308
+ lambda: Confirm.ask(message, console=self.console)
309
+ )
henchman/cli/input.py CHANGED
@@ -5,6 +5,7 @@ This module handles user input including @ file references and ! shell commands.
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
+ import asyncio
8
9
  import contextlib
9
10
  import re
10
11
  from pathlib import Path
@@ -168,3 +169,67 @@ class InputHandler:
168
169
  The prompt string.
169
170
  """
170
171
  return self._prompt
172
+
173
+
174
+ class KeyMonitor:
175
+ """Monitors for specific keys while an async task is running.
176
+
177
+ This is used to detect Escape or Ctrl+C while the agent is generating output.
178
+ """
179
+
180
+ def __init__(self) -> None:
181
+ """Initialize the key monitor."""
182
+ from prompt_toolkit.input import create_input
183
+ self.input = create_input()
184
+ self._stop_event = asyncio.Event()
185
+ self._exit_event = asyncio.Event()
186
+ self._suspended = asyncio.Event()
187
+ self._suspended.set() # Not suspended initially
188
+ self._in_raw_mode = False
189
+
190
+ async def suspend(self) -> None:
191
+ """Suspend monitoring to allow other input reading."""
192
+ self._suspended.clear()
193
+ # Wait for the monitor loop to exit the raw mode block
194
+ while self._in_raw_mode:
195
+ await asyncio.sleep(0.01)
196
+
197
+ def resume(self) -> None:
198
+ """Resume monitoring."""
199
+ self._suspended.set()
200
+
201
+ async def monitor(self) -> None:
202
+ """Monitor input for keys. Run this in a background task."""
203
+ while not self._stop_event.is_set() and not self._exit_event.is_set():
204
+ if not self._suspended.is_set():
205
+ await asyncio.sleep(0.1)
206
+ continue
207
+
208
+ try:
209
+ with self.input.raw_mode():
210
+ self._in_raw_mode = True
211
+ with self.input.attach(lambda: None):
212
+ while not self._stop_event.is_set() and not self._exit_event.is_set() and self._suspended.is_set():
213
+ # Check for keys every 100ms
214
+ keys = self.input.read_keys()
215
+ for key in keys:
216
+ if key.key == Keys.Escape:
217
+ self._stop_event.set()
218
+ elif key.key == Keys.ControlC:
219
+ self._exit_event.set()
220
+ await asyncio.sleep(0.1)
221
+ finally:
222
+ self._in_raw_mode = False
223
+ # If we were suspended, wait a bit before potentially re-entering raw mode
224
+ if not self._suspended.is_set():
225
+ await asyncio.sleep(0.1)
226
+
227
+ @property
228
+ def stop_requested(self) -> bool:
229
+ """Check if Escape was pressed."""
230
+ return self._stop_event.is_set()
231
+
232
+ @property
233
+ def exit_requested(self) -> bool:
234
+ """Check if Ctrl+C was pressed."""
235
+ return self._exit_event.is_set()