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.
- traceforge/__init__.py +3 -0
- traceforge/__main__.py +3 -0
- traceforge/acp_server.py +570 -0
- traceforge/agent.py +680 -0
- traceforge/altium/__init__.py +6 -0
- traceforge/altium/cfb.py +70 -0
- traceforge/altium/cli.py +60 -0
- traceforge/altium/model.py +163 -0
- traceforge/altium/outputs.py +24 -0
- traceforge/altium/pcb.py +301 -0
- traceforge/altium/project.py +328 -0
- traceforge/altium/schematic.py +400 -0
- traceforge/altium/tools.py +166 -0
- traceforge/approval.py +219 -0
- traceforge/attachments.py +346 -0
- traceforge/background.py +253 -0
- traceforge/bench/__init__.py +5 -0
- traceforge/bench/adapters.py +337 -0
- traceforge/bench/checks.py +410 -0
- traceforge/bench/cli.py +121 -0
- traceforge/bench/design.py +439 -0
- traceforge/bench/kicad.py +99 -0
- traceforge/bench/partscheck.py +159 -0
- traceforge/bench/review.py +100 -0
- traceforge/bench/runner.py +226 -0
- traceforge/bench/score.py +204 -0
- traceforge/bench/tasks.py +102 -0
- traceforge/chat.py +1185 -0
- traceforge/checks.py +328 -0
- traceforge/checks_sides.py +118 -0
- traceforge/cli.py +2224 -0
- traceforge/clipboard.py +107 -0
- traceforge/config.py +299 -0
- traceforge/context.py +276 -0
- traceforge/design/__init__.py +1 -0
- traceforge/design/constraints.py +397 -0
- traceforge/design/generate.py +1789 -0
- traceforge/design/outline.py +905 -0
- traceforge/design/schematic_layout.py +932 -0
- traceforge/design/schematic_lint.py +329 -0
- traceforge/design/schematic_route.py +207 -0
- traceforge/design/sheets.py +854 -0
- traceforge/design/sides.py +328 -0
- traceforge/design/spec.py +462 -0
- traceforge/design/specsheet.py +2351 -0
- traceforge/design/specsheet_render.py +183 -0
- traceforge/desktop/__init__.py +109 -0
- traceforge/desktop/keys.py +90 -0
- traceforge/desktop/project.py +443 -0
- traceforge/desktop/review.py +20 -0
- traceforge/desktop/server.py +1277 -0
- traceforge/desktop/static/app.css +223 -0
- traceforge/desktop/static/app.js +683 -0
- traceforge/desktop/static/highlight.js +77 -0
- traceforge/desktop/static/index.html +89 -0
- traceforge/desktop/static/md.js +90 -0
- traceforge/desktop/static/viewer.js +213 -0
- traceforge/desktop/symbols.py +212 -0
- traceforge/desktop/update.py +65 -0
- traceforge/diagnostics.py +67 -0
- traceforge/errata.py +219 -0
- traceforge/fab.py +108 -0
- traceforge/fab_layers.py +44 -0
- traceforge/features/__init__.py +6 -0
- traceforge/features/candidates.py +117 -0
- traceforge/features/cli.py +200 -0
- traceforge/features/package.py +433 -0
- traceforge/features/revdiff.py +224 -0
- traceforge/features/sarif.py +145 -0
- traceforge/features/viewer.py +265 -0
- traceforge/firmware/__init__.py +3 -0
- traceforge/firmware/cli.py +174 -0
- traceforge/firmware/drivers.py +372 -0
- traceforge/firmware/drivers_data.py +256 -0
- traceforge/firmware/mcus.py +177 -0
- traceforge/firmware/netlist.py +55 -0
- traceforge/firmware/pinmap.py +296 -0
- traceforge/firmware/scaffold.py +466 -0
- traceforge/firmware/toolchain.py +262 -0
- traceforge/hooks.py +237 -0
- traceforge/kicad/__init__.py +0 -0
- traceforge/kicad/cli.py +519 -0
- traceforge/kicad/diffpair.py +174 -0
- traceforge/kicad/edgegeom.py +191 -0
- traceforge/kicad/edit.py +405 -0
- traceforge/kicad/escape.py +1410 -0
- traceforge/kicad/formats.py +1073 -0
- traceforge/kicad/impedance.py +320 -0
- traceforge/kicad/install.py +75 -0
- traceforge/kicad/libraries.py +490 -0
- traceforge/kicad/models3d.py +942 -0
- traceforge/kicad/outputs.py +81 -0
- traceforge/kicad/pcb.py +787 -0
- traceforge/kicad/planes.py +1242 -0
- traceforge/kicad/project.py +136 -0
- traceforge/kicad/route.py +2698 -0
- traceforge/kicad/routegeom.py +164 -0
- traceforge/kicad/schematic.py +268 -0
- traceforge/kicad/sides.py +34 -0
- traceforge/kicad/stackup.py +424 -0
- traceforge/kicad_plugin/__init__.py +12 -0
- traceforge/kicad_plugin/actions.py +92 -0
- traceforge/kicad_plugin/cli.py +66 -0
- traceforge/kicad_plugin/client.py +86 -0
- traceforge/kicad_plugin/context.py +257 -0
- traceforge/kicad_plugin/discovery.py +81 -0
- traceforge/kicad_plugin/install.py +203 -0
- traceforge/kicad_plugin/overlay.py +60 -0
- traceforge/kicad_plugin/payload/action_plugin.py +65 -0
- traceforge/kicad_plugin/payload/ipc_main.py +42 -0
- traceforge/kicad_plugin/payload/plugin.json +90 -0
- traceforge/kicad_plugin/payload/requirements.txt +1 -0
- traceforge/kicad_plugin/views.py +202 -0
- traceforge/langchain.py +57 -0
- traceforge/local.py +580 -0
- traceforge/mcp_server.py +304 -0
- traceforge/models.py +310 -0
- traceforge/next_steps.py +110 -0
- traceforge/parts/__init__.py +52 -0
- traceforge/parts/alternates.py +488 -0
- traceforge/parts/bom.py +423 -0
- traceforge/parts/brief.py +705 -0
- traceforge/parts/catalog.py +297 -0
- traceforge/parts/cli_sourcing.py +156 -0
- traceforge/parts/data/duty_rates.yaml +37 -0
- traceforge/parts/data/hs_codes.yaml +103 -0
- traceforge/parts/datasheet.py +278 -0
- traceforge/parts/fx.py +100 -0
- traceforge/parts/model.py +185 -0
- traceforge/parts/net.py +187 -0
- traceforge/parts/offers.py +46 -0
- traceforge/parts/packages.py +397 -0
- traceforge/parts/pinout.py +714 -0
- traceforge/parts/providers/__init__.py +17 -0
- traceforge/parts/providers/base.py +100 -0
- traceforge/parts/providers/jlc.py +364 -0
- traceforge/parts/providers/keyed.py +661 -0
- traceforge/parts/report.py +508 -0
- traceforge/parts/supply.py +330 -0
- traceforge/parts/verify.py +455 -0
- traceforge/parts/web.py +575 -0
- traceforge/plans.py +70 -0
- traceforge/preferences.py +127 -0
- traceforge/prices.py +130 -0
- traceforge/prices.yaml +28 -0
- traceforge/prompts/__init__.py +54 -0
- traceforge/prompts/system.md +103 -0
- traceforge/providers.py +764 -0
- traceforge/remote/__init__.py +33 -0
- traceforge/remote/client.py +422 -0
- traceforge/remote/oidc.py +240 -0
- traceforge/remote/orgs.py +510 -0
- traceforge/remote/server.py +690 -0
- traceforge/remote/store.py +100 -0
- traceforge/review_changes.py +187 -0
- traceforge/routing.py +99 -0
- traceforge/runtime.py +435 -0
- traceforge/sessions.py +324 -0
- traceforge/sexpr.py +279 -0
- traceforge/sim/__init__.py +17 -0
- traceforge/sim/acmath.py +234 -0
- traceforge/sim/circuit.py +499 -0
- traceforge/sim/cli.py +162 -0
- traceforge/sim/engine.py +172 -0
- traceforge/sim/ltspice.py +175 -0
- traceforge/sim/measure.py +123 -0
- traceforge/sim/models.py +76 -0
- traceforge/sim/project.py +218 -0
- traceforge/sim/rawfile.py +123 -0
- traceforge/sim/results.py +298 -0
- traceforge/sim/tune.py +172 -0
- traceforge/skill_commands.py +165 -0
- traceforge/skill_library/circuit-simulation/SKILL.md +30 -0
- traceforge/skill_library/compliance-review/SKILL.md +51 -0
- traceforge/skill_library/design-review/SKILL.md +47 -0
- traceforge/skill_library/errata-hunt/SKILL.md +30 -0
- traceforge/skill_library/firmware/SKILL.md +26 -0
- traceforge/skill_library/power-budget/SKILL.md +27 -0
- traceforge/skill_library/release-package/SKILL.md +29 -0
- traceforge/skill_library/sourcing-brief/SKILL.md +24 -0
- traceforge/skill_library/thermal-review/SKILL.md +15 -0
- traceforge/skills.py +131 -0
- traceforge/thermal.py +557 -0
- traceforge/tools/__init__.py +48 -0
- traceforge/tools/agent_tools.py +213 -0
- traceforge/tools/base.py +303 -0
- traceforge/tools/batch.py +155 -0
- traceforge/tools/connections.py +245 -0
- traceforge/tools/files.py +777 -0
- traceforge/tools/firmware_tools.py +218 -0
- traceforge/tools/kicad_tools.py +1072 -0
- traceforge/tools/models3d_tools.py +141 -0
- traceforge/tools/parts_tools.py +473 -0
- traceforge/tools/processes.py +246 -0
- traceforge/tools/review_tools.py +178 -0
- traceforge/tools/sim_tools.py +230 -0
- traceforge/tui.py +620 -0
- traceforge/updates.py +124 -0
- traceforge/validate/__init__.py +77 -0
- traceforge/validate/builtins.py +1091 -0
- traceforge/validate/circuits.py +1240 -0
- traceforge/validate/config.py +410 -0
- traceforge/validate/default.yaml +113 -0
- traceforge/validate/design.py +191 -0
- traceforge/validate/explain.py +112 -0
- traceforge/validate/grounding.py +169 -0
- traceforge/validate/ipc2221.py +350 -0
- traceforge/validate/multilayer.py +805 -0
- traceforge/validate/netmodel.py +261 -0
- traceforge/validate/partcheck.py +472 -0
- traceforge/validate/report.py +280 -0
- traceforge/validate/review.py +322 -0
- traceforge/validate/runner.py +662 -0
- traceforge/workspace.py +171 -0
- traceforge_pcb-1.0.0.dist-info/METADATA +463 -0
- traceforge_pcb-1.0.0.dist-info/RECORD +219 -0
- traceforge_pcb-1.0.0.dist-info/WHEEL +4 -0
- traceforge_pcb-1.0.0.dist-info/entry_points.txt +2 -0
- 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
|