chunksmith-cli 0.4.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 (65) hide show
  1. chunksmith_cli/__init__.py +3 -0
  2. chunksmith_cli/__main__.py +41 -0
  3. chunksmith_cli/agent/__init__.py +1 -0
  4. chunksmith_cli/agent/agent_display.py +160 -0
  5. chunksmith_cli/agent/agent_session.py +294 -0
  6. chunksmith_cli/agent/agent_stream.py +152 -0
  7. chunksmith_cli/agent/agent_wizard.py +5 -0
  8. chunksmith_cli/agent_display.py +6 -0
  9. chunksmith_cli/agent_session.py +6 -0
  10. chunksmith_cli/agent_stream.py +6 -0
  11. chunksmith_cli/agent_wizard.py +6 -0
  12. chunksmith_cli/assets/chunksmith_logo.png +0 -0
  13. chunksmith_cli/branding.py +6 -0
  14. chunksmith_cli/config.py +6 -0
  15. chunksmith_cli/core/__init__.py +21 -0
  16. chunksmith_cli/core/artifact_layout.py +60 -0
  17. chunksmith_cli/core/branding.py +137 -0
  18. chunksmith_cli/core/config.py +32 -0
  19. chunksmith_cli/core/media_preview.py +72 -0
  20. chunksmith_cli/core/menu.py +110 -0
  21. chunksmith_cli/core/menus.py +55 -0
  22. chunksmith_cli/core/panels.py +24 -0
  23. chunksmith_cli/core/paths.py +869 -0
  24. chunksmith_cli/core/prefs_mapper.py +97 -0
  25. chunksmith_cli/core/saved_catalog.py +180 -0
  26. chunksmith_cli/core/theme.py +38 -0
  27. chunksmith_cli/elements_json_prompt.py +6 -0
  28. chunksmith_cli/json_view.py +6 -0
  29. chunksmith_cli/media_preview.py +6 -0
  30. chunksmith_cli/menu.py +6 -0
  31. chunksmith_cli/menus.py +55 -0
  32. chunksmith_cli/multi_indexing_wizard.py +3 -0
  33. chunksmith_cli/outline_browser.py +6 -0
  34. chunksmith_cli/panels.py +6 -0
  35. chunksmith_cli/partition_prefs.py +74 -0
  36. chunksmith_cli/paths.py +6 -0
  37. chunksmith_cli/pdf_prompt.py +6 -0
  38. chunksmith_cli/pipelines/__init__.py +1 -0
  39. chunksmith_cli/pipelines/mapping_validation.py +31 -0
  40. chunksmith_cli/pipelines/multi_indexing_config.py +35 -0
  41. chunksmith_cli/pipelines/multi_indexing_prompts.py +375 -0
  42. chunksmith_cli/pipelines/multi_indexing_runtime.py +38 -0
  43. chunksmith_cli/pipelines/multi_indexing_storage.py +157 -0
  44. chunksmith_cli/pipelines/multi_indexing_wizard.py +218 -0
  45. chunksmith_cli/pipelines/pageindex_wizard.py +140 -0
  46. chunksmith_cli/pipelines/run_multi.py +21 -0
  47. chunksmith_cli/prompts/__init__.py +1 -0
  48. chunksmith_cli/prompts/elements_json_prompt.py +49 -0
  49. chunksmith_cli/prompts/pdf_prompt.py +37 -0
  50. chunksmith_cli/saved_catalog.py +6 -0
  51. chunksmith_cli/theme.py +6 -0
  52. chunksmith_cli/tree_view.py +6 -0
  53. chunksmith_cli/view_session.py +6 -0
  54. chunksmith_cli/views/__init__.py +1 -0
  55. chunksmith_cli/views/json_view.py +11 -0
  56. chunksmith_cli/views/outline_browser.py +247 -0
  57. chunksmith_cli/views/tree_view.py +59 -0
  58. chunksmith_cli/views/view_session.py +32 -0
  59. chunksmith_cli/wizard.py +357 -0
  60. chunksmith_cli-0.4.0.dist-info/METADATA +61 -0
  61. chunksmith_cli-0.4.0.dist-info/RECORD +65 -0
  62. chunksmith_cli-0.4.0.dist-info/WHEEL +5 -0
  63. chunksmith_cli-0.4.0.dist-info/entry_points.txt +2 -0
  64. chunksmith_cli-0.4.0.dist-info/licenses/LICENSE.vectify +21 -0
  65. chunksmith_cli-0.4.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3 @@
1
+ """Interactive CLI: ChunkSmith PDF (pageindex), multimodal (Unstructured JSON + PDF), or saved JSON view."""
2
+
3
+ __all__: list[str] = []
@@ -0,0 +1,41 @@
1
+ """ChunkSmith interactive CLI — ``python -m chunksmith_cli`` or ``chunksmith``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+
8
+ def main() -> int:
9
+ if len(sys.argv) > 1 and sys.argv[1] in ("--version", "-V"):
10
+ from chunksmith_cli.theme import VERSION
11
+
12
+ print(f"chunksmith-cli {VERSION}")
13
+ return 0
14
+
15
+ from chunksmith_cli.branding import ensure_utf8_stdio
16
+ from chunksmith_cli.theme import make_console
17
+ from chunksmith_cli.wizard import run_interactive
18
+
19
+ ensure_utf8_stdio()
20
+ console = make_console()
21
+
22
+ try:
23
+ return run_interactive(console)
24
+ except KeyboardInterrupt:
25
+ console.print("\n[dim]Interrupted. Goodbye.[/dim]")
26
+ return 130
27
+
28
+
29
+ def __version__() -> str:
30
+ from chunksmith_cli.theme import VERSION as v
31
+
32
+ return v
33
+
34
+
35
+ if __name__ == "__main__":
36
+ if len(sys.argv) > 1 and sys.argv[1] in ("--version", "-V"):
37
+ from chunksmith_cli.theme import VERSION
38
+
39
+ print(f"chunksmith-cli {VERSION}")
40
+ raise SystemExit(0)
41
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """LangChain agent over saved ChunkSmith indexes."""
@@ -0,0 +1,160 @@
1
+ """Render tables and figures from agent events in the terminal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import sys
8
+ from html import unescape
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from rich.align import Align
13
+ from rich.console import Console
14
+ from rich.markup import escape
15
+ from rich.panel import Panel
16
+
17
+ from chunksmith_cli.config import open_images_in_viewer, show_images_in_terminal, show_tables_in_terminal
18
+
19
+ try:
20
+ from rich.image import Image as RichImage
21
+ except ImportError: # pragma: no cover
22
+ RichImage = None # type: ignore[misc, assignment]
23
+
24
+
25
+ def html_table_preview(html: str, *, max_len: int = 2400) -> str:
26
+ """Plain-text preview of HTML table content for terminal display."""
27
+ text = re.sub(r"<br\s*/?>", "\n", html or "", flags=re.IGNORECASE)
28
+ text = re.sub(r"</t[rdh]>", "\t", text, flags=re.IGNORECASE)
29
+ text = re.sub(r"<[^>]+>", "", text)
30
+ text = unescape(text)
31
+ text = re.sub(r"[ \t\f\v]+", " ", text)
32
+ text = re.sub(r" *\n *", "\n", text)
33
+ text = re.sub(r"\n{3,}", "\n\n", text).strip()
34
+ if len(text) > max_len:
35
+ return text[:max_len] + "\n…"
36
+ return text or "(empty table)"
37
+
38
+
39
+ def print_table(console: Console, payload: dict[str, Any]) -> None:
40
+ nid = payload.get("node_id")
41
+ pg = payload.get("page_number")
42
+ preview = html_table_preview(str(payload.get("html") or ""))
43
+ title = f"[bold yellow]Table[/bold yellow] · node {escape(str(nid or '?'))} · page {escape(str(pg or '?'))}"
44
+ console.print(Panel(escape(preview), title=title, border_style="yellow"))
45
+
46
+
47
+ def _open_image_file(path: Path) -> bool:
48
+ """Open image in the system default viewer (Windows/macOS/Linux)."""
49
+ if not path.is_file():
50
+ return False
51
+ try:
52
+ if sys.platform == "win32":
53
+ os.startfile(str(path)) # noqa: S606
54
+ elif sys.platform == "darwin":
55
+ os.system(f'open "{path}"') # noqa: S605
56
+ else:
57
+ os.system(f'xdg-open "{path}"') # noqa: S605
58
+ return True
59
+ except OSError:
60
+ return False
61
+
62
+
63
+ def print_figure(console: Console, payload: dict[str, Any]) -> None:
64
+ path = payload.get("path")
65
+ pg = payload.get("page_number")
66
+ nid = payload.get("node_id")
67
+ caption = f"node {nid} · page {pg}"
68
+ path_str = str(path or "").strip()
69
+ file_path = Path(path_str) if path_str else None
70
+ rendered_inline = False
71
+
72
+ if file_path and file_path.is_file() and RichImage is not None:
73
+ try:
74
+ img = RichImage.from_file(str(file_path), width=min(72, max(40, console.width - 4)))
75
+ console.print(
76
+ Panel(
77
+ Align.center(img),
78
+ title=f"[bold cyan]Figure[/bold cyan] · {escape(caption)}",
79
+ border_style="cyan",
80
+ )
81
+ )
82
+ rendered_inline = True
83
+ except Exception:
84
+ rendered_inline = False
85
+
86
+ if rendered_inline:
87
+ console.print(f"[dim]{escape(path_str)}[/dim]\n")
88
+ if open_images_in_viewer():
89
+ _open_image_file(file_path)
90
+ return
91
+
92
+ hint = "[dim]Inline image preview is not supported in this terminal (common on Windows PowerShell).[/dim]\n"
93
+ if file_path and file_path.is_file():
94
+ if sys.platform == "win32":
95
+ hint += f'[dim]Open manually:[/dim] start "" {escape(path_str)}\n'
96
+ if open_images_in_viewer():
97
+ if _open_image_file(file_path):
98
+ hint += "[green]Opened in your default image viewer.[/green]\n"
99
+ elif sys.platform == "win32":
100
+ hint += (
101
+ "[dim]Tip: set[/dim] [bold]CHUNKSMITH_CLI_OPEN_IMAGES=1[/bold] "
102
+ "[dim]to auto-open figures in Photos.[/dim]\n"
103
+ )
104
+ else:
105
+ hint += "[yellow]Image file not found on disk.[/yellow]\n"
106
+
107
+ console.print(
108
+ Panel(
109
+ f"{hint}\n[bold]{escape(path_str or '(no image path)')}[/bold]",
110
+ title=f"[bold cyan]Figure file[/bold cyan] · {escape(caption)}",
111
+ border_style="cyan",
112
+ )
113
+ )
114
+ console.print()
115
+
116
+
117
+ def print_tables_summary(console: Console, count: int) -> None:
118
+ if count:
119
+ console.print(f"[dim]Tables in context:[/dim] {count}\n")
120
+
121
+
122
+ def print_figures_summary(console: Console, count: int) -> None:
123
+ if count:
124
+ console.print(f"[dim]Figures in context:[/dim] {count}\n")
125
+
126
+
127
+ def print_media_mentions(console: Console, payload: dict[str, Any]) -> None:
128
+ hint = str(payload.get("hint") or "").strip()
129
+ if hint:
130
+ console.print(Panel(escape(hint), title="[bold yellow]Media[/bold yellow]", border_style="yellow"))
131
+
132
+ figures = payload.get("figures") or []
133
+ if figures and show_images_in_terminal():
134
+ lines = [
135
+ f"• [dim]node {escape(str(f.get('node_id') or '?'))}[/dim] — {escape(str(f.get('caption') or ''))}"
136
+ for f in figures[:12]
137
+ ]
138
+ console.print(
139
+ Panel(
140
+ "\n".join(lines),
141
+ title="[bold cyan]Figures mentioned in text[/bold cyan]",
142
+ border_style="cyan",
143
+ )
144
+ )
145
+
146
+ tables = payload.get("tables") or []
147
+ if tables and show_tables_in_terminal():
148
+ lines = [
149
+ f"• [dim]node {escape(str(t.get('node_id') or '?'))}[/dim] — {escape(str(t.get('caption') or ''))}"
150
+ for t in tables[:12]
151
+ ]
152
+ console.print(
153
+ Panel(
154
+ "\n".join(lines),
155
+ title="[bold yellow]Tables mentioned in text[/bold yellow]",
156
+ border_style="yellow",
157
+ )
158
+ )
159
+ if figures or tables:
160
+ console.print()
@@ -0,0 +1,294 @@
1
+ """Agent CLI session state and actions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any, Callable
8
+
9
+ from rich.console import Console
10
+ from rich.markup import escape
11
+ from rich.prompt import Confirm, Prompt
12
+
13
+ from chunksmith_cli.agent_stream import handle_agent_event
14
+ from chunksmith_cli.menu import prompt_menu
15
+ from chunksmith_cli.menus import AGENT_MENU
16
+ from chunksmith_cli.paths import (
17
+ default_project_logs_dir,
18
+ ensure_cli_storage,
19
+ resolve_agent_index_input,
20
+ )
21
+
22
+
23
+ def _index_media_counts(doc_index: Any) -> tuple[int, int]:
24
+ from chunksmith_agent.index_context import index_media_counts
25
+
26
+ return index_media_counts(doc_index)
27
+
28
+
29
+ def _media_loaded_line(doc_index: Any) -> str:
30
+ """Counts shown on the main Loaded / Ready line."""
31
+ n_tables, n_images = _index_media_counts(doc_index)
32
+ if n_tables or n_images:
33
+ return (
34
+ f"[dim]·[/dim] [bold cyan]{n_tables}[/bold cyan] tables loaded "
35
+ f"[dim]·[/dim] [bold cyan]{n_images}[/bold cyan] figures loaded"
36
+ )
37
+ return "[dim]· 0 tables · 0 figures (text-only index)[/dim]"
38
+
39
+
40
+ def _print_index_media_hint(console: Console, doc_index: Any) -> None:
41
+ from rich.panel import Panel
42
+
43
+ from chunksmith_agent.index_context import index_media_inventory
44
+
45
+ n_tables, n_images = _index_media_counts(doc_index)
46
+ table_lines, figure_lines = index_media_inventory(doc_index)
47
+ extra_tables = max(0, n_tables - len(table_lines))
48
+ extra_figures = max(0, n_images - len(figure_lines))
49
+
50
+ if n_tables or n_images:
51
+ body_parts = [
52
+ f"[bold yellow]Tables loaded:[/bold yellow] [bold]{n_tables}[/bold]",
53
+ ]
54
+ if table_lines:
55
+ body_parts.append("\n".join(escape(line) for line in table_lines))
56
+ if extra_tables:
57
+ body_parts.append(f"[dim]… and {extra_tables} more table(s)[/dim]")
58
+ else:
59
+ body_parts.append("[dim](none)[/dim]")
60
+
61
+ body_parts.append(f"\n[bold cyan]Figures loaded:[/bold cyan] [bold]{n_images}[/bold]")
62
+ if figure_lines:
63
+ body_parts.append("\n".join(escape(line) for line in figure_lines))
64
+ if extra_figures:
65
+ body_parts.append(f"[dim]… and {extra_figures} more figure(s)[/dim]")
66
+ else:
67
+ body_parts.append("[dim](none)[/dim]")
68
+
69
+ body_parts.append(
70
+ "\n[dim]In chat, tables/figures show after search (sections in Sources, up to 3 each). "
71
+ "Windows PowerShell often shows [bold]paths only[/bold], not inline pictures — set "
72
+ "[bold]CHUNKSMITH_CLI_OPEN_IMAGES=1[/bold] to open PNGs in Photos.[/dim]"
73
+ )
74
+ console.print(
75
+ Panel(
76
+ "\n".join(body_parts),
77
+ title="[bold green]Loaded from JSON[/bold green]",
78
+ border_style="green",
79
+ )
80
+ )
81
+ console.print()
82
+ return
83
+ console.print(
84
+ Panel(
85
+ "[bold]Tables loaded:[/bold] 0\n"
86
+ "[bold]Figures loaded:[/bold] 0\n\n"
87
+ "[dim]This JSON has outline/text only. For tables and images, index with "
88
+ "load a [bold]*_canonical_bundle.json[/bold] or use [bold]chunking → Multimodal index[/bold].[/dim]",
89
+ title="[bold yellow]Text-only index[/bold yellow]",
90
+ border_style="yellow",
91
+ )
92
+ )
93
+ console.print()
94
+
95
+
96
+ @dataclass
97
+ class AgentCliState:
98
+ agent: Any | None = None
99
+ loaded_name: str | None = None
100
+
101
+ @property
102
+ def is_ready(self) -> bool:
103
+ return self.agent is not None
104
+
105
+ def status_line(self) -> str:
106
+ if not self.is_ready:
107
+ return "[dim]No document loaded yet — pick option 1 to open a saved index.[/dim]"
108
+ n = len(self.agent.index.media_by_node)
109
+ media = _media_loaded_line(self.agent.index)
110
+ return (
111
+ f"[green]Ready:[/green] [bold]{escape(self.loaded_name or 'index')}[/bold] [dim]({n} nodes)[/dim] {media}"
112
+ )
113
+
114
+
115
+ def _find_saved_indexes(storage: Path) -> list[dict[str, str]]:
116
+ rows: list[dict[str, str]] = []
117
+ seen: set[str] = set()
118
+
119
+ def _add(*, stem: str, pageindex: str, canonical: str, source: str) -> None:
120
+ if stem in seen:
121
+ return
122
+ seen.add(stem)
123
+ rows.append(
124
+ {
125
+ "stem": stem,
126
+ "pageindex": pageindex,
127
+ "canonical": canonical,
128
+ "source": source,
129
+ }
130
+ )
131
+
132
+ for p in sorted(storage.glob("*_pageindex.json"), reverse=True):
133
+ stem = p.name.replace("_pageindex.json", "")
134
+ cb = storage / f"{stem}_canonical_bundle.json"
135
+ _add(
136
+ stem=stem,
137
+ pageindex=str(p),
138
+ canonical=str(cb) if cb.is_file() else "",
139
+ source="cli/data",
140
+ )
141
+
142
+ logs = default_project_logs_dir()
143
+ if logs is not None:
144
+ json_dir = logs / "json"
145
+ if json_dir.is_dir():
146
+ for cb in sorted(json_dir.glob("*_canonical_bundle.json"), reverse=True):
147
+ stem = cb.name.replace("_canonical_bundle.json", "")
148
+ pi = json_dir / f"{stem}_pageindex.json"
149
+ _add(
150
+ stem=stem,
151
+ pageindex=str(pi) if pi.is_file() else "",
152
+ canonical=str(cb),
153
+ source="logs",
154
+ )
155
+ return rows
156
+
157
+
158
+ def _load_saved(console: Console, state: AgentCliState, storage: Path) -> None:
159
+ from chunksmith_agent import ChunkSmithAgent
160
+ from chunksmith_agent.index_builder import build_document_index_from_saved
161
+
162
+ rows = _find_saved_indexes(storage)
163
+ logs_hint = ""
164
+ logs = default_project_logs_dir()
165
+ if logs is not None:
166
+ logs_hint = f"\n [cyan]{logs}[/cyan] [dim](artifact folder → newest bundle + images)[/dim]"
167
+ console.print(
168
+ f"\n[bold]Load saved index[/bold]\n"
169
+ f"[dim]Enter a path, artifact folder, or stem. Examples:[/dim]\n"
170
+ f" [cyan]{storage / 'mydoc_20260517T120000Z_pageindex.json'}[/cyan]\n"
171
+ f" [cyan]logs[/cyan] or [cyan]logs/json/mydoc_*_canonical_bundle.json[/cyan]"
172
+ f"{logs_hint}\n"
173
+ f" [cyan]mydoc_20260517T120000Z[/cyan]\n"
174
+ f"[dim]Empty line = pick from list · quotes and ~ supported[/dim]\n"
175
+ )
176
+ if rows:
177
+ console.print("[dim]Recent indexes:[/dim]")
178
+ for i, r in enumerate(rows[:8], start=1):
179
+ tag = f" [dim]({r['source']})[/dim]" if r.get("source") else ""
180
+ console.print(f" [cyan]{i:>2}[/] {escape(r['stem'])}{tag}")
181
+
182
+ while True:
183
+ raw = Prompt.ask(
184
+ "[bold]Index path or stem[/bold]",
185
+ default="",
186
+ show_default=False,
187
+ ).strip()
188
+
189
+ if not raw and rows:
190
+ pick = Prompt.ask("[bold]Or pick number[/bold]", default="1")
191
+ try:
192
+ row = rows[int(pick) - 1]
193
+ except (ValueError, IndexError):
194
+ console.print("[red]Invalid number.[/red]")
195
+ continue
196
+ resolved_pageindex = Path(row["pageindex"])
197
+ resolved_canonical = Path(row["canonical"]) if row["canonical"] else None
198
+ label = row["stem"]
199
+ break
200
+
201
+ if not raw:
202
+ console.print("[dim]Cancelled.[/dim]")
203
+ return
204
+
205
+ resolved = resolve_agent_index_input(raw, storage=storage)
206
+ if resolved is None:
207
+ console.print(
208
+ "[yellow]Could not find a valid index at that path.[/yellow]\n"
209
+ "[dim]Use an artifact folder (``logs`` with ``json/`` + ``image/``), a "
210
+ "``*_canonical_bundle.json``, ``*_pageindex.json``, or a stem.[/dim]\n"
211
+ )
212
+ continue
213
+
214
+ resolved_pageindex = resolved.pageindex_path
215
+ resolved_canonical = resolved.canonical_bundle_path
216
+ label = resolved.label
217
+ break
218
+
219
+ try:
220
+ doc_index = build_document_index_from_saved(
221
+ pageindex_path=resolved_pageindex,
222
+ canonical_bundle_path=resolved_canonical,
223
+ artifact_root=resolved.artifact_root,
224
+ )
225
+ except Exception as e:
226
+ console.print(f"[bold red]Load failed:[/bold red] {e}")
227
+ return
228
+
229
+ state.agent = ChunkSmithAgent(doc_index)
230
+ state.agent.reset_conversation()
231
+ state.loaded_name = label
232
+ console.print(f"\n[bold green]Loaded.[/bold green] {state.status_line()}\n")
233
+ _print_index_media_hint(console, doc_index)
234
+ if Confirm.ask("[bold]Start chatting now?[/bold]", default=True):
235
+ _chat_loop(console, state)
236
+
237
+
238
+ def _chat_loop(console: Console, state: AgentCliState) -> None:
239
+ if not state.is_ready:
240
+ console.print("[yellow]Load a document first (option 1 — open saved index).[/yellow]")
241
+ return
242
+
243
+ console.print(
244
+ f"\n[dim]Chatting about[/dim] [bold]{escape(state.loaded_name or 'document')}[/bold]. "
245
+ "[dim]Type[/dim] [bold]exit[/bold] [dim]to return to the agent menu.[/dim]\n"
246
+ )
247
+ while True:
248
+ q = Prompt.ask("[bold #00b4a6]You[/]").strip()
249
+ if not q:
250
+ continue
251
+ if q.lower() in ("exit", "quit", "q", "back"):
252
+ console.print("[dim]Back to agent menu.[/dim]\n")
253
+ break
254
+ console.print()
255
+ for _ in state.agent.ask_events(
256
+ q,
257
+ event_sink=lambda n, p: handle_agent_event(console, n, p),
258
+ emit_image_events=True,
259
+ emit_table_events=True,
260
+ ):
261
+ pass
262
+ console.print()
263
+
264
+
265
+ _AGENT_ACTIONS: dict[str, Callable[[Console, AgentCliState, Path], None]] = {
266
+ "1": lambda c, s, st: _load_saved(c, s, st),
267
+ "2": lambda c, s, st: _chat_loop(c, s),
268
+ }
269
+
270
+
271
+ def run_agent_session(console: Console) -> int:
272
+ state = AgentCliState()
273
+ storage = ensure_cli_storage()
274
+
275
+ while True:
276
+ console.print()
277
+ console.print(state.status_line())
278
+ choice = prompt_menu(
279
+ console,
280
+ title="ChunkSmith Agent",
281
+ options=AGENT_MENU,
282
+ prompt="What next",
283
+ default_key="2" if state.is_ready else "1",
284
+ allow_back=True,
285
+ hint="Option 2 (chat) needs a loaded index — use option 1 first.",
286
+ )
287
+ if choice == "exit":
288
+ return 0
289
+ if choice == "back":
290
+ return 0
291
+
292
+ action = _AGENT_ACTIONS.get(choice)
293
+ if action:
294
+ action(console, state, storage)
@@ -0,0 +1,152 @@
1
+ """Render ChunkSmith agent events in the terminal (search, thinking, answer)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from rich.console import Console
8
+ from rich.markup import escape
9
+ from rich.panel import Panel
10
+ from rich.rule import Rule
11
+
12
+ from chunksmith_cli.agent_display import (
13
+ print_figure,
14
+ print_figures_summary,
15
+ print_media_mentions,
16
+ print_table,
17
+ print_tables_summary,
18
+ )
19
+ from chunksmith_cli.config import show_images_in_terminal, show_tables_in_terminal
20
+ from chunksmith_cli.theme import TEAL
21
+
22
+ _streamed_thinking = False
23
+ _thinking_header_printed = False
24
+
25
+
26
+ def _reset_agent_stream_state() -> None:
27
+ global _streamed_thinking, _thinking_header_printed
28
+ _streamed_thinking = False
29
+ _thinking_header_printed = False
30
+
31
+
32
+ def handle_agent_event(console: Console, name: str, payload: dict[str, Any]) -> None:
33
+ """Print one agent event."""
34
+ global _streamed_thinking, _thinking_header_printed
35
+
36
+ if name == "agent:connected":
37
+ _reset_agent_stream_state()
38
+ console.print(Rule("[bold]ChunkSmith Agent[/bold]", style=TEAL))
39
+ return
40
+
41
+ if name == "agent:search_start":
42
+ q = escape(str(payload.get("query", "")))
43
+ if payload.get("reused"):
44
+ hint = escape(str(payload.get("hint") or "Using the same sections as your last question."))
45
+ console.print(
46
+ Panel(
47
+ f"{q}\n\n[dim]{hint}[/dim]",
48
+ title="[bold cyan]Your question[/bold cyan]",
49
+ border_style="cyan",
50
+ )
51
+ )
52
+ return
53
+ console.print(
54
+ Panel(
55
+ q,
56
+ title="[bold cyan]Your question[/bold cyan]",
57
+ border_style="cyan",
58
+ )
59
+ )
60
+ return
61
+
62
+ if name == "agent:thinking":
63
+ if _streamed_thinking:
64
+ console.print()
65
+ _thinking_header_printed = False
66
+ return
67
+ text = str(payload.get("text") or "").strip()
68
+ phase = escape(str(payload.get("phase", "selection")))
69
+ if text:
70
+ console.print(
71
+ Panel(
72
+ escape(text),
73
+ title=f"[bold magenta]Reasoning — {phase}[/bold magenta]",
74
+ border_style="magenta",
75
+ )
76
+ )
77
+ return
78
+
79
+ if name == "agent:thinking_token":
80
+ ch = payload.get("chunk") or ""
81
+ if not ch:
82
+ return
83
+ if not _thinking_header_printed:
84
+ console.print("\n[bold magenta]Reasoning[/bold magenta]")
85
+ _thinking_header_printed = True
86
+ _streamed_thinking = True
87
+ console.print(f"[magenta]{escape(str(ch))}[/magenta]", end="", highlight=False)
88
+ return
89
+
90
+ if name == "agent:search_complete":
91
+ if payload.get("reused"):
92
+ console.print("[dim]No new section search (follow-up / similar question).[/dim]\n")
93
+ return
94
+ sources = payload.get("sources") or []
95
+ nids = payload.get("node_ids") or []
96
+ lines = [
97
+ f"• {escape(str(s))} [dim](node {escape(str(nids[i]) if i < len(nids) else '?')})[/dim]"
98
+ for i, s in enumerate(sources[:12])
99
+ ]
100
+ if len(sources) > 12:
101
+ lines.append(f"[dim]… and {len(sources) - 12} more[/dim]")
102
+ console.print(
103
+ Panel(
104
+ "\n".join(lines) if lines else "[dim]No sections matched.[/dim]",
105
+ title=f"[bold green]Sources[/bold green] · {payload.get('chunks_count', 0)} sections",
106
+ border_style="green",
107
+ )
108
+ )
109
+ console.print()
110
+ return
111
+
112
+ if name == "agent:tables_found":
113
+ if show_tables_in_terminal():
114
+ print_tables_summary(console, int(payload.get("count") or 0))
115
+ return
116
+
117
+ if name == "agent:table":
118
+ if show_tables_in_terminal():
119
+ print_table(console, payload)
120
+ return
121
+
122
+ if name == "agent:images_found":
123
+ if show_images_in_terminal():
124
+ print_figures_summary(console, int(payload.get("count") or 0))
125
+ return
126
+
127
+ if name == "agent:image":
128
+ if show_images_in_terminal():
129
+ print_figure(console, payload)
130
+ return
131
+
132
+ if name == "agent:media_mentions":
133
+ print_media_mentions(console, payload)
134
+ return
135
+
136
+ if name == "agent:response_start":
137
+ console.print(Rule("[bold]Answer[/bold]", style="blue"))
138
+ return
139
+
140
+ if name == "agent:token":
141
+ ch = payload.get("content") or ""
142
+ if ch:
143
+ console.print(str(ch), end="", highlight=False)
144
+ return
145
+
146
+ if name == "agent:complete":
147
+ console.print()
148
+ return
149
+
150
+ if name == "agent:end":
151
+ console.print(Rule(style="dim"))
152
+ return
@@ -0,0 +1,5 @@
1
+ """Backward-compatible entry; implementation lives in ``agent_session``."""
2
+
3
+ from chunksmith_cli.agent_session import run_agent_session
4
+
5
+ __all__ = ["run_agent_session"]
@@ -0,0 +1,6 @@
1
+ """Backward-compatible import path. Prefer `cli.agent.agent_display`."""
2
+
3
+ import importlib
4
+
5
+ _mod = importlib.import_module("chunksmith_cli.agent.agent_display")
6
+ globals().update({k: v for k, v in _mod.__dict__.items() if not k.startswith("__")})
@@ -0,0 +1,6 @@
1
+ """Backward-compatible import path. Prefer `cli.agent.agent_session`."""
2
+
3
+ import importlib
4
+
5
+ _mod = importlib.import_module("chunksmith_cli.agent.agent_session")
6
+ globals().update({k: v for k, v in _mod.__dict__.items() if not k.startswith("__")})
@@ -0,0 +1,6 @@
1
+ """Backward-compatible import path. Prefer `cli.agent.agent_stream`."""
2
+
3
+ import importlib
4
+
5
+ _mod = importlib.import_module("chunksmith_cli.agent.agent_stream")
6
+ globals().update({k: v for k, v in _mod.__dict__.items() if not k.startswith("__")})
@@ -0,0 +1,6 @@
1
+ """Backward-compatible import path. Prefer `cli.agent.agent_wizard`."""
2
+
3
+ import importlib
4
+
5
+ _mod = importlib.import_module("chunksmith_cli.agent.agent_wizard")
6
+ globals().update({k: v for k, v in _mod.__dict__.items() if not k.startswith("__")})