tenseleyflow/loader / f0c4a17

Browse files

Surface definition-of-done state in CLI and TUI

Authored by espadonne
SHA
f0c4a1751c6bf030a43bfe87f13bfd84f1156fc1
Parents
7d46142
Tree
438a411

9 changed files

StatusFile+-
M src/loader/cli/__init__.py 9 1
M src/loader/cli/main.py 22 4
A src/loader/cli/rendering.py 15 0
M src/loader/ui/__init__.py 9 1
M src/loader/ui/adapter.py 32 8
M src/loader/ui/app.py 34 12
A src/loader/ui/status_helpers.py 34 0
M src/loader/ui/widgets/status_line.py 42 0
A tests/test_status_surfaces.py 32 0
src/loader/cli/__init__.pymodified
@@ -1,5 +1,13 @@
11
 """CLI interface."""
22
 
3
-from .main import main
3
+from typing import Any
4
+
5
+
6
+def main(*args: Any, **kwargs: Any):
7
+    """Lazy wrapper for the Click entrypoint."""
8
+    from .main import main as click_main
9
+
10
+    return click_main(*args, **kwargs)
11
+
412
 
513
 __all__ = ["main"]
src/loader/cli/main.pymodified
@@ -11,6 +11,8 @@ from rich.panel import Panel
1111
 from rich.prompt import Confirm
1212
 from rich.table import Table
1313
 
14
+from .rendering import format_dod_status
15
+
1416
 console = Console()
1517
 
1618
 
@@ -369,14 +371,22 @@ async def run_once(agent, prompt: str, skip_confirmation: bool = False) -> None:
369371
                 console.print(f" [dim]({elapsed:.1f}s)[/dim]")
370372
                 thinking_start = None
371373
             args_str = _format_tool_args(event.tool_args)
372
-            console.print(f"[cyan]> {event.tool_name}[/cyan]({args_str})")
374
+            tool_label = (
375
+                f"verify {event.tool_name}"
376
+                if event.phase == "verification"
377
+                else event.tool_name
378
+            )
379
+            console.print(f"[cyan]> {tool_label}[/cyan]({args_str})")
373380
         elif event.type == "tool_result":
374381
             # Show result in a compact panel
375382
             lines = event.content.splitlines()
376383
             preview = "\n".join(lines[:10])
377384
             if len(lines) > 10:
378385
                 preview += f"\n[dim]... ({len(lines) - 10} more lines)[/dim]"
379
-            console.print(Panel(preview, border_style="dim"))
386
+            border_style = "magenta" if event.phase == "verification" else "dim"
387
+            console.print(Panel(preview, border_style=border_style))
388
+        elif event.type == "dod_status":
389
+            console.print(f"[dim]{format_dod_status(event)}[/dim]")
380390
         elif event.type == "recovery":
381391
             console.print(f"[yellow]Recovering from error ({event.recovery_attempt}/3)...[/yellow]")
382392
         elif event.type == "error":
@@ -485,7 +495,12 @@ async def run_interactive(agent, skip_confirmation: bool = False) -> None:
485495
                     console.print()  # New line after any streamed content
486496
                     streaming_started = False
487497
                 args_str = _format_tool_args(event.tool_args)
488
-                console.print(f"[cyan]> {event.tool_name}[/cyan]({args_str})")
498
+                tool_label = (
499
+                    f"verify {event.tool_name}"
500
+                    if event.phase == "verification"
501
+                    else event.tool_name
502
+                )
503
+                console.print(f"[cyan]> {tool_label}[/cyan]({args_str})")
489504
             elif event.type == "tool_result":
490505
                 # Show compact result
491506
                 lines = event.content.splitlines()
@@ -493,7 +508,10 @@ async def run_interactive(agent, skip_confirmation: bool = False) -> None:
493508
                     preview = event.content
494509
                 else:
495510
                     preview = "\n".join(lines[:3]) + f"\n[dim]... ({len(lines) - 3} more lines)[/dim]"
496
-                console.print(f"[dim]{preview}[/dim]")
511
+                style = "magenta" if event.phase == "verification" else "dim"
512
+                console.print(f"[{style}]{preview}[/{style}]")
513
+            elif event.type == "dod_status":
514
+                console.print(f"\n[dim]{format_dod_status(event)}[/dim]")
497515
             elif event.type == "recovery":
498516
                 console.print(f"\n[yellow]Recovering from error ({event.recovery_attempt}/3)...[/yellow]")
499517
             elif event.type == "error":
src/loader/cli/rendering.pyadded
@@ -0,0 +1,15 @@
1
+"""Lightweight CLI rendering helpers."""
2
+
3
+from __future__ import annotations
4
+
5
+from ..runtime.events import AgentEvent
6
+
7
+
8
+def format_dod_status(event: AgentEvent) -> str:
9
+    """Format a definition-of-done status event for CLI output."""
10
+    parts = [f"DoD: {event.dod_status or 'unknown'}"]
11
+    if event.pending_items_count is not None:
12
+        parts.append(f"{event.pending_items_count} pending")
13
+    if event.last_verification_result:
14
+        parts.append(f"last verify: {event.last_verification_result}")
15
+    return " | ".join(parts)
src/loader/ui/__init__.pymodified
@@ -1,5 +1,13 @@
11
 """Textual TUI for Loader."""
22
 
3
-from .app import LoaderApp
3
+from typing import Any
4
+
5
+
6
+def LoaderApp(*args: Any, **kwargs: Any):
7
+    """Lazy wrapper for the Textual application."""
8
+    from .app import LoaderApp as _LoaderApp
9
+
10
+    return _LoaderApp(*args, **kwargs)
11
+
412
 
513
 __all__ = ["LoaderApp"]
src/loader/ui/adapter.pymodified
@@ -9,14 +9,14 @@ from ..agent.loop import AgentEvent
99
 
1010
 if TYPE_CHECKING:
1111
     from ..agent.reasoning import (
12
-        TaskDecomposition,
13
-        Subtask,
14
-        SelfCritique,
15
-        ConfidenceAssessment,
1612
         ActionVerification,
17
-        TaskCompletionCheck,
18
-        RollbackPlan,
13
+        ConfidenceAssessment,
1914
         RollbackAction,
15
+        RollbackPlan,
16
+        SelfCritique,
17
+        Subtask,
18
+        TaskCompletionCheck,
19
+        TaskDecomposition,
2020
     )
2121
 
2222
 
@@ -42,6 +42,7 @@ class ToolCallStarted(Message):
4242
 
4343
     tool_name: str
4444
     tool_args: dict
45
+    phase: str | None = None
4546
 
4647
 
4748
 @dataclass
@@ -51,6 +52,7 @@ class ToolCallCompleted(Message):
5152
     tool_name: str
5253
     content: str
5354
     is_error: bool = False
55
+    phase: str | None = None
5456
     # For edit tool diffs
5557
     old_string: str | None = None
5658
     new_string: str | None = None
@@ -182,6 +184,16 @@ class RollbackSummary(Message):
182184
     rollback_plan: "RollbackPlan | None" = None
183185
 
184186
 
187
+@dataclass
188
+class DefinitionOfDoneUpdated(Message):
189
+    """Definition-of-done status changed."""
190
+
191
+    content: str
192
+    dod_status: str
193
+    pending_items_count: int = 0
194
+    last_verification_result: str | None = None
195
+
196
+
185197
 class EventAdapter:
186198
     """Adapts Agent callback events to Textual messages."""
187199
 
@@ -244,6 +256,7 @@ class EventAdapter:
244256
                     ToolCallStarted(
245257
                         tool_name=tool_name,
246258
                         tool_args=tool_args,
259
+                        phase=event.phase,
247260
                     )
248261
                 )
249262
 
@@ -297,7 +310,7 @@ class EventAdapter:
297310
                         )
298311
                         self._debug_log(f"  edit extracted: old={bool(old_string)} ({len(old_string) if old_string else 0} chars), new={bool(new_string)} ({len(new_string) if new_string else 0} chars), path={file_path}")
299312
                     else:
300
-                        self._debug_log(f"  edit: tool_args was empty!")
313
+                        self._debug_log("  edit: tool_args was empty!")
301314
                 elif tool_name == "write":
302315
                     # For writes, content is the new file content
303316
                     # Try multiple key names that models might use
@@ -315,13 +328,14 @@ class EventAdapter:
315328
                         )
316329
                         self._debug_log(f"  write extracted: new={bool(new_string)} ({len(new_string) if new_string else 0} chars), path={file_path}")
317330
                     else:
318
-                        self._debug_log(f"  write: tool_args was empty!")
331
+                        self._debug_log("  write: tool_args was empty!")
319332
 
320333
                 self.app.post_message(
321334
                     ToolCallCompleted(
322335
                         tool_name=tool_name,
323336
                         content=event.content,
324337
                         is_error=event.is_error,
338
+                        phase=event.phase,
325339
                         old_string=old_string,
326340
                         new_string=new_string,
327341
                         file_path=file_path,
@@ -414,3 +428,13 @@ class EventAdapter:
414428
                     content=event.content,
415429
                     rollback_plan=event.rollback_plan,
416430
                 ))
431
+
432
+            case "dod_status":
433
+                self.app.post_message(
434
+                    DefinitionOfDoneUpdated(
435
+                        content=event.content,
436
+                        dod_status=event.dod_status or "",
437
+                        pending_items_count=event.pending_items_count or 0,
438
+                        last_verification_result=event.last_verification_result,
439
+                    )
440
+                )
src/loader/ui/app.pymodified
@@ -5,13 +5,12 @@ import time
55
 from pathlib import Path
66
 
77
 from rich.markup import escape
8
-
8
+from textual import work
99
 from textual.app import App, ComposeResult
1010
 from textual.binding import Binding
1111
 from textual.containers import Container, ScrollableContainer
1212
 from textual.reactive import reactive
1313
 from textual.widgets import Footer, Input, Static
14
-from textual import work
1514
 from textual.worker import Worker, get_current_worker
1615
 
1716
 from ..agent.loop import Agent, AgentEvent
@@ -21,6 +20,7 @@ from .adapter import (
2120
     ConfidenceAssessed,
2221
     CritiquePerformed,
2322
     DecompositionCreated,
23
+    DefinitionOfDoneUpdated,
2424
     ErrorOccurred,
2525
     EventAdapter,
2626
     PlanCreated,
@@ -36,7 +36,14 @@ from .adapter import (
3636
     ToolCallStarted,
3737
     VerificationPerformed,
3838
 )
39
-from .widgets import ApprovalBar, ConfirmationModal, DiffWidget, InputArea, StatusLine, StreamingText, ToolCallWidget
39
+from .widgets import (
40
+    ApprovalBar,
41
+    DiffWidget,
42
+    InputArea,
43
+    StatusLine,
44
+    StreamingText,
45
+    ToolCallWidget,
46
+)
4047
 
4148
 
4249
 class LoaderApp(App):
@@ -174,7 +181,9 @@ class LoaderApp(App):
174181
         # Show generating status immediately (before async work starts)
175182
         self.is_generating = True
176183
         self._start_timer()
177
-        self.query_one(StatusLine).set_generating(True)
184
+        status = self.query_one(StatusLine)
185
+        status.clear_definition_of_done()
186
+        status.set_generating(True)
178187
 
179188
         # Start agent task
180189
         self.run_agent(user_input)
@@ -291,7 +300,6 @@ class LoaderApp(App):
291300
         details: str,
292301
     ) -> bool:
293302
         """Show approval bar and wait for user response."""
294
-        import threading
295303
 
296304
         # Create a future to wait on
297305
         loop = asyncio.get_event_loop()
@@ -305,7 +313,7 @@ class LoaderApp(App):
305313
             try:
306314
                 approval_bar.show_approval(tool_name, message, details)
307315
                 with open("/tmp/loader_debug.log", "a") as f:
308
-                    f.write(f"[approval] Bar shown, waiting for user input\n")
316
+                    f.write("[approval] Bar shown, waiting for user input\n")
309317
             except Exception as e:
310318
                 with open("/tmp/loader_debug.log", "a") as f:
311319
                     f.write(f"[approval] Error showing bar: {e}\n")
@@ -328,10 +336,10 @@ class LoaderApp(App):
328336
             except Exception:
329337
                 pass
330338
             return result
331
-        except asyncio.TimeoutError:
339
+        except TimeoutError:
332340
             try:
333341
                 with open("/tmp/loader_debug.log", "a") as f:
334
-                    f.write(f"[approval] Timeout waiting for user\n")
342
+                    f.write("[approval] Timeout waiting for user\n")
335343
             except Exception:
336344
                 pass
337345
             return False
@@ -348,7 +356,7 @@ class LoaderApp(App):
348356
         """Handle approval from the bar."""
349357
         try:
350358
             with open("/tmp/loader_debug.log", "a") as f:
351
-                f.write(f"[approval] Approved handler called\n")
359
+                f.write("[approval] Approved handler called\n")
352360
         except Exception:
353361
             pass
354362
         if self._pending_confirmation and not self._pending_confirmation.done():
@@ -358,7 +366,7 @@ class LoaderApp(App):
358366
         """Handle rejection from the bar."""
359367
         try:
360368
             with open("/tmp/loader_debug.log", "a") as f:
361
-                f.write(f"[approval] Rejected handler called\n")
369
+                f.write("[approval] Rejected handler called\n")
362370
         except Exception:
363371
             pass
364372
         if self._pending_confirmation and not self._pending_confirmation.done():
@@ -370,7 +378,7 @@ class LoaderApp(App):
370378
         """Handle edit request - put command in input for editing."""
371379
         try:
372380
             with open("/tmp/loader_debug.log", "a") as f:
373
-                f.write(f"[approval] Edit handler called\n")
381
+                f.write("[approval] Edit handler called\n")
374382
         except Exception:
375383
             pass
376384
         if self._pending_confirmation and not self._pending_confirmation.done():
@@ -386,6 +394,7 @@ class LoaderApp(App):
386394
     async def run_agent(self, user_input: str) -> str:
387395
         """Run the agent asynchronously."""
388396
         import asyncio
397
+
389398
         import httpx
390399
 
391400
         worker = get_current_worker()
@@ -499,7 +508,11 @@ class LoaderApp(App):
499508
 
500509
         # Create tool widget
501510
         widget = ToolCallWidget(
502
-            tool_name=message.tool_name,
511
+            tool_name=(
512
+                f"verify {message.tool_name}"
513
+                if message.phase == "verification"
514
+                else message.tool_name
515
+            ),
503516
             tool_args=message.tool_args,
504517
         )
505518
         msg_area.mount(widget)
@@ -583,6 +596,14 @@ class LoaderApp(App):
583596
         if not self._streamed_content and message.content.strip():
584597
             self._add_message(message.content)
585598
 
599
+    def on_definition_of_done_updated(self, message: DefinitionOfDoneUpdated) -> None:
600
+        """Handle definition-of-done status changes."""
601
+        self.query_one(StatusLine).update_definition_of_done(
602
+            message.dod_status,
603
+            message.pending_items_count,
604
+            message.last_verification_result,
605
+        )
606
+
586607
     def on_steering_received(self, message: SteeringReceived) -> None:
587608
         """Handle steering message being processed by agent."""
588609
         # Don't display anything - auto-steering is internal
@@ -722,6 +743,7 @@ class LoaderApp(App):
722743
         msg_area = self.query_one("#message-area", ScrollableContainer)
723744
         msg_area.remove_children()
724745
         self.agent.clear_history()
746
+        self.query_one(StatusLine).clear_definition_of_done()
725747
         self._add_message("[dim]Conversation cleared.[/dim]")
726748
 
727749
     def action_cancel(self) -> None:
src/loader/ui/status_helpers.pyadded
@@ -0,0 +1,34 @@
1
+"""Formatting helpers for user-visible runtime status."""
2
+
3
+from __future__ import annotations
4
+
5
+
6
+def format_definition_of_done_parts(
7
+    status: str,
8
+    pending_items_count: int,
9
+    last_verification_result: str,
10
+) -> list[str]:
11
+    """Format definition-of-done state for the status line."""
12
+    if not status:
13
+        return []
14
+
15
+    dod_color = {
16
+        "draft": "cyan",
17
+        "in_progress": "cyan",
18
+        "verifying": "yellow",
19
+        "fixing": "magenta",
20
+        "done": "green",
21
+        "failed": "red",
22
+    }.get(status, "white")
23
+    parts = [f"[{dod_color}]DoD: {status}[/{dod_color}]"]
24
+    parts.append(f"[dim]{pending_items_count} pending[/dim]")
25
+
26
+    if last_verification_result:
27
+        verify_color = "green" if last_verification_result == "passed" else "red"
28
+        if last_verification_result == "skipped":
29
+            verify_color = "dim"
30
+        parts.append(
31
+            f"[{verify_color}]verify {last_verification_result}[/{verify_color}]"
32
+        )
33
+
34
+    return parts
src/loader/ui/widgets/status_line.pymodified
@@ -3,6 +3,8 @@
33
 from textual.reactive import reactive
44
 from textual.widgets import Static
55
 
6
+from ..status_helpers import format_definition_of_done_parts
7
+
68
 
79
 class StatusLine(Static):
810
     """Status bar showing model info, activity, elapsed time, and tokens."""
@@ -12,6 +14,9 @@ class StatusLine(Static):
1214
     activity: reactive[str] = reactive("")
1315
     elapsed: reactive[float] = reactive(0.0)
1416
     tokens: reactive[int] = reactive(0)
17
+    dod_status: reactive[str] = reactive("")
18
+    pending_items_count: reactive[int] = reactive(0)
19
+    last_verification_result: reactive[str] = reactive("")
1520
 
1621
     def render(self) -> str:
1722
         """Render the status line."""
@@ -29,6 +34,14 @@ class StatusLine(Static):
2934
         if self.tokens > 0:
3035
             parts.append(f"[dim]{self.tokens} tokens[/dim]")
3136
 
37
+        parts.extend(
38
+            format_definition_of_done_parts(
39
+                self.dod_status,
40
+                self.pending_items_count,
41
+                self.last_verification_result,
42
+            )
43
+        )
44
+
3245
         # Model info
3346
         if self.model:
3447
             parts.append(f"[blue]{self.model}[/blue]")
@@ -51,6 +64,18 @@ class StatusLine(Static):
5164
         """React to token count changes."""
5265
         self.refresh()
5366
 
67
+    def watch_dod_status(self, dod_status: str) -> None:
68
+        """React to DoD status changes."""
69
+        self.refresh()
70
+
71
+    def watch_pending_items_count(self, pending_items_count: int) -> None:
72
+        """React to DoD pending item changes."""
73
+        self.refresh()
74
+
75
+    def watch_last_verification_result(self, last_verification_result: str) -> None:
76
+        """React to verification result changes."""
77
+        self.refresh()
78
+
5479
     def set_generating(self, is_generating: bool) -> None:
5580
         """Set generating state."""
5681
         if is_generating:
@@ -66,3 +91,20 @@ class StatusLine(Static):
6691
     def update_tokens(self, tokens: int) -> None:
6792
         """Update token count."""
6893
         self.tokens = tokens
94
+
95
+    def update_definition_of_done(
96
+        self,
97
+        status: str,
98
+        pending_items_count: int,
99
+        last_verification_result: str | None,
100
+    ) -> None:
101
+        """Update definition-of-done status."""
102
+        self.dod_status = status
103
+        self.pending_items_count = pending_items_count
104
+        self.last_verification_result = last_verification_result or ""
105
+
106
+    def clear_definition_of_done(self) -> None:
107
+        """Clear definition-of-done status."""
108
+        self.dod_status = ""
109
+        self.pending_items_count = 0
110
+        self.last_verification_result = ""
tests/test_status_surfaces.pyadded
@@ -0,0 +1,32 @@
1
+"""Tests for user-visible definition-of-done status formatting."""
2
+
3
+from loader.cli.rendering import format_dod_status
4
+from loader.runtime.events import AgentEvent
5
+from loader.ui.status_helpers import format_definition_of_done_parts
6
+
7
+
8
+def test_status_helper_formats_definition_of_done_parts() -> None:
9
+    parts = format_definition_of_done_parts("verifying", 1, "failed")
10
+
11
+    assert parts == [
12
+        "[yellow]DoD: verifying[/yellow]",
13
+        "[dim]1 pending[/dim]",
14
+        "[red]verify failed[/red]",
15
+    ]
16
+
17
+
18
+def test_status_helper_omits_definition_of_done_when_absent() -> None:
19
+    assert format_definition_of_done_parts("", 0, "") == []
20
+
21
+
22
+def test_cli_dod_status_format_includes_pending_and_verification() -> None:
23
+    event = AgentEvent(
24
+        type="dod_status",
25
+        dod_status="fixing",
26
+        pending_items_count=2,
27
+        last_verification_result="failed",
28
+    )
29
+
30
+    formatted = format_dod_status(event)
31
+
32
+    assert formatted == "DoD: fixing | 2 pending | last verify: failed"