tenseleyflow/loader / de49a58

Browse files

Overhaul bash rendering and operator controls

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
de49a584a5da60982e4b3be99b443ffad60d3f01
Parents
b1c0cdf
Tree
6fefdb2

13 changed files

StatusFile+-
M src/loader/cli/main.py 297 45
M src/loader/runtime/events.py 1 0
M src/loader/runtime/explore.py 5 0
M src/loader/runtime/finalization.py 5 0
M src/loader/runtime/tool_batches.py 5 0
M src/loader/runtime/workflow_lanes.py 5 0
M src/loader/ui/adapter.py 9 1
M src/loader/ui/app.py 145 13
M src/loader/ui/widgets/approval_bar.py 36 7
M src/loader/ui/widgets/input_area.py 3 0
M src/loader/ui/widgets/tool_widget.py 163 27
A tests/test_bash_operator_surfaces.py 124 0
M tests/test_tool_batches.py 66 1
src/loader/cli/main.pymodified
@@ -4,13 +4,16 @@ import asyncio
44
 import json
55
 import re
66
 import sys
7
+from typing import Any
78
 
89
 import click
9
-from rich.console import Console
10
+from rich import box
11
+from rich.console import Console, Group
1012
 from rich.markdown import Markdown
1113
 from rich.panel import Panel
1214
 from rich.prompt import Confirm, Prompt
1315
 from rich.table import Table
16
+from rich.text import Text
1417
 
1518
 from ..runtime.inspection import (
1619
     CheckStatus,
@@ -469,7 +472,10 @@ async def _main(
469472
             "[bold blue]Loader[/bold blue]\n" + " | ".join(status_parts),
470473
             border_style="blue",
471474
         ))
472
-        console.print("[dim]Type 'exit' to quit, 'clear' to reset conversation[/dim]\n")
475
+        console.print(
476
+            "[dim]Type 'exit' to quit, 'clear' to reset conversation, "
477
+            "'jobs' to inspect bash jobs[/dim]\n"
478
+        )
473479
         await run_interactive(shell_owner, skip_confirmation=yes)
474480
     else:
475481
         # Launch TUI
@@ -503,6 +509,212 @@ def _format_tool_args(args: dict | None) -> str:
503509
     return ", ".join(parts)
504510
 
505511
 
512
+_SPECIAL_TOOL_LABELS = {
513
+    "bash": "Bash",
514
+    "bash_jobs": "Bash Jobs",
515
+    "bash_wait": "Bash Wait",
516
+    "bash_kill": "Bash Kill",
517
+}
518
+
519
+
520
+def _tool_label(tool_name: str, phase: str | None = None) -> str:
521
+    label = _SPECIAL_TOOL_LABELS.get(tool_name, tool_name)
522
+    if phase == "verification":
523
+        return f"Verify {label}"
524
+    return label
525
+
526
+
527
+def _truncate_tool_text(
528
+    text: str,
529
+    *,
530
+    line_limit: int,
531
+    char_limit: int = 6_000,
532
+) -> tuple[str, bool]:
533
+    lines = text.splitlines()
534
+    if len(lines) <= line_limit and len(text) <= char_limit:
535
+        return text, False
536
+
537
+    preview = "\n".join(lines[:line_limit])
538
+    if len(preview) > char_limit:
539
+        preview = preview[:char_limit]
540
+    return preview, True
541
+
542
+
543
+def _render_bash_call(tool_args: dict | None, *, phase: str | None = None):
544
+    command = str((tool_args or {}).get("command", "")).strip() or "(empty command)"
545
+    title = _tool_label("bash", phase)
546
+    border_style = "magenta" if phase == "verification" else "cyan"
547
+    return Group(
548
+        Text(title, style=f"bold {border_style}"),
549
+        Panel(
550
+            Text(command),
551
+            title="Command",
552
+            border_style=border_style,
553
+            box=box.SQUARE,
554
+            expand=True,
555
+        ),
556
+    )
557
+
558
+
559
+def _render_bash_result(
560
+    content: str,
561
+    *,
562
+    metadata: dict[str, Any] | None,
563
+    is_error: bool,
564
+    phase: str | None = None,
565
+) -> Panel:
566
+    metadata = metadata or {}
567
+    title = _tool_label("bash", phase)
568
+    lines = []
569
+    status_value = str(metadata.get("status", "failed" if is_error else "completed"))
570
+    lines.append(f"Status: {status_value.replace('_', ' ')}")
571
+    if metadata.get("job_id"):
572
+        lines.append(f"Job: {metadata['job_id']}")
573
+    if metadata.get("pid"):
574
+        lines.append(f"PID: {metadata['pid']}")
575
+    if metadata.get("exit_code") is not None:
576
+        lines.append(f"Exit: {metadata['exit_code']}")
577
+    if metadata.get("background") is not None:
578
+        lines.append(
579
+            f"Mode: {'background' if metadata.get('background') else 'foreground'}"
580
+        )
581
+
582
+    stdout_text = str(metadata.get("stdout", "") or "")
583
+    stderr_text = str(metadata.get("stderr", "") or "")
584
+    show_summary_note = (
585
+        (not stdout_text and not stderr_text and bool(content.strip()))
586
+        or status_value not in {"completed", "running"}
587
+    )
588
+    body = "\n".join(lines)
589
+    if show_summary_note and content.strip():
590
+        preview, truncated = _truncate_tool_text(content, line_limit=20)
591
+        body = f"{body}\n\n{preview}" if body else preview
592
+        if truncated:
593
+            body += "\n… truncated for display; full result preserved in session"
594
+
595
+    border_style = "red" if is_error else ("magenta" if phase == "verification" else "green")
596
+    return Panel(
597
+        body or "(no output)",
598
+        title=f"[bold {border_style}]{title}[/bold {border_style}]",
599
+        border_style=border_style,
600
+        box=box.SQUARE,
601
+        expand=True,
602
+    )
603
+
604
+
605
+def _print_tool_call(tool_name: str, tool_args: dict | None, phase: str | None = None) -> None:
606
+    if tool_name == "bash":
607
+        console.print(_render_bash_call(tool_args, phase=phase))
608
+        return
609
+
610
+    args_str = _format_tool_args(tool_args)
611
+    console.print(f"[cyan]> {_tool_label(tool_name, phase)}[/cyan]({args_str})")
612
+
613
+
614
+def _print_tool_result(
615
+    tool_name: str,
616
+    content: str,
617
+    *,
618
+    metadata: dict[str, Any] | None = None,
619
+    is_error: bool = False,
620
+    phase: str | None = None,
621
+    preview_lines: int = 10,
622
+) -> None:
623
+    if tool_name == "bash":
624
+        console.print(
625
+            _render_bash_result(
626
+                content,
627
+                metadata=metadata,
628
+                is_error=is_error,
629
+                phase=phase,
630
+            )
631
+        )
632
+        return
633
+
634
+    preview, truncated = _truncate_tool_text(content, line_limit=preview_lines)
635
+    if truncated:
636
+        preview += "\n[dim]... truncated for display; full result preserved in session[/dim]"
637
+    border_style = "red" if is_error else ("magenta" if phase == "verification" else "dim")
638
+    console.print(Panel(preview or "(no output)", border_style=border_style))
639
+
640
+
641
+def _parse_local_bash_command(user_input: str) -> tuple[str, dict[str, object]] | None:
642
+    parts = user_input.strip().split()
643
+    if not parts:
644
+        return None
645
+
646
+    command = parts[0].lstrip("/").lower()
647
+    if command == "jobs":
648
+        if len(parts) > 2:
649
+            raise ValueError("Usage: jobs [limit]")
650
+        tool_args: dict[str, object] = {}
651
+        if len(parts) == 2:
652
+            tool_args["limit"] = max(1, int(parts[1]))
653
+        return "bash_jobs", tool_args
654
+
655
+    if command == "wait":
656
+        if len(parts) not in {2, 3}:
657
+            raise ValueError("Usage: wait <job-id> [timeout-seconds]")
658
+        tool_args = {"job_id": parts[1]}
659
+        if len(parts) == 3:
660
+            tool_args["timeout"] = float(parts[2])
661
+        return "bash_wait", tool_args
662
+
663
+    if command == "kill":
664
+        if len(parts) not in {2, 3}:
665
+            raise ValueError("Usage: kill <job-id> [force-after-ms]")
666
+        tool_args = {"job_id": parts[1]}
667
+        if len(parts) == 3:
668
+            tool_args["force_after_ms"] = int(parts[2])
669
+        return "bash_kill", tool_args
670
+
671
+    return None
672
+
673
+
674
+async def _run_local_bash_command(
675
+    shell_owner: RuntimeShellOwner,
676
+    tool_name: str,
677
+    tool_args: dict[str, object],
678
+) -> None:
679
+    _print_tool_call(tool_name, tool_args, phase="local")
680
+    result = await shell_owner.registry.execute(tool_name, **tool_args)
681
+    _print_tool_result(
682
+        tool_name,
683
+        result.output,
684
+        metadata=result.metadata,
685
+        is_error=result.is_error,
686
+        phase="local",
687
+        preview_lines=8,
688
+    )
689
+
690
+
691
+def _get_bash_tool(shell_owner: RuntimeShellOwner):
692
+    from ..tools.shell_tools import BashTool
693
+
694
+    tool = shell_owner.registry.get("bash")
695
+    return tool if isinstance(tool, BashTool) else None
696
+
697
+
698
+async def _interrupt_active_foreground_bash(shell_owner: RuntimeShellOwner) -> bool:
699
+    bash_tool = _get_bash_tool(shell_owner)
700
+    if bash_tool is None:
701
+        return False
702
+
703
+    result = await bash_tool.manager.interrupt_active_foreground()
704
+    if result is None:
705
+        return False
706
+
707
+    _print_tool_result(
708
+        "bash",
709
+        result.output,
710
+        metadata=result.metadata,
711
+        is_error=result.is_error,
712
+        phase="local",
713
+        preview_lines=8,
714
+    )
715
+    return True
716
+
717
+
506718
 async def run_once(
507719
     shell_owner: RuntimeShellOwner,
508720
     prompt: str,
@@ -552,21 +764,20 @@ async def run_once(
552764
                 elapsed = time.time() - thinking_start
553765
                 console.print(f" [dim]({elapsed:.1f}s)[/dim]")
554766
                 thinking_start = None
555
-            args_str = _format_tool_args(event.tool_args)
556
-            tool_label = (
557
-                f"verify {event.tool_name}"
558
-                if event.phase == "verification"
559
-                else event.tool_name
767
+            _print_tool_call(
768
+                getattr(event, "tool_name", "") or "",
769
+                getattr(event, "tool_args", None),
770
+                getattr(event, "phase", None),
560771
             )
561
-            console.print(f"[cyan]> {tool_label}[/cyan]({args_str})")
562772
         elif event.type == "tool_result":
563
-            # Show result in a compact panel
564
-            lines = event.content.splitlines()
565
-            preview = "\n".join(lines[:10])
566
-            if len(lines) > 10:
567
-                preview += f"\n[dim]... ({len(lines) - 10} more lines)[/dim]"
568
-            border_style = "magenta" if event.phase == "verification" else "dim"
569
-            console.print(Panel(preview, border_style=border_style))
773
+            _print_tool_result(
774
+                getattr(event, "tool_name", "") or "",
775
+                event.content,
776
+                metadata=getattr(event, "tool_metadata", None),
777
+                is_error=getattr(event, "is_error", False),
778
+                phase=getattr(event, "phase", None),
779
+                preview_lines=10,
780
+            )
570781
         elif event.type == "dod_status":
571782
             console.print(f"[dim]{format_dod_status(event)}[/dim]")
572783
         elif event.type == "recovery":
@@ -588,6 +799,11 @@ async def run_once(
588799
         console.print("\n[red]Request timed out.[/red]")
589800
         console.print("[dim]The model is taking too long. Try a smaller model or simpler prompt.[/dim]")
590801
         return
802
+    except KeyboardInterrupt:
803
+        console.print()
804
+        if not await _interrupt_active_foreground_bash(shell_owner):
805
+            console.print("[yellow]Cancelled.[/yellow]")
806
+        return
591807
     except ConfirmationRequired as e:
592808
         console.print(f"\n[yellow]Confirmation required:[/yellow] {e.message}")
593809
         if e.details:
@@ -595,14 +811,20 @@ async def run_once(
595811
         if Confirm.ask("Proceed?"):
596812
             shell_owner.registry.skip_confirmation = True
597813
             streamed_response = False  # Reset for continuation
598
-            response = await shell_owner.run(
599
-                "Continue with the previous action.",
600
-                on_event=on_event,
601
-                on_user_question=_ask_user_question_cli,
602
-            )
603
-            if not streamed_response:
604
-                console.print(Markdown(clean_response(response)))
605
-            shell_owner.registry.skip_confirmation = skip_confirmation
814
+            try:
815
+                response = await shell_owner.run(
816
+                    "Continue with the previous action.",
817
+                    on_event=on_event,
818
+                    on_user_question=_ask_user_question_cli,
819
+                )
820
+                if not streamed_response:
821
+                    console.print(Markdown(clean_response(response)))
822
+            except KeyboardInterrupt:
823
+                console.print()
824
+                if not await _interrupt_active_foreground_bash(shell_owner):
825
+                    console.print("[yellow]Cancelled.[/yellow]")
826
+            finally:
827
+                shell_owner.registry.skip_confirmation = skip_confirmation
606828
         else:
607829
             console.print("[red]Aborted.[/red]")
608830
 
@@ -622,7 +844,10 @@ async def run_interactive(
622844
     history_file = os.path.expanduser("~/.loader_history")
623845
     session = PromptSession(history=FileHistory(history_file))
624846
 
625
-    console.print("[dim]Type 'exit' to quit, 'clear' to reset conversation[/dim]\n")
847
+    console.print(
848
+        "[dim]Type 'exit' to quit, 'clear' to reset conversation, "
849
+        "'jobs' to inspect bash jobs[/dim]\n"
850
+    )
626851
 
627852
     while True:
628853
         try:
@@ -647,6 +872,18 @@ async def run_interactive(
647872
             console.print("[dim]Conversation cleared[/dim]")
648873
             continue
649874
 
875
+        try:
876
+            local_bash = _parse_local_bash_command(user_input)
877
+        except ValueError as exc:
878
+            console.print(f"[red]{exc}[/red]\n")
879
+            continue
880
+
881
+        if local_bash is not None:
882
+            tool_name, tool_args = local_bash
883
+            await _run_local_bash_command(shell_owner, tool_name, tool_args)
884
+            console.print()
885
+            continue
886
+
650887
         import time
651888
         thinking_start = None
652889
         streaming_started = False
@@ -699,22 +936,20 @@ async def run_interactive(
699936
                 if streaming_started:
700937
                     console.print()  # New line after any streamed content
701938
                     streaming_started = False
702
-                args_str = _format_tool_args(event.tool_args)
703
-                tool_label = (
704
-                    f"verify {event.tool_name}"
705
-                    if event.phase == "verification"
706
-                    else event.tool_name
939
+                _print_tool_call(
940
+                    getattr(event, "tool_name", "") or "",
941
+                    getattr(event, "tool_args", None),
942
+                    getattr(event, "phase", None),
707943
                 )
708
-                console.print(f"[cyan]> {tool_label}[/cyan]({args_str})")
709944
             elif event.type == "tool_result":
710
-                # Show compact result
711
-                lines = event.content.splitlines()
712
-                if len(lines) <= 3:
713
-                    preview = event.content
714
-                else:
715
-                    preview = "\n".join(lines[:3]) + f"\n[dim]... ({len(lines) - 3} more lines)[/dim]"
716
-                style = "magenta" if event.phase == "verification" else "dim"
717
-                console.print(f"[{style}]{preview}[/{style}]")
945
+                _print_tool_result(
946
+                    getattr(event, "tool_name", "") or "",
947
+                    event.content,
948
+                    metadata=getattr(event, "tool_metadata", None),
949
+                    is_error=getattr(event, "is_error", False),
950
+                    phase=getattr(event, "phase", None),
951
+                    preview_lines=3,
952
+                )
718953
             elif event.type == "dod_status":
719954
                 console.print(f"\n[dim]{format_dod_status(event)}[/dim]")
720955
             elif event.type == "recovery":
@@ -740,6 +975,12 @@ async def run_interactive(
740975
             console.print("  • A simpler prompt")
741976
             console.print("  • Check if Ollama is overloaded")
742977
             continue
978
+        except KeyboardInterrupt:
979
+            console.print()
980
+            if not await _interrupt_active_foreground_bash(shell_owner):
981
+                console.print("[yellow]Cancelled.[/yellow]")
982
+            console.print()
983
+            continue
743984
         except ConfirmationRequired as e:
744985
             console.print(f"\n[yellow]Confirmation required:[/yellow] {e.message}")
745986
             if e.details:
@@ -757,6 +998,11 @@ async def run_interactive(
757998
                     if not streamed_response:
758999
                         console.print(Markdown(clean_response(response)))
7591000
                     console.print()
1001
+                except KeyboardInterrupt:
1002
+                    console.print()
1003
+                    if not await _interrupt_active_foreground_bash(shell_owner):
1004
+                        console.print("[yellow]Cancelled.[/yellow]")
1005
+                    console.print()
7601006
                 finally:
7611007
                     shell_owner.registry.skip_confirmation = skip_confirmation
7621008
             else:
@@ -1312,14 +1558,20 @@ async def _explore_main(
13121558
 
13131559
     def on_event(event) -> None:
13141560
         if event.type == "tool_call":
1315
-            args_str = _format_tool_args(event.tool_args)
1316
-            console.print(f"[cyan]> {event.tool_name}[/cyan]({args_str})")
1561
+            _print_tool_call(
1562
+                getattr(event, "tool_name", "") or "",
1563
+                getattr(event, "tool_args", None),
1564
+                getattr(event, "phase", None),
1565
+            )
13171566
         elif event.type == "tool_result":
1318
-            lines = event.content.splitlines()
1319
-            preview = "\n".join(lines[:8])
1320
-            if len(lines) > 8:
1321
-                preview += f"\n[dim]... ({len(lines) - 8} more lines)[/dim]"
1322
-            console.print(Panel(preview, border_style="dim"))
1567
+            _print_tool_result(
1568
+                getattr(event, "tool_name", "") or "",
1569
+                event.content,
1570
+                metadata=getattr(event, "tool_metadata", None),
1571
+                is_error=getattr(event, "is_error", False),
1572
+                phase=getattr(event, "phase", None),
1573
+                preview_lines=8,
1574
+            )
13231575
 
13241576
     response = await shell_owner.run_explore(prompt, on_event=on_event, fresh=fresh)
13251577
     console.print(Markdown(clean_response(response)))
src/loader/runtime/events.pymodified
@@ -29,6 +29,7 @@ class AgentEvent:
2929
     content: str = ""
3030
     tool_name: str | None = None
3131
     tool_args: dict[str, Any] | None = None
32
+    tool_metadata: dict[str, Any] | None = None
3233
     phase: str | None = None
3334
     step_info: str | None = None
3435
     recovery_attempt: int | None = None
src/loader/runtime/explore.pymodified
@@ -185,6 +185,11 @@ class ExploreRuntime:
185185
                             type="tool_result",
186186
                             content=outcome.event_content,
187187
                             tool_name=tool_call.name,
188
+                            tool_metadata=(
189
+                                outcome.registry_result.metadata
190
+                                if outcome.registry_result is not None
191
+                                else None
192
+                            ),
188193
                             is_error=outcome.is_error,
189194
                             phase="explore",
190195
                         )
src/loader/runtime/finalization.pymodified
@@ -425,6 +425,11 @@ class TurnFinalizer:
425425
                     type="tool_result",
426426
                     content=outcome.event_content,
427427
                     tool_name=verification_call.name,
428
+                    tool_metadata=(
429
+                        outcome.registry_result.metadata
430
+                        if outcome.registry_result is not None
431
+                        else None
432
+                    ),
428433
                     is_error=outcome.is_error,
429434
                     phase="verification",
430435
                 )
src/loader/runtime/tool_batches.pymodified
@@ -194,6 +194,11 @@ class ToolBatchRunner:
194194
                     type="tool_result",
195195
                     content=outcome.event_content,
196196
                     tool_name=tool_call.name,
197
+                    tool_metadata=(
198
+                        outcome.registry_result.metadata
199
+                        if outcome.registry_result is not None
200
+                        else None
201
+                    ),
197202
                     is_error=outcome.is_error,
198203
                     phase="assistant",
199204
                 )
src/loader/runtime/workflow_lanes.pymodified
@@ -327,6 +327,11 @@ class WorkflowLaneRunner:
327327
                 type="tool_result",
328328
                 content=outcome.event_content,
329329
                 tool_name=tool_call.name,
330
+                tool_metadata=(
331
+                    outcome.registry_result.metadata
332
+                    if outcome.registry_result is not None
333
+                    else None
334
+                ),
330335
                 is_error=outcome.is_error,
331336
                 phase="plan",
332337
             )
src/loader/ui/adapter.pymodified
@@ -1,6 +1,7 @@
11
 """Event adapter bridging Agent events to Textual messages."""
22
 
33
 from dataclasses import dataclass
4
+from typing import Any
45
 from typing import TYPE_CHECKING
56
 
67
 from textual.message import Message
@@ -52,6 +53,7 @@ class ToolCallCompleted(Message):
5253
     content: str
5354
     is_error: bool = False
5455
     phase: str | None = None
56
+    metadata: dict[str, Any] | None = None
5557
     # For edit tool diffs
5658
     old_string: str | None = None
5759
     new_string: str | None = None
@@ -391,6 +393,7 @@ class EventAdapter:
391393
                         content=event.content,
392394
                         is_error=event.is_error,
393395
                         phase=event.phase,
396
+                        metadata=event.tool_metadata,
394397
                         old_string=old_string,
395398
                         new_string=new_string,
396399
                         file_path=file_path,
@@ -399,7 +402,12 @@ class EventAdapter:
399402
 
400403
                 # Update the todo list widget when TodoWrite succeeds
401404
                 if tool_name == "TodoWrite" and not event.is_error:
402
-                    new_todos = self._extract_todos(event.content, tool_args)
405
+                    metadata_todos = (event.tool_metadata or {}).get("new_todos", [])
406
+                    new_todos = (
407
+                        metadata_todos
408
+                        if isinstance(metadata_todos, list) and metadata_todos
409
+                        else self._extract_todos(event.content, tool_args)
410
+                    )
403411
                     if new_todos:
404412
                         self.app.post_message(TodoListUpdated(todos=new_todos))
405413
 
src/loader/ui/app.pymodified
@@ -15,6 +15,7 @@ from textual.worker import Worker, get_current_worker
1515
 
1616
 from ..runtime.events import AgentEvent
1717
 from ..runtime.runtime_api import RuntimeShellOwner
18
+from ..tools.shell_tools import BashTool
1819
 from .adapter import (
1920
     ArtifactCreated,
2021
     ClearStream,
@@ -151,7 +152,7 @@ class LoaderApp(App):
151152
             "Press Ctrl+C to quit, Ctrl+L to clear.[/dim]"
152153
         )
153154
         self._add_message(
154
-            "[dim]Commands: /help, /model, /clear, /exit[/dim]"
155
+            "[dim]Commands: /help, /model, /jobs, /wait, /kill, /clear, /exit[/dim]"
155156
         )
156157
 
157158
     def _add_message(self, content: str, classes: str = "") -> None:
@@ -254,6 +255,15 @@ class LoaderApp(App):
254255
         elif cmd == "models":
255256
             self._handle_model_command("")  # List models
256257
 
258
+        elif cmd == "jobs":
259
+            self._handle_jobs_command(args)
260
+
261
+        elif cmd == "wait":
262
+            self._handle_wait_command(args)
263
+
264
+        elif cmd == "kill":
265
+            self._handle_kill_command(args)
266
+
257267
         else:
258268
             self._add_message(
259269
                 f"[red]Unknown command: /{cmd}[/red]\n"
@@ -269,12 +279,107 @@ class LoaderApp(App):
269279
 [cyan]/clear[/cyan], [cyan]/c[/cyan]         Clear the conversation
270280
 [cyan]/model[/cyan], [cyan]/models[/cyan]    Open model selector (fzf-style)
271281
 [cyan]/model[/cyan] [dim]<name>[/dim]     Switch to a specific model
282
+[cyan]/jobs[/cyan] [dim][limit][/dim]     List active and recent bash jobs
283
+[cyan]/wait[/cyan] [dim]<job-id> [timeout][/dim] Wait for a bash job to finish
284
+[cyan]/kill[/cyan] [dim]<job-id> [ms][/dim] Stop a tracked bash job
272285
 
273286
 [bold]Shortcuts:[/bold]
274287
 [dim]Ctrl+C[/dim]            Exit
275
-[dim]Ctrl+L[/dim]            Clear conversation"""
288
+[dim]Ctrl+L[/dim]            Clear conversation
289
+[dim]Esc[/dim]               Interrupt foreground bash or cancel the turn"""
276290
         self._add_message(help_text)
277291
 
292
+    def _get_bash_tool(self) -> BashTool | None:
293
+        tool = self.shell_owner.registry.get("bash")
294
+        return tool if isinstance(tool, BashTool) else None
295
+
296
+    def _launch_local_tool(self, tool_name: str, tool_args: dict[str, object]) -> None:
297
+        asyncio.create_task(self._execute_local_tool(tool_name, tool_args))
298
+
299
+    async def _execute_local_tool(
300
+        self,
301
+        tool_name: str,
302
+        tool_args: dict[str, object],
303
+    ) -> None:
304
+        self.post_message(
305
+            ToolCallStarted(
306
+                tool_name=tool_name,
307
+                tool_args=tool_args,
308
+                phase="local",
309
+            )
310
+        )
311
+        try:
312
+            result = await self.shell_owner.registry.execute(tool_name, **tool_args)
313
+        except Exception as exc:
314
+            self.post_message(
315
+                ToolCallCompleted(
316
+                    tool_name=tool_name,
317
+                    content=f"Tool execution error: {exc}",
318
+                    is_error=True,
319
+                    phase="local",
320
+                )
321
+            )
322
+            return
323
+        self.post_message(
324
+            ToolCallCompleted(
325
+                tool_name=tool_name,
326
+                content=result.output,
327
+                is_error=result.is_error,
328
+                phase="local",
329
+                metadata=result.metadata,
330
+            )
331
+        )
332
+
333
+    def _handle_jobs_command(self, args: str) -> None:
334
+        limit = 20
335
+        if args.strip():
336
+            try:
337
+                limit = max(1, int(args.strip()))
338
+            except ValueError:
339
+                self._add_message("[red]Usage: /jobs [limit][/red]")
340
+                return
341
+        self._launch_local_tool("bash_jobs", {"limit": limit})
342
+
343
+    def _handle_wait_command(self, args: str) -> None:
344
+        parts = args.split()
345
+        if not parts:
346
+            self._add_message("[red]Usage: /wait <job-id> [timeout-seconds][/red]")
347
+            return
348
+        tool_args: dict[str, object] = {"job_id": parts[0]}
349
+        if len(parts) > 1:
350
+            try:
351
+                tool_args["timeout"] = float(parts[1])
352
+            except ValueError:
353
+                self._add_message("[red]Usage: /wait <job-id> [timeout-seconds][/red]")
354
+                return
355
+        self._launch_local_tool("bash_wait", tool_args)
356
+
357
+    def _handle_kill_command(self, args: str) -> None:
358
+        parts = args.split()
359
+        if not parts:
360
+            self._add_message("[red]Usage: /kill <job-id> [force-after-ms][/red]")
361
+            return
362
+        tool_args: dict[str, object] = {"job_id": parts[0]}
363
+        if len(parts) > 1:
364
+            try:
365
+                tool_args["force_after_ms"] = int(parts[1])
366
+            except ValueError:
367
+                self._add_message("[red]Usage: /kill <job-id> [force-after-ms][/red]")
368
+                return
369
+        self._launch_local_tool("bash_kill", tool_args)
370
+
371
+    async def _interrupt_active_bash_job(self) -> None:
372
+        bash_tool = self._get_bash_tool()
373
+        if bash_tool is None:
374
+            return
375
+        await bash_tool.manager.interrupt_active_foreground()
376
+
377
+    def _terminate_all_bash_jobs(self) -> list[str]:
378
+        bash_tool = self._get_bash_tool()
379
+        if bash_tool is None:
380
+            return []
381
+        return bash_tool.manager.terminate_all_now()
382
+
278383
     def _handle_model_command(self, args: str) -> None:
279384
         """Handle /model command - switch or show selector."""
280385
         if not args:
@@ -639,12 +744,9 @@ class LoaderApp(App):
639744
 
640745
         # Create tool widget
641746
         widget = ToolCallWidget(
642
-            tool_name=(
643
-                f"verify {message.tool_name}"
644
-                if message.phase == "verification"
645
-                else message.tool_name
646
-            ),
747
+            tool_name=message.tool_name,
647748
             tool_args=message.tool_args,
749
+            phase=message.phase,
648750
         )
649751
         msg_area.mount(widget)
650752
         widget.set_running()  # Must be after mount() so children exist
@@ -674,14 +776,17 @@ class LoaderApp(App):
674776
         tool_widget = None
675777
         if self._tool_widget_queue:
676778
             for i, w in enumerate(self._tool_widget_queue):
677
-                # Match on tool name (strip "verify " prefix for verification phase)
678
-                widget_name = w.tool_name.removeprefix("verify ")
679
-                if widget_name == message.tool_name:
779
+                if w.tool_name == message.tool_name and w.phase == message.phase:
680780
                     tool_widget = self._tool_widget_queue.pop(i)
681781
                     break
682782
             else:
683
-                # No name match — fall back to FIFO
684
-                tool_widget = self._tool_widget_queue.pop(0)
783
+                for i, w in enumerate(self._tool_widget_queue):
784
+                    if w.tool_name == message.tool_name:
785
+                        tool_widget = self._tool_widget_queue.pop(i)
786
+                        break
787
+                else:
788
+                    # No name match — fall back to FIFO
789
+                    tool_widget = self._tool_widget_queue.pop(0)
685790
 
686791
         # Check if this is an edit tool with diff info
687792
         # Note: old_string can be empty string (inserting), so check `is not None`
@@ -717,7 +822,9 @@ class LoaderApp(App):
717822
             # Update existing tool widget with result
718823
             self._debug_log("  -> showing regular tool widget result")
719824
             tool_widget.set_result(
720
-                message.content, is_error=message.is_error
825
+                message.content,
826
+                is_error=message.is_error,
827
+                metadata=message.metadata,
721828
             )
722829
 
723830
         msg_area.scroll_end(animate=False)
@@ -924,8 +1031,16 @@ class LoaderApp(App):
9241031
     # Actions
9251032
     def action_clear_messages(self) -> None:
9261033
         """Clear all messages."""
1034
+        killed_jobs = self._terminate_all_bash_jobs()
1035
+        self.workers.cancel_all()
1036
+        self.is_generating = False
1037
+        self._stop_timer()
1038
+        self.query_one(StatusLine).set_generating(False)
9271039
         msg_area = self.query_one("#message-area", ScrollableContainer)
9281040
         msg_area.remove_children()
1041
+        self._current_streaming = None
1042
+        self._streamed_content = False
1043
+        self._tool_widget_queue.clear()
9291044
         self.shell_owner.clear_history()
9301045
         self.query_one(StatusLine).clear_definition_of_done()
9311046
         self.query_one(StatusLine).update_session_id(self.shell_owner.session.session_id)
@@ -934,15 +1049,32 @@ class LoaderApp(App):
9341049
         )
9351050
         self.query_one(StatusLine).update_workflow_mode("execute")
9361051
         self._add_message("[dim]Conversation cleared.[/dim]")
1052
+        if killed_jobs:
1053
+            self._add_message(
1054
+                f"[yellow]Stopped bash jobs:[/yellow] {', '.join(killed_jobs)}"
1055
+            )
9371056
 
9381057
     def action_cancel(self) -> None:
9391058
         """Cancel current operation."""
1059
+        bash_tool = self._get_bash_tool()
1060
+        if (
1061
+            bash_tool is not None
1062
+            and bash_tool.manager.active_foreground_job_id is not None
1063
+        ):
1064
+            asyncio.create_task(self._interrupt_active_bash_job())
1065
+            return
9401066
         # Cancel any running workers
9411067
         self.workers.cancel_all()
9421068
         self.is_generating = False
9431069
         self._stop_timer()
9441070
         self.query_one(StatusLine).set_generating(False)
9451071
 
1072
+    def on_unmount(self) -> None:
1073
+        """Clean up any tracked bash jobs when the TUI exits."""
1074
+        killed_jobs = self._terminate_all_bash_jobs()
1075
+        if killed_jobs:
1076
+            self._debug_log(f"on_unmount: stopped bash jobs {killed_jobs}")
1077
+
9461078
 
9471079
 def _definition_of_done_verification_attempt(dod) -> str | None:
9481080
     """Render one compact verification-attempt label from DoD state."""
src/loader/ui/widgets/approval_bar.pymodified
@@ -1,5 +1,9 @@
11
 """Approval bar widget for command confirmation (Claude Code style)."""
22
 
3
+from rich import box
4
+from rich.console import Group
5
+from rich.panel import Panel
6
+from rich.text import Text
37
 from textual.app import ComposeResult
48
 from textual.binding import Binding
59
 from textual.message import Message
@@ -25,7 +29,7 @@ class ApprovalBar(Widget, can_focus=True):
2529
     DEFAULT_CSS = """
2630
     ApprovalBar {
2731
         height: auto;
28
-        max-height: 4;
32
+        max-height: 12;
2933
         display: none;
3034
         padding: 0 1;
3135
         background: $warning 15%;
@@ -79,13 +83,38 @@ class ApprovalBar(Widget, can_focus=True):
7983
         self._full_command = details
8084
 
8185
         preview = details if details else message
82
-        if len(preview) > 70:
83
-            preview = preview[:67] + "..."
8486
         content = self.query_one("#approval-content", Static)
85
-        content.update(
86
-            f"[bold $warning]\\[{tool_name}][/] {preview}  "
87
-            f"[bold green]\\[Y][/]es  [bold red]\\[n][/]o  [bold]\\[e][/]dit"
88
-        )
87
+        if tool_name == "bash":
88
+            header = Text("Bash", style="bold yellow")
89
+            command = Text(preview or "(empty command)")
90
+            controls = Text.assemble(
91
+                ("[Y]", "bold green"),
92
+                ("es  ",),
93
+                ("[n]", "bold red"),
94
+                ("o  ",),
95
+                ("[e]", "bold"),
96
+                ("dit",),
97
+            )
98
+            content.update(
99
+                Group(
100
+                    header,
101
+                    Panel(
102
+                        command,
103
+                        title="Command",
104
+                        border_style="yellow",
105
+                        box=box.SQUARE,
106
+                        expand=True,
107
+                    ),
108
+                    controls,
109
+                )
110
+            )
111
+        else:
112
+            if len(preview) > 70:
113
+                preview = preview[:67] + "..."
114
+            content.update(
115
+                f"[bold $warning]\\[{tool_name}][/] {preview}  "
116
+                f"[bold green]\\[Y][/]es  [bold red]\\[n][/]o  [bold]\\[e][/]dit"
117
+            )
89118
 
90119
         # Show the bar
91120
         self.add_class("visible")
src/loader/ui/widgets/input_area.pymodified
@@ -11,6 +11,9 @@ SLASH_COMMANDS = [
1111
     "/help",
1212
     "/model",
1313
     "/models",
14
+    "/jobs",
15
+    "/wait",
16
+    "/kill",
1417
     "/clear",
1518
     "/exit",
1619
 ]
src/loader/ui/widgets/tool_widget.pymodified
@@ -1,6 +1,11 @@
1
-"""Tool call widget with inline truncation (claw-code style)."""
1
+"""Tool call widget with bash-specific rich rendering."""
22
 
3
+from typing import Any
4
+
5
+from rich import box
6
+from rich.console import Group
37
 from rich.markup import escape
8
+from rich.panel import Panel
49
 from rich.text import Text
510
 from textual.app import ComposeResult
611
 from textual.containers import Vertical
@@ -18,6 +23,13 @@ _TRUNCATION_NOTICE = "truncated for display; full result preserved in session"
1823
 class ToolCallWidget(Vertical):
1924
     """Widget for tool calls with inline content display."""
2025
 
26
+    TOOL_LABELS = {
27
+        "bash": "Bash",
28
+        "bash_jobs": "Bash Jobs",
29
+        "bash_wait": "Bash Wait",
30
+        "bash_kill": "Bash Kill",
31
+    }
32
+
2133
     TOOL_BULLETS = {
2234
         "pending": "[yellow]○[/yellow]",
2335
         "running": "[yellow]◐[/yellow]",
@@ -31,22 +43,62 @@ class ToolCallWidget(Vertical):
3143
         self,
3244
         tool_name: str,
3345
         tool_args: dict | None = None,
46
+        phase: str | None = None,
3447
         **kwargs,
3548
     ) -> None:
3649
         super().__init__(**kwargs)
3750
         self.tool_name = tool_name
3851
         self.tool_args = tool_args or {}
52
+        self.phase = phase
3953
         self._result: str = ""
4054
         self._is_error: bool = False
55
+        self._metadata: dict[str, Any] = {}
4156
 
4257
     def compose(self) -> ComposeResult:
43
-        args_str = self._format_args()
44
-
4558
         yield Static(
46
-            f"{self.TOOL_BULLETS['pending']} [bold cyan]{self.tool_name}[/bold cyan]({args_str})",
59
+            self._header_markup(),
4760
             id="tool-header",
4861
             classes="tool-header",
4962
         )
63
+        yield Static(self._build_initial_summary(), id="tool-summary", classes="tool-summary")
64
+
65
+    def _format_args(self) -> str:
66
+        """Format tool arguments for display."""
67
+        if self._is_bash_command_tool():
68
+            return ""
69
+        if not self.tool_args:
70
+            return ""
71
+        parts = []
72
+        for k, v in self.tool_args.items():
73
+            if isinstance(v, str):
74
+                limit = 200 if k in ("file_path", "path") else (80 if k == "content" else 40)
75
+                if len(v) > limit:
76
+                    v = v[: limit - 3] + "..."
77
+                parts.append(f'{k}="[dim]{escape(v)}[/dim]"')
78
+            else:
79
+                parts.append(f"{k}={escape(repr(v))}")
80
+        return ", ".join(parts)
81
+
82
+    def _display_name(self) -> str:
83
+        base = self.TOOL_LABELS.get(self.tool_name, self.tool_name)
84
+        if self.phase == "verification":
85
+            return f"Verify {base}"
86
+        return base
87
+
88
+    def _is_bash_command_tool(self) -> bool:
89
+        return self.tool_name == "bash"
90
+
91
+    def _header_markup(self) -> str:
92
+        args_str = self._format_args()
93
+        bullet = self.TOOL_BULLETS.get(self.state, self.TOOL_BULLETS["pending"])
94
+        color = "red" if self._is_error else "cyan"
95
+        label = self._display_name()
96
+        suffix = f"({args_str})" if args_str else ""
97
+        return f"{bullet} [bold {color}]{label}[/bold {color}]{suffix}"
98
+
99
+    def _build_initial_summary(self):
100
+        if self._is_bash_command_tool():
101
+            return Group(self._render_bash_command_panel())
50102
 
51103
         # For write/edit tools, show content as pre-approval preview
52104
         initial_summary = Text()
@@ -66,38 +118,127 @@ class ToolCallWidget(Vertical):
66118
                         f"({_TRUNCATION_NOTICE})\n",
67119
                         style="dim",
68120
                     )
69
-        yield Static(initial_summary, id="tool-summary", classes="tool-summary")
121
+        return initial_summary
70122
 
71
-    def _format_args(self) -> str:
72
-        """Format tool arguments for display."""
73
-        if not self.tool_args:
74
-            return ""
75
-        parts = []
76
-        for k, v in self.tool_args.items():
77
-            if isinstance(v, str):
78
-                limit = 200 if k in ("file_path", "path") else (80 if k == "content" else 40)
79
-                if len(v) > limit:
80
-                    v = v[: limit - 3] + "..."
81
-                parts.append(f'{k}="[dim]{escape(v)}[/dim]"')
82
-            else:
83
-                parts.append(f"{k}={escape(repr(v))}")
84
-        return ", ".join(parts)
123
+    def _render_bash_command_panel(self) -> Panel:
124
+        command = str(self.tool_args.get("command", "")).strip() or "(empty command)"
125
+        return Panel(
126
+            Text(command),
127
+            title="Command",
128
+            border_style="cyan",
129
+            box=box.SQUARE,
130
+            expand=True,
131
+        )
132
+
133
+    def _truncate_result(self, result: str, *, line_limit: int) -> tuple[str, bool]:
134
+        lines = result.splitlines()
135
+        if len(lines) <= line_limit and len(result) <= TOOL_RESULT_MAX_CHARS:
136
+            return result, False
137
+
138
+        display = lines[:line_limit]
139
+        text = "\n".join(display)
140
+        if len(text) > TOOL_RESULT_MAX_CHARS:
141
+            text = text[:TOOL_RESULT_MAX_CHARS]
142
+        return text, True
143
+
144
+    def _build_bash_result(self, result: str):
145
+        metadata = self._metadata
146
+        renderables = [self._render_bash_command_panel()]
147
+        status = Text()
148
+        status.append(
149
+            "✗ Failed\n" if self._is_error else "✓ Success\n",
150
+            style="bold red" if self._is_error else "bold green",
151
+        )
152
+
153
+        detail_lines = []
154
+        status_value = str(metadata.get("status", "failed" if self._is_error else "completed"))
155
+        detail_lines.append(f"Status: {status_value.replace('_', ' ')}")
156
+        job_id = metadata.get("job_id")
157
+        if job_id:
158
+            detail_lines.append(f"Job: {job_id}")
159
+        pid = metadata.get("pid")
160
+        if pid:
161
+            detail_lines.append(f"PID: {pid}")
162
+        if metadata.get("exit_code") is not None:
163
+            detail_lines.append(f"Exit: {metadata['exit_code']}")
164
+        if metadata.get("background") is not None:
165
+            detail_lines.append(
166
+                f"Mode: {'background' if metadata.get('background') else 'foreground'}"
167
+            )
168
+        if detail_lines:
169
+            status.append("\n".join(detail_lines))
170
+
171
+        stdout_text = str(metadata.get("stdout", "") or "")
172
+        stderr_text = str(metadata.get("stderr", "") or "")
173
+        show_summary_note = (
174
+            (not stdout_text and not stderr_text and bool(result.strip()))
175
+            or status_value not in {"completed", "running"}
176
+        )
177
+        if show_summary_note and result.strip():
178
+            if status.plain:
179
+                status.append("\n\n")
180
+            preview, truncated = self._truncate_result(result, line_limit=24)
181
+            status.append(preview)
182
+            if truncated:
183
+                status.append(f"\n… {_TRUNCATION_NOTICE}", style="dim")
184
+
185
+        renderables.append(
186
+            Panel(
187
+                status,
188
+                title="Status",
189
+                border_style="red" if self._is_error else "green",
190
+                box=box.SQUARE,
191
+                expand=True,
192
+            )
193
+        )
194
+
195
+        for stream_name, stream_text, truncated in (
196
+            ("stdout", stdout_text, bool(metadata.get("stdout_truncated"))),
197
+            ("stderr", stderr_text, bool(metadata.get("stderr_truncated"))),
198
+        ):
199
+            if not stream_text:
200
+                continue
201
+            preview, preview_truncated = self._truncate_result(stream_text, line_limit=40)
202
+            stream_panel_text = Text(preview)
203
+            if truncated or preview_truncated:
204
+                stream_panel_text.append(f"\n… {_TRUNCATION_NOTICE}", style="dim")
205
+            renderables.append(
206
+                Panel(
207
+                    stream_panel_text,
208
+                    title=stream_name,
209
+                    border_style="red" if stream_name == "stderr" else "dim",
210
+                    box=box.SQUARE,
211
+                    expand=True,
212
+                )
213
+            )
214
+
215
+        return Group(*renderables)
85216
 
86217
     def set_running(self) -> None:
87218
         """Mark as running."""
88219
         self.state = "running"
89220
         self._update_header()
90221
 
91
-    def set_result(self, result: str, is_error: bool = False) -> None:
222
+    def set_result(
223
+        self,
224
+        result: str,
225
+        is_error: bool = False,
226
+        metadata: dict[str, Any] | None = None,
227
+    ) -> None:
92228
         """Update widget with tool result using inline truncation."""
93229
         self._result = result
94230
         self._is_error = is_error
231
+        self._metadata = metadata or {}
95232
         self.state = "error" if is_error else "success"
96233
 
97
-        self.remove_class("pending", "error", "success")
234
+        self.remove_class("pending", "running", "error", "success")
98235
         self.add_class(self.state)
99236
         self._update_header()
100237
 
238
+        if self._is_bash_command_tool():
239
+            self.query_one("#tool-summary", Static).update(self._build_bash_result(result))
240
+            return
241
+
101242
         summary = Text()
102243
         if is_error:
103244
             summary.append("✗ Failed\n", style="bold red")
@@ -129,12 +270,7 @@ class ToolCallWidget(Vertical):
129270
 
130271
     def _update_header(self) -> None:
131272
         """Update the header with current state."""
132
-        args_str = self._format_args()
133
-        bullet = self.TOOL_BULLETS.get(self.state, self.TOOL_BULLETS["pending"])
134
-        color = "red" if self._is_error else "cyan"
135
-        self.query_one("#tool-header", Static).update(
136
-            f"{bullet} [bold {color}]{self.tool_name}[/bold {color}]({args_str})"
137
-        )
273
+        self.query_one("#tool-header", Static).update(self._header_markup())
138274
 
139275
     def watch_state(self, state: str) -> None:
140276
         """React to state changes."""
tests/test_bash_operator_surfaces.pyadded
@@ -0,0 +1,124 @@
1
+"""Tests for bash job metadata and operator-facing surfaces."""
2
+
3
+from __future__ import annotations
4
+
5
+from types import SimpleNamespace
6
+
7
+import pytest
8
+from rich.console import Console
9
+
10
+import loader.cli.main as cli_main_module
11
+from loader.runtime.events import AgentEvent
12
+from loader.tools import BashTool
13
+from loader.ui.adapter import EventAdapter, ToolCallCompleted
14
+from loader.ui.widgets.tool_widget import ToolCallWidget
15
+
16
+
17
+class _FakeApp:
18
+    def __init__(self) -> None:
19
+        self.messages: list[object] = []
20
+
21
+    def post_message(self, message: object) -> None:
22
+        self.messages.append(message)
23
+
24
+
25
+def _render_text(renderable, *, width: int = 100) -> str:
26
+    console = Console(record=True, width=width)
27
+    console.print(renderable)
28
+    return console.export_text(styles=False)
29
+
30
+
31
+def test_event_adapter_preserves_tool_metadata_on_completion() -> None:
32
+    app = _FakeApp()
33
+    adapter = EventAdapter(app)  # type: ignore[arg-type]
34
+    metadata = {"job_id": "bash-3", "status": "running", "background": True}
35
+
36
+    adapter.handle_event(
37
+        AgentEvent(
38
+            type="tool_call",
39
+            tool_name="bash",
40
+            tool_args={"command": "python -m http.server 8000", "background": True},
41
+            phase="assistant",
42
+        )
43
+    )
44
+    adapter.handle_event(
45
+        AgentEvent(
46
+            type="tool_result",
47
+            tool_name="bash",
48
+            content="Started bash job bash-3",
49
+            tool_metadata=metadata,
50
+            phase="assistant",
51
+        )
52
+    )
53
+
54
+    completed = next(message for message in app.messages if isinstance(message, ToolCallCompleted))
55
+    assert completed.metadata == metadata
56
+
57
+
58
+def test_tool_call_widget_renders_full_bash_command_in_box() -> None:
59
+    command = "python -m http.server 8000 --directory /tmp/preview-pages"
60
+    widget = ToolCallWidget("bash", {"command": command})
61
+
62
+    header = widget._header_markup()
63
+    rendered = _render_text(widget._build_initial_summary(), width=120)
64
+
65
+    assert "Bash" in header
66
+    assert "command=" not in header
67
+    assert "Command" in rendered
68
+    assert command in rendered
69
+
70
+
71
+def test_cli_print_tool_call_renders_bash_panel_without_truncating(monkeypatch: pytest.MonkeyPatch) -> None:
72
+    console = Console(record=True, width=120)
73
+    monkeypatch.setattr(cli_main_module, "console", console)
74
+
75
+    command = "python -m http.server 8000 --directory /tmp/preview-pages"
76
+    cli_main_module._print_tool_call("bash", {"command": command})
77
+
78
+    rendered = console.export_text(styles=False)
79
+    assert "Bash" in rendered
80
+    assert "Command" in rendered
81
+    assert command in rendered
82
+    assert "command=" not in rendered
83
+
84
+
85
+def test_cli_parse_local_bash_commands_supports_slash_aliases() -> None:
86
+    assert cli_main_module._parse_local_bash_command("/jobs 5") == ("bash_jobs", {"limit": 5})
87
+    assert cli_main_module._parse_local_bash_command("/wait bash-7 2.5") == (
88
+        "bash_wait",
89
+        {"job_id": "bash-7", "timeout": 2.5},
90
+    )
91
+    assert cli_main_module._parse_local_bash_command("kill bash-2 50") == (
92
+        "bash_kill",
93
+        {"job_id": "bash-2", "force_after_ms": 50},
94
+    )
95
+
96
+
97
+@pytest.mark.asyncio
98
+async def test_cli_interrupt_active_foreground_bash_prints_interrupted_result(
99
+    monkeypatch: pytest.MonkeyPatch,
100
+) -> None:
101
+    console = Console(record=True, width=120)
102
+    monkeypatch.setattr(cli_main_module, "console", console)
103
+
104
+    bash_tool = BashTool(timeout=10.0)
105
+    job = await bash_tool.manager.start(
106
+        command='python -c "import time; time.sleep(30)"',
107
+        cwd=None,
108
+        timeout=10.0,
109
+        background=False,
110
+        mutability="workspace-write",
111
+    )
112
+    owner = SimpleNamespace(
113
+        registry=SimpleNamespace(get=lambda name: bash_tool if name == "bash" else None)
114
+    )
115
+
116
+    try:
117
+        interrupted = await cli_main_module._interrupt_active_foreground_bash(owner)
118
+        assert interrupted is True
119
+        assert bash_tool.manager.active_foreground_job_id is None
120
+        rendered = console.export_text(styles=False)
121
+        assert "Status: interrupted" in rendered
122
+    finally:
123
+        if job.is_running:
124
+            await bash_tool.manager.kill_job(job.job_id, interrupted=True)
tests/test_tool_batches.pymodified
@@ -153,6 +153,7 @@ def tool_outcome(
153153
     tool_call: ToolCall,
154154
     output: str,
155155
     is_error: bool,
156
+    metadata: dict[str, object] | None = None,
156157
 ) -> ToolExecutionOutcome:
157158
     return ToolExecutionOutcome(
158159
         tool_call=tool_call,
@@ -166,7 +167,11 @@ def tool_outcome(
166167
         event_content=output,
167168
         is_error=is_error,
168169
         result_output=output,
169
-        registry_result=RegistryToolResult(output=output, is_error=is_error),
170
+        registry_result=RegistryToolResult(
171
+            output=output,
172
+            is_error=is_error,
173
+            metadata=metadata or {},
174
+        ),
170175
     )
171176
 
172177
 
@@ -276,6 +281,66 @@ async def test_tool_batch_runner_tracks_recovery_with_legacy_context(temp_dir: P
276281
     assert any(event.type == "recovery" for event in events)
277282
 
278283
 
284
+@pytest.mark.asyncio
285
+async def test_tool_batch_runner_emits_tool_metadata(temp_dir: Path) -> None:
286
+    async def assess_confidence(tool_name: str, tool_args: dict, context: str) -> ConfidenceAssessment:
287
+        raise AssertionError("Confidence scoring should be disabled in this scenario")
288
+
289
+    async def verify_action(tool_name: str, tool_args: dict, result: str, expected: str = "") -> ActionVerification:
290
+        raise AssertionError("Verification should not run for this scenario")
291
+
292
+    context = build_context(
293
+        temp_dir=temp_dir,
294
+        messages=[],
295
+        safeguards=FakeSafeguards(),
296
+        assess_confidence=assess_confidence,
297
+        verify_action=verify_action,
298
+        auto_recover=False,
299
+    )
300
+    runner = ToolBatchRunner(context, DefinitionOfDoneStore(temp_dir))
301
+    tool_call = ToolCall(
302
+        id="bash-1",
303
+        name="bash",
304
+        arguments={"command": "python -m http.server 8000", "background": True},
305
+    )
306
+    metadata = {
307
+        "job_id": "bash-1",
308
+        "status": "running",
309
+        "background": True,
310
+    }
311
+    executor = FakeExecutor(
312
+        [
313
+            tool_outcome(
314
+                tool_call=tool_call,
315
+                output="Started bash job bash-1",
316
+                is_error=False,
317
+                metadata=metadata,
318
+            )
319
+        ]
320
+    )
321
+    events: list[AgentEvent] = []
322
+
323
+    async def emit(event: AgentEvent) -> None:
324
+        events.append(event)
325
+
326
+    await runner.execute_batch(
327
+        tool_calls=[tool_call],
328
+        tool_source="assistant",
329
+        pending_tool_calls_seen=set(),
330
+        emit=emit,
331
+        summary=TurnSummary(final_response=""),
332
+        dod=create_definition_of_done("Launch a preview server"),
333
+        executor=executor,  # type: ignore[arg-type]
334
+        on_confirmation=None,
335
+        on_user_question=None,
336
+        emit_confirmation=None,
337
+        consecutive_errors=0,
338
+    )
339
+
340
+    tool_result = next(event for event in events if event.type == "tool_result")
341
+    assert tool_result.tool_metadata == metadata
342
+
343
+
279344
 @pytest.mark.asyncio
280345
 async def test_tool_batch_runner_verifies_with_context_services(temp_dir: Path) -> None:
281346
     verification_calls: list[str] = []