traceforge-pcb 1.0.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 (219) hide show
  1. traceforge/__init__.py +3 -0
  2. traceforge/__main__.py +3 -0
  3. traceforge/acp_server.py +570 -0
  4. traceforge/agent.py +680 -0
  5. traceforge/altium/__init__.py +6 -0
  6. traceforge/altium/cfb.py +70 -0
  7. traceforge/altium/cli.py +60 -0
  8. traceforge/altium/model.py +163 -0
  9. traceforge/altium/outputs.py +24 -0
  10. traceforge/altium/pcb.py +301 -0
  11. traceforge/altium/project.py +328 -0
  12. traceforge/altium/schematic.py +400 -0
  13. traceforge/altium/tools.py +166 -0
  14. traceforge/approval.py +219 -0
  15. traceforge/attachments.py +346 -0
  16. traceforge/background.py +253 -0
  17. traceforge/bench/__init__.py +5 -0
  18. traceforge/bench/adapters.py +337 -0
  19. traceforge/bench/checks.py +410 -0
  20. traceforge/bench/cli.py +121 -0
  21. traceforge/bench/design.py +439 -0
  22. traceforge/bench/kicad.py +99 -0
  23. traceforge/bench/partscheck.py +159 -0
  24. traceforge/bench/review.py +100 -0
  25. traceforge/bench/runner.py +226 -0
  26. traceforge/bench/score.py +204 -0
  27. traceforge/bench/tasks.py +102 -0
  28. traceforge/chat.py +1185 -0
  29. traceforge/checks.py +328 -0
  30. traceforge/checks_sides.py +118 -0
  31. traceforge/cli.py +2224 -0
  32. traceforge/clipboard.py +107 -0
  33. traceforge/config.py +299 -0
  34. traceforge/context.py +276 -0
  35. traceforge/design/__init__.py +1 -0
  36. traceforge/design/constraints.py +397 -0
  37. traceforge/design/generate.py +1789 -0
  38. traceforge/design/outline.py +905 -0
  39. traceforge/design/schematic_layout.py +932 -0
  40. traceforge/design/schematic_lint.py +329 -0
  41. traceforge/design/schematic_route.py +207 -0
  42. traceforge/design/sheets.py +854 -0
  43. traceforge/design/sides.py +328 -0
  44. traceforge/design/spec.py +462 -0
  45. traceforge/design/specsheet.py +2351 -0
  46. traceforge/design/specsheet_render.py +183 -0
  47. traceforge/desktop/__init__.py +109 -0
  48. traceforge/desktop/keys.py +90 -0
  49. traceforge/desktop/project.py +443 -0
  50. traceforge/desktop/review.py +20 -0
  51. traceforge/desktop/server.py +1277 -0
  52. traceforge/desktop/static/app.css +223 -0
  53. traceforge/desktop/static/app.js +683 -0
  54. traceforge/desktop/static/highlight.js +77 -0
  55. traceforge/desktop/static/index.html +89 -0
  56. traceforge/desktop/static/md.js +90 -0
  57. traceforge/desktop/static/viewer.js +213 -0
  58. traceforge/desktop/symbols.py +212 -0
  59. traceforge/desktop/update.py +65 -0
  60. traceforge/diagnostics.py +67 -0
  61. traceforge/errata.py +219 -0
  62. traceforge/fab.py +108 -0
  63. traceforge/fab_layers.py +44 -0
  64. traceforge/features/__init__.py +6 -0
  65. traceforge/features/candidates.py +117 -0
  66. traceforge/features/cli.py +200 -0
  67. traceforge/features/package.py +433 -0
  68. traceforge/features/revdiff.py +224 -0
  69. traceforge/features/sarif.py +145 -0
  70. traceforge/features/viewer.py +265 -0
  71. traceforge/firmware/__init__.py +3 -0
  72. traceforge/firmware/cli.py +174 -0
  73. traceforge/firmware/drivers.py +372 -0
  74. traceforge/firmware/drivers_data.py +256 -0
  75. traceforge/firmware/mcus.py +177 -0
  76. traceforge/firmware/netlist.py +55 -0
  77. traceforge/firmware/pinmap.py +296 -0
  78. traceforge/firmware/scaffold.py +466 -0
  79. traceforge/firmware/toolchain.py +262 -0
  80. traceforge/hooks.py +237 -0
  81. traceforge/kicad/__init__.py +0 -0
  82. traceforge/kicad/cli.py +519 -0
  83. traceforge/kicad/diffpair.py +174 -0
  84. traceforge/kicad/edgegeom.py +191 -0
  85. traceforge/kicad/edit.py +405 -0
  86. traceforge/kicad/escape.py +1410 -0
  87. traceforge/kicad/formats.py +1073 -0
  88. traceforge/kicad/impedance.py +320 -0
  89. traceforge/kicad/install.py +75 -0
  90. traceforge/kicad/libraries.py +490 -0
  91. traceforge/kicad/models3d.py +942 -0
  92. traceforge/kicad/outputs.py +81 -0
  93. traceforge/kicad/pcb.py +787 -0
  94. traceforge/kicad/planes.py +1242 -0
  95. traceforge/kicad/project.py +136 -0
  96. traceforge/kicad/route.py +2698 -0
  97. traceforge/kicad/routegeom.py +164 -0
  98. traceforge/kicad/schematic.py +268 -0
  99. traceforge/kicad/sides.py +34 -0
  100. traceforge/kicad/stackup.py +424 -0
  101. traceforge/kicad_plugin/__init__.py +12 -0
  102. traceforge/kicad_plugin/actions.py +92 -0
  103. traceforge/kicad_plugin/cli.py +66 -0
  104. traceforge/kicad_plugin/client.py +86 -0
  105. traceforge/kicad_plugin/context.py +257 -0
  106. traceforge/kicad_plugin/discovery.py +81 -0
  107. traceforge/kicad_plugin/install.py +203 -0
  108. traceforge/kicad_plugin/overlay.py +60 -0
  109. traceforge/kicad_plugin/payload/action_plugin.py +65 -0
  110. traceforge/kicad_plugin/payload/ipc_main.py +42 -0
  111. traceforge/kicad_plugin/payload/plugin.json +90 -0
  112. traceforge/kicad_plugin/payload/requirements.txt +1 -0
  113. traceforge/kicad_plugin/views.py +202 -0
  114. traceforge/langchain.py +57 -0
  115. traceforge/local.py +580 -0
  116. traceforge/mcp_server.py +304 -0
  117. traceforge/models.py +310 -0
  118. traceforge/next_steps.py +110 -0
  119. traceforge/parts/__init__.py +52 -0
  120. traceforge/parts/alternates.py +488 -0
  121. traceforge/parts/bom.py +423 -0
  122. traceforge/parts/brief.py +705 -0
  123. traceforge/parts/catalog.py +297 -0
  124. traceforge/parts/cli_sourcing.py +156 -0
  125. traceforge/parts/data/duty_rates.yaml +37 -0
  126. traceforge/parts/data/hs_codes.yaml +103 -0
  127. traceforge/parts/datasheet.py +278 -0
  128. traceforge/parts/fx.py +100 -0
  129. traceforge/parts/model.py +185 -0
  130. traceforge/parts/net.py +187 -0
  131. traceforge/parts/offers.py +46 -0
  132. traceforge/parts/packages.py +397 -0
  133. traceforge/parts/pinout.py +714 -0
  134. traceforge/parts/providers/__init__.py +17 -0
  135. traceforge/parts/providers/base.py +100 -0
  136. traceforge/parts/providers/jlc.py +364 -0
  137. traceforge/parts/providers/keyed.py +661 -0
  138. traceforge/parts/report.py +508 -0
  139. traceforge/parts/supply.py +330 -0
  140. traceforge/parts/verify.py +455 -0
  141. traceforge/parts/web.py +575 -0
  142. traceforge/plans.py +70 -0
  143. traceforge/preferences.py +127 -0
  144. traceforge/prices.py +130 -0
  145. traceforge/prices.yaml +28 -0
  146. traceforge/prompts/__init__.py +54 -0
  147. traceforge/prompts/system.md +103 -0
  148. traceforge/providers.py +764 -0
  149. traceforge/remote/__init__.py +33 -0
  150. traceforge/remote/client.py +422 -0
  151. traceforge/remote/oidc.py +240 -0
  152. traceforge/remote/orgs.py +510 -0
  153. traceforge/remote/server.py +690 -0
  154. traceforge/remote/store.py +100 -0
  155. traceforge/review_changes.py +187 -0
  156. traceforge/routing.py +99 -0
  157. traceforge/runtime.py +435 -0
  158. traceforge/sessions.py +324 -0
  159. traceforge/sexpr.py +279 -0
  160. traceforge/sim/__init__.py +17 -0
  161. traceforge/sim/acmath.py +234 -0
  162. traceforge/sim/circuit.py +499 -0
  163. traceforge/sim/cli.py +162 -0
  164. traceforge/sim/engine.py +172 -0
  165. traceforge/sim/ltspice.py +175 -0
  166. traceforge/sim/measure.py +123 -0
  167. traceforge/sim/models.py +76 -0
  168. traceforge/sim/project.py +218 -0
  169. traceforge/sim/rawfile.py +123 -0
  170. traceforge/sim/results.py +298 -0
  171. traceforge/sim/tune.py +172 -0
  172. traceforge/skill_commands.py +165 -0
  173. traceforge/skill_library/circuit-simulation/SKILL.md +30 -0
  174. traceforge/skill_library/compliance-review/SKILL.md +51 -0
  175. traceforge/skill_library/design-review/SKILL.md +47 -0
  176. traceforge/skill_library/errata-hunt/SKILL.md +30 -0
  177. traceforge/skill_library/firmware/SKILL.md +26 -0
  178. traceforge/skill_library/power-budget/SKILL.md +27 -0
  179. traceforge/skill_library/release-package/SKILL.md +29 -0
  180. traceforge/skill_library/sourcing-brief/SKILL.md +24 -0
  181. traceforge/skill_library/thermal-review/SKILL.md +15 -0
  182. traceforge/skills.py +131 -0
  183. traceforge/thermal.py +557 -0
  184. traceforge/tools/__init__.py +48 -0
  185. traceforge/tools/agent_tools.py +213 -0
  186. traceforge/tools/base.py +303 -0
  187. traceforge/tools/batch.py +155 -0
  188. traceforge/tools/connections.py +245 -0
  189. traceforge/tools/files.py +777 -0
  190. traceforge/tools/firmware_tools.py +218 -0
  191. traceforge/tools/kicad_tools.py +1072 -0
  192. traceforge/tools/models3d_tools.py +141 -0
  193. traceforge/tools/parts_tools.py +473 -0
  194. traceforge/tools/processes.py +246 -0
  195. traceforge/tools/review_tools.py +178 -0
  196. traceforge/tools/sim_tools.py +230 -0
  197. traceforge/tui.py +620 -0
  198. traceforge/updates.py +124 -0
  199. traceforge/validate/__init__.py +77 -0
  200. traceforge/validate/builtins.py +1091 -0
  201. traceforge/validate/circuits.py +1240 -0
  202. traceforge/validate/config.py +410 -0
  203. traceforge/validate/default.yaml +113 -0
  204. traceforge/validate/design.py +191 -0
  205. traceforge/validate/explain.py +112 -0
  206. traceforge/validate/grounding.py +169 -0
  207. traceforge/validate/ipc2221.py +350 -0
  208. traceforge/validate/multilayer.py +805 -0
  209. traceforge/validate/netmodel.py +261 -0
  210. traceforge/validate/partcheck.py +472 -0
  211. traceforge/validate/report.py +280 -0
  212. traceforge/validate/review.py +322 -0
  213. traceforge/validate/runner.py +662 -0
  214. traceforge/workspace.py +171 -0
  215. traceforge_pcb-1.0.0.dist-info/METADATA +463 -0
  216. traceforge_pcb-1.0.0.dist-info/RECORD +219 -0
  217. traceforge_pcb-1.0.0.dist-info/WHEEL +4 -0
  218. traceforge_pcb-1.0.0.dist-info/entry_points.txt +2 -0
  219. traceforge_pcb-1.0.0.dist-info/licenses/LICENSE +21 -0
traceforge/tui.py ADDED
@@ -0,0 +1,620 @@
1
+ """The full-screen terminal UI: a Textual front end for :class:`traceforge.chat.ChatSession`.
2
+
3
+ Everything the session does (slash commands, turns, approvals) lives in
4
+ :mod:`traceforge.chat`; this module only draws it. Needs the ``tui`` extra
5
+ (``pip install "traceforge-pcb[tui]"``).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from rich.markdown import Markdown
16
+ from rich.markup import escape
17
+ from textual import events, on
18
+ from textual.app import App, ComposeResult
19
+ from textual.binding import Binding
20
+ from textual.containers import Horizontal, Vertical, VerticalScroll
21
+ from textual.screen import ModalScreen
22
+ from textual.suggester import SuggestFromList
23
+ from textual.widgets import Button, Collapsible, Input, Label, OptionList, Static
24
+
25
+ from traceforge import __version__
26
+ from traceforge.agent import AgentEvent
27
+ from traceforge.approval import ApprovalAnswer, ApprovalRequest
28
+ from traceforge.config import Settings
29
+
30
+ _TODO_MARKS = {"completed": "[green]✓[/]", "in_progress": "[yellow]▸[/]", "pending": "·"}
31
+
32
+
33
+ def _brief(arguments: dict[str, Any], limit: int = 70) -> str:
34
+ for key in ("path", "command", "query", "reference", "description"):
35
+ if isinstance(arguments.get(key), str):
36
+ value = arguments[key].splitlines()[0] if arguments[key] else ""
37
+ return value[:limit]
38
+ text = json.dumps(arguments, default=str)
39
+ return text if len(text) <= limit else text[: limit - 1] + "…"
40
+
41
+
42
+ # ------------------------------------------------------------------ dialogs
43
+
44
+
45
+ class ApprovalScreen(ModalScreen[ApprovalAnswer]):
46
+ """Yes / always this tool / switch to auto / no (with an optional reason)."""
47
+
48
+ BINDINGS = [
49
+ Binding("y", "answer('yes')", "yes"),
50
+ Binding("a", "answer('tool')", "always"),
51
+ Binding("escape", "answer('no')", "no"),
52
+ ]
53
+
54
+ def __init__(self, request: ApprovalRequest):
55
+ super().__init__()
56
+ self.request = request
57
+
58
+ def compose(self) -> ComposeResult:
59
+ r = self.request
60
+ with Vertical(id="dialog"):
61
+ danger = " [red](dangerous)[/]" if r.dangerous else ""
62
+ yield Label(f"[bold]Allow {escape(r.tool)}?[/]{danger}")
63
+ yield Static(escape(r.summary or _brief(r.arguments, 400)), id="summary")
64
+ yield Input(placeholder="reason, if you say no (optional)", id="reason")
65
+ with Horizontal(id="buttons"):
66
+ yield Button("Yes", id="yes", variant="success")
67
+ yield Button(f"Always {r.tool}", id="tool")
68
+ yield Button("Switch to auto", id="auto", variant="warning")
69
+ yield Button("No", id="no", variant="error")
70
+
71
+ def on_mount(self) -> None:
72
+ self.query_one("#yes", Button).focus()
73
+
74
+ def action_answer(self, choice: str) -> None:
75
+ if isinstance(self.focused, Input) and choice != "no":
76
+ return
77
+ self._answer(choice)
78
+
79
+ def _answer(self, choice: str) -> None:
80
+ if choice == "no":
81
+ reason = self.query_one("#reason", Input).value.strip()
82
+ self.dismiss(ApprovalAnswer(False, "once", reason))
83
+ else:
84
+ self.dismiss(ApprovalAnswer(True, "once" if choice == "yes" else choice))
85
+
86
+ @on(Button.Pressed)
87
+ def _pressed(self, event: Button.Pressed) -> None:
88
+ self._answer(event.button.id or "no")
89
+
90
+ @on(Input.Submitted, "#reason")
91
+ def _reason(self) -> None:
92
+ self._answer("no")
93
+
94
+
95
+ class ChoiceScreen(ModalScreen[str | None]):
96
+ """Pick one value from a list; escape picks nothing."""
97
+
98
+ BINDINGS = [Binding("escape", "dismiss_none", "cancel")]
99
+
100
+ def __init__(self, title: str, options: list[tuple[str, str]]):
101
+ super().__init__()
102
+ self.title_text, self.options = title, options
103
+
104
+ def compose(self) -> ComposeResult:
105
+ with Vertical(id="dialog"):
106
+ yield Label(f"[bold]{escape(self.title_text)}[/]")
107
+ yield OptionList(*[label for _value, label in self.options], id="choices")
108
+
109
+ def on_mount(self) -> None:
110
+ self.query_one(OptionList).focus()
111
+
112
+ def action_dismiss_none(self) -> None:
113
+ self.dismiss(None)
114
+
115
+ @on(OptionList.OptionSelected)
116
+ def _selected(self, event: OptionList.OptionSelected) -> None:
117
+ self.dismiss(self.options[event.option_index][0])
118
+
119
+
120
+ class TextScreen(ModalScreen[str]):
121
+ """A free-text answer to the agent's question."""
122
+
123
+ def __init__(self, question: str):
124
+ super().__init__()
125
+ self.question = question
126
+
127
+ def compose(self) -> ComposeResult:
128
+ with Vertical(id="dialog"):
129
+ yield Label(f"[bold]{escape(self.question)}[/]")
130
+ yield Input(id="answer")
131
+
132
+ def on_mount(self) -> None:
133
+ self.query_one(Input).focus()
134
+
135
+ @on(Input.Submitted)
136
+ def _submitted(self, event: Input.Submitted) -> None:
137
+ self.dismiss(event.value)
138
+
139
+
140
+ IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
141
+ MENTION_SUFFIXES = IMAGE_SUFFIXES | {
142
+ ".kicad_sch",
143
+ ".kicad_pcb",
144
+ ".kicad_pro",
145
+ ".md",
146
+ ".pdf",
147
+ ".c",
148
+ ".h",
149
+ ".py",
150
+ ".csv",
151
+ }
152
+
153
+
154
+ def mention_token(value: str) -> str | None:
155
+ """The ``@word`` being typed at the end of the line, without the ``@``."""
156
+ word = value.rsplit(None, 1)[-1] if value.strip() and not value.endswith(" ") else ""
157
+ return word[1:] if word.startswith("@") else None
158
+
159
+
160
+ def mention_matches(pool: list[str], prefix: str, limit: int = 8) -> list[str]:
161
+ """Candidates for ``@prefix``: reference designators first, then files."""
162
+ needle = prefix.lower()
163
+ starts = [c for c in pool if c.lower().startswith(needle)]
164
+ inside = [c for c in pool if needle in c.lower() and c not in starts]
165
+ return (starts + inside)[:limit]
166
+
167
+
168
+ class PromptInput(Input):
169
+ """The prompt line: tab completes a slash command or an @mention; digits pick a chip."""
170
+
171
+ BINDINGS = [
172
+ Binding("tab", "complete", "complete", show=False, priority=True),
173
+ Binding("ctrl+g", "paste_image", "paste image", show=False),
174
+ ]
175
+
176
+ def action_complete(self) -> None:
177
+ app = self.app
178
+ if isinstance(app, TraceforgeApp) and app.accept_mention():
179
+ return
180
+ suggestion = getattr(self, "_suggestion", "")
181
+ if suggestion and suggestion != self.value:
182
+ self.value = suggestion + " "
183
+ self.cursor_position = len(self.value)
184
+
185
+ def action_paste_image(self) -> None:
186
+ if isinstance(self.app, TraceforgeApp):
187
+ self.app.send("/paste", echo=False)
188
+
189
+ def on_paste(self, event: events.Paste) -> None:
190
+ """Pasting the path of an image attaches it instead of typing the path."""
191
+ raw = event.text.strip().strip("'\"")
192
+ if raw and "\n" not in raw and Path(raw).suffix.lower() in IMAGE_SUFFIXES:
193
+ if Path(raw).expanduser().is_file() and isinstance(self.app, TraceforgeApp):
194
+ event.prevent_default()
195
+ event.stop()
196
+ self.app.send(f"/attach {raw}", echo=False)
197
+
198
+ async def on_key(self, event: events.Key) -> None:
199
+ app = self.app
200
+ if (
201
+ isinstance(app, TraceforgeApp)
202
+ and not self.value
203
+ and event.character
204
+ and event.character.isdigit()
205
+ and app.session.suggestions
206
+ and 1 <= int(event.character) <= len(app.session.suggestions)
207
+ ):
208
+ event.prevent_default()
209
+ event.stop()
210
+ app.send(app.session.suggestions[int(event.character) - 1])
211
+
212
+
213
+ # ----------------------------------------------------------------- the UI
214
+
215
+
216
+ class TuiUI:
217
+ """Implements :class:`traceforge.chat.ChatUI` by drawing into the app."""
218
+
219
+ def __init__(self, app: TraceforgeApp):
220
+ self.app = app
221
+
222
+ async def on_event(self, event: AgentEvent) -> None:
223
+ self.app.handle_event(event)
224
+
225
+ async def _modal(self, screen: ModalScreen) -> Any:
226
+ future: asyncio.Future = asyncio.get_running_loop().create_future()
227
+
228
+ def done(result: Any) -> None:
229
+ if not future.done():
230
+ future.set_result(result)
231
+
232
+ self.app.end_stream()
233
+ await self.app.push_screen(screen, done)
234
+ return await future
235
+
236
+ async def approve(self, request: ApprovalRequest) -> bool | ApprovalAnswer:
237
+ return await self._modal(ApprovalScreen(request))
238
+
239
+ async def ask(self, question: str, options: list[str]) -> str:
240
+ if options:
241
+ answer = await self._modal(ChoiceScreen(question, [(o, o) for o in options]))
242
+ return answer or ""
243
+ return await self._modal(TextScreen(question))
244
+
245
+ async def choose(self, title: str, options: list[tuple[str, str]]) -> str | None:
246
+ return await self._modal(ChoiceScreen(title, options))
247
+
248
+ async def confirm(self, question: str) -> bool:
249
+ return await self._modal(ChoiceScreen(question, [("yes", "Yes"), ("no", "No")])) == "yes"
250
+
251
+ def show(self, text: str, *, markdown: bool = False) -> None:
252
+ self.app.write(Markdown(text) if markdown else f"[dim]{escape(text)}[/]", "note")
253
+
254
+ def error(self, text: str) -> None:
255
+ self.app.write(f"[red]{escape(text)}[/]", "error")
256
+
257
+ async def clear(self) -> None:
258
+ self.app.end_stream()
259
+ await self.app.query_one("#log").remove_children()
260
+
261
+ def prefill(self, text: str) -> None:
262
+ prompt = self.app.query_one("#prompt", Input)
263
+ prompt.value = text
264
+ prompt.cursor_position = len(text)
265
+ prompt.focus()
266
+
267
+ def suggest(self, steps: list[str]) -> None:
268
+ self.app.show_chips(steps)
269
+
270
+ def side_panel(self, question: str, answer: str) -> None:
271
+ panel = self.app.write(Markdown(answer), "side")
272
+ panel.border_title = f"side question: {question[:60]}"
273
+
274
+ async def set_theme(self, name: str) -> None:
275
+ from traceforge import preferences
276
+
277
+ names = sorted(self.app.available_themes)
278
+ if not name:
279
+ name = await self._modal(ChoiceScreen("Theme", [(n, n) for n in names])) or ""
280
+ if not name:
281
+ return
282
+ if name not in names:
283
+ self.error(f"unknown theme {name!r}; choose from {', '.join(names)}")
284
+ return
285
+ self.app.theme = name
286
+ preferences.update_preferences(tui_theme=name)
287
+ self.show(f"theme {name}")
288
+
289
+ async def replay(self, thread) -> None:
290
+ self.show(f"resumed {thread.id}: {thread.title} ({len(thread.messages)} messages)")
291
+ for message in reversed(thread.messages):
292
+ if message.get("role") != "assistant":
293
+ continue
294
+ texts = [
295
+ b.get("text", "")
296
+ for b in message.get("content", [])
297
+ if isinstance(b, dict) and b.get("type") == "text"
298
+ ]
299
+ if any(texts):
300
+ self.app.write(Markdown("\n".join(texts)), "assistant")
301
+ break
302
+
303
+
304
+ class TraceforgeApp(App):
305
+ TITLE = "traceforge"
306
+ CSS = """
307
+ #log { height: 1fr; padding: 0 1; }
308
+ #log > Static { margin: 0 0 1 0; }
309
+ .user { color: $accent; text-style: bold; }
310
+ .error { color: $error; }
311
+ Collapsible { margin: 0 0 1 0; }
312
+ #status { height: 1; background: $panel; color: $text-muted; padding: 0 1; }
313
+ #prompt { dock: bottom; }
314
+ ModalScreen { align: center middle; }
315
+ #dialog { width: 80%; max-width: 100; height: auto; border: thick $accent;
316
+ background: $surface; padding: 1 2; }
317
+ #dialog OptionList { height: auto; max-height: 16; }
318
+ #buttons { height: auto; margin-top: 1; }
319
+ #buttons Button { margin-right: 1; }
320
+ #summary { margin: 1 0; }
321
+ .side { border: round $secondary; padding: 0 1; }
322
+ #chips { height: auto; max-height: 6; }
323
+ #chips Button { margin-right: 1; min-width: 6; }
324
+ #mentions { height: auto; max-height: 9; display: none; border: round $accent; }
325
+ """
326
+ BINDINGS = [
327
+ Binding("escape", "interrupt", "interrupt"),
328
+ Binding("ctrl+c", "interrupt_or_quit", "quit", priority=True),
329
+ Binding("ctrl+l", "clear_screen", "clear"),
330
+ Binding("ctrl+t", "threads", "threads"),
331
+ Binding("ctrl+b", "side_question", "side question"),
332
+ Binding("f1", "help", "help"),
333
+ ]
334
+
335
+ def __init__(
336
+ self,
337
+ workspace: Path,
338
+ settings: Settings,
339
+ *,
340
+ mode: str = "agent",
341
+ resume: str | None = None,
342
+ message: str | None = None,
343
+ client: Any = None,
344
+ store: Any = None,
345
+ ):
346
+ super().__init__()
347
+ from traceforge.chat import ChatSession
348
+
349
+ self.ui = TuiUI(self)
350
+ self.session = ChatSession(
351
+ workspace, settings, self.ui, mode=mode, client=client, store=store
352
+ )
353
+ self.resume_id, self.first_message = resume, message
354
+ self._stream: Static | None = None
355
+ self._buffer = ""
356
+ self._tools: dict[str, Collapsible] = {}
357
+ self.mention_pool: list[str] = []
358
+
359
+ # ------------------------------------------------------------- layout
360
+
361
+ def compose(self) -> ComposeResult:
362
+ yield VerticalScroll(id="log")
363
+ yield Horizontal(id="chips")
364
+ yield OptionList(id="mentions")
365
+ yield Static(id="status")
366
+ suggestions = [name for name, _summary in self.session.completions()]
367
+ yield PromptInput(
368
+ placeholder="Type a request, or / for commands",
369
+ suggester=SuggestFromList(suggestions, case_sensitive=False),
370
+ id="prompt",
371
+ )
372
+
373
+ async def on_mount(self) -> None:
374
+ s = self.session
375
+ self.write(
376
+ f"[bold]traceforge[/] {__version__} · {escape(str(s.workspace))}\n"
377
+ "[dim]Enter sends · / for commands (tab completes) · esc interrupts · ctrl+c quits[/]",
378
+ "note",
379
+ )
380
+ from traceforge import preferences
381
+
382
+ saved = preferences.get("tui_theme")
383
+ if saved in self.available_themes:
384
+ self.theme = saved
385
+ self.refresh_status()
386
+ self.query_one("#prompt", Input).focus()
387
+ self.query_one("#chips").display = False
388
+ self.run_worker(self._load_mentions(), exit_on_error=False, group="mentions")
389
+ if self.resume_id or self.first_message:
390
+ self.run_worker(self._startup(), exit_on_error=False, group="turn")
391
+
392
+ async def _startup(self) -> None:
393
+ if self.resume_id:
394
+ try:
395
+ await self.session.resume(self.resume_id)
396
+ except LookupError as exc:
397
+ self.ui.error(str(exc))
398
+ if self.first_message:
399
+ await self._submit(self.first_message)
400
+
401
+ # --------------------------------------------------------------- output
402
+
403
+ def write(self, renderable: Any, classes: str = "") -> Static:
404
+ self.end_stream()
405
+ widget = Static(renderable, classes=classes)
406
+ widget.source = getattr(renderable, "markup", renderable) # plain text, for tests
407
+ log = self.query_one("#log", VerticalScroll)
408
+ log.mount(widget)
409
+ log.scroll_end(animate=False)
410
+ return widget
411
+
412
+ def end_stream(self) -> None:
413
+ self._stream, self._buffer = None, ""
414
+
415
+ def handle_event(self, event: AgentEvent) -> None:
416
+ kind, data = event.kind, event.data
417
+ log = self.query_one("#log", VerticalScroll)
418
+ if kind == "text":
419
+ self._buffer += str(data)
420
+ if self._stream is None:
421
+ self._stream = Static(classes="assistant")
422
+ log.mount(self._stream)
423
+ self._stream.update(Markdown(self._buffer))
424
+ self._stream.source = self._buffer
425
+ log.scroll_end(animate=False)
426
+ elif kind == "tool_call":
427
+ self.end_stream()
428
+ arguments = data.get("input") or {}
429
+ result_box = Static("")
430
+ card = Collapsible(
431
+ Static(escape(json.dumps(arguments, indent=2, default=str)[:4000])),
432
+ result_box,
433
+ title=f"⚙ {data.get('name')} {_brief(arguments)}",
434
+ collapsed=True,
435
+ )
436
+ card.result_box = result_box # type: ignore[attr-defined]
437
+ self._tools[str(data.get("id"))] = card
438
+ log.mount(card)
439
+ log.scroll_end(animate=False)
440
+ elif kind == "tool_result":
441
+ card = self._tools.get(str(data.get("id")))
442
+ content = str(data.get("content") or "")
443
+ mark = "[red]✗[/]" if data.get("is_error") else "[green]✓[/]"
444
+ if card is None:
445
+ self.write(f"{mark} {escape(str(data.get('name')))}")
446
+ return
447
+ card.title = f"{'✗' if data.get('is_error') else '✓'} {card.title[2:]}"
448
+ card.result_box.update( # type: ignore[attr-defined]
449
+ f"{mark} " + escape(content[:4000] + ("…" if len(content) > 4000 else ""))
450
+ )
451
+ elif kind == "todos":
452
+ rows = [
453
+ f"{_TODO_MARKS.get(t.get('status'), '·')} {escape(str(t.get('content')))}"
454
+ for t in data or []
455
+ ]
456
+ self.write("[bold]todos[/]\n" + "\n".join(rows), "note")
457
+ elif kind == "usage":
458
+ self.refresh_status()
459
+ elif kind in {"status", "notice"}:
460
+ message = data.get("message") if isinstance(data, dict) else data
461
+ self.write(f"[dim]{escape(str(message))}[/]", "note")
462
+ elif kind == "error":
463
+ self.ui.error(str(data))
464
+ elif kind == "done":
465
+ self.end_stream()
466
+ self.refresh_status()
467
+
468
+ def status_text(self) -> str:
469
+ from traceforge import models, routing
470
+
471
+ s = self.session
472
+ route = routing.route(s.settings, routing.role_for_mode(s.mode))
473
+ info = s.status()
474
+ tokens = s.usage.input_tokens + s.usage.output_tokens + s.usage.cache_read_input_tokens
475
+ busy = " · [yellow]working…[/]" if s.busy else ""
476
+ branch = s.thread.meta.get("branch_name") if s.thread else None
477
+ branch = f" · branch {escape(str(branch))}" if branch else ""
478
+ undo = f" · {len(s.undo_stack)} undoable" if s.undo_stack else ""
479
+ return (
480
+ f"{s.mode} · {escape(models.label(route.model))} · effort {route.effort} · "
481
+ f"approvals {s.settings.approval} · {tokens:,} tokens · "
482
+ f"context {info['context_percent']}%{branch}{undo}{busy}"
483
+ )
484
+
485
+ def refresh_status(self) -> None:
486
+ self.query_one("#status", Static).update(self.status_text())
487
+
488
+ # ---------------------------------------------------------------- input
489
+
490
+ @on(Input.Submitted, "#prompt")
491
+ def _prompt(self, event: Input.Submitted) -> None:
492
+ text = event.value.strip()
493
+ event.input.value = ""
494
+ if text:
495
+ self.send(text)
496
+
497
+ def send(self, text: str, *, echo: bool = True) -> None:
498
+ """Run one line of input (a prompt, a command, or a chosen next step)."""
499
+ if self.session.busy:
500
+ self.ui.error("a turn is running; press esc to interrupt it")
501
+ return
502
+ if text.isdigit() and self.session.suggestions:
503
+ number = int(text)
504
+ if 1 <= number <= len(self.session.suggestions):
505
+ text = self.session.suggestions[number - 1]
506
+ self.query_one("#chips").display = False
507
+ if echo:
508
+ self.write(f"› {escape(text)}", "user")
509
+ self.run_worker(self._submit(text), exit_on_error=False, group="turn")
510
+
511
+ def show_chips(self, steps: list[str]) -> None:
512
+ chips = self.query_one("#chips", Horizontal)
513
+ chips.remove_children()
514
+ chips.mount(*[Button(f"{n} {s}", id=f"chip{n}") for n, s in enumerate(steps, 1)])
515
+ chips.display = True
516
+
517
+ @on(Button.Pressed, "#chips Button")
518
+ def _chip(self, event: Button.Pressed) -> None:
519
+ number = int((event.button.id or "chip0")[4:])
520
+ if 0 < number <= len(self.session.suggestions):
521
+ self.send(self.session.suggestions[number - 1])
522
+
523
+ # ------------------------------------------------------------ @ mentions
524
+
525
+ async def _load_mentions(self) -> None:
526
+ """Reference designators from the project, then its files, for @ completion."""
527
+ import os
528
+
529
+ def scan() -> list[str]:
530
+ pool: list[str] = []
531
+ try:
532
+ from traceforge.desktop.project import components
533
+
534
+ pool += sorted({str(c["ref"]) for c in components(self.session.workspace)})
535
+ except Exception: # noqa: BLE001 - completion must never break the UI
536
+ pass
537
+ skip = {".git", ".traceforge", "node_modules", "__pycache__", ".venv", "venv"}
538
+ root = self.session.workspace
539
+ for folder, dirs, files in os.walk(root):
540
+ dirs[:] = [d for d in dirs if d not in skip]
541
+ for name in files:
542
+ if Path(name).suffix.lower() in MENTION_SUFFIXES:
543
+ pool.append((Path(folder) / name).relative_to(root).as_posix())
544
+ if len(pool) > 3000:
545
+ break
546
+ return pool
547
+
548
+ self.mention_pool = await asyncio.to_thread(scan)
549
+
550
+ @on(Input.Changed, "#prompt")
551
+ def _changed(self, event: Input.Changed) -> None:
552
+ options = self.query_one("#mentions", OptionList)
553
+ prefix = mention_token(event.value)
554
+ found = mention_matches(self.mention_pool, prefix) if prefix is not None else []
555
+ options.clear_options()
556
+ if found:
557
+ options.add_options(found)
558
+ options.highlighted = 0
559
+ options.display = bool(found)
560
+
561
+ def accept_mention(self) -> bool:
562
+ options = self.query_one("#mentions", OptionList)
563
+ if not options.display or options.highlighted is None:
564
+ return False
565
+ choice = str(options.get_option_at_index(options.highlighted).prompt)
566
+ prompt = self.query_one("#prompt", Input)
567
+ head = prompt.value.rsplit("@", 1)[0]
568
+ prompt.value = f"{head}@{choice} "
569
+ prompt.cursor_position = len(prompt.value)
570
+ options.display = False
571
+ return True
572
+
573
+ async def _submit(self, text: str) -> None:
574
+ task = asyncio.ensure_future(self.session.submit(text))
575
+ self.set_timer(0.05, self.refresh_status)
576
+ try:
577
+ await task
578
+ except asyncio.CancelledError:
579
+ pass
580
+ except Exception as exc: # noqa: BLE001 - keep the UI alive
581
+ self.ui.error(f"{type(exc).__name__}: {exc}")
582
+ self.end_stream()
583
+ self.refresh_status()
584
+ if self.session.exit_requested:
585
+ await self.session.processes.kill_all()
586
+ self.exit(0)
587
+
588
+ async def action_clear_screen(self) -> None:
589
+ self.send("/clear", echo=False)
590
+
591
+ def action_threads(self) -> None:
592
+ self.send("/resume", echo=False)
593
+
594
+ def action_side_question(self) -> None:
595
+ self.ui.prefill("/btw ")
596
+
597
+ def action_help(self) -> None:
598
+ self.send("/help", echo=False)
599
+
600
+ async def action_interrupt(self) -> None:
601
+ if await self.session.cancel():
602
+ self.write("[yellow]interrupted[/]", "note")
603
+
604
+ async def action_interrupt_or_quit(self) -> None:
605
+ if not await self.session.cancel():
606
+ await self.session.processes.kill_all()
607
+ self.exit(0)
608
+
609
+
610
+ def run_tui(
611
+ workspace: Path,
612
+ settings: Settings,
613
+ *,
614
+ mode: str = "agent",
615
+ resume: str | None = None,
616
+ message: str | None = None,
617
+ ) -> int:
618
+ app = TraceforgeApp(workspace, settings, mode=mode, resume=resume, message=message)
619
+ result = app.run()
620
+ return result if isinstance(result, int) else 0