seedcode-cli 6.2.5__py3-none-any.whl → 7.1.0__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.
Files changed (42) hide show
  1. seedcode/__init__.py +5 -2
  2. seedcode/_default_key_template.py +1 -1
  3. seedcode/app.py +484 -48
  4. seedcode/codemode.py +48 -8
  5. seedcode/commands/__init__.py +19 -1
  6. seedcode/commands/about.py +10 -1
  7. seedcode/commands/agent.py +2 -1
  8. seedcode/commands/assist.py +2 -1
  9. seedcode/commands/chat.py +3 -3
  10. seedcode/commands/codemode.py +10 -1
  11. seedcode/commands/desktop.py +2 -1
  12. seedcode/commands/mode.py +3 -1
  13. seedcode/commands/session.py +137 -0
  14. seedcode/commands/status.py +47 -4
  15. seedcode/computer/browser.py +15 -2
  16. seedcode/computer/browser_engine.py +23 -3
  17. seedcode/computer/controller.py +16 -3
  18. seedcode/computer/lifecycle_guard.py +173 -0
  19. seedcode/computer/windows.py +13 -1
  20. seedcode/core/agent.py +239 -19
  21. seedcode/core/models.py +37 -1
  22. seedcode/core/session.py +967 -0
  23. seedcode/core/tasks.py +902 -0
  24. seedcode/defaults.py +8 -4
  25. seedcode/tools/base.py +11 -1
  26. seedcode/tools/filesystem.py +13 -3
  27. seedcode/tools/terminal.py +4 -1
  28. seedcode/tools/textio.py +10 -2
  29. seedcode/ui/__init__.py +100 -18
  30. seedcode/ui/codemode_header.py +348 -0
  31. seedcode/ui/dashboard.py +326 -68
  32. seedcode/ui/layout.py +39 -0
  33. seedcode/ui/renderer.py +23 -4
  34. seedcode/ui/session_view.py +280 -0
  35. seedcode/ui/tasks.py +698 -0
  36. seedcode/utils/text.py +107 -0
  37. {seedcode_cli-6.2.5.dist-info → seedcode_cli-7.1.0.dist-info}/METADATA +242 -46
  38. {seedcode_cli-6.2.5.dist-info → seedcode_cli-7.1.0.dist-info}/RECORD +41 -34
  39. seedcode/assets/logo.txt +0 -15
  40. {seedcode_cli-6.2.5.dist-info → seedcode_cli-7.1.0.dist-info}/WHEEL +0 -0
  41. {seedcode_cli-6.2.5.dist-info → seedcode_cli-7.1.0.dist-info}/entry_points.txt +0 -0
  42. {seedcode_cli-6.2.5.dist-info → seedcode_cli-7.1.0.dist-info}/licenses/LICENSE +0 -0
seedcode/__init__.py CHANGED
@@ -6,9 +6,12 @@ Plant ideas. Grow code.
6
6
  # Single source of truth for the application version. Every consumer reads it:
7
7
  # the PyPI build (via [tool.hatch.version]), the CLI --version flag, the
8
8
  # Windows installer metadata, the GitHub release tag, and the WinGet manifest.
9
- __version__ = "6.2.5"
10
- __author__ = "Al shahriar sowan"
9
+ __version__ = "7.1.0"
10
+ __author__ = "Al Shahriar Sowan"
11
11
  __publisher__ = "Eagox Studio"
12
+ # Current author/company links, surfaced by /about and the README credits.
13
+ __portfolio_url__ = "https://alshahriarsayon.vercel.app/"
14
+ __studio_url__ = "https://eagoxstudio.vercel.app/"
12
15
 
13
16
  APP_NAME = "Seed Code"
14
17
  TAGLINE = "Plant ideas. Grow code."
@@ -1,4 +1,4 @@
1
- """Embedded built-in credential for the Default provider (v6.2.5 artifact).
1
+ """Embedded built-in credential for the Default provider (v7.1.0 artifact).
2
2
 
3
3
  This file is NOT committed to source control. The release build generates
4
4
  ``seedcode/_default_key.py`` from the local ``.env`` (see
seedcode/app.py CHANGED
@@ -30,7 +30,7 @@ from .commands.theme import pick_theme
30
30
  from .config import load_config
31
31
  from .core.agent import AgentEngine, strip_tool_blocks
32
32
  from .core.chat import ChatEngine, ChatError
33
- from .core.lifecycle import lifecycle
33
+ from .core.lifecycle import LifecycleError, lifecycle
34
34
  from .core.models import AppConfig
35
35
  from .core.providers import PROVIDERS, provider_label, provider_ready
36
36
  from .core.providers.freemodel import AUTO_MODEL
@@ -40,6 +40,7 @@ from .ui import UI
40
40
  from .ui.badges import badge_for_status
41
41
  from .ui.menu import MenuItem, run_menu
42
42
  from .ui.reference import CONTROLS_HINT, INPUT_HINT
43
+ from .ui.tasks import TaskFlow, TaskState
43
44
  from .ui.textbox import prompt_label
44
45
  from .ui.theme import pt_style, set_active_theme
45
46
  from .utils.logger import get_logger
@@ -84,7 +85,17 @@ def _release_desktop_resources() -> None:
84
85
  Cleanup clears caches and closes driver connections only. It must never
85
86
  terminate SeedCode — historically a finally-block that "cleaned up" by
86
87
  exiting was one of the auto-exit paths.
88
+
89
+ v7.1.0 additionally guarantees the other direction: cleanup never closes a
90
+ user-facing application. Applications Seed Code opened stay open, which is
91
+ why this hook releases only internal resources (sockets, drivers, caches).
87
92
  """
93
+ try:
94
+ from .computer import lifecycle_guard
95
+
96
+ lifecycle_guard.release_internal() # logs what is deliberately left open
97
+ except Exception:
98
+ pass
88
99
  try:
89
100
  from .computer.browser_cdp import reset as _cdp_reset
90
101
 
@@ -256,56 +267,469 @@ def _handle_chat(ui: UI, engine: ChatEngine, history: HistoryStore, text: str) -
256
267
  ui.dim("(no response)")
257
268
 
258
269
 
259
- def _handle_agent(ui: UI, agent: AgentEngine, history: HistoryStore, text: str) -> None:
260
- """Run one full Assist turn (tool loop) and render the final answer.
270
+ class _TaskPresenter:
271
+ """Bridges engine activity to the live step view (and plain narration).
261
272
 
262
- Lifecycle note: every outcome — success, tool failure, provider error,
263
- Ctrl+C — ends with a rendered message and a return. Nothing here may
264
- terminate the process; the surrounding ``task_span`` guarantees the
265
- lifecycle returns to IDLE and the REPL keeps prompting.
273
+ One presenter per chat session; ``flow`` is set for the duration of a turn
274
+ so the engine's callbacks land in that turn's task view. Everything here is
275
+ best-effort: the presenter can never fail a task.
276
+ """
277
+
278
+ # Live command output is echoed compactly; the full output still goes to
279
+ # the model and the log file, and errors are never hidden.
280
+ MAX_ECHO_LINES = 8
281
+
282
+ def __init__(self, ui: UI) -> None:
283
+ self.ui = ui
284
+ self.flow: TaskFlow | None = None
285
+ self._echo_lines = 0
286
+ self._echo_elided = False
287
+
288
+ # --- one command's live output ------------------------------------------
289
+ def reset_echo(self) -> None:
290
+ self._echo_lines = 0
291
+ self._echo_elided = False
292
+
293
+ def on_output(self, line: str) -> None:
294
+ """Compact live echo: the first few lines, then a single elision note."""
295
+ if self._echo_lines < self.MAX_ECHO_LINES:
296
+ self.ui.dim(f" │ {line[:200]}")
297
+ elif not self._echo_elided:
298
+ self._echo_elided = True
299
+ self.ui.dim(
300
+ f" │ … output continues (first {self.MAX_ECHO_LINES} lines shown; "
301
+ "the agent and the log file get it all)"
302
+ )
303
+ self._echo_lines += 1
304
+
305
+ # --- narration (human-readable) -----------------------------------------
306
+ def on_event(self, kind: str, detail: str) -> None:
307
+ flow = self.flow
308
+ if kind == "say" and flow is not None:
309
+ flow.observe_text(detail)
310
+ return
311
+ if kind == "error":
312
+ self.ui.dim(f" ✖ {detail.splitlines()[0][:120]}")
313
+ elif kind == "limit":
314
+ self.ui.warning(f"Assist stopped: {detail}")
315
+ elif kind == "call" and flow is None:
316
+ # With a task view on screen the step list already shows the tool
317
+ # work; without one, narrate it (the plain/legacy experience).
318
+ self.ui.dim(f" ⚒ {detail}")
319
+
320
+ # --- structured activity -> step states ---------------------------------
321
+ def on_step(self, payload: dict) -> None:
322
+ flow = self.flow
323
+ if flow is None:
324
+ return
325
+ phase = str(payload.get("phase") or "")
326
+ name = str(payload.get("name") or "")
327
+ args = payload.get("args") or {}
328
+ if phase == "tool_start":
329
+ self.reset_echo()
330
+ flow.observe_tool_start(name, args)
331
+ elif phase == "tool_done":
332
+ flow.observe_tool_done(
333
+ name,
334
+ bool(payload.get("ok")),
335
+ str(payload.get("output") or ""),
336
+ args,
337
+ )
338
+ elif phase == "say":
339
+ flow.observe_text(str(payload.get("text") or ""))
340
+
341
+
342
+ # --- persistent session events (Code Mode) ------------------------------
343
+ def on_session_event(self, kind: str, detail: str) -> None:
344
+ """Render compact progress for the persistent session (v7.1.0).
345
+
346
+ Only what the user needs: the plan, the task that just finished, the
347
+ live action, a repair in progress, and the outcome. Raw model output is
348
+ never dumped here.
349
+ """
350
+ from .core import session as session_mod
351
+
352
+ flow = self.flow
353
+ session = session_mod.current_session()
354
+ if kind == "plan":
355
+ self.ui.dim(" planning the work…")
356
+ return
357
+ if kind == "plan_ready":
358
+ self.ui.dim(f" plan: {detail}")
359
+ elif kind == "task_start":
360
+ active = None
361
+ if flow is not None and session is not None:
362
+ flow.attach_plan(session.graph)
363
+ active = session.graph.active() or session.graph.next_task()
364
+ if active is not None:
365
+ flow.set_progress(active.id, session.graph.total, active.title)
366
+ # The live action line reports the real phase (v7.1.0).
367
+ flow.set_activity(f"Working on: {active.title}")
368
+ self.ui.dim(f" → {detail}")
369
+ elif kind == "verify":
370
+ # A real phase, not decoration: the session is checking acceptance
371
+ # criteria against observed evidence right now.
372
+ if flow is not None:
373
+ flow.set_activity("Verifying acceptance criteria")
374
+ elif kind == "task_done":
375
+ if flow is not None and session is not None:
376
+ # Refresh the checklist so the finished task shows as done.
377
+ flow.attach_plan(session.graph)
378
+ self.ui.success(detail)
379
+ elif kind == "recovery":
380
+ if flow is not None:
381
+ flow.set_activity("Diagnosing the failure and repairing")
382
+ self.ui.warning(detail)
383
+ elif kind == "task_blocked":
384
+ self.ui.warning(detail)
385
+ elif kind == "task_failed":
386
+ self.ui.error(detail)
387
+ elif kind == "final_verify":
388
+ self.ui.dim(" final verification…")
389
+ if flow is not None:
390
+ flow.set_activity("Verifying the project (tests / build)")
391
+ elif kind in ("retry", "continue"):
392
+ self.ui.dim(f" {detail}")
393
+ if flow is not None and session is not None:
394
+ flow.note_call(session.model_calls)
395
+ state = {
396
+ "session_paused": "paused",
397
+ "session_cancelled": "cancelled",
398
+ "session_failed": "failed",
399
+ "session_completed": "completed",
400
+ }.get(kind)
401
+ if flow is not None and state is not None:
402
+ if session is not None:
403
+ flow.attach_plan(session.graph) # final, real plan state
404
+ flow.set_state(state)
405
+
406
+
407
+ def _advance(lc, method: str) -> None:
408
+ """Best-effort lifecycle transition around one turn.
409
+
410
+ The REPL owns the turn span (``PLANNING`` → … → ``IDLE``); this lets the
411
+ turn *narrate* where it is without ever turning a phase mismatch into a
412
+ task failure — a lifecycle bookkeeping problem must not stop the work.
413
+ """
414
+ try:
415
+ getattr(lc, method)()
416
+ except LifecycleError:
417
+ _log.debug("lifecycle transition %s skipped", method)
418
+
419
+
420
+ def _codemode_enabled() -> bool:
421
+ """Whether Code Mode is active (the persistent-session path)."""
422
+ try:
423
+ from . import codemode_state as _cms
424
+
425
+ state = _cms.codemode_state()
426
+ return bool(state.enabled and state.workspace is not None)
427
+ except Exception:
428
+ return False
429
+
430
+
431
+ def resume_codemode_session(ui: UI, config: AppConfig, history=None) -> bool:
432
+ """Continue a paused/checkpointed Code Mode session (used by /resume).
433
+
434
+ Returns whether a session was actually resumed. The agent is rebuilt for
435
+ the current config (exactly as a fresh turn would), so a resume after a
436
+ provider or permission change still runs with the live settings.
266
437
  """
438
+ from .core import session as session_mod
439
+ from .core.session import CodeSession
440
+
441
+ workspace, store = _codemode_workspace()
442
+ if store is None:
443
+ return False
444
+ agent = _make_agent(ui, config, _TaskPresenter(ui))
445
+ presenter = agent_presenter(agent)
446
+ session = CodeSession.resume(
447
+ agent,
448
+ store=store,
449
+ workspace=workspace,
450
+ on_event=presenter.on_session_event if presenter is not None else None,
451
+ )
452
+ if session is None:
453
+ return False
454
+ session.control.clear()
455
+ _handle_codemode(
456
+ ui,
457
+ agent,
458
+ history if history is not None else HistoryStore(provider_id=config.provider),
459
+ session.request,
460
+ presenter,
461
+ session=session,
462
+ )
463
+ session_mod.set_current_session(None)
464
+ return True
465
+
466
+
467
+ def agent_presenter(agent: AgentEngine) -> _TaskPresenter | None:
468
+ """The presenter an engine was built with (used by /resume)."""
469
+ presenter = getattr(agent, "presenter", None)
470
+ return presenter if isinstance(presenter, _TaskPresenter) else None
471
+
472
+
473
+ def _task_mode_label(config: AppConfig) -> str:
474
+ """The mode named in a task header (Code Mode sharpens Assist Mode)."""
475
+ try:
476
+ from .codemode_state import codemode_state
477
+
478
+ if codemode_state().enabled:
479
+ return "Code Mode"
480
+ except Exception:
481
+ pass
482
+ return "Assist Mode"
483
+
484
+
485
+ def _save_codemode_summary(agent: AgentEngine, text: str, outcome: str) -> None:
486
+ """v6.2.0: Code Mode sessions leave a compact summary in .seedcode/sessions."""
487
+ try:
488
+ from . import codemode_state as _cms
489
+
490
+ state = _cms.codemode_state()
491
+ if state.enabled and state.store is not None:
492
+ state.store.save_session_summary({
493
+ "goal": text[:200],
494
+ "outcome": (outcome or "(no response)")[:400],
495
+ "tool_calls": sum(1 for m in agent.messages if m.role == "tool"),
496
+ })
497
+ except Exception:
498
+ pass
499
+
500
+
501
+ def _codemode_workspace():
502
+ """The active Code Mode workspace + store, or (CWD, None) when unavailable."""
503
+ try:
504
+ from . import codemode_state as _cms
505
+
506
+ state = _cms.codemode_state()
507
+ if state.enabled and state.workspace is not None:
508
+ return state.workspace, state.store
509
+ except Exception:
510
+ _log.exception("could not read the Code Mode workspace")
511
+ from pathlib import Path
512
+
513
+ return Path.cwd(), None
514
+
515
+
516
+ def _handle_codemode(
517
+ ui: UI,
518
+ agent: AgentEngine,
519
+ history: HistoryStore,
520
+ text: str,
521
+ presenter: _TaskPresenter | None = None,
522
+ *,
523
+ session=None,
524
+ ) -> None:
525
+ """Run one persistent Code Mode session: plan → tasks → verification.
526
+
527
+ Unlike a single agent turn, this keeps working until every planned task is
528
+ verified (or the session is paused, cancelled, or blocked), then runs final
529
+ verification. The task view shows the plan and real progress, and the CLI
530
+ is ready for the next task in every outcome.
531
+ """
532
+ from .core import session as session_mod
533
+ from .core.session import CodeSession, SessionStatus
534
+
267
535
  lc = lifecycle()
536
+ workspace, store = _codemode_workspace()
537
+ flow = TaskFlow.for_ui(ui, mode_label="Code Mode", task=text)
538
+ if presenter is not None:
539
+ presenter.flow = flow
540
+ if flow is not None:
541
+ flow.begin()
542
+ flow.start()
543
+
544
+ if session is None:
545
+ session = CodeSession(
546
+ agent,
547
+ workspace=workspace,
548
+ request=text,
549
+ store=store,
550
+ on_event=presenter.on_session_event if presenter is not None else None,
551
+ )
552
+ elif presenter is not None:
553
+ session.set_observer(presenter.on_session_event)
554
+
555
+ session_mod.set_current_session(session)
556
+ status = SessionStatus.IDLE
557
+ try:
558
+ _advance(lc, "to_executing")
559
+ status = session.run()
560
+ _advance(lc, "to_verifying")
561
+ except KeyboardInterrupt:
562
+ session_mod.stop(session)
563
+ status = SessionStatus.CANCELLED
564
+ session.reason = "cancelled by the user"
565
+ except Exception as exc: # an engine bug must not kill the session either
566
+ status = SessionStatus.FAILED
567
+ session.reason = f"{type(exc).__name__}: {exc}"
568
+ _log.exception("code mode session failed")
569
+ finally:
570
+ if presenter is not None:
571
+ presenter.flow = None
572
+
573
+ if session.store is not None and status is not SessionStatus.PAUSED:
574
+ _save_codemode_summary(agent, text, session.reason or status.value)
575
+
576
+ if status is SessionStatus.COMPLETED:
577
+ _advance(lc, "to_responding")
578
+ history.save(agent.transcript)
579
+ _report_session_summary(ui, session)
580
+ if flow is not None:
581
+ flow.finish("completed")
582
+ return
583
+
584
+ if status is SessionStatus.PAUSED:
585
+ # State is preserved and checkpointed: /resume continues this session.
586
+ if flow is not None:
587
+ flow.set_state("paused")
588
+ flow.stop()
589
+ ui.warning("Session paused — state saved. Send /resume to continue.")
590
+ session_mod.set_current_session(session)
591
+ return
592
+
593
+ session_mod.set_current_session(None)
594
+ if status is SessionStatus.CANCELLED:
595
+ ui.blank()
596
+ # v7.1.0: state is checkpointed on the way out, so say so — the session
597
+ # is resumable rather than lost (the terminal itself stays open).
598
+ ui.dim(
599
+ "(session stopped — the plan, completed work and the project files "
600
+ "were kept; /resume continues from the checkpoint)"
601
+ )
602
+ if flow is not None:
603
+ flow.finish("cancelled")
604
+ return
605
+ reason = session.reason or "the session could not finish the plan"
606
+ ui.error(reason)
607
+ if flow is not None:
608
+ flow.finish("failed", reason)
268
609
 
610
+
611
+ def _report_session_summary(ui: UI, session) -> None:
612
+ """The final block: the evidence, in one compact summary (v7.1.0).
613
+
614
+ Completion is evidence-gated, so the closing block reports the evidence
615
+ itself rather than a restatement of the model's replies: the verification
616
+ result, the tests that actually ran, the commands that actually succeeded,
617
+ and the files the engine really touched. Lines that have nothing to report
618
+ are omitted instead of being filled in with a placeholder.
619
+ """
620
+ graph = session.graph
621
+ done, total = graph.progress()
622
+ ui.success(f"Project completed — {done}/{total} tasks verified")
623
+ evidence = session.session_evidence
624
+ if evidence.tests:
625
+ # What matters is the final state: the last test run passed.
626
+ passed = evidence.tests[-1].ok
627
+ ui.dim(f" ✓ Verification: {'accepted' if passed else 'tests NOT passing'}")
628
+ ui.dim(f" ✓ Tests: {'passed' if passed else 'FAILED'}")
629
+ else:
630
+ ui.dim(" ✓ Verification: accepted (no test run in this project)")
631
+ if session.state.changed_files:
632
+ ui.dim(f" ✓ Files: {len(session.state.changed_files)} affected")
633
+ commands = evidence.commands
634
+ if commands:
635
+ ok = sum(1 for record in commands if record.ok)
636
+ ui.dim(f" ✓ Commands: {ok}/{len(commands)} ok")
637
+ if session.state.inspected:
638
+ ui.dim(f" • Inspected: {len(session.state.inspected)} item(s)")
639
+
640
+
641
+ def _handle_agent(
642
+ ui: UI,
643
+ agent: AgentEngine,
644
+ history: HistoryStore,
645
+ text: str,
646
+ presenter: _TaskPresenter | None = None,
647
+ ) -> None:
648
+ """Run one full Assist/Code turn (tool loop) and render the final answer.
649
+
650
+ The turn is shown as a compact live task flow whose step states follow real
651
+ engine activity (see :mod:`seedcode.ui.tasks`) — never a fake progress bar.
652
+
653
+ Lifecycle note: every outcome — success, tool failure, provider error,
654
+ Ctrl+C — ends by printing a status, finishing the task view and RETURNING.
655
+ Nothing here may terminate the process; the surrounding ``task_span``
656
+ guarantees the lifecycle returns to IDLE and the REPL keeps prompting, and
657
+ the explicit task-flow finish line always says the CLI is ready for more.
658
+ """
659
+ if _codemode_enabled():
660
+ _handle_codemode(ui, agent, history, text, presenter)
661
+ return
662
+
663
+ lc = lifecycle()
664
+ flow = TaskFlow.for_ui(
665
+ ui, mode_label=_task_mode_label(agent.config), task=text
666
+ )
667
+ if presenter is not None:
668
+ presenter.flow = flow
669
+ if flow is not None:
670
+ flow.begin()
671
+ flow.start()
672
+
673
+ outcome = "completed"
674
+ reason = ""
675
+ reply: str | None = None
269
676
  try:
270
- with ui.thinking("Working"):
271
- lc.to_executing()
677
+ if flow is None: # the task view already shows that work is happening
678
+ with ui.thinking("Working"):
679
+ _advance(lc, "to_executing")
680
+ reply = agent.run_turn(text)
681
+ else:
682
+ _advance(lc, "to_executing")
272
683
  reply = agent.run_turn(text)
273
- lc.to_verifying()
684
+ _advance(lc, "to_verifying")
274
685
  except ChatError as exc:
275
- ui.error(str(exc))
276
- return
686
+ outcome, reason = "failed", str(exc)
277
687
  except KeyboardInterrupt:
278
- # Ctrl+C aborts the remaining Assist steps; work already done stays.
279
- ui.blank()
280
- ui.dim("(assist turn cancelled — completed tool actions were kept)")
688
+ # Ctrl+C aborts the remaining steps; work already done stays.
689
+ outcome = "cancelled"
690
+ except Exception as exc: # an engine bug must not kill the session either
691
+ outcome, reason = "failed", f"{type(exc).__name__}: {exc}"
692
+ _log.exception("assist turn failed")
693
+ finally:
694
+ if presenter is not None:
695
+ presenter.flow = None
696
+
697
+ if outcome != "completed":
698
+ if reason:
699
+ ui.error(reason)
700
+ else:
701
+ ui.blank()
702
+ ui.dim("(task cancelled — completed tool actions were kept)")
703
+ _save_codemode_summary(agent, text, reason or "task cancelled")
704
+ if flow is not None:
705
+ flow.finish(outcome, reason)
281
706
  return
282
707
 
283
- lc.to_responding()
284
- final = strip_tool_blocks(reply)
708
+ _advance(lc, "to_responding")
709
+ if flow is not None:
710
+ flow.update("verify", TaskState.RUNNING, "checking the result")
711
+ # Hand the screen back before streaming the answer: one live display at
712
+ # a time, and the answer deserves the full width.
713
+ flow.stop()
714
+ final = strip_tool_blocks(reply or "")
285
715
  if final.strip():
286
716
  with ui.streaming() as renderer:
287
717
  renderer.feed(final)
288
718
  else:
289
719
  ui.dim("(no response)")
720
+ if flow is not None:
721
+ flow.update("verify", TaskState.COMPLETED, "answer produced")
290
722
  history.save(agent.transcript)
291
- # v6.2.0: Code Mode sessions leave a compact summary in .seedcode/sessions.
292
- try:
293
- from . import codemode_state as _cms
294
-
295
- state = _cms.codemode_state()
296
- if state.enabled and state.store is not None:
297
- state.store.save_session_summary({
298
- "goal": text[:200],
299
- "outcome": (final.strip() or "(no response)")[:400],
300
- "tool_calls": sum(
301
- 1 for m in agent.messages if m.role == "tool"
302
- ),
303
- })
304
- except Exception:
305
- pass
723
+ _save_codemode_summary(agent, text, final.strip() or "(no response)")
724
+ if flow is not None:
725
+ # Prints the persistent final block and leaves the prompt ready for the
726
+ # next task — the terminal is never closed from here.
727
+ flow.finish("completed")
306
728
 
307
729
 
308
- def _make_agent(ui: UI, config: AppConfig) -> AgentEngine:
730
+ def _make_agent(
731
+ ui: UI, config: AppConfig, presenter: "_TaskPresenter | None" = None
732
+ ) -> AgentEngine:
309
733
  """Build an Assist engine bound to the CWD and the configured permissions."""
310
734
  # Lazy import (matching _make_desktop_session): the optional, platform-
311
735
  # specific computer package stays out of app.py's top-level import graph.
@@ -318,18 +742,20 @@ def _make_agent(ui: UI, config: AppConfig) -> AgentEngine:
318
742
  if permissions.level.allows_desktop and is_available()[0]:
319
743
  permissions.desktop = _make_desktop_session(ui)
320
744
  permissions.gate = _make_action_gate(ui)
321
- # Live terminal output: echo each line a running command prints.
322
- permissions.on_output = lambda line: ui.dim(f" │ {line[:200]}")
323
-
324
- def narrate(kind: str, detail: str) -> None:
325
- if kind == "call":
326
- ui.dim(f" ⚒ {detail}")
327
- elif kind == "error":
328
- ui.dim(f" ✖ {detail.splitlines()[0][:120]}")
329
- elif kind == "limit":
330
- ui.warning(f"Assist stopped: {detail}")
331
745
 
332
- return AgentEngine(config, permissions, on_event=narrate)
746
+ presenter = presenter or _TaskPresenter(ui)
747
+ # Live terminal output: a compact echo of what a running command prints.
748
+ permissions.on_output = presenter.on_output
749
+
750
+ engine = AgentEngine(
751
+ config,
752
+ permissions,
753
+ on_event=presenter.on_event,
754
+ on_step=presenter.on_step,
755
+ )
756
+ # Attached so /resume can continue a paused session with the same view.
757
+ engine.presenter = presenter # type: ignore[attr-defined]
758
+ return engine
333
759
 
334
760
 
335
761
  def _make_action_gate(ui: UI):
@@ -402,14 +828,22 @@ def _chat_loop(
402
828
  history: HistoryStore,
403
829
  session: PromptSession | _PlainSession,
404
830
  ) -> None:
405
- """Interactive chat until /exit (returns to the main menu)."""
831
+ """Interactive chat until /exit (returns to the main menu).
832
+
833
+ Only an explicit exit leaves here: a finished task (Code Mode, Assist
834
+ Mode, Agent Mode or a plain chat turn) returns to the prompt with
835
+ ``Ready for next task.`` and the session keeps running.
836
+ """
406
837
  ctx = CommandContext(ui=ui, config=config, engine=engine)
407
838
  ui.dim(INPUT_HINT)
408
839
 
409
840
  # The Assist engine is built lazily on the first assist-mode turn and
410
841
  # rebuilt when the permission or desktop mode changes (its system
411
- # prompt and permission gates reflect both).
842
+ # prompt and permission gates reflect both). The presenter that owns the
843
+ # live step view lives for the whole chat session, so each turn can bind
844
+ # its own task flow to the same engine callbacks.
412
845
  agent: AgentEngine | None = None
846
+ presenter = _TaskPresenter(ui)
413
847
  agent_perm = config.permission_mode
414
848
  # v6.2.0: track Code Mode so a /codemode toggle rebuilds the agent with
415
849
  # workspace context (the engine is constructed lazily per turn).
@@ -481,10 +915,12 @@ def _chat_loop(
481
915
  or agent_perm != config.permission_mode
482
916
  or agent_codemode != codemode_now
483
917
  ):
484
- agent = _make_agent(ui, config)
918
+ agent = _make_agent(ui, config, presenter)
485
919
  agent_perm = config.permission_mode
486
920
  agent_codemode = codemode_now
487
- _handle_agent(ui, agent, history, text)
921
+ # One task flow per turn: the engine reports real activity into
922
+ # it, and the REPL keeps prompting when the turn ends.
923
+ _handle_agent(ui, agent, history, text, presenter)
488
924
  else:
489
925
  _handle_chat(ui, engine, history, text)
490
926