qubasic 0.4.1__tar.gz → 0.5.1__tar.gz

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 (59) hide show
  1. {qubasic-0.4.1 → qubasic-0.5.1}/CHANGELOG.md +27 -0
  2. {qubasic-0.4.1/qubasic.egg-info → qubasic-0.5.1}/PKG-INFO +1 -1
  3. {qubasic-0.4.1 → qubasic-0.5.1}/pyproject.toml +1 -1
  4. {qubasic-0.4.1 → qubasic-0.5.1/qubasic.egg-info}/PKG-INFO +1 -1
  5. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic.egg-info/SOURCES.txt +1 -0
  6. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/__init__.py +1 -1
  7. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/display.py +10 -0
  8. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/locc_execution.py +3 -1
  9. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/profiler.py +3 -1
  10. qubasic-0.5.1/qubasic_core/qol.py +703 -0
  11. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/terminal.py +69 -22
  12. {qubasic-0.4.1 → qubasic-0.5.1}/LICENSE +0 -0
  13. {qubasic-0.4.1 → qubasic-0.5.1}/MANIFEST.in +0 -0
  14. {qubasic-0.4.1 → qubasic-0.5.1}/README.md +0 -0
  15. {qubasic-0.4.1 → qubasic-0.5.1}/examples/bell.qb +0 -0
  16. {qubasic-0.4.1 → qubasic-0.5.1}/examples/grover3.qb +0 -0
  17. {qubasic-0.4.1 → qubasic-0.5.1}/examples/locc_teleport.qb +0 -0
  18. {qubasic-0.4.1 → qubasic-0.5.1}/examples/sweep_rx.qb +0 -0
  19. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic.egg-info/dependency_links.txt +0 -0
  20. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic.egg-info/entry_points.txt +0 -0
  21. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic.egg-info/requires.txt +0 -0
  22. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic.egg-info/top_level.txt +0 -0
  23. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic.py +0 -0
  24. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/__main__.py +0 -0
  25. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/analysis.py +0 -0
  26. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/backend.py +0 -0
  27. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/classic.py +0 -0
  28. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/control_flow.py +0 -0
  29. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/debug.py +0 -0
  30. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/demos.py +0 -0
  31. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/engine.py +0 -0
  32. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/engine_state.py +0 -0
  33. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/errors.py +0 -0
  34. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/exec_context.py +0 -0
  35. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/executor.py +0 -0
  36. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/expression.py +0 -0
  37. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/file_io.py +0 -0
  38. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/gates.py +0 -0
  39. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/help_text.py +0 -0
  40. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/io_protocol.py +0 -0
  41. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/locc.py +0 -0
  42. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/locc_commands.py +0 -0
  43. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/locc_display.py +0 -0
  44. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/locc_engine.py +0 -0
  45. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/memory.py +0 -0
  46. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/mock_backend.py +0 -0
  47. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/noise_mixin.py +0 -0
  48. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/parser.py +0 -0
  49. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/patterns.py +0 -0
  50. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/program_mgmt.py +0 -0
  51. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/protocol.py +0 -0
  52. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/scope.py +0 -0
  53. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/screen.py +0 -0
  54. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/state_display.py +0 -0
  55. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/statements.py +0 -0
  56. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/strings.py +0 -0
  57. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/subs.py +0 -0
  58. {qubasic-0.4.1 → qubasic-0.5.1}/qubasic_core/sweep.py +0 -0
  59. {qubasic-0.4.1 → qubasic-0.5.1}/setup.cfg +0 -0
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 (2026-03-30)
4
+
5
+ - **Colorized histograms**: bars colored green/yellow/dim by probability in terminal
6
+ - **Animated STEP mode**: press A during STEP for auto-advance with configurable delay
7
+ - **"Did you mean?"**: typo suggestions for misspelled commands (BLASCH -> BLOCH)
8
+ - **Status bar prompt**: REPL prompt shows qubit count, LOCC, noise status
9
+ - **DRAW command**: braille-character Bloch sphere rendering
10
+ - **Color-coded LIST**: gates in cyan, flow in yellow, comments dim (THEME controls)
11
+ - **Gate throughput**: RUN summary shows gates/s and circuit complexity (T-count, CNOT count)
12
+ - **COMPARE command**: run circuit with two methods and diff output distributions
13
+ - **HEATMAP command**: qubit-qubit entanglement entropy grid
14
+ - **Startup tip of the day**: random tip shown at REPL launch
15
+ - **ANIMATE command**: animated parameter sweep with in-place terminal updates
16
+ - **QUIZ mode**: interactive quantum computing quiz with multiple choice
17
+ - **Tab completion for file paths**: SAVE/LOAD/INCLUDE complete .qb filenames
18
+ - **DIFF command**: diff current program against another BANK slot
19
+ - **PLOT command**: ASCII scatter plot of P(top state) vs swept variable
20
+ - **UNDO preview**: shows what lines will change before applying
21
+ - **THEME command**: switch color schemes (default, retro, none)
22
+ - **F1/F2/F3 demo shortcuts**: load Bell, GHZ, Grover demos via function keys
23
+ - **Quantum spinner**: |0>, |+>, |1>, |-> cycling during STATS and LOCC progress
24
+ - **EXPLAIN command**: describe each program line in plain English
25
+ - **LOCC progress spinner**: quantum-themed progress during SEND-based LOCC runs
26
+ - **CLIP command**: copy last results to system clipboard
27
+ - **Sound on completion**: terminal bell for runs exceeding 2 seconds
28
+ - New QoLMixin with 24 quality-of-life features
29
+
3
30
  ## 0.4.1 (2026-03-30)
4
31
 
5
32
  - **Fix SEED dispatch**: moved from no-arg to with-arg dispatch table so `SEED 42` works from the REPL
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qubasic
3
- Version: 0.4.1
3
+ Version: 0.5.1
4
4
  Summary: Quantum BASIC Interactive Terminal
5
5
  Author-email: "Charles C. Norton" <machineelv@gmail.com>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qubasic"
7
- version = "0.4.1"
7
+ version = "0.5.1"
8
8
  description = "Quantum BASIC Interactive Terminal"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qubasic
3
- Version: 0.4.1
3
+ Version: 0.5.1
4
4
  Summary: Quantum BASIC Interactive Terminal
5
5
  Author-email: "Charles C. Norton" <machineelv@gmail.com>
6
6
  License-Expression: MIT
@@ -46,6 +46,7 @@ qubasic_core/patterns.py
46
46
  qubasic_core/profiler.py
47
47
  qubasic_core/program_mgmt.py
48
48
  qubasic_core/protocol.py
49
+ qubasic_core/qol.py
49
50
  qubasic_core/scope.py
50
51
  qubasic_core/screen.py
51
52
  qubasic_core/state_display.py
@@ -28,7 +28,7 @@ __all__ = [
28
28
  'GATE_TABLE', 'GATE_ALIASES',
29
29
  ]
30
30
 
31
- __version__ = '0.4.1'
31
+ __version__ = '0.5.1'
32
32
 
33
33
  def __getattr__(name):
34
34
  """Lazy import heavy modules on first access."""
@@ -88,11 +88,21 @@ class DisplayMixin:
88
88
  max_count = max(c for _, c in display) if display else 1
89
89
  max_label = max(len(k) for k, _ in display) if display else 1
90
90
 
91
+ _theme = getattr(self, '_theme', {})
91
92
  for state, count in display:
92
93
  pct = 100 * count / total
93
94
  bar_len = int(HISTOGRAM_BAR_WIDTH * count / max_count)
94
95
  bar = '\u2588' * bar_len
95
96
  ket = f"|{state}\u27E9"
97
+ # Colorize bar by probability
98
+ if _theme and sys.stdout.isatty():
99
+ rst = _theme.get('reset', '')
100
+ if pct > 40:
101
+ bar = f"{_theme.get('bar_hi', '')}{bar}{rst}"
102
+ elif pct > 10:
103
+ bar = f"{_theme.get('bar_mid', '')}{bar}{rst}"
104
+ else:
105
+ bar = f"{_theme.get('bar_lo', '')}{bar}{rst}"
96
106
  self.io.writeln(f" {ket:>{max_label+3}} {count:>6} ({pct:5.1f}%) {bar}")
97
107
 
98
108
  if len(sorted_counts) > MAX_HISTOGRAM_STATES:
@@ -134,7 +134,9 @@ class LOCCExecutionMixin:
134
134
  counts_joint[jkey] = counts_joint.get(jkey, 0) + 1
135
135
  if shots > 50 and (shot + 1) % progress_interval == 0:
136
136
  pct = 100 * (shot + 1) // shots
137
- _prog = f" {pct}% ({shot+1}/{shots} shots)..."
137
+ from qubasic_core.qol import quantum_spin
138
+ spin = quantum_spin(shot)
139
+ _prog = f" {spin} {pct}% ({shot+1}/{shots} shots)..."
138
140
  if hasattr(self.io, 'write') and sys.stdout.isatty():
139
141
  self.io.write(_prog + '\r')
140
142
  # Non-terminal: skip progress to avoid \r noise
@@ -128,7 +128,9 @@ class ProfilerMixin:
128
128
  break
129
129
  self._stats_runs.append(dict(self.last_counts))
130
130
  if n > 10 and (trial + 1) % (n // 10) == 0:
131
- self.io.write(f" {100 * (trial + 1) // n}%..." + '\r')
131
+ from qubasic_core.qol import quantum_spin
132
+ spin = quantum_spin(trial)
133
+ self.io.write(f" {spin} {100 * (trial + 1) // n}%..." + '\r')
132
134
  if n > 10:
133
135
  self.io.write(" " * 30 + '\r')
134
136
  self.io.writeln(f"Collected {len(self._stats_runs)} runs ({n} trials)")
@@ -0,0 +1,703 @@
1
+ """QUBASIC quality-of-life features — visual, fun, and convenience commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ import math
7
+ import os
8
+ import random
9
+ import sys
10
+ import time
11
+ from typing import Any
12
+
13
+ import numpy as np
14
+
15
+
16
+ # ═══════════════════════════════════════════════════════════════════════
17
+ # Quantum spinner (#21)
18
+ # ═══════════════════════════════════════════════════════════════════════
19
+
20
+ _QUANTUM_SPINNER = [
21
+ '|0\u27E9', '|+\u27E9', '|1\u27E9', '|-\u27E9',
22
+ '|i\u27E9', '|\u03C8\u27E9', '|0\u27E9', '|\u03D5\u27E9',
23
+ ]
24
+
25
+
26
+ def quantum_spin(step: int) -> str:
27
+ """Return the spinner frame for the given step."""
28
+ return _QUANTUM_SPINNER[step % len(_QUANTUM_SPINNER)]
29
+
30
+
31
+ # ═══════════════════════════════════════════════════════════════════════
32
+ # Tips of the day (#11)
33
+ # ═══════════════════════════════════════════════════════════════════════
34
+
35
+ _TIPS = [
36
+ "Use CTRL H 0, 1 to make any gate controlled.",
37
+ "STEP mode lets you watch the statevector evolve line by line.",
38
+ "REWIND goes back in time during STEP mode.",
39
+ "LOCC JOINT 3 3 enables quantum teleportation with classical correction.",
40
+ "SWEEP var 0 PI 10 shows how probability changes with a parameter.",
41
+ "NOISE depolarizing 0.05 adds realistic noise to your circuit.",
42
+ "SEED 42 makes your results reproducible.",
43
+ "DEMO LIST shows all 12 built-in quantum algorithms.",
44
+ "BLOCH draws an ASCII Bloch sphere for each qubit.",
45
+ "INV RX 0.5, 0 applies the inverse (dagger) of any gate.",
46
+ "UNITARY MYGATE = [[0,1],[1,0]] defines a custom gate from a matrix.",
47
+ "DEF BELL = H 0 : CX 0,1 creates a reusable gate sequence.",
48
+ "POKE $D000, 8 sets the qubit count via the memory map.",
49
+ "SYS $E000 runs a built-in algorithm by address.",
50
+ "STATS 100 runs 100 trials and shows mean/stddev per state.",
51
+ "PROFILE ON then RUN shows per-line execution time.",
52
+ "ENTROPY 0 measures entanglement of qubit 0 with the rest.",
53
+ "EXPECT ZZ 0 1 computes the two-qubit ZZ correlation.",
54
+ "BANK 1 switches to a second program slot (auto-saves current).",
55
+ "SCREEN 3 auto-displays Bloch spheres after every RUN.",
56
+ "CONSISTENCY cross-checks statevector, density, Bloch, and histogram.",
57
+ "FOR I = 0 TO 7 / H I / NEXT I applies H to 8 qubits in a loop.",
58
+ "FIND \"CX\" searches your program for a string.",
59
+ "CHECKSUM gives an MD5 hash for program verification.",
60
+ "10 H 0 : CX 0,1 : RZ PI/4, 0 puts three gates on one line.",
61
+ ]
62
+
63
+
64
+ def tip_of_the_day() -> str:
65
+ """Return a random tip."""
66
+ return random.choice(_TIPS)
67
+
68
+
69
+ # ═══════════════════════════════════════════════════════════════════════
70
+ # "Did you mean?" (#4)
71
+ # ═══════════════════════════════════════════════════════════════════════
72
+
73
+ _ALL_COMMANDS = [
74
+ 'RUN', 'LIST', 'NEW', 'SAVE', 'LOAD', 'QUBITS', 'SHOTS', 'METHOD',
75
+ 'DEF', 'REG', 'LET', 'STEP', 'STATE', 'HIST', 'BLOCH', 'PROBS',
76
+ 'DEMO', 'DELETE', 'RENUM', 'DEFS', 'REGS', 'VARS', 'HELP',
77
+ 'CIRCUIT', 'LOCC', 'SEND', 'SHARE', 'SWEEP', 'INCLUDE', 'EXPORT',
78
+ 'DECOMPOSE', 'NOISE', 'EXPECT', 'DENSITY', 'ENTROPY', 'CSV',
79
+ 'MEASURE', 'BARRIER', 'PRINT', 'INPUT', 'DIM', 'UNITARY',
80
+ 'LOCCINFO', 'RAM', 'UNDO', 'CLEAR', 'PEEK', 'POKE', 'SYS',
81
+ 'DUMP', 'MAP', 'CATALOG', 'MONITOR', 'SCREEN', 'COLOR', 'CLS',
82
+ 'LOCATE', 'PLAY', 'PROMPT', 'BREAK', 'WATCH', 'PROFILE', 'STATS',
83
+ 'REWIND', 'FORWARD', 'HISTORY', 'TRON', 'TROFF', 'CONT',
84
+ 'AUTO', 'EDIT', 'COPY', 'MOVE', 'FIND', 'REPLACE', 'BANK',
85
+ 'CHAIN', 'MERGE', 'CHECKSUM', 'VERSION', 'SEED', 'PROBE',
86
+ 'RESTORE', 'OPEN', 'CLOSE', 'IMPORT', 'SAMPLE', 'ESTIMATE',
87
+ 'BENCH', 'SET_STATE', 'CIRCUIT_DEF', 'APPLY_CIRCUIT',
88
+ 'CONSISTENCY', 'COMPARE', 'HEATMAP', 'ANIMATE', 'QUIZ',
89
+ 'DIFF', 'PLOT', 'THEME', 'CLIP', 'EXPLAIN',
90
+ # Gates
91
+ 'H', 'X', 'Y', 'Z', 'S', 'T', 'SDG', 'TDG', 'SX', 'ID',
92
+ 'RX', 'RY', 'RZ', 'P', 'U', 'CX', 'CZ', 'CY', 'CH',
93
+ 'SWAP', 'DCX', 'ISWAP', 'CRX', 'CRY', 'CRZ', 'CP',
94
+ 'RXX', 'RYY', 'RZZ', 'CCX', 'CSWAP',
95
+ ]
96
+
97
+
98
+ def did_you_mean(word: str) -> str | None:
99
+ """Return a suggestion for a misspelled command, or None."""
100
+ w = word.upper()
101
+ if w in _ALL_COMMANDS:
102
+ return None
103
+ matches = difflib.get_close_matches(w, _ALL_COMMANDS, n=1, cutoff=0.6)
104
+ return matches[0] if matches else None
105
+
106
+
107
+ # ═══════════════════════════════════════════════════════════════════════
108
+ # Color themes (#19)
109
+ # ═══════════════════════════════════════════════════════════════════════
110
+
111
+ THEMES = {
112
+ 'default': {'reset': '\033[0m', 'gate': '\033[36m', 'flow': '\033[33m',
113
+ 'comment': '\033[2m', 'string': '\033[32m', 'number': '\033[35m',
114
+ 'error': '\033[31m', 'bar_hi': '\033[32m', 'bar_mid': '\033[33m',
115
+ 'bar_lo': '\033[2m', 'prompt': ''},
116
+ 'retro': {'reset': '\033[0m', 'gate': '\033[32m', 'flow': '\033[32m',
117
+ 'comment': '\033[2;32m', 'string': '\033[32m', 'number': '\033[32m',
118
+ 'error': '\033[1;32m', 'bar_hi': '\033[1;32m', 'bar_mid': '\033[32m',
119
+ 'bar_lo': '\033[2;32m', 'prompt': '\033[32m'},
120
+ 'none': {'reset': '', 'gate': '', 'flow': '', 'comment': '', 'string': '',
121
+ 'number': '', 'error': '', 'bar_hi': '', 'bar_mid': '', 'bar_lo': '',
122
+ 'prompt': ''},
123
+ }
124
+
125
+
126
+ # ═══════════════════════════════════════════════════════════════════════
127
+ # Braille Bloch sphere (#6)
128
+ # ═══════════════════════════════════════════════════════════════════════
129
+
130
+ _BRAILLE_BASE = 0x2800
131
+ # Braille dot positions: col0=(0,1,2,6), col1=(3,4,5,7)
132
+ _BRAILLE_DOTS = [
133
+ (0, 0, 0x01), (0, 1, 0x02), (0, 2, 0x04), (0, 3, 0x40),
134
+ (1, 0, 0x08), (1, 1, 0x10), (1, 2, 0x20), (1, 3, 0x80),
135
+ ]
136
+
137
+
138
+ def braille_bloch(x: float, y: float, z: float, radius: int = 10) -> list[str]:
139
+ """Render a Bloch sphere in braille characters (XZ plane projection).
140
+
141
+ Returns a list of strings (lines) forming the braille image.
142
+ """
143
+ W = radius * 2 + 1 # pixels
144
+ # Braille cell = 2 wide x 4 tall pixels
145
+ cols = (W + 1) // 2
146
+ rows = (W + 3) // 4
147
+
148
+ grid = [[0] * cols for _ in range(rows)]
149
+
150
+ def _set_pixel(px: int, py: int) -> None:
151
+ if 0 <= px < W and 0 <= py < W:
152
+ bc = px // 2
153
+ br = py // 4
154
+ dx = px % 2
155
+ dy = py % 4
156
+ for dc, dr, bit in _BRAILLE_DOTS:
157
+ if dc == dx and dr == dy:
158
+ grid[br][bc] |= bit
159
+
160
+ cx, cy = radius, radius
161
+
162
+ # Draw circle
163
+ for angle in range(0, 360, 3):
164
+ rad = math.radians(angle)
165
+ px = round(cx + radius * math.cos(rad))
166
+ py = round(cy + radius * math.sin(rad))
167
+ _set_pixel(px, py)
168
+
169
+ # Axes (horizontal and vertical through center)
170
+ for i in range(W):
171
+ _set_pixel(i, cy)
172
+ _set_pixel(cx, i)
173
+
174
+ # State point
175
+ px = round(cx + x * (radius - 1))
176
+ pz = round(cy - z * (radius - 1))
177
+ # Draw a 2x2 block for visibility
178
+ for dx in range(-1, 2):
179
+ for dy in range(-1, 2):
180
+ _set_pixel(px + dx, pz + dy)
181
+
182
+ lines = []
183
+ for row in grid:
184
+ line = ''.join(chr(_BRAILLE_BASE + cell) for cell in row)
185
+ lines.append(line)
186
+ return lines
187
+
188
+
189
+ # ═══════════════════════════════════════════════════════════════════════
190
+ # QoL Mixin
191
+ # ═══════════════════════════════════════════════════════════════════════
192
+
193
+ class QoLMixin:
194
+ """Quality-of-life commands for QBasicTerminal.
195
+
196
+ Provides: COMPARE, HEATMAP, ANIMATE, QUIZ, DIFF, PLOT, THEME,
197
+ CLIP, EXPLAIN, and enhanced display features.
198
+ """
199
+
200
+ def _init_qol(self) -> None:
201
+ self._theme: dict[str, str] = THEMES['default']
202
+ self._theme_name: str = 'default'
203
+
204
+ # ── #4: "Did you mean?" in dispatch ──────────────────────────────
205
+
206
+ def _suggest_command(self, word: str) -> None:
207
+ """Print a suggestion if the word is close to a known command."""
208
+ suggestion = did_you_mean(word)
209
+ if suggestion:
210
+ self.io.writeln(f" Did you mean: {suggestion}?")
211
+
212
+ # ── #5: Status bar prompt ─────────────────────────────────────────
213
+
214
+ def _status_prompt(self) -> str:
215
+ """Build a prompt showing current config."""
216
+ parts = [f"{self.num_qubits}q"]
217
+ if self.locc_mode:
218
+ parts.append("LOCC")
219
+ if self._noise_model:
220
+ parts.append("noisy")
221
+ return ' '.join(parts) + ' ] '
222
+
223
+ # ── #6: Braille Bloch sphere ──────────────────────────────────────
224
+
225
+ def cmd_draw(self, rest: str) -> None:
226
+ """DRAW [qubit] — braille Bloch sphere."""
227
+ sv = self._active_sv
228
+ if sv is None:
229
+ self.io.writeln("?NO STATE -- RUN first")
230
+ return
231
+ n = self._active_nqubits
232
+ if rest.strip():
233
+ q = int(rest.strip())
234
+ x, y, z = self._bloch_vector(sv, q, n)
235
+ self._draw_braille_bloch(x, y, z, q)
236
+ else:
237
+ for q in range(min(n, 4)):
238
+ x, y, z = self._bloch_vector(sv, q, n)
239
+ self._draw_braille_bloch(x, y, z, q)
240
+
241
+ def _draw_braille_bloch(self, x: float, y: float, z: float, qubit: int) -> None:
242
+ label = f"q{qubit} ({x:.2f},{y:.2f},{z:.2f})"
243
+ self.io.writeln(f" {label}")
244
+ for line in braille_bloch(x, y, z):
245
+ self.io.writeln(f" {line}")
246
+
247
+ # ── #7: Color-coded LIST ──────────────────────────────────────────
248
+
249
+ def cmd_list_colored(self) -> None:
250
+ """LIST with syntax highlighting."""
251
+ if not self.program:
252
+ self.io.writeln("EMPTY PROGRAM")
253
+ return
254
+ t = self._theme
255
+ from qubasic_core.gates import GATE_TABLE, GATE_ALIASES
256
+ gate_names = set(GATE_TABLE.keys()) | set(GATE_ALIASES.keys())
257
+ flow_kw = {'FOR', 'NEXT', 'WHILE', 'WEND', 'IF', 'THEN', 'ELSE',
258
+ 'GOTO', 'GOSUB', 'RETURN', 'END', 'DO', 'LOOP', 'EXIT',
259
+ 'SUB', 'FUNCTION', 'CALL', 'SELECT', 'CASE'}
260
+ for num in sorted(self.program.keys()):
261
+ raw = self.program[num]
262
+ upper = raw.strip().upper()
263
+ if upper.startswith('REM') or upper.startswith("'"):
264
+ colored = f"{t['comment']}{raw}{t['reset']}"
265
+ else:
266
+ words = raw.split()
267
+ parts = []
268
+ for w in words:
269
+ wu = w.upper().rstrip(',').rstrip(':')
270
+ if wu in gate_names:
271
+ parts.append(f"{t['gate']}{w}{t['reset']}")
272
+ elif wu in flow_kw:
273
+ parts.append(f"{t['flow']}{w}{t['reset']}")
274
+ elif w.startswith('"') or w.startswith("'"):
275
+ parts.append(f"{t['string']}{w}{t['reset']}")
276
+ else:
277
+ parts.append(w)
278
+ colored = ' '.join(parts)
279
+ self.io.writeln(f" {num:5d} {colored}")
280
+
281
+ # ── #8: Gate throughput in RUN summary ────────────────────────────
282
+ # (Injected into cmd_run output — see terminal.py modification)
283
+
284
+ # ── #9: COMPARE ──────────────────────────────────────────────────
285
+
286
+ def cmd_compare(self, rest: str) -> None:
287
+ """COMPARE method1 method2 — run circuit with two methods and diff results."""
288
+ parts = rest.split()
289
+ if len(parts) < 2:
290
+ self.io.writeln("?USAGE: COMPARE <method1> <method2>")
291
+ return
292
+ m1, m2 = parts[0].lower(), parts[1].lower()
293
+ old_method = self.sim_method
294
+ results = {}
295
+ for m in [m1, m2]:
296
+ self.sim_method = m
297
+ old_io = self.io
298
+
299
+ class _NullIO:
300
+ def write(self, t: str) -> None: pass
301
+ def writeln(self, t: str) -> None: pass
302
+ def read_line(self, p: str) -> str: return ''
303
+ self.io = _NullIO()
304
+ try:
305
+ self.cmd_run()
306
+ finally:
307
+ self.io = old_io
308
+ results[m] = dict(self.last_counts) if self.last_counts else {}
309
+ self.sim_method = old_method
310
+ # Display comparison
311
+ all_states = sorted(set(list(results[m1].keys()) + list(results[m2].keys())))
312
+ total1 = sum(results[m1].values()) or 1
313
+ total2 = sum(results[m2].values()) or 1
314
+ self.io.writeln(f"\n {'State':>12} {m1:>12} {m2:>12} {'diff':>8}")
315
+ for s in all_states[:32]:
316
+ c1 = results[m1].get(s, 0)
317
+ c2 = results[m2].get(s, 0)
318
+ p1, p2 = c1 / total1, c2 / total2
319
+ diff = p2 - p1
320
+ self.io.writeln(f" |{s}\u27E9 {p1:>11.3%} {p2:>11.3%} {diff:>+7.3%}")
321
+ self.io.writeln('')
322
+
323
+ # ── #10: HEATMAP ─────────────────────────────────────────────────
324
+
325
+ def cmd_heatmap(self, rest: str = '') -> None:
326
+ """HEATMAP — qubit-qubit entanglement entropy grid."""
327
+ sv = self._active_sv
328
+ if sv is None:
329
+ self.io.writeln("?NO STATE -- RUN first")
330
+ return
331
+ n = self._active_nqubits
332
+ n_show = min(n, 12)
333
+ self.io.writeln(f"\n Entanglement heatmap ({n_show} qubits):")
334
+ _shades = ' \u2591\u2592\u2593\u2588'
335
+ header = ' ' + ''.join(f'{q:>4}' for q in range(n_show))
336
+ self.io.writeln(header)
337
+ sv_flat = np.ascontiguousarray(sv).ravel()
338
+ for qi in range(n_show):
339
+ row = f' {qi:>2} '
340
+ for qj in range(n_show):
341
+ if qi == qj:
342
+ row += ' - '
343
+ continue
344
+ keep = sorted({qi, qj})
345
+ trace_out = [q for q in range(n) if q not in keep]
346
+ psi_t = sv_flat.reshape([2] * n)
347
+ if trace_out:
348
+ rho = np.tensordot(psi_t, psi_t.conj(), axes=(trace_out, trace_out))
349
+ else:
350
+ rho = np.outer(sv_flat, sv_flat.conj())
351
+ rho = rho.reshape(4, 4)
352
+ ev = np.linalg.eigvalsh(rho)
353
+ ev = ev[ev > 1e-14]
354
+ S = -np.sum(ev * np.log2(ev)) if len(ev) > 0 else 0.0
355
+ idx = min(len(_shades) - 1, int(S * 2))
356
+ row += f' {_shades[idx]}{S:.0f} ' if S > 0.05 else ' . '
357
+ self.io.writeln(row)
358
+ self.io.writeln('')
359
+
360
+ # ── #12: ANIMATE ──────────────────────────────────────────────────
361
+
362
+ def cmd_animate(self, rest: str) -> None:
363
+ """ANIMATE var start end [steps] [delay] — animated parameter sweep."""
364
+ parts = rest.split()
365
+ if len(parts) < 3:
366
+ self.io.writeln("?USAGE: ANIMATE <var> <start> <end> [steps] [delay_ms]")
367
+ return
368
+ var = parts[0]
369
+ start = self.eval_expr(parts[1])
370
+ end = self.eval_expr(parts[2])
371
+ steps = int(parts[3]) if len(parts) > 3 else 10
372
+ delay = float(parts[4]) / 1000 if len(parts) > 4 else 0.3
373
+ values = [start + (end - start) * i / max(1, steps - 1) for i in range(steps)]
374
+
375
+ class _NullIO:
376
+ def write(self, t: str) -> None: pass
377
+ def writeln(self, t: str) -> None: pass
378
+ def read_line(self, p: str) -> str: return ''
379
+
380
+ for i, val in enumerate(values):
381
+ self.variables[var] = val
382
+ old_io = self.io
383
+ self.io = _NullIO()
384
+ try:
385
+ self.cmd_run()
386
+ finally:
387
+ self.io = old_io
388
+ if self.last_counts:
389
+ ranked = sorted(self.last_counts.items(), key=lambda x: -x[1])
390
+ total = sum(self.last_counts.values())
391
+ top = ranked[0]
392
+ bar = '\u2588' * int(30 * top[1] / total)
393
+ frame = f"\r {var}={val:>8.3f} |{top[0]}\u27E9 {top[1]/total:>5.1%} {bar:<30}"
394
+ old_io.write(frame)
395
+ time.sleep(delay)
396
+ self.io.writeln('')
397
+
398
+ # ── #13: QUIZ ─────────────────────────────────────────────────────
399
+
400
+ def cmd_quiz(self, rest: str = '') -> None:
401
+ """QUIZ — quantum computing quiz."""
402
+ quizzes = [
403
+ ("H|0> = ?", ["|+>", "|1>", "|->", "|0>"], 0),
404
+ ("CX|10> = ?", ["|11>", "|10>", "|00>", "|01>"], 0),
405
+ ("X|0> = ?", ["|1>", "|0>", "|+>", "|->"], 0),
406
+ ("Z|+> = ?", ["|->", "|+>", "|0>", "|1>"], 0),
407
+ ("H|1> = ?", ["|->", "|+>", "|0>", "|1>"], 0),
408
+ ("What gate creates superposition?", ["H", "X", "Z", "CX"], 0),
409
+ ("Bell state is:", ["|00>+|11>", "|00>+|01>", "|01>+|10>", "|00>-|11>"], 0),
410
+ ("CNOT flips target when control is:", ["|1>", "|0>", "|+>", "|->"], 0),
411
+ ]
412
+ q = random.choice(quizzes)
413
+ prompt, options, correct = q
414
+ self.io.writeln(f"\n QUIZ: {prompt}")
415
+ indices = list(range(len(options)))
416
+ random.shuffle(indices)
417
+ correct_label = None
418
+ for label_idx, orig_idx in enumerate(indices):
419
+ letter = chr(65 + label_idx)
420
+ marker = ""
421
+ if orig_idx == correct:
422
+ correct_label = letter
423
+ self.io.writeln(f" {letter}) {options[orig_idx]}")
424
+ try:
425
+ answer = self.io.read_line(" Your answer: ").strip().upper()
426
+ except (EOFError, KeyboardInterrupt):
427
+ self.io.writeln("")
428
+ return
429
+ if answer == correct_label:
430
+ self.io.writeln(" Correct!")
431
+ else:
432
+ self.io.writeln(f" Wrong -- the answer is {correct_label}) {options[correct]}")
433
+ self.io.writeln('')
434
+
435
+ # ── #15: DIFF ─────────────────────────────────────────────────────
436
+
437
+ def cmd_diff(self, rest: str) -> None:
438
+ """DIFF <slot> — diff current program against another bank slot."""
439
+ if not rest.strip():
440
+ self.io.writeln("?USAGE: DIFF <slot>")
441
+ return
442
+ slot = int(rest.strip())
443
+ other = self._program_slots.get(slot, {})
444
+ if not other:
445
+ self.io.writeln(f"?BANK {slot} is empty")
446
+ return
447
+ current_lines = [f"{n} {self.program[n]}" for n in sorted(self.program.keys())]
448
+ other_lines = [f"{n} {other[n]}" for n in sorted(other.keys())]
449
+ diff = list(difflib.unified_diff(other_lines, current_lines,
450
+ fromfile=f'bank {slot}', tofile='current',
451
+ lineterm=''))
452
+ if diff:
453
+ for line in diff:
454
+ self.io.writeln(f" {line}")
455
+ else:
456
+ self.io.writeln(f" No differences with bank {slot}")
457
+
458
+ # ── #16: Circuit complexity readout ────────────────────────────────
459
+
460
+ def _circuit_complexity(self) -> str:
461
+ """Return a complexity summary string for the last circuit."""
462
+ if not self.last_circuit:
463
+ return ''
464
+ qc = self.last_circuit
465
+ ops = qc.count_ops()
466
+ t_count = ops.get('t', 0) + ops.get('tdg', 0)
467
+ cx_count = ops.get('cx', 0) + ops.get('cnot', 0)
468
+ parts = []
469
+ if t_count:
470
+ parts.append(f"T-count={t_count}")
471
+ if cx_count:
472
+ parts.append(f"CNOT={cx_count}")
473
+ return ', '.join(parts)
474
+
475
+ # ── #17: PLOT ─────────────────────────────────────────────────────
476
+
477
+ def cmd_plot(self, rest: str) -> None:
478
+ """PLOT var start end [steps] — braille line plot of P(|0...0>) vs variable."""
479
+ parts = rest.split()
480
+ if len(parts) < 3:
481
+ self.io.writeln("?USAGE: PLOT <var> <start> <end> [steps]")
482
+ return
483
+ var = parts[0]
484
+ start = self.eval_expr(parts[1])
485
+ end = self.eval_expr(parts[2])
486
+ steps = int(parts[3]) if len(parts) > 3 else 20
487
+ values = [start + (end - start) * i / max(1, steps - 1) for i in range(steps)]
488
+
489
+ class _NullIO:
490
+ def write(self, t: str) -> None: pass
491
+ def writeln(self, t: str) -> None: pass
492
+ def read_line(self, p: str) -> str: return ''
493
+
494
+ xs, ys = [], []
495
+ for val in values:
496
+ self.variables[var] = val
497
+ old_io = self.io
498
+ self.io = _NullIO()
499
+ try:
500
+ self.cmd_run()
501
+ finally:
502
+ self.io = old_io
503
+ if self.last_counts:
504
+ total = sum(self.last_counts.values())
505
+ top_state = max(self.last_counts, key=self.last_counts.get)
506
+ xs.append(val)
507
+ ys.append(self.last_counts[top_state] / total)
508
+
509
+ if not xs:
510
+ self.io.writeln("?No data to plot")
511
+ return
512
+ # ASCII plot
513
+ W, H = 60, 12
514
+ y_min, y_max = min(ys), max(ys)
515
+ if y_max - y_min < 0.001:
516
+ y_max = y_min + 0.1
517
+ self.io.writeln(f"\n P(top) vs {var}: {start} to {end}")
518
+ canvas = [[' '] * W for _ in range(H)]
519
+ for i, (xv, yv) in enumerate(zip(xs, ys)):
520
+ col = int((i / max(1, len(xs) - 1)) * (W - 1))
521
+ row = H - 1 - int(((yv - y_min) / (y_max - y_min)) * (H - 1))
522
+ row = max(0, min(H - 1, row))
523
+ col = max(0, min(W - 1, col))
524
+ canvas[row][col] = '\u2022'
525
+ for r, line in enumerate(canvas):
526
+ label = f"{y_max - (y_max - y_min) * r / (H - 1):.2f}" if r in (0, H - 1) else ' '
527
+ self.io.writeln(f" {label:>5} |{''.join(line)}|")
528
+ self.io.writeln(f" {start:<10.3f}{' ' * (W - 20)}{end:>10.3f}")
529
+ self.io.writeln('')
530
+
531
+ # ── #18: UNDO with preview ────────────────────────────────────────
532
+
533
+ def cmd_undo_preview(self) -> None:
534
+ """UNDO — show what will change and restore."""
535
+ if not self._undo_stack:
536
+ self.io.writeln("?NOTHING TO UNDO")
537
+ return
538
+ prev = self._undo_stack[-1]
539
+ # Show diff
540
+ added = set(self.program.keys()) - set(prev.keys())
541
+ removed = set(prev.keys()) - set(self.program.keys())
542
+ changed = {k for k in set(self.program.keys()) & set(prev.keys())
543
+ if self.program[k] != prev[k]}
544
+ if added:
545
+ self.io.writeln(f" Undo will remove: {sorted(added)}")
546
+ if removed:
547
+ self.io.writeln(f" Undo will restore: {sorted(removed)}")
548
+ if changed:
549
+ for k in sorted(changed):
550
+ self.io.writeln(f" Undo will revert {k}: {self.program[k]} -> {prev[k]}")
551
+ # Apply
552
+ self.program = self._undo_stack.pop()
553
+ self._parsed.clear()
554
+ self.io.writeln(f"UNDO ({len(self.program)} lines)")
555
+
556
+ # ── #19: THEME ────────────────────────────────────────────────────
557
+
558
+ def cmd_theme(self, rest: str) -> None:
559
+ """THEME [name] — switch color theme (default, retro, none)."""
560
+ name = rest.strip().lower() if rest.strip() else ''
561
+ if not name:
562
+ self.io.writeln(f" Current theme: {self._theme_name}")
563
+ self.io.writeln(f" Available: {', '.join(THEMES.keys())}")
564
+ return
565
+ if name not in THEMES:
566
+ self.io.writeln(f"?Unknown theme: {name}. Available: {', '.join(THEMES.keys())}")
567
+ return
568
+ self._theme = THEMES[name]
569
+ self._theme_name = name
570
+ self.io.writeln(f"THEME = {name}")
571
+
572
+ # ── #22: EXPLAIN ──────────────────────────────────────────────────
573
+
574
+ def cmd_explain(self, rest: str = '') -> None:
575
+ """EXPLAIN — describe each program line in plain English."""
576
+ if not self.program:
577
+ self.io.writeln("EMPTY PROGRAM")
578
+ return
579
+ from qubasic_core.gates import GATE_TABLE, GATE_ALIASES
580
+ gate_desc = {
581
+ 'H': 'put qubit {q} in superposition',
582
+ 'X': 'flip qubit {q} (NOT)',
583
+ 'Y': 'Pauli-Y on qubit {q}',
584
+ 'Z': 'flip phase of qubit {q}',
585
+ 'S': 'S-phase on qubit {q}',
586
+ 'T': 'T-phase on qubit {q}',
587
+ 'SDG': 'S-dagger on qubit {q}',
588
+ 'TDG': 'T-dagger on qubit {q}',
589
+ 'SX': 'sqrt(X) on qubit {q}',
590
+ 'ID': 'identity on qubit {q}',
591
+ 'CX': 'CNOT: flip {q1} if {q0}=|1>',
592
+ 'CZ': 'CZ: phase flip if both |1>',
593
+ 'CY': 'controlled-Y',
594
+ 'CH': 'controlled-H',
595
+ 'CCX': 'Toffoli: flip {q2} if {q0},{q1}=|1>',
596
+ 'CSWAP': 'Fredkin: swap if control=|1>',
597
+ 'SWAP': 'swap qubits {q0} and {q1}',
598
+ 'RX': 'rotate {q} around X by {p}',
599
+ 'RY': 'rotate {q} around Y by {p}',
600
+ 'RZ': 'rotate {q} around Z by {p}',
601
+ 'P': 'phase({p}) on qubit {q}',
602
+ 'CP': 'controlled-phase({p})',
603
+ 'CRX': 'controlled-RX({p})',
604
+ 'CRY': 'controlled-RY({p})',
605
+ 'CRZ': 'controlled-RZ({p})',
606
+ 'RXX': 'XX interaction({p})',
607
+ 'RYY': 'YY interaction({p})',
608
+ 'RZZ': 'ZZ interaction({p})',
609
+ 'U': 'general unitary U({p})',
610
+ 'DCX': 'double-CNOT',
611
+ 'ISWAP': 'iSWAP',
612
+ }
613
+ flow_desc = {
614
+ 'FOR': 'begin loop', 'NEXT': 'end of loop',
615
+ 'WHILE': 'loop while condition holds', 'WEND': 'end of WHILE loop',
616
+ 'IF': 'conditional branch', 'GOTO': 'jump to line',
617
+ 'GOSUB': 'call subroutine', 'RETURN': 'return from subroutine',
618
+ 'END': 'stop execution', 'MEASURE': 'measure all qubits',
619
+ 'BARRIER': 'optimization barrier', 'PRINT': 'output text or value',
620
+ 'LET': 'set variable', 'DIM': 'create array',
621
+ 'DO': 'begin DO loop', 'LOOP': 'end of DO loop',
622
+ 'EXIT': 'exit loop early', 'SELECT': 'begin SELECT CASE',
623
+ 'CASE': 'match case', 'CALL': 'call subroutine',
624
+ 'SUB': 'define subroutine', 'FUNCTION': 'define function',
625
+ 'DATA': 'inline data', 'READ': 'read from DATA',
626
+ 'RESTORE': 'reset DATA pointer',
627
+ }
628
+ for num in sorted(self.program.keys()):
629
+ raw = self.program[num].strip()
630
+ upper = raw.upper()
631
+ if upper.startswith('REM') or upper.startswith("'"):
632
+ self.io.writeln(f" {num:5d} (comment)")
633
+ continue
634
+ # Split on colons and explain each part
635
+ parts = [s.strip() for s in raw.split(':') if s.strip()]
636
+ descs = []
637
+ for part in parts:
638
+ word = part.split()[0].upper() if part.split() else ''
639
+ canonical = GATE_ALIASES.get(word, word)
640
+ if canonical in gate_desc:
641
+ info = GATE_TABLE.get(canonical)
642
+ args = part.split(None, 1)[1].strip() if ' ' in part else ''
643
+ arg_list = [a.strip() for a in args.split(',')]
644
+ n_params = info[0] if info else 0
645
+ params = arg_list[:n_params]
646
+ qubits = arg_list[n_params:]
647
+ fmt = gate_desc[canonical]
648
+ fmt = fmt.replace('{q}', ', '.join(qubits) if qubits else '?')
649
+ fmt = fmt.replace('{p}', ', '.join(params) if params else '?')
650
+ for i, q in enumerate(qubits):
651
+ fmt = fmt.replace(f'{{q{i}}}', q)
652
+ descs.append(fmt)
653
+ elif word in flow_desc:
654
+ descs.append(flow_desc[word])
655
+ elif word.startswith('@'):
656
+ reg = word[1:]
657
+ inner = part.split(None, 1)[1].strip() if ' ' in part else ''
658
+ descs.append(f"@{reg}: {inner}")
659
+ elif word == 'SEND':
660
+ descs.append("measure and send classical bit")
661
+ elif word == 'SHARE':
662
+ descs.append("create shared entanglement")
663
+ else:
664
+ descs.append(part)
665
+ self.io.writeln(f" {num:5d} {'; '.join(descs)}")
666
+
667
+ # ── #24: CLIP ─────────────────────────────────────────────────────
668
+
669
+ def cmd_clip(self, rest: str = '') -> None:
670
+ """CLIP — copy last results to clipboard (if available)."""
671
+ lines = []
672
+ if self.last_counts:
673
+ total = sum(self.last_counts.values())
674
+ for s, c in sorted(self.last_counts.items(), key=lambda x: -x[1]):
675
+ lines.append(f"|{s}> {c}/{total} ({100*c/total:.1f}%)")
676
+ elif self.last_sv is not None:
677
+ n = self._active_nqubits
678
+ sv = np.ascontiguousarray(self.last_sv).ravel()
679
+ for i, a in enumerate(sv):
680
+ if abs(a) > 1e-8:
681
+ lines.append(f"|{format(i, f'0{n}b')}> {a.real:+.4f}{a.imag:+.4f}j")
682
+ else:
683
+ self.io.writeln("?NO RESULTS TO COPY")
684
+ return
685
+ text = '\n'.join(lines)
686
+ try:
687
+ if sys.platform == 'win32':
688
+ import subprocess
689
+ subprocess.run(['clip'], input=text.encode(), check=True)
690
+ elif sys.platform == 'darwin':
691
+ import subprocess
692
+ subprocess.run(['pbcopy'], input=text.encode(), check=True)
693
+ else:
694
+ import subprocess
695
+ subprocess.run(['xclip', '-selection', 'clipboard'],
696
+ input=text.encode(), check=True, timeout=2)
697
+ self.io.writeln(f"COPIED {len(lines)} lines to clipboard")
698
+ except Exception:
699
+ self.io.writeln("?Clipboard not available. Output:")
700
+ for line in lines[:20]:
701
+ self.io.writeln(f" {line}")
702
+ if len(lines) > 20:
703
+ self.io.writeln(f" ... ({len(lines) - 20} more)")
@@ -57,6 +57,7 @@ from qubasic_core.program_mgmt import ProgramMgmtMixin
57
57
  from qubasic_core.profiler import ProfilerMixin
58
58
  from qubasic_core.noise_mixin import NoiseMixin
59
59
  from qubasic_core.state_display import StateDisplayMixin
60
+ from qubasic_core.qol import QoLMixin, did_you_mean, tip_of_the_day, quantum_spin
60
61
  from qubasic_core.errors import QBasicError, QBasicBuildError, QBasicRangeError
61
62
  from qubasic_core.io_protocol import StdIOPort
62
63
  from qubasic_core.parser import parse_stmt
@@ -126,7 +127,7 @@ class QBasicTerminal(Engine, ExecutorMixin, ExpressionMixin, DisplayMixin, DemoM
126
127
  LOCCMixin, ControlFlowMixin, FileIOMixin, AnalysisMixin,
127
128
  SweepMixin, MemoryMixin, StringMixin, ScreenMixin, ClassicMixin,
128
129
  SubroutineMixin, DebugMixin, ProgramMgmtMixin, ProfilerMixin,
129
- NoiseMixin, StateDisplayMixin):
130
+ NoiseMixin, StateDisplayMixin, QoLMixin):
130
131
  # Architecture: QBasicTerminal composes Engine (state) + 16 mixins (behavior).
131
132
  #
132
133
  # Mixin map (each provides specific methods; see TerminalProtocol for contract):
@@ -198,6 +199,10 @@ class QBasicTerminal(Engine, ExecutorMixin, ExpressionMixin, DisplayMixin, DemoM
198
199
  # Reject absolute paths (Unix / or Windows drive letters)
199
200
  if os.path.isabs(path):
200
201
  raise ValueError("Absolute paths are not allowed")
202
+ # Catch Windows drive letters on non-Windows hosts (e.g. CI on Linux)
203
+ import re as _re
204
+ if _re.match(r'^[A-Za-z]:[/\\]', path):
205
+ raise ValueError("Absolute paths are not allowed")
201
206
  # Reject UNC paths (\\server\share) and extended-length paths (\\?\)
202
207
  if path.startswith('\\\\'):
203
208
  raise ValueError("UNC/extended paths are not allowed")
@@ -215,6 +220,7 @@ class QBasicTerminal(Engine, ExecutorMixin, ExpressionMixin, DisplayMixin, DemoM
215
220
  self._init_program_mgmt()
216
221
  self._init_profiler()
217
222
  self._init_file_handles()
223
+ self._init_qol()
218
224
 
219
225
  # ── Backend factory ─────────────────────────────────────────────
220
226
 
@@ -340,10 +346,27 @@ class QBasicTerminal(Engine, ExecutorMixin, ExpressionMixin, DisplayMixin, DemoM
340
346
  matches += [s for s in self.subroutines if s.startswith(t)]
341
347
  matches += [v for v in self.variables if v.upper().startswith(t)]
342
348
  matches += [r for r in self.registers if r.upper().startswith(t)]
349
+ # File path completion for SAVE/LOAD/INCLUDE
350
+ try:
351
+ line_buf = readline.get_line_buffer()
352
+ first = line_buf.split()[0].upper() if line_buf.split() else ''
353
+ if first in ('SAVE', 'LOAD', 'INCLUDE', 'IMPORT', 'CHAIN', 'MERGE'):
354
+ import glob as _glob
355
+ pattern = text + '*.qb' if not text.endswith('.qb') else text + '*'
356
+ matches = _glob.glob(pattern)
357
+ except Exception:
358
+ pass
343
359
  return matches[state] + ' ' if state < len(matches) else None
344
360
  readline.set_completer(completer)
345
361
  readline.parse_and_bind('tab: complete')
346
362
  readline.set_completer_delims(' \t\n')
363
+ # Bind F1-F3 to load demos (terminal permitting)
364
+ try:
365
+ readline.parse_and_bind('"\\eOP": "DEMO BELL\\n"')
366
+ readline.parse_and_bind('"\\eOQ": "DEMO GHZ\\n"')
367
+ readline.parse_and_bind('"\\eOR": "DEMO GROVER\\n"')
368
+ except Exception:
369
+ pass
347
370
  except ImportError:
348
371
  # On Windows, readline is not bundled; try pyreadline3 as fallback
349
372
  if sys.platform == 'win32':
@@ -364,10 +387,12 @@ class QBasicTerminal(Engine, ExecutorMixin, ExpressionMixin, DisplayMixin, DemoM
364
387
  except Exception:
365
388
  pass
366
389
  self.print_banner()
390
+ self.io.writeln(f" Tip: {tip_of_the_day()}")
367
391
  self._setup_readline()
368
392
  while True:
369
393
  try:
370
- line = self.io.read_line(self._prompt).strip()
394
+ prompt = self._status_prompt() if self._prompt == '] ' else self._prompt
395
+ line = self.io.read_line(prompt).strip()
371
396
  if line:
372
397
  self.process(line)
373
398
  except KeyboardInterrupt:
@@ -461,6 +486,11 @@ class QBasicTerminal(Engine, ExecutorMixin, ExpressionMixin, DisplayMixin, DemoM
461
486
  'CIRCUIT_DEF': 'cmd_circuit_def', 'APPLY_CIRCUIT': 'cmd_apply_circuit',
462
487
  'HELP': 'cmd_help', 'CONSISTENCY': 'cmd_consistency',
463
488
  'SEED': 'cmd_seed',
489
+ # QoL features
490
+ 'COMPARE': 'cmd_compare', 'HEATMAP': 'cmd_heatmap',
491
+ 'ANIMATE': 'cmd_animate', 'QUIZ': 'cmd_quiz',
492
+ 'DIFF': 'cmd_diff', 'PLOT': 'cmd_plot', 'THEME': 'cmd_theme',
493
+ 'CLIP': 'cmd_clip', 'EXPLAIN': 'cmd_explain', 'DRAW': 'cmd_draw',
464
494
  }
465
495
 
466
496
  def dispatch(self, line: str) -> None:
@@ -496,6 +526,7 @@ class QBasicTerminal(Engine, ExecutorMixin, ExpressionMixin, DisplayMixin, DemoM
496
526
  self.run_immediate(line)
497
527
  except Exception as e:
498
528
  self.io.writeln(f"?SYNTAX ERROR: {e}")
529
+ self._suggest_command(cmd)
499
530
 
500
531
  def _quit(self) -> None:
501
532
  """Exit the REPL by raising EOFError."""
@@ -607,8 +638,9 @@ class QBasicTerminal(Engine, ExecutorMixin, ExpressionMixin, DisplayMixin, DemoM
607
638
  if not self.program:
608
639
  self.io.writeln("EMPTY PROGRAM")
609
640
  return
610
- lines = sorted(self.program.keys())
611
- for num in lines:
641
+ if self._theme_name != 'none' and sys.stdout.isatty():
642
+ return self.cmd_list_colored()
643
+ for num in sorted(self.program.keys()):
612
644
  self.io.writeln(f" {num:5d} {self.program[num]}")
613
645
 
614
646
  def cmd_new(self, *, silent: bool = False) -> None:
@@ -1170,16 +1202,22 @@ class QBasicTerminal(Engine, ExecutorMixin, ExpressionMixin, DisplayMixin, DemoM
1170
1202
  _noise_tag = f"noise=depol({m['noise_depol_p']})" if m['noise_depol_p'] > 0 else "noise=on"
1171
1203
  _meta_parts.append(_noise_tag)
1172
1204
  _meta = ', '.join(_meta_parts)
1205
+ _throughput = f", {m['gates']/m['time_s']:.0f} gates/s" if m['time_s'] > 0.001 and m['gates'] > 0 else ""
1206
+ _complexity = self._circuit_complexity()
1207
+ _cx_str = f", {_complexity}" if _complexity else ""
1173
1208
  self.io.writeln(f"\nRAN {len(self.program)} lines, {self.num_qubits} qubits, "
1174
1209
  f"{self.shots} shots in {m['time_s']:.2f}s "
1175
- f"[depth={m['depth']}, gates={m['gates']}, {_meta}]")
1210
+ f"[depth={m['depth']}, gates={m['gates']}{_throughput}, {_meta}{_cx_str}]")
1176
1211
  if method in ('unitary', 'superop'):
1177
1212
  pass # matrix already displayed above
1178
1213
  elif has_measure and self.last_counts:
1179
1214
  self.print_histogram(self.last_counts)
1180
1215
  self._auto_display()
1181
1216
  else:
1182
- self.io.writeln("(no MEASURE in program use STATE or PROBS to inspect)")
1217
+ self.io.writeln("(no MEASURE in program \u2014 use STATE or PROBS to inspect)")
1218
+ # Sound on completion for long runs (#25)
1219
+ if m['time_s'] > 2.0 and sys.stdout.isatty():
1220
+ self.io.write('\a')
1183
1221
 
1184
1222
  def cmd_sample(self, rest: str = '') -> None:
1185
1223
  """SAMPLE [shots] — sample the current circuit using SamplerV2 primitive."""
@@ -1267,8 +1305,8 @@ class QBasicTerminal(Engine, ExecutorMixin, ExpressionMixin, DisplayMixin, DemoM
1267
1305
  max_iterations=self._max_iterations, qc=qc,
1268
1306
  )
1269
1307
 
1270
- self.io.writeln(f"STEP MODE {len(sorted_lines)} lines, {self.num_qubits} qubits")
1271
- self.io.writeln("Press ENTER to advance, Q to quit\n")
1308
+ self.io.writeln(f"STEP MODE \u2014 {len(sorted_lines)} lines, {self.num_qubits} qubits")
1309
+ self.io.writeln("Press ENTER to advance, A for auto-play, Q to quit\n")
1272
1310
 
1273
1311
  while ctx.ip < len(sorted_lines):
1274
1312
  ctx.iteration_count += 1
@@ -1295,21 +1333,35 @@ class QBasicTerminal(Engine, ExecutorMixin, ExpressionMixin, DisplayMixin, DemoM
1295
1333
  except Exception:
1296
1334
  self.io.writeln(" (state unavailable)")
1297
1335
 
1298
- # Wait for input
1299
- try:
1300
- user = self.io.read_line(" [ENTER/Q] ").strip().upper()
1301
- if user == 'Q':
1302
- self.io.writeln("STOPPED")
1336
+ # Wait for input (or auto-advance)
1337
+ if not getattr(self, '_step_auto', False):
1338
+ try:
1339
+ user = self.io.read_line(" [ENTER/A/Q] ").strip().upper()
1340
+ if user == 'Q':
1341
+ self.io.writeln("STOPPED")
1342
+ return
1343
+ if user == 'A' or user.startswith('A'):
1344
+ self._step_auto = True
1345
+ delay = 0.5
1346
+ if len(user) > 1:
1347
+ try:
1348
+ delay = float(user[1:]) / 1000
1349
+ except ValueError:
1350
+ pass
1351
+ self._step_delay = delay
1352
+ except (KeyboardInterrupt, EOFError):
1353
+ self.io.writeln("\nSTOPPED")
1303
1354
  return
1304
- except (KeyboardInterrupt, EOFError):
1305
- self.io.writeln("\nSTOPPED")
1306
- return
1355
+ if getattr(self, '_step_auto', False):
1356
+ import time as _time
1357
+ _time.sleep(getattr(self, '_step_delay', 0.5))
1307
1358
 
1308
1359
  if isinstance(result, int):
1309
1360
  ctx.ip = result
1310
1361
  else:
1311
1362
  ctx.ip += 1
1312
1363
 
1364
+ self._step_auto = False
1313
1365
  self.io.writeln("DONE")
1314
1366
 
1315
1367
  # run_immediate provided by ExecutorMixin.
@@ -1996,12 +2048,7 @@ class QBasicTerminal(Engine, ExecutorMixin, ExpressionMixin, DisplayMixin, DemoM
1996
2048
  # cmd_clear provided by ProgramMgmtMixin.
1997
2049
 
1998
2050
  def cmd_undo(self) -> None:
1999
- if not self._undo_stack:
2000
- self.io.writeln("NOTHING TO UNDO")
2001
- return
2002
- self.program = self._undo_stack.pop()
2003
- self._parsed = {num: parse_stmt(s) for num, s in self.program.items()}
2004
- self.io.writeln(f"UNDO ({len(self.program)} lines)")
2051
+ return self.cmd_undo_preview()
2005
2052
 
2006
2053
  # LOCC execution (_locc_run, _locc_exec_line, etc.) provided by LOCCMixin.
2007
2054
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes