cctally 1.53.0 → 1.54.0

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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,27 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.54.0] - 2026-06-22
9
+
10
+ ### Added
11
+ - **Dashboard conversation viewer — side-by-side session comparison.** A new **⟷ Compare with…** control in the reader head puts the conversation list into a pick-mode (a banner with Cancel/Esc; the anchor session's row greyed out) to choose a second session, then opens a side-by-side **prompt-sequence diff** of the two runs — built for comparing variations of the same or a similar task to see where they diverged and which run was cheaper / faster / cleaner. A metrics-delta strip heads the view with A→B Cost, Tokens, Prompts, Errors, Duration, and Files (an improvement arrow only on the lower-is-better cost / errors / duration), the two prompt spines are aligned by their normalized first lines with a ⚡ divergence marker on replaced regions and a hatched gap where one run has an extra prompt, and clicking any row lazily expands both runs' full prompt text inline (new `GET /api/conversation/<id>/prompts` route behind the same fail-closed transcript gate) with an "open in reader →" jump per side. The view is two-column on a wide viewport and a unified single column below ~1100px, divergence is conveyed by the ⚡ bar + ◆ markers + del/add styling (never color alone), and the comparison is shareable and cold-loadable via its `#/conversations/compare/<A>/<B>` URL with ⇄ swap / ✕ close. (#217)
12
+ - **Dashboard conversation viewer — transcript export & per-card extraction.** The reader header gains an `Export ▾` menu with four Markdown scopes — Whole transcript, Prompts only, Chat only, and Replay recipe (a numbered list of your prompts) — each offering Copy (clipboard) and Download (`.md`); the export is computed server-side over the whole assembled session (new `GET /api/conversation/<id>/export` route behind the same fail-closed transcript gate), so it's complete even for a long transcript you've only partially paged in, and truncated tool data is marked `… [truncated]` rather than shown as complete. A diff card additionally offers a `.patch` download (a real `diff --git`/`@@` unified diff — Write yields an applyable add-patch, Edit/MultiEdit are snippet-relative best-effort), and a Bash card offers a `copy full` action (`$ cmd` + stdout + stderr, with a truncation marker when clipped). (#217)
13
+ - **Dashboard conversation viewer — files-touched tab + tool-type/subagent focus filters.** The outline sidebar gains an `[Outline] [Files]` tab listing every file the session modified via an Edit/MultiEdit/Write call (in first-touch order) with a summed `+N −M` badge per path, expandable to per-touch jump rows that scroll the transcript to the change; a truncated edit with no computable stat is still listed (its badge side omitted), and `NotebookEdit`/read-only tools never appear. The focus control gains a `▾ More` menu adding three filters on the same single-select axis — Edits (Edit/MultiEdit/Write turns), Bash, and a Subagent submenu that isolates one top-level subagent thread (labelled by its agent kind, key fallback when no metadata) — that filter the reading column like the four primary modes; `v` keeps cycling only the four primary modes and returns to All from a More filter. (#217)
14
+ - **Dashboard conversation viewer — git-context diff rendering + a task-completion summary.** An injected `Injected context` block that carries an unfenced git diff (e.g. `diff --git a/CLAUDE.md b/CLAUDE.md … @@ … @@`) now renders as a real red/green unified diff — per-file path header, a `+N −M` stat, and the same syntax-highlighted hunk rows as an Edit diff — with the surrounding prose left as Markdown; detection is conservative (a real `diff --git` marker, never a bare `-`/`+` bullet), so a Markdown list is never mistaken for a diff. And when a session's main-thread to-do checklist ends fully completed, the reader header shows a green `✓ Complete · N` chip (always visible regardless of scroll) that jumps to the final checklist, plus a `✓ Session complete (N tasks)` outline landmark — both gated on the final *main-thread* snapshot being entirely done, so a subagent's own checklist never triggers them. (#217)
15
+ - **Dashboard conversation viewer — in-reader cost/token analytics.** Each assistant turn's cost footer gains a thin micro-bar whose width and intensity encode that turn's cost relative to the most-expensive turn loaded so far (the exact `$`/token text is unchanged; the bar is a relative cue with the precise cost in its tooltip); the reader header carries a cumulative-cost chip showing `$so-far / $total` plus a progress bar that tracks your scroll position, prefixed `~` when earlier pages aren't loaded yet (a lower bound) and hidden both on a costless session and until you've scrolled a turn into view (so it never flashes a transient `$0.00`); and the outline's cache-rebuild stat expands from a single count into a per-rebuild jump list — each row labelled with its turn, wasted tokens, and `~$` cost, worst-first, capped at three with a "+N more" expander, honoring the `dashboard.cache_failure_markers` opt-out. (#217)
16
+ - **Dashboard conversation viewer — per-turn bookmarks & notes.** Each turn's action row gains a ★ bookmark toggle with an inline note editor (saved on Enter/blur, cancelled on Esc); bookmarks persist in your browser's local storage only — nothing leaves the machine — and surface as ★ landmarks in the outline (labelled with your note or the turn's heading, including bookmarks on subagent turns), with a ★ chip in the outline's "Jump to" cluster and reader keys `i` / `I` to step to the next / previous bookmark and `t` to toggle a bookmark on the current turn. (#217)
17
+ - **Dashboard conversation viewer — inline PDF rendering.** A PDF attachment now offers a `view inline ▾` toggle that renders the PDF in the reading column at a capped, scrollable height (with a `collapse ▴` control); the viewer mounts — and the bytes fetch — only when expanded, is served behind the same privacy gate as every other transcript media request, and falls back to the `open ↗` link automatically in a browser without a built-in PDF viewer. (#217)
18
+
19
+ ### Changed
20
+ - **Dashboard conversation search — the Files facet now matches a file-path substring instead of a path prefix.** Searching the Files facet for a bare basename (`package.json`) or a mid-path fragment (`cctally`) now returns the sessions that touched a matching path, instead of requiring a leading path prefix; the match is a case-insensitive substring over the (modest) touched-file table. (#223)
21
+ - **Dashboard conversation find bar — regex matches are now underlined in the transcript.** In regex find mode the in-prose highlight is no longer suppressed: matches get a best-effort inline underline (per rendered text segment, case-toggle-aware), alongside the existing match count and jump-to-match. Invalid or pathological patterns degrade to no underline (the match count still drives navigation). (#223)
22
+
23
+ ### Fixed
24
+ - **Dashboard conversation viewer — the reader-header `Export ▾` and focus `▾ More` menus are now fully keyboard-navigable.** Both popovers previously exposed a `role="menu"` with no arrow-key navigation; they now implement the full menu keyboard pattern — opening moves focus to the first item, `↑`/`↓` cycle through items (wrapping), `Home`/`End` jump to the ends, and `Esc` closes and restores focus to the trigger, with a single roving tab stop so `Tab` moves past the menu rather than through every item. In the focus `▾ More` menu, `→` opens the Subagent submenu (focusing its first entry) and `←` collapses back to the parent. (#224)
25
+ - **Dashboard conversation viewer — git-context diff path parsing no longer over-captures a trailing word into the file path.** The `diff --git a/… b/…` path capture now stops at whitespace instead of greedily running to end-of-line, so a pathological marker line carrying trailing prose after the `b/` path can't bleed that text into the displayed new-path header; real injected diffs (whose paths are whitespace-free) are unaffected. (#224)
26
+ - Conversation viewer: subagents dispatched without an explicit type (the default general-purpose case, e.g. SDD implementors/reviewers) now show their kind, description, and token/duration/tool usage in the card chip and outline — previously only explicitly-typed subagents (e.g. Explore) did (#225).
27
+ - **Dashboard conversation viewer — jumping to a deeply-nested subagent no longer occasionally leaves that subagent's card collapsed.** When you jump to a target inside a subagent that was itself spawned by another subagent (a grandchild thread), the reader force-opens the whole ancestor chain; that expansion now latches each card's open-state in the same render that opens it instead of a beat later, so the brief reset that runs once the jump has scrolled into place can no longer race the parent's expansion and silently re-collapse the nested target. (#222)
28
+
8
29
  ## [1.53.0] - 2026-06-21
9
30
 
10
31
  ### Added
@@ -5483,6 +5483,15 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5483
5483
  # Live-tail SSE for the open reader (spec §2). Matched BEFORE the
5484
5484
  # <id> reader catch-all.
5485
5485
  self._handle_get_conversation_events(path)
5486
+ elif path.startswith("/api/conversation/") and path.endswith("/export"):
5487
+ # #217 S5: whole-session Markdown export (F1/F5). Matched BEFORE the
5488
+ # <id> reader catch-all (same precedence as /outline).
5489
+ self._handle_get_conversation_export(path)
5490
+ elif path.startswith("/api/conversation/") and path.endswith("/prompts"):
5491
+ # #217 S7: ordered main-thread prompt spine for session comparison
5492
+ # (F10). Matched BEFORE the <id> reader catch-all (same precedence
5493
+ # as /outline).
5494
+ self._handle_get_conversation_prompts(path)
5486
5495
  elif path.startswith("/api/conversation/"):
5487
5496
  self._handle_get_conversation_detail(path)
5488
5497
  else:
@@ -7877,6 +7886,75 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7877
7886
  return
7878
7887
  self._respond_json(200, body)
7879
7888
 
7889
+ def _handle_get_conversation_prompts(self, path: str) -> None:
7890
+ """``GET /api/conversation/<sid>/prompts`` — ordered main-thread human
7891
+ prompts + full text (#217 S7 F10, the session-comparison spine). Same
7892
+ fail-closed transcript privacy gate as ``/outline`` —
7893
+ ``_require_transcripts_allowed()`` ONLY (no ``_check_origin_csrf``: the
7894
+ sibling transcript GETs gate on this predicate alone). Unknown id → 404.
7895
+ """
7896
+ if not self._require_transcripts_allowed():
7897
+ return
7898
+ import urllib.parse as _u
7899
+ session_id = _u.unquote(path[len("/api/conversation/"):-len("/prompts")])
7900
+ if not session_id:
7901
+ self.send_error(404, "conversation not found")
7902
+ return
7903
+ ok, body = self._run_conversation_query(
7904
+ lambda conn: self._conversation_query().get_conversation_prompts(conn, session_id),
7905
+ "/api/conversation/prompts")
7906
+ if not ok:
7907
+ return
7908
+ if body is None:
7909
+ self.send_error(404, "conversation not found")
7910
+ return
7911
+ self._respond_json(200, body)
7912
+
7913
+ _CONV_EXPORT_SCOPES = ("all", "prompts", "chat", "recipe")
7914
+
7915
+ def _handle_get_conversation_export(self, path: str) -> None:
7916
+ """``GET /api/conversation/<sid>/export?scope=<all|prompts|chat|recipe>``
7917
+ — whole-session Markdown (issue #217 S5 F1/F5).
7918
+
7919
+ Same fail-closed transcript privacy gate as ``/outline`` / ``/payload``
7920
+ / ``/find`` — ``_require_transcripts_allowed()`` ONLY. **No
7921
+ ``_check_origin_csrf``** (Codex P0-1): the sibling transcript GETs gate
7922
+ on this predicate alone; ``_check_origin_csrf`` rejects a missing
7923
+ ``Origin`` and would make export STRICTER than its sibling reader routes.
7924
+
7925
+ ``scope`` is validated HERE, BEFORE the kernel (the
7926
+ ``_run_conversation_query``-collapses-kernel-exceptions-to-500 gotcha —
7927
+ an invalid scope is a clean 400, never a 500). Unknown session → 404.
7928
+ Emits ``text/markdown; charset=utf-8`` (the client builds the download
7929
+ Blob/filename, so no ``Content-Disposition`` is needed)."""
7930
+ if not self._require_transcripts_allowed():
7931
+ return
7932
+ import urllib.parse as _u
7933
+ session_id = _u.unquote(path[len("/api/conversation/"):-len("/export")])
7934
+ q = _u.parse_qs(self.path.partition("?")[2])
7935
+ scope = _qs_str(q, "scope", "all")
7936
+ if scope not in self._CONV_EXPORT_SCOPES:
7937
+ self._respond_json(400, {"error": f"unknown scope: {scope}"})
7938
+ return
7939
+ if not session_id:
7940
+ self.send_error(404, "conversation not found")
7941
+ return
7942
+ ok, body = self._run_conversation_query(
7943
+ lambda conn: self._conversation_query().get_conversation_export(
7944
+ conn, session_id, scope),
7945
+ "/api/conversation/export")
7946
+ if not ok:
7947
+ return
7948
+ if body is None:
7949
+ self.send_error(404, "conversation not found")
7950
+ return
7951
+ data = body.encode("utf-8")
7952
+ self.send_response(200)
7953
+ self.send_header("Content-Type", "text/markdown; charset=utf-8")
7954
+ self.send_header("Content-Length", str(len(data)))
7955
+ self.end_headers()
7956
+ self.wfile.write(data)
7957
+
7880
7958
  def _handle_get_conversation_find(self, path: str) -> None:
7881
7959
  """``GET /api/conversation/<sid>/find?q=...&kind=...`` — in-conversation
7882
7960
  find → document-ordered rendered-turn anchors (#177 S6). Same fail-closed
@@ -2413,14 +2413,15 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
2413
2413
  tool TEXT NOT NULL,
2414
2414
  UNIQUE(message_id, file_path, tool)
2415
2415
  );
2416
- -- COLLATE NOCASE is load-bearing (#217 S2 / I-3 review Important #1): the
2417
- -- default ``LIKE`` is case-insensitive, and SQLite only rides a btree index
2418
- -- for ``file_path LIKE 'prefix%'`` when the index column folds the same way
2419
- -- (NOCASE) a BINARY-collated index leaves the prefix probe a full SCAN. So
2420
- -- the kind=files PREFIX branch (``_search_files``) is genuinely index-assisted
2421
- -- ONLY with this NOCASE collation. (The substring branch leads with '%' and is
2422
- -- a documented scan regardless.) NOCASE folds A-Z/a-z the same way the LIKE
2423
- -- built-in does, so matching semantics are byte-identical to the prior index.
2416
+ -- COLLATE NOCASE keeps the index's case-folding identical to the default
2417
+ -- (case-insensitive) ``LIKE`` (#217 S2 / I-3 review Important #1): NOCASE folds
2418
+ -- A-Z/a-z the same way the LIKE built-in does, so matching semantics are
2419
+ -- byte-identical to a BINARY index. (#223: ``_search_files`` now substring-
2420
+ -- matches EVERY query (``file_path LIKE '%q%'``) a deliberate scan over the
2421
+ -- modest touch table — so this index no longer drives a prefix probe; it is
2422
+ -- retained as-is with no schema change. NOCASE was originally load-bearing so a
2423
+ -- ``LIKE 'prefix%'`` could ride the btree, which SQLite only does when the index
2424
+ -- column folds the same way.)
2424
2425
  CREATE INDEX IF NOT EXISTS idx_file_touches_path
2425
2426
  ON conversation_file_touches(file_path COLLATE NOCASE);
2426
2427
 
@@ -617,9 +617,12 @@ def _attach_subagent_result(blocks, obj):
617
617
  block — the unambiguous subagent-spawn result shape. Zero or >1 tool_result
618
618
  blocks: no-op (the kernel then degrades that subagent card to title-only).
619
619
  The kind (subagent_type) is captured separately on the spawn tool_use block;
620
- the kernel joins the two on tool_use_id. ``agentId`` == the subagent file's
621
- ``_subagent_key``. Meta keys are normalized to snake_case here so the kernel
622
- stays a pure pass-through (same posture as is_error / tool_use_id)."""
620
+ the kernel identifies a spawn by the tool NAME (Agent/Task), so an untyped
621
+ general-purpose spawn no subagent_type recorded still links, with the
622
+ kernel defaulting its kind to ``general-purpose`` (#225). The kernel joins
623
+ the two on tool_use_id. ``agentId`` == the subagent file's ``_subagent_key``.
624
+ Meta keys are normalized to snake_case here so the kernel stays a pure
625
+ pass-through (same posture as is_error / tool_use_id)."""
623
626
  tur = obj.get("toolUseResult")
624
627
  if not isinstance(tur, dict):
625
628
  return
@@ -0,0 +1,325 @@
1
+ """Pure, I/O-free Markdown serializer for a conversation session (issue #217 S5,
2
+ F1/F5). No DB / filesystem / locks — fed the assembled ``items`` (+
3
+ ``subagent_meta``) from ``_assemble_session`` in ``_lib_conversation_query.py``,
4
+ so the renderer stays golden-testable (the same purity contract as
5
+ ``_lib_share.py``). Golden-tested in ``tests/test_conversation_export.py``.
6
+
7
+ Public surface::
8
+
9
+ export_session_markdown(items, scope, *, subagent_meta=None,
10
+ title=None, session_id=None) -> str
11
+
12
+ ``scope`` is one of ``all`` / ``chat`` / ``prompts`` / ``recipe`` (the four
13
+ export scopes; the handler validates the value, so an unknown scope here falls
14
+ back to the ``all`` renderer). ``subagent_meta`` supplies subagent labels
15
+ (Codex P1-2); the serializer groups subagent turns by ``subagent_key`` in
16
+ document order — a deliberate simplification of the reader's full nesting tree.
17
+
18
+ Item / block shape (matches ``_assemble_session``): each item is a dict with
19
+ ``kind`` (``human``/``assistant``/``tool_result``/``meta``), ``anchor`` (with
20
+ ``uuid``), ``ts``, ``text``, ``blocks`` (list), ``subagent_key`` (``None`` for
21
+ the main session), ``is_sidechain``, plus ``meta_kind`` on meta items and
22
+ ``model`` on assistant turns. Tool blocks are ``kind == "tool_call"`` with
23
+ ``name`` / ``input`` (bounded dict) / ``input_truncated`` / ``result``
24
+ (``{text, truncated, is_error, ...}`` or ``None``) / optional ``edit_stat`` /
25
+ optional ``stderr`` (the structured Bash stderr suffix of ``result.text``).
26
+ """
27
+
28
+ # Deliberately the narrow {Edit, MultiEdit, Write} set — NOT the wider
29
+ # `_FILE_TOUCH_TOOLS` (which includes NotebookEdit). Edit-family tool calls
30
+ # render as a fenced ```diff block.
31
+ _EDIT_TOOLS = {"edit", "multiedit", "write"}
32
+ _TRUNCATED = " … [truncated]"
33
+
34
+
35
+ def export_session_markdown(items, scope, *, subagent_meta=None, title=None,
36
+ session_id=None):
37
+ """Render a whole assembled session to Markdown for one export scope."""
38
+ items = list(items or [])
39
+ subagent_meta = subagent_meta or {}
40
+ if scope == "recipe":
41
+ return _render_recipe(items, title)
42
+ if scope == "prompts":
43
+ return _render_prompts(items, title)
44
+ if scope == "chat":
45
+ return _render_chat(items, title)
46
+ return _render_all(items, subagent_meta, title) # "all" (default)
47
+
48
+
49
+ # ---- shared predicates / helpers ------------------------------------------
50
+
51
+ def _is_main(it):
52
+ """True for a main-session turn (not a subagent thread, not a sidechain)."""
53
+ return it.get("subagent_key") is None and not it.get("is_sidechain")
54
+
55
+
56
+ def _item_text(it):
57
+ return (it.get("text") or "").strip()
58
+
59
+
60
+ def _human_prompts(items):
61
+ """Main-session human turns with non-empty prose, in document order."""
62
+ return [it for it in items
63
+ if it.get("kind") == "human" and _is_main(it) and _item_text(it)]
64
+
65
+
66
+ def extract_prompt_entries(items):
67
+ """Ordered main-thread human prompts as ``[{"uuid", "text"}]``.
68
+
69
+ Single source of truth for the structured prompt spine (#217 S7) — reuses
70
+ the same main-thread predicate (``_human_prompts``) and the same prose
71
+ extraction (``_item_text``) as the recipe/prompts export, so the
72
+ ``/prompts`` route and the export can never drift. The uuid is the turn's
73
+ ``anchor`` uuid — the same identity ``get_conversation_outline`` emits — so
74
+ the spine aligns 1:1 with the outline's human turns.
75
+ """
76
+ return [{"uuid": it["anchor"]["uuid"], "text": _item_text(it)}
77
+ for it in _human_prompts(items)]
78
+
79
+
80
+ def _title_header(title):
81
+ """An H1 title block (with a trailing blank line) or empty string."""
82
+ t = (title or "").strip()
83
+ return f"# {t}\n\n" if t else ""
84
+
85
+
86
+ def _role_header(it):
87
+ kind = it.get("kind")
88
+ ts = it.get("ts") or ""
89
+ if kind == "human":
90
+ label = "## 👤 Human"
91
+ elif kind == "assistant":
92
+ label = "## 🤖 Assistant"
93
+ elif kind == "meta":
94
+ label = f"## ⚙️ {_meta_label(it)}"
95
+ else: # tool_result orphan / other
96
+ label = "## 🔧 Tool result"
97
+ return f"{label}{f' · {ts}' if ts else ''}"
98
+
99
+
100
+ def _meta_label(it):
101
+ mk = (it.get("meta_kind") or "note")
102
+ return mk.replace("_", " ").title()
103
+
104
+
105
+ def _blockquote(text):
106
+ """Render ``text`` as a Markdown blockquote (one ``> `` per line)."""
107
+ lines = (text or "").splitlines() or [""]
108
+ return "\n".join(f"> {ln}" if ln else ">" for ln in lines)
109
+
110
+
111
+ # ---- tool-call rendering ---------------------------------------------------
112
+
113
+ def _render_tool_call(b):
114
+ """Render one ``tool_call`` block to a Markdown chunk."""
115
+ name = b.get("name") or "Tool"
116
+ nm = name.lower()
117
+ inp = b.get("input") if isinstance(b.get("input"), dict) else {}
118
+ if nm in _EDIT_TOOLS:
119
+ return _render_edit_call(name, inp, b)
120
+ if nm == "bash":
121
+ return _render_bash_call(inp, b)
122
+ return _render_generic_call(name, b)
123
+
124
+
125
+ def _edit_diff_lines(name, inp):
126
+ """Reconstruct +/- diff lines for an Edit/MultiEdit/Write call from the
127
+ bounded input (best-effort — bounded leaves may be clipped)."""
128
+ nm = (name or "").lower()
129
+ lines = []
130
+ if nm == "write":
131
+ content = inp.get("content")
132
+ if isinstance(content, str):
133
+ for ln in content.splitlines():
134
+ lines.append("+" + ln)
135
+ return lines
136
+ if nm == "multiedit":
137
+ edits = inp.get("edits")
138
+ if isinstance(edits, list):
139
+ for e in edits:
140
+ e = e if isinstance(e, dict) else {}
141
+ lines.extend(_one_edit_lines(e.get("old_string"),
142
+ e.get("new_string")))
143
+ return lines
144
+ # edit
145
+ return _one_edit_lines(inp.get("old_string"), inp.get("new_string"))
146
+
147
+
148
+ def _one_edit_lines(old, new):
149
+ out = []
150
+ if isinstance(old, str):
151
+ for ln in old.splitlines():
152
+ out.append("-" + ln)
153
+ if isinstance(new, str):
154
+ for ln in new.splitlines():
155
+ out.append("+" + ln)
156
+ return out
157
+
158
+
159
+ def _render_edit_call(name, inp, b):
160
+ path = inp.get("file_path")
161
+ path = path if isinstance(path, str) and path else "(file)"
162
+ diff = "\n".join(_edit_diff_lines(name, inp))
163
+ trunc = _TRUNCATED if b.get("input_truncated") else ""
164
+ head = f"**{name}** `{path}`{trunc}"
165
+ return f"{head}\n\n```diff\n{diff}\n```"
166
+
167
+
168
+ def _render_bash_call(inp, b):
169
+ command = inp.get("command")
170
+ command = command if isinstance(command, str) else ""
171
+ res = b.get("result") if isinstance(b.get("result"), dict) else None
172
+ out_lines = [f"$ {command}"]
173
+ if res is not None:
174
+ text = res.get("text") or ""
175
+ stderr = b.get("stderr")
176
+ stdout = text
177
+ # result.text == stdout + stderr (the empirical Bash storage shape); when
178
+ # the structured stderr suffix is present, split it out for clarity.
179
+ if isinstance(stderr, str) and stderr and text.endswith(stderr):
180
+ stdout = text[: len(text) - len(stderr)]
181
+ if stdout:
182
+ out_lines.append(stdout.rstrip("\n"))
183
+ if isinstance(stderr, str) and stderr:
184
+ out_lines.append("# stderr")
185
+ out_lines.append(stderr.rstrip("\n"))
186
+ if res.get("truncated"):
187
+ out_lines.append(_TRUNCATED.strip())
188
+ body = "\n".join(out_lines)
189
+ return f"```bash\n{body}\n```"
190
+
191
+
192
+ def _render_generic_call(name, b):
193
+ parts = [f"**Tool: {name}**"]
194
+ summary = b.get("input_summary")
195
+ if isinstance(summary, str) and summary.strip() and summary.strip() != "{}":
196
+ parts.append(f"`{summary.strip()}`")
197
+ res = b.get("result") if isinstance(b.get("result"), dict) else None
198
+ if res is not None:
199
+ text = (res.get("text") or "").rstrip("\n")
200
+ trunc = _TRUNCATED if res.get("truncated") else ""
201
+ if text:
202
+ parts.append(f"```\n{text}\n```{trunc}" if not trunc
203
+ else f"```\n{text}\n```\n{trunc.strip()}")
204
+ elif trunc:
205
+ parts.append(trunc.strip())
206
+ return "\n\n".join(parts)
207
+
208
+
209
+ # ---- per-turn rendering (the `all` matrix) ---------------------------------
210
+
211
+ def _render_turn_body(it):
212
+ """The body chunks of a single turn (prose, thinking, tool calls, meta)."""
213
+ chunks = []
214
+ kind = it.get("kind")
215
+ prose = _item_text(it)
216
+
217
+ if kind == "meta":
218
+ # A meta turn renders its body as a labeled blockquote.
219
+ if prose:
220
+ chunks.append(_blockquote(prose))
221
+ return chunks
222
+
223
+ if prose:
224
+ chunks.append(prose)
225
+
226
+ for b in it.get("blocks", []):
227
+ bk = b.get("kind")
228
+ if bk == "thinking":
229
+ tx = (b.get("text") or "").strip()
230
+ if tx:
231
+ chunks.append("> 💭 Thinking\n>\n" + _blockquote(tx))
232
+ elif bk in ("tool_call", "tool_use"):
233
+ chunks.append(_render_tool_call(b))
234
+ elif bk == "tool_result":
235
+ # orphan tool_result block (rare — a result the kernel could not fold)
236
+ tx = (b.get("text") or "").rstrip("\n")
237
+ trunc = _TRUNCATED.strip() if b.get("truncated") else ""
238
+ if tx:
239
+ chunks.append(f"**Tool result**\n\n```\n{tx}\n```"
240
+ + (f"\n{trunc}" if trunc else ""))
241
+ return chunks
242
+
243
+
244
+ def _render_all(items, subagent_meta, title):
245
+ """Full-fidelity, document-order render. Main-session turns render inline;
246
+ consecutive subagent turns (same subagent_key) group under one heading."""
247
+ parts = [_title_header(title)] if _title_header(title) else []
248
+ cur_subagent = None # the subagent_key whose heading is currently open
249
+
250
+ for it in items:
251
+ sk = it.get("subagent_key")
252
+ if sk is not None or it.get("is_sidechain"):
253
+ # Subagent turn — open a grouping heading on a new key.
254
+ if sk != cur_subagent:
255
+ label = _subagent_label(sk, subagent_meta)
256
+ parts.append(f"### ⎇ Subagent: {label}")
257
+ cur_subagent = sk
258
+ else:
259
+ cur_subagent = None # back to the main thread
260
+
261
+ body = _render_turn_body(it)
262
+ block = [_role_header(it)]
263
+ block.extend(body)
264
+ parts.append("\n\n".join(p for p in block if p))
265
+
266
+ return "\n\n".join(p for p in parts if p).rstrip() + "\n"
267
+
268
+
269
+ def _subagent_label(sk, subagent_meta):
270
+ meta = subagent_meta.get(sk) if isinstance(subagent_meta, dict) else None
271
+ if isinstance(meta, dict):
272
+ kind = meta.get("kind") or meta.get("subagent_type")
273
+ if isinstance(kind, str) and kind.strip():
274
+ return kind.strip()
275
+ return sk if isinstance(sk, str) and sk else "subagent"
276
+
277
+
278
+ # ---- the `chat` scope (prose-only, main-session) --------------------------
279
+
280
+ def _render_chat(items, title):
281
+ """Human + assistant prose only — no thinking, no tools, no meta;
282
+ main-session only. Deliberately leaner than the live 'chat' focus mode
283
+ (which retains thinking) — an intentional divergence (spec §1)."""
284
+ parts = [_title_header(title)] if _title_header(title) else []
285
+ for it in items:
286
+ if it.get("kind") not in ("human", "assistant"):
287
+ continue
288
+ if not _is_main(it):
289
+ continue
290
+ prose = _item_text(it)
291
+ if not prose:
292
+ continue
293
+ parts.append(f"{_role_header(it)}\n\n{prose}")
294
+ return "\n\n".join(p for p in parts if p).rstrip() + "\n"
295
+
296
+
297
+ # ---- the `prompts` scope --------------------------------------------------
298
+
299
+ def _render_prompts(items, title):
300
+ """Main-session human turns only, each as ``## Prompt N`` + full text."""
301
+ parts = [_title_header(title)] if _title_header(title) else []
302
+ for n, it in enumerate(_human_prompts(items), start=1):
303
+ parts.append(f"## Prompt {n}\n\n{_item_text(it)}")
304
+ return "\n\n".join(p for p in parts if p).rstrip() + "\n"
305
+
306
+
307
+ # ---- the `recipe` scope (F5) ----------------------------------------------
308
+
309
+ def _render_recipe(items, title):
310
+ """A ``# Replay recipe`` header + a numbered list of main-session prompts —
311
+ the re-runnable script form."""
312
+ prompts = _human_prompts(items)
313
+ out = ["# Replay recipe"]
314
+ if title and title.strip():
315
+ out.append(f"_{title.strip()}_")
316
+ if prompts:
317
+ numbered = []
318
+ for n, it in enumerate(prompts, start=1):
319
+ # Collapse internal newlines so each prompt is one numbered step.
320
+ text = " ".join(_item_text(it).split())
321
+ numbered.append(f"{n}. {text}")
322
+ out.append("\n".join(numbered))
323
+ else:
324
+ out.append("_(no prompts)_")
325
+ return "\n\n".join(out).rstrip() + "\n"