zeroquantz 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. zeroquantz/__init__.py +14 -0
  2. zeroquantz/__main__.py +8 -0
  3. zeroquantz/agent/__init__.py +16 -0
  4. zeroquantz/agent/dispatcher.py +520 -0
  5. zeroquantz/agent/intents.py +46 -0
  6. zeroquantz/agent/parser.py +255 -0
  7. zeroquantz/benchmark/__init__.py +7 -0
  8. zeroquantz/benchmark/latency.py +66 -0
  9. zeroquantz/benchmark/memory.py +41 -0
  10. zeroquantz/benchmark/quality.py +38 -0
  11. zeroquantz/benchmark/runner.py +151 -0
  12. zeroquantz/cli/__init__.py +7 -0
  13. zeroquantz/cli/app.py +98 -0
  14. zeroquantz/cli/commands.py +459 -0
  15. zeroquantz/cli/interactive.py +56 -0
  16. zeroquantz/core/__init__.py +7 -0
  17. zeroquantz/core/artifacts.py +179 -0
  18. zeroquantz/core/context.py +127 -0
  19. zeroquantz/core/events.py +30 -0
  20. zeroquantz/core/exceptions.py +105 -0
  21. zeroquantz/core/session.py +202 -0
  22. zeroquantz/core/subenv.py +202 -0
  23. zeroquantz/deploy/__init__.py +25 -0
  24. zeroquantz/deploy/assets.py +161 -0
  25. zeroquantz/deploy/launcher.py +80 -0
  26. zeroquantz/deploy/runtime_env.py +66 -0
  27. zeroquantz/deploy/targets.py +154 -0
  28. zeroquantz/export/__init__.py +8 -0
  29. zeroquantz/export/exporter.py +68 -0
  30. zeroquantz/export/report.py +203 -0
  31. zeroquantz/hardware/__init__.py +15 -0
  32. zeroquantz/hardware/capabilities.py +152 -0
  33. zeroquantz/hardware/detector.py +200 -0
  34. zeroquantz/hardware/gpu.py +31 -0
  35. zeroquantz/models/__init__.py +8 -0
  36. zeroquantz/models/architecture.py +168 -0
  37. zeroquantz/models/downloader.py +161 -0
  38. zeroquantz/models/hf_auth.py +105 -0
  39. zeroquantz/models/inspector.py +249 -0
  40. zeroquantz/models/metadata.py +108 -0
  41. zeroquantz/models/search.py +71 -0
  42. zeroquantz/optimization/__init__.py +22 -0
  43. zeroquantz/optimization/candidate.py +272 -0
  44. zeroquantz/optimization/constraints.py +70 -0
  45. zeroquantz/optimization/fit.py +203 -0
  46. zeroquantz/optimization/pareto.py +66 -0
  47. zeroquantz/optimization/planner.py +297 -0
  48. zeroquantz/optimization/recommender.py +149 -0
  49. zeroquantz/profiling/__init__.py +18 -0
  50. zeroquantz/profiling/calibration.py +74 -0
  51. zeroquantz/profiling/sensitivity.py +234 -0
  52. zeroquantz/quantization/__init__.py +17 -0
  53. zeroquantz/quantization/backends/__init__.py +8 -0
  54. zeroquantz/quantization/backends/bitsandbytes.py +210 -0
  55. zeroquantz/quantization/backends/torchao.py +198 -0
  56. zeroquantz/quantization/base.py +136 -0
  57. zeroquantz/quantization/catalog.py +321 -0
  58. zeroquantz/quantization/config.py +106 -0
  59. zeroquantz/quantization/gguf_pipeline.py +210 -0
  60. zeroquantz/quantization/isolated.py +248 -0
  61. zeroquantz/quantization/memory.py +133 -0
  62. zeroquantz/quantization/native.py +91 -0
  63. zeroquantz/quantization/registry.py +101 -0
  64. zeroquantz/render.py +341 -0
  65. zeroquantz/runtimes/__init__.py +18 -0
  66. zeroquantz/runtimes/base.py +64 -0
  67. zeroquantz/runtimes/compatibility.py +91 -0
  68. zeroquantz/runtimes/registry.py +70 -0
  69. zeroquantz/runtimes/transformers.py +53 -0
  70. zeroquantz/runtimes/vllm.py +83 -0
  71. zeroquantz/tui/__init__.py +13 -0
  72. zeroquantz/tui/app.py +77 -0
  73. zeroquantz/tui/banner.py +47 -0
  74. zeroquantz/tui/screens/__init__.py +25 -0
  75. zeroquantz/tui/screens/confirm.py +41 -0
  76. zeroquantz/tui/screens/execute.py +194 -0
  77. zeroquantz/tui/screens/model_select.py +206 -0
  78. zeroquantz/tui/screens/plan.py +177 -0
  79. zeroquantz/tui/screens/quantize_select.py +272 -0
  80. zeroquantz/tui/screens/settings.py +219 -0
  81. zeroquantz/tui/screens/token.py +94 -0
  82. zeroquantz/tui/screens/welcome.py +128 -0
  83. zeroquantz/tui/screens/workspace.py +175 -0
  84. zeroquantz/tui/styles/app.tcss +424 -0
  85. zeroquantz/tui/widgets/__init__.py +9 -0
  86. zeroquantz/tui/widgets/chip.py +36 -0
  87. zeroquantz/tui/widgets/sidebar.py +107 -0
  88. zeroquantz/tui/widgets/status_bar.py +43 -0
  89. zeroquantz/utils/__init__.py +8 -0
  90. zeroquantz/utils/config.py +46 -0
  91. zeroquantz/utils/env.py +78 -0
  92. zeroquantz/utils/logging.py +73 -0
  93. zeroquantz/utils/metrics.py +98 -0
  94. zeroquantz/utils/paths.py +57 -0
  95. zeroquantz/utils/units.py +134 -0
  96. zeroquantz/verification/__init__.py +17 -0
  97. zeroquantz/verification/logits.py +55 -0
  98. zeroquantz/verification/report.py +186 -0
  99. zeroquantz/verification/weights.py +44 -0
  100. zeroquantz/version.py +8 -0
  101. zeroquantz-0.1.0.dist-info/METADATA +72 -0
  102. zeroquantz-0.1.0.dist-info/RECORD +105 -0
  103. zeroquantz-0.1.0.dist-info/WHEEL +4 -0
  104. zeroquantz-0.1.0.dist-info/entry_points.txt +2 -0
  105. zeroquantz-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,219 @@
1
+ """Settings: manage downloaded models, quantized outputs, and the HF token."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ import sys
8
+
9
+ from rich.text import Text
10
+ from textual import work
11
+ from textual.app import ComposeResult
12
+ from textual.binding import Binding
13
+ from textual.containers import Vertical
14
+ from textual.screen import Screen
15
+ from textual.widgets import DataTable, Footer, Static, TabbedContent, TabPane
16
+
17
+ from zeroquantz.core import artifacts
18
+ from zeroquantz.models import hf_auth
19
+ from zeroquantz.tui.screens.confirm import ConfirmScreen
20
+ from zeroquantz.tui.screens.token import TokenScreen
21
+ from zeroquantz.utils import units
22
+
23
+
24
+ class SettingsScreen(Screen):
25
+ BINDINGS = [
26
+ Binding("escape", "back", "Back"),
27
+ Binding("1", "tab_downloaded", "Downloaded", show=False),
28
+ Binding("2", "tab_quantized", "Quantized", show=False),
29
+ Binding("3", "tab_token", "Token", show=False),
30
+ Binding("r", "refresh", "Refresh"),
31
+ Binding("d", "delete", "Delete"),
32
+ Binding("o", "open", "Open"),
33
+ Binding("e", "token_edit", "Set token"),
34
+ Binding("c", "token_clear", "Clear token"),
35
+ ]
36
+
37
+ def __init__(self) -> None:
38
+ super().__init__()
39
+ self._downloaded: list[artifacts.DownloadedModel] = []
40
+ self._quantized: list[artifacts.QuantizedArtifact] = []
41
+
42
+ def compose(self) -> ComposeResult:
43
+ with Vertical(id="settings-page"):
44
+ yield Static("Settings", classes="page-title")
45
+ with TabbedContent(id="settings-tabs", initial="tab-downloaded"):
46
+ with TabPane("Downloaded models", id="tab-downloaded"):
47
+ yield DataTable(id="dl-table", cursor_type="row", zebra_stripes=True)
48
+ with TabPane("Quantized models", id="tab-quantized"):
49
+ yield DataTable(id="q-table", cursor_type="row", zebra_stripes=True)
50
+ with TabPane("Hugging Face token", id="tab-token"):
51
+ yield Static("", id="tok-status")
52
+ yield Footer()
53
+
54
+ def on_mount(self) -> None:
55
+ self.query_one("#dl-table", DataTable).add_columns("Model", "Size", "Files")
56
+ self.query_one("#q-table", DataTable).add_columns("Name", "Base model", "Format", "Size")
57
+ self._refresh_quantized()
58
+ self._refresh_token()
59
+ self._refresh_downloaded()
60
+
61
+ # ---- refresh ------------------------------------------------------------
62
+
63
+ @work(thread=True, exclusive=True)
64
+ def _refresh_downloaded(self) -> None:
65
+ data = artifacts.list_downloaded_models()
66
+ self.app.call_from_thread(self._fill_downloaded, data)
67
+
68
+ def _fill_downloaded(self, data: list[artifacts.DownloadedModel]) -> None:
69
+ self._downloaded = data
70
+ table = self.query_one("#dl-table", DataTable)
71
+ table.clear()
72
+ total = 0
73
+ for m in data:
74
+ total += m.size_bytes
75
+ table.add_row(m.repo_id, units.humanize_bytes(m.size_bytes), str(m.nb_files))
76
+ if not data:
77
+ table.add_row("(cache empty)", "", "")
78
+ self._set_tab_title("tab-downloaded", f"Downloaded models · {units.humanize_bytes(total)}")
79
+
80
+ def _refresh_quantized(self) -> None:
81
+ self._quantized = artifacts.default_quantized_registry().list()
82
+ table = self.query_one("#q-table", DataTable)
83
+ table.clear()
84
+ total = 0
85
+ for a in self._quantized:
86
+ total += a.size_bytes
87
+ name = os.path.basename(a.path.rstrip("/\\"))
88
+ table.add_row(name, a.base_model or "?", a.format_id or "?", units.humanize_bytes(a.size_bytes))
89
+ if not self._quantized:
90
+ table.add_row("(none yet)", "", "", "")
91
+ self._set_tab_title("tab-quantized", f"Quantized models · {units.humanize_bytes(total)}")
92
+
93
+ def _refresh_token(self) -> None:
94
+ token = hf_auth.current_token()
95
+ status = self.query_one("#tok-status", Static)
96
+ if token:
97
+ status.update(Text.assemble(
98
+ ("● ", "#3fb950"), (f"Signed in — token {hf_auth.masked(token)}\n", "#3fb950"),
99
+ ("Gated models and higher Hub rate limits are enabled.", "#8b949e"),
100
+ ))
101
+ else:
102
+ status.update(Text.assemble(
103
+ ("○ ", "#8b949e"), ("No token set\n", "#8b949e"),
104
+ ("Add one to access gated models (Llama, …) and raise rate limits.", "#8b949e"),
105
+ ))
106
+
107
+ def _set_tab_title(self, pane_id: str, title: str) -> None:
108
+ try:
109
+ self.query_one("#settings-tabs", TabbedContent).get_tab(pane_id).label = title
110
+ except Exception:
111
+ pass
112
+
113
+ # ---- tab switching ------------------------------------------------------
114
+
115
+ def _active(self) -> str:
116
+ return self.query_one("#settings-tabs", TabbedContent).active
117
+
118
+ def action_tab_downloaded(self) -> None:
119
+ self.query_one("#settings-tabs", TabbedContent).active = "tab-downloaded"
120
+
121
+ def action_tab_quantized(self) -> None:
122
+ self.query_one("#settings-tabs", TabbedContent).active = "tab-quantized"
123
+
124
+ def action_tab_token(self) -> None:
125
+ self.query_one("#settings-tabs", TabbedContent).active = "tab-token"
126
+
127
+ # ---- actions ------------------------------------------------------------
128
+
129
+ def action_refresh(self) -> None:
130
+ active = self._active()
131
+ if active == "tab-downloaded":
132
+ self._refresh_downloaded()
133
+ elif active == "tab-quantized":
134
+ self._refresh_quantized()
135
+ else:
136
+ self._refresh_token()
137
+
138
+ def action_delete(self) -> None:
139
+ active = self._active()
140
+ if active == "tab-downloaded":
141
+ self._delete_downloaded()
142
+ elif active == "tab-quantized":
143
+ self._delete_quantized()
144
+
145
+ def _delete_downloaded(self) -> None:
146
+ row = self.query_one("#dl-table", DataTable).cursor_row
147
+ if row is None or row >= len(self._downloaded):
148
+ return
149
+ model = self._downloaded[row]
150
+ msg = f"Delete cached model '{model.repo_id}'?"
151
+ detail = f"Frees {units.humanize_bytes(model.size_bytes)} from the Hugging Face cache."
152
+ self.app.push_screen(
153
+ ConfirmScreen(msg, detail=detail),
154
+ lambda ok, r=model.repo_id: self._do_delete_downloaded(r) if ok else None,
155
+ )
156
+
157
+ @work(thread=True, exclusive=True)
158
+ def _do_delete_downloaded(self, repo_id: str) -> None:
159
+ try:
160
+ artifacts.delete_downloaded_model(repo_id)
161
+ except Exception:
162
+ pass
163
+ data = artifacts.list_downloaded_models()
164
+ self.app.call_from_thread(self._fill_downloaded, data)
165
+
166
+ def _delete_quantized(self) -> None:
167
+ row = self.query_one("#q-table", DataTable).cursor_row
168
+ if row is None or row >= len(self._quantized):
169
+ return
170
+ artifact = self._quantized[row]
171
+ msg = f"Delete quantized model at '{artifact.path}'?"
172
+ detail = f"Permanently removes the directory ({units.humanize_bytes(artifact.size_bytes)})."
173
+ self.app.push_screen(
174
+ ConfirmScreen(msg, detail=detail),
175
+ lambda ok, p=artifact.path: self._do_delete_quantized(p) if ok else None,
176
+ )
177
+
178
+ def _do_delete_quantized(self, path: str) -> None:
179
+ artifacts.default_quantized_registry().delete(path)
180
+ self._refresh_quantized()
181
+
182
+ def action_open(self) -> None:
183
+ if self._active() != "tab-quantized":
184
+ return
185
+ row = self.query_one("#q-table", DataTable).cursor_row
186
+ if row is None or row >= len(self._quantized):
187
+ return
188
+ _open_folder(self._quantized[row].path)
189
+
190
+ def action_token_edit(self) -> None:
191
+ if self._active() == "tab-token":
192
+ self.app.push_screen(TokenScreen(), lambda _changed: self._refresh_token())
193
+
194
+ def action_token_clear(self) -> None:
195
+ if self._active() != "tab-token" or not hf_auth.current_token():
196
+ return
197
+ self.app.push_screen(
198
+ ConfirmScreen("Clear the saved Hugging Face token?"),
199
+ lambda ok: self._do_clear_token() if ok else None,
200
+ )
201
+
202
+ def _do_clear_token(self) -> None:
203
+ hf_auth.clear_token()
204
+ self._refresh_token()
205
+
206
+ def action_back(self) -> None:
207
+ self.app.pop_screen()
208
+
209
+
210
+ def _open_folder(path: str) -> None:
211
+ try:
212
+ if sys.platform.startswith("win"):
213
+ os.startfile(path) # noqa: S606
214
+ elif sys.platform == "darwin":
215
+ subprocess.Popen(["open", path])
216
+ else:
217
+ subprocess.Popen(["xdg-open", path])
218
+ except Exception:
219
+ pass
@@ -0,0 +1,94 @@
1
+ """A modal dialog for entering / clearing a Hugging Face token.
2
+
3
+ Keyboard-driven: Enter validates & saves, Ctrl+D clears, Esc closes.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from rich.text import Text
9
+ from textual import on, work
10
+ from textual.app import ComposeResult
11
+ from textual.binding import Binding
12
+ from textual.containers import Vertical
13
+ from textual.screen import ModalScreen
14
+ from textual.widgets import Input, Static
15
+
16
+ from zeroquantz.models import hf_auth
17
+
18
+
19
+ class TokenScreen(ModalScreen[bool]):
20
+ """Enter, validate, and persist a Hugging Face access token."""
21
+
22
+ BINDINGS = [
23
+ Binding("escape", "close", "Close"),
24
+ Binding("ctrl+d", "clear", "Clear token"),
25
+ ]
26
+
27
+ def compose(self) -> ComposeResult:
28
+ with Vertical(id="token-dialog"):
29
+ yield Static("Hugging Face token", classes="dialog-title")
30
+ yield Static(
31
+ "Add a token to access gated models (Llama, etc.) and raise Hub rate limits.\n"
32
+ "Create one at https://huggingface.co/settings/tokens (read scope is enough).",
33
+ classes="dialog-sub",
34
+ )
35
+ yield Static("", id="token-status")
36
+ yield Input(placeholder="hf_…", password=True, id="token-input")
37
+ yield Static(
38
+ "[white]Enter[/white] save [white]Ctrl+D[/white] clear [white]Esc[/white] close",
39
+ id="token-hint",
40
+ classes="page-hint",
41
+ )
42
+
43
+ def on_mount(self) -> None:
44
+ self._refresh_status()
45
+ self.query_one("#token-input", Input).focus()
46
+
47
+ def _refresh_status(self) -> None:
48
+ token = hf_auth.current_token()
49
+ status = self.query_one("#token-status", Static)
50
+ if token:
51
+ status.update(Text.assemble(("● ", "#3fb950"), (f"token set ({hf_auth.masked(token)})", "#3fb950")))
52
+ else:
53
+ status.update(Text("○ no token set", style="#8b949e"))
54
+
55
+ # ---- actions ------------------------------------------------------------
56
+
57
+ @on(Input.Submitted, "#token-input")
58
+ def _submit(self, event: Input.Submitted) -> None:
59
+ token = event.value.strip()
60
+ if not token:
61
+ self.query_one("#token-status", Static).update(Text("Enter a token first.", style="#d29922"))
62
+ return
63
+ self.query_one("#token-status", Static).update(Text("checking token …", style="#d29922"))
64
+ self._validate(token)
65
+
66
+ @work(thread=True, exclusive=True)
67
+ def _validate(self, token: str) -> None:
68
+ try:
69
+ name = hf_auth.save_token(token)
70
+ self.app.call_from_thread(self._saved, name)
71
+ except Exception as exc:
72
+ message = exc.format() if hasattr(exc, "format") else str(exc)
73
+ self.app.call_from_thread(self._failed, message)
74
+
75
+ def _saved(self, name: str | None) -> None:
76
+ who = f" as {name}" if name else ""
77
+ self.query_one("#token-status", Static).update(
78
+ Text(f"✓ signed in{who} — gated models & higher limits enabled", style="#3fb950")
79
+ )
80
+ self.query_one("#token-input", Input).value = ""
81
+ self._changed = True
82
+
83
+ def _failed(self, message: str) -> None:
84
+ self.query_one("#token-status", Static).update(Text(f"✗ {message.splitlines()[0]}", style="#f85149"))
85
+
86
+ def action_clear(self) -> None:
87
+ hf_auth.clear_token()
88
+ self._changed = True
89
+ self.query_one("#token-input", Input).value = ""
90
+ self._refresh_status()
91
+ self.query_one("#token-status", Static).update(Text("token removed", style="#8b949e"))
92
+
93
+ def action_close(self) -> None:
94
+ self.dismiss(getattr(self, "_changed", False))
@@ -0,0 +1,128 @@
1
+ """The landing screen: ZEROQUANTZ wordmark, hardware status, arrow-key menu."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.text import Text
6
+ from textual import on
7
+ from textual.app import ComposeResult
8
+ from textual.containers import Vertical
9
+ from textual.screen import Screen
10
+ from textual.widgets import OptionList, Static
11
+ from textual.widgets.option_list import Option
12
+
13
+ from zeroquantz.models import hf_auth
14
+ from zeroquantz.tui.banner import banner_text
15
+
16
+
17
+ class WelcomeScreen(Screen):
18
+ """First screen — mirrors the requested launcher aesthetic."""
19
+
20
+ def compose(self) -> ComposeResult:
21
+ with Vertical(id="welcome"):
22
+ yield Static("", classes="banner", id="banner")
23
+ yield Static(
24
+ "Interactive Model Optimization Environment", classes="tagline"
25
+ )
26
+ yield Static("", classes="status-line", id="status")
27
+ yield Static(
28
+ "What would you like to do? (Use arrow keys)", classes="prompt-line"
29
+ )
30
+ yield OptionList(id="menu")
31
+
32
+ def on_mount(self) -> None:
33
+ self._refresh_banner()
34
+ self._refresh_status()
35
+ self._build_menu()
36
+ self.query_one("#menu", OptionList).focus()
37
+
38
+ def on_resize(self) -> None:
39
+ self._refresh_banner()
40
+
41
+ # ---- content ------------------------------------------------------------
42
+
43
+ def _refresh_banner(self) -> None:
44
+ width = self.app.size.width or 80
45
+ self.query_one("#banner", Static).update(
46
+ Text(banner_text(width - 4), style="bold #c9d1d9", justify="center")
47
+ )
48
+
49
+ def _refresh_status(self) -> None:
50
+ hw = self.app.ctx.hardware
51
+ text = Text()
52
+ if hw.cuda_available:
53
+ text.append("● ", style="#3fb950")
54
+ text.append(hw.gpu_name, style="#3fb950")
55
+ text.append(f" VRAM {hw.total_vram_gb:g} GB", style="#8b949e")
56
+ text.append(f" CUDA {hw.cuda_version or 'detected'}", style="#8b949e")
57
+ else:
58
+ text.append("⚠ No CUDA GPU detected — planning-only mode", style="#d29922")
59
+ if hf_auth.current_token():
60
+ text.append(" HF token ✓", style="#3fb950")
61
+ self.query_one("#status", Static).update(text)
62
+
63
+ def _build_menu(self) -> None:
64
+ menu = self.query_one("#menu", OptionList)
65
+ menu.clear_options()
66
+ self._items = [
67
+ ("optimize", "Optimize a model", "guided: model → quantization → export", "●", "bold #58a6ff"),
68
+ ("hardware", "Inspect hardware", "detected GPU & precision support", "●", "bold #58a6ff"),
69
+ ("command", "Command mode & help", "slash commands, natural language, /help", "●", "bold #58a6ff"),
70
+ ]
71
+ latest = self.app.ctx.repo.latest()
72
+ if latest is not None:
73
+ self._items.append(
74
+ ("resume", "Resume last session", f"continue “{latest.name}”", "●", "bold #58a6ff")
75
+ )
76
+ self._items.append((
77
+ "settings", "Settings",
78
+ "manage downloaded / quantized models & HF token", "●", "bold #58a6ff",
79
+ ))
80
+ self._items.append(("exit", "Exit", "", "○", "#8b949e"))
81
+ for idx, item in enumerate(self._items):
82
+ menu.add_option(Option(_option_prompt(item, pointed=idx == 0), id=item[0]))
83
+
84
+ @on(OptionList.OptionHighlighted)
85
+ def _highlighted(self, event: OptionList.OptionHighlighted) -> None:
86
+ if not getattr(self, "_items", None):
87
+ return
88
+ menu = event.option_list
89
+ try: # move the → pointer to the highlighted row (graceful if API differs)
90
+ for idx, item in enumerate(self._items):
91
+ menu.replace_option_prompt_at_index(
92
+ idx, _option_prompt(item, pointed=idx == event.option_index)
93
+ )
94
+ except Exception:
95
+ pass
96
+
97
+ # ---- actions ------------------------------------------------------------
98
+
99
+ @on(OptionList.OptionSelected)
100
+ def _selected(self, event: OptionList.OptionSelected) -> None:
101
+ choice = event.option.id
102
+ if choice == "exit":
103
+ self.app.exit()
104
+ elif choice == "hardware":
105
+ self.app.open_workspace("/hardware")
106
+ elif choice == "command":
107
+ self.app.open_workspace("/help") # enter command mode with help shown
108
+ elif choice == "settings":
109
+ self.app.push_settings()
110
+ elif choice == "resume":
111
+ self.app.resume_last_session()
112
+ else: # optimize -> guided wizard
113
+ self.app.start_wizard()
114
+
115
+ def on_screen_resume(self) -> None:
116
+ # Returning from Settings may have changed the token — refresh the chip.
117
+ self._refresh_status()
118
+
119
+
120
+ def _option_prompt(item: tuple[str, str, str, str, str], *, pointed: bool) -> Text:
121
+ _id, label, desc, bullet, label_style = item
122
+ text = Text()
123
+ text.append("→ " if pointed else " ", style="bold #3fb950")
124
+ text.append(f"{bullet} ", style="#3fb950" if bullet == "●" else "#6e7681")
125
+ text.append(label, style=label_style)
126
+ if desc:
127
+ text.append(f" — {desc}", style="#6e7681")
128
+ return text
@@ -0,0 +1,175 @@
1
+ """The main workspace: conversation log, input, and the state sidebar.
2
+
3
+ User input and slash commands are parsed to intents and dispatched on a worker
4
+ thread (so a long quantize/benchmark never freezes the UI), with progress shown
5
+ live. Results render through the shared :mod:`zeroquantz.render` layer.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from rich.text import Text
11
+ from textual import work
12
+ from textual.app import ComposeResult
13
+ from textual.binding import Binding
14
+ from textual.containers import Horizontal
15
+ from textual.screen import Screen
16
+ from textual.widgets import Input, RichLog, Static
17
+
18
+ from zeroquantz.agent.dispatcher import COMMANDS
19
+ from zeroquantz.agent.intents import IntentKind
20
+ from zeroquantz.agent.parser import IntentParser
21
+ from zeroquantz.render import render_result
22
+ from zeroquantz.tui.widgets import Sidebar, StatusBar
23
+
24
+ _SLASH_COMMANDS = [cmd.split()[0] for cmd, _ in COMMANDS]
25
+
26
+
27
+ class WorkspaceScreen(Screen):
28
+ BINDINGS = [
29
+ Binding("ctrl+l", "clear", "Clear"),
30
+ Binding("ctrl+b", "toggle_sidebar", "Sidebar"),
31
+ Binding("ctrl+c", "quit", "Quit", priority=True),
32
+ Binding("escape", "home", "Home"),
33
+ ]
34
+
35
+ def __init__(self) -> None:
36
+ super().__init__()
37
+ self._history: list[str] = []
38
+ self._hist_idx = 0
39
+
40
+ def compose(self) -> ComposeResult:
41
+ yield StatusBar(id="topbar")
42
+ with Horizontal(id="body"):
43
+ yield RichLog(id="conversation", wrap=True, highlight=False, markup=False)
44
+ yield Sidebar(id="sidebar")
45
+ yield Static("", id="progress")
46
+ yield Input(
47
+ placeholder="Type a command or ask in plain English… (/help, /exit)",
48
+ id="prompt",
49
+ )
50
+
51
+ def on_mount(self) -> None:
52
+ log = self.query_one("#conversation", RichLog)
53
+ log.write(
54
+ Text.assemble(
55
+ ("ZeroQuantz ready. ", "bold #3fb950"),
56
+ ("Try ", "#8b949e"),
57
+ ("/model Qwen/Qwen3-8B", "#58a6ff"),
58
+ (" or “fit under 8GB for vLLM”.", "#8b949e"),
59
+ )
60
+ )
61
+ self._refresh_panels()
62
+ self.query_one("#prompt", Input).focus()
63
+ pending = getattr(self.app, "pending_command", None)
64
+ if pending:
65
+ self.app.pending_command = None
66
+ self._echo(pending)
67
+ self._dispatch(pending)
68
+
69
+ # ---- input handling -----------------------------------------------------
70
+
71
+ def on_input_submitted(self, event: Input.Submitted) -> None:
72
+ text = event.value.strip()
73
+ event.input.value = ""
74
+ if not text:
75
+ return
76
+ self._history.append(text)
77
+ self._hist_idx = len(self._history)
78
+ self._echo(text)
79
+ # EXIT is handled synchronously so the app closes immediately.
80
+ if IntentParser.parse(text).kind is IntentKind.EXIT:
81
+ self.app.exit()
82
+ return
83
+ self._dispatch(text)
84
+
85
+ def on_key(self, event) -> None: # noqa: ANN001
86
+ inp = self.query_one("#prompt", Input)
87
+ if not inp.has_focus:
88
+ return
89
+ if event.key == "up":
90
+ self._history_step(-1)
91
+ event.stop()
92
+ elif event.key == "down":
93
+ self._history_step(1)
94
+ event.stop()
95
+ elif event.key == "tab":
96
+ self._autocomplete()
97
+ event.stop()
98
+ event.prevent_default()
99
+
100
+ def _history_step(self, delta: int) -> None:
101
+ if not self._history:
102
+ return
103
+ self._hist_idx = max(0, min(len(self._history), self._hist_idx + delta))
104
+ inp = self.query_one("#prompt", Input)
105
+ inp.value = self._history[self._hist_idx] if self._hist_idx < len(self._history) else ""
106
+ inp.cursor_position = len(inp.value)
107
+
108
+ def _autocomplete(self) -> None:
109
+ inp = self.query_one("#prompt", Input)
110
+ value = inp.value
111
+ if not value.startswith("/"):
112
+ return
113
+ matches = [c for c in _SLASH_COMMANDS if c.startswith(value)]
114
+ if len(matches) == 1:
115
+ inp.value = matches[0] + " "
116
+ inp.cursor_position = len(inp.value)
117
+ elif len(matches) > 1:
118
+ self.query_one("#conversation", RichLog).write(
119
+ Text(" ".join(matches), style="#6e7681")
120
+ )
121
+
122
+ # ---- dispatch -----------------------------------------------------------
123
+
124
+ @work(thread=True, exclusive=True)
125
+ def _dispatch(self, text: str) -> None:
126
+ intent = IntentParser.parse(text)
127
+
128
+ def progress(stage: str, fraction: float) -> None:
129
+ self.app.call_from_thread(self._set_progress, stage, fraction)
130
+
131
+ result = self.app.dispatcher.dispatch(intent, self.app.ctx, progress)
132
+ self.app.call_from_thread(self._show_result, result)
133
+
134
+ def _show_result(self, result) -> None: # noqa: ANN001
135
+ self._set_progress(None, 0.0)
136
+ self.query_one("#conversation", RichLog).write(render_result(result))
137
+ self._refresh_panels()
138
+ if result.should_exit:
139
+ self.app.exit()
140
+
141
+ def _set_progress(self, stage: str | None, fraction: float) -> None:
142
+ prog = self.query_one("#progress", Static)
143
+ if stage is None:
144
+ prog.remove_class("active")
145
+ prog.update("")
146
+ return
147
+ filled = int(round(fraction * 20))
148
+ bar = "█" * filled + "░" * (20 - filled)
149
+ prog.update(Text(f" {bar} {stage} ({fraction*100:.0f}%)", style="#d29922"))
150
+ prog.add_class("active")
151
+
152
+ # ---- helpers ------------------------------------------------------------
153
+
154
+ def _echo(self, text: str) -> None:
155
+ self.query_one("#conversation", RichLog).write(
156
+ Text.assemble(("› ", "bold #3fb950"), (text, "bold white"))
157
+ )
158
+
159
+ def _refresh_panels(self) -> None:
160
+ self.query_one("#topbar", StatusBar).update_context(self.app.ctx)
161
+ self.query_one("#sidebar", Sidebar).update_context(self.app.ctx)
162
+
163
+ # ---- actions ------------------------------------------------------------
164
+
165
+ def action_clear(self) -> None:
166
+ self.query_one("#conversation", RichLog).clear()
167
+
168
+ def action_toggle_sidebar(self) -> None:
169
+ self.query_one("#sidebar", Sidebar).toggle_class("hidden")
170
+
171
+ def action_home(self) -> None:
172
+ self.app.pop_screen()
173
+
174
+ def action_quit(self) -> None:
175
+ self.app.exit()