shortcutxl 0.2.13 → 0.2.14

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 (31) hide show
  1. package/dist/core/keybindings.js +3 -1
  2. package/dist/core/settings-manager.js +29 -0
  3. package/dist/custom/install-utils.js +0 -3
  4. package/dist/custom/prompts/api.js +1 -1
  5. package/dist/custom/providers/provider-ids.js +1 -0
  6. package/dist/custom/tools/excel-approval.js +180 -0
  7. package/dist/custom/tools/excel-exec.js +66 -11
  8. package/dist/custom/tools/task/render.js +1 -1
  9. package/dist/main.js +12 -1
  10. package/dist/modes/interactive/components/footer.js +10 -0
  11. package/dist/modes/interactive/components/settings-selector.js +11 -0
  12. package/dist/modes/interactive/interactive-mode.js +29 -5
  13. package/package.json +5 -3
  14. package/xll/ShortcutXL.xll +0 -0
  15. package/xll/modules/shortcut_xl/__init__.py +29 -14
  16. package/xll/modules/shortcut_xl/_com.py +1 -0
  17. package/xll/modules/shortcut_xl/_diff_highlight.py +133 -91
  18. package/xll/modules/shortcut_xl/_exec_entry.py +150 -0
  19. package/xll/modules/shortcut_xl/_log.py +1 -1
  20. package/xll/modules/shortcut_xl/_managed.py +15 -9
  21. package/xll/modules/shortcut_xl/_threading.py +4 -3
  22. package/xll/modules/shortcut_xl/_tracking.py +8 -2
  23. package/xll/modules/shortcut_xl/api/__init__.py +2 -2
  24. package/xll/modules/shortcut_xl/api/format.py +10 -5
  25. package/xll/modules/shortcut_xl/api/range_formatter.py +4 -4
  26. package/xll/modules/shortcut_xl/api/workbook.py +3 -8
  27. package/xll/modules/shortcut_xl/api/worksheet.py +7 -7
  28. package/xll/modules/shortcut_xl/api-reference.py +3 -0
  29. /package/skills/{COM-advanced-api → com-advanced-api}/SKILL.md +0 -0
  30. /package/skills/{COM-advanced-api → com-advanced-api}/excel-type-library.py +0 -0
  31. /package/skills/{COM-advanced-api → com-advanced-api}/office-type-library.py +0 -0
@@ -1,37 +1,47 @@
1
- """Diff highlighting — CF overlay + MessageBox approval for cell changes.
1
+ """Diff highlighting — CF overlay for cell changes + revert.
2
2
 
3
3
  After user code executes and dirty cells are collected, this module:
4
4
  1. Applies Conditional Formatting to highlight changed cells (yellow)
5
- 2. Shows a Win32 MessageBox asking the user to keep or reject changes
6
- 3. If rejected, reverts cells using oldValue/oldFormula from the diff
7
- 4. Always cleans up CF rules before returning
5
+ skipped for >10K changes (too many COM calls)
6
+ 2. Stores CF state and revert data keyed by transaction ID
8
7
 
9
- Performance: CF rules are applied via batched Union ranges (not per-cell),
10
- so highlighting cost is O(n / batch_size) COM calls, not O(n).
11
- Revert writes are per-cell but in-process COM (~microseconds each).
8
+ TS only receives the cfTxId. Revert data stays server-side no
9
+ round-trip through HTTP/JSON. Cleanup and revert are triggered by
10
+ the TS layer via /exec control requests referencing the cfTxId.
11
+
12
+ Thread safety: all public functions are called via _run_on_main
13
+ (serialized on the main thread) AND s_exec_lock in C serializes
14
+ all /exec requests. No concurrent access to module-level state.
12
15
  """
13
16
 
14
- import ctypes
15
17
  from shortcut_xl._log import xl_log
16
18
 
17
- # Win32 MessageBox constants
18
- _MB_YESNO = 0x04
19
- _MB_ICONQUESTION = 0x20
20
- _IDYES = 6
21
-
22
19
  # Excel constants
23
20
  _XL_EXPRESSION = 2 # xlExpression CF type
24
21
  _XL_CALC_MANUAL = -4135 # xlCalculationManual
25
22
  _HIGHLIGHT_COLOR = 0x00FFFF # yellow (BGR for #FFFF00) — Track Changes color
26
23
 
27
- # Max length for comma-separated address string (Excel limit)
28
- _ADDR_BATCH_LIMIT = 255
24
+ # Thresholds
25
+ _MAX_HIGHLIGHT_CELLS = 10_000 # skip CF above this — too many COM calls
26
+ _ADDR_BATCH_LIMIT = 255 # max chars per comma-separated address string
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Transaction state keyed by ID
30
+ # ---------------------------------------------------------------------------
31
+ _next_tx_id = 0
32
+ _cf_state = {} # tx_id -> list of CF rule COM objects
33
+ _revert_data = {} # tx_id -> list of dirty cell dicts (for server-side revert)
34
+ _MAX_TX_ENTRIES = 10 # evict oldest if exceeded (guards against leaked entries)
29
35
 
30
36
 
31
37
  def _group_by_sheet(dirty_cells):
32
38
  by_sheet = {}
33
39
  for cell in dirty_cells:
34
- by_sheet.setdefault(cell['sheet'], []).append(cell)
40
+ sheet = cell.get('sheet', '')
41
+ if not sheet:
42
+ xl_log(f"_group_by_sheet: skipping cell with no sheet key: {cell}")
43
+ continue
44
+ by_sheet.setdefault(sheet, []).append(cell)
35
45
  return by_sheet
36
46
 
37
47
 
@@ -58,40 +68,116 @@ def _build_union(app, ws, addresses):
58
68
 
59
69
 
60
70
  def _apply_cf_highlights(app, by_sheet):
61
- """Apply CF rules to highlight changed cells. Returns cleanup state."""
62
- cf_state = [] # (ws, count_before) for cleanup
71
+ """Apply CF rules to highlight changed cells. Returns list of rule COM objects."""
72
+ rules = []
63
73
  for sheet_name, cells in by_sheet.items():
64
74
  try:
65
75
  ws = app.Worksheets(sheet_name)
66
- count_before = ws.Cells.FormatConditions.Count
67
76
  rng = _build_union(app, ws, [c['address'] for c in cells])
68
77
  if rng is None:
69
78
  continue
70
- rng.FormatConditions.Add(Type=_XL_EXPRESSION, Formula1="=TRUE")
71
- rule = rng.FormatConditions(rng.FormatConditions.Count)
79
+ rule = rng.FormatConditions.Add(_XL_EXPRESSION, None, "=TRUE")
72
80
  rule.Interior.Color = _HIGHLIGHT_COLOR
73
- cf_state.append((ws, count_before))
81
+ rules.append(rule)
74
82
  except Exception as e:
75
83
  xl_log(f"_apply_cf_highlights({sheet_name}): {e}")
76
- return cf_state
84
+ return rules
85
+
86
+
87
+ def highlight_changes(app, dirty_cells):
88
+ """Apply CF highlights and store revert data for changed cells.
89
+
90
+ Returns a transaction ID (string) for later cleanup/revert via
91
+ cleanup_by_tx or revert_by_tx. Always returns an ID, even if no
92
+ highlights were applied (for consistent API).
93
+ """
94
+ global _next_tx_id
95
+
96
+ tx_id = str(_next_tx_id)
97
+ _next_tx_id += 1
98
+
99
+ # Evict oldest entries if we're at the cap (guards against leaks
100
+ # when TS crashes before sending cleanup).
101
+ while len(_revert_data) >= _MAX_TX_ENTRIES:
102
+ oldest = next(iter(_revert_data))
103
+ _evict_tx(oldest)
104
+
105
+ # Always store revert data (even above highlight threshold).
106
+ if dirty_cells:
107
+ _revert_data[tx_id] = dirty_cells
108
+
109
+ if not dirty_cells or len(dirty_cells) > _MAX_HIGHLIGHT_CELLS:
110
+ return tx_id
111
+
112
+ by_sheet = _group_by_sheet(dirty_cells)
113
+ try:
114
+ app.ScreenUpdating = False
115
+ except Exception:
116
+ pass
77
117
 
118
+ rules = _apply_cf_highlights(app, by_sheet)
119
+ if rules:
120
+ _cf_state[tx_id] = rules
78
121
 
79
- def _cleanup_cf(cf_state):
80
- """Remove CF rules added during highlighting."""
81
- for ws, count_before in cf_state:
122
+ try:
123
+ app.ScreenUpdating = True
124
+ except Exception:
125
+ pass
126
+
127
+ return tx_id
128
+
129
+
130
+ def _evict_tx(tx_id):
131
+ """Remove all state for a transaction (CF rules + revert data)."""
132
+ _revert_data.pop(tx_id, None)
133
+ for rule in _cf_state.pop(tx_id, []):
134
+ try:
135
+ rule.Delete()
136
+ except Exception:
137
+ pass
138
+
139
+
140
+ def cleanup_by_tx(tx_id):
141
+ """Remove CF highlights and revert data for a transaction.
142
+
143
+ Called after user accepts changes — clears both CF rules and stored
144
+ revert data so the transaction is fully released.
145
+ """
146
+ tx_id = str(tx_id)
147
+ _revert_data.pop(tx_id, None)
148
+ rules = _cf_state.pop(tx_id, [])
149
+ for rule in rules:
82
150
  try:
83
- while ws.Cells.FormatConditions.Count > count_before:
84
- ws.Cells.FormatConditions(
85
- ws.Cells.FormatConditions.Count).Delete()
151
+ rule.Delete()
86
152
  except Exception as e:
87
- xl_log(f"_cleanup_cf: {e}")
153
+ xl_log(f"cleanup_by_tx: {e}")
154
+
88
155
 
156
+ def revert_by_tx(app, tx_id):
157
+ """Look up revert data by transaction ID and restore old values.
89
158
 
90
- def _revert_cells(app, dirty_cells):
159
+ Returns the number of cells reverted (0 if tx_id not found).
160
+ Also cleans up CF highlights for the transaction.
161
+ """
162
+ tx_id = str(tx_id)
163
+ dirty_cells = _revert_data.pop(tx_id, None)
164
+ # Clean up CF highlights too.
165
+ for rule in _cf_state.pop(tx_id, []):
166
+ try:
167
+ rule.Delete()
168
+ except Exception as e:
169
+ xl_log(f"revert_by_tx cleanup: {e}")
170
+ if not dirty_cells:
171
+ return 0
172
+ revert_cells(app, dirty_cells)
173
+ return len(dirty_cells)
174
+
175
+
176
+ def revert_cells(app, dirty_cells):
91
177
  """Restore old values/formulas for rejected changes.
92
178
 
93
- Sets calc to manual during revert to avoid expensive recalcs
94
- on each write, then restores original calc mode.
179
+ Takes an explicit list of dirty cell dicts. All data stays in-process
180
+ (no JSON round-trip) datetime and other Python types are preserved.
95
181
  """
96
182
  orig_calc = None
97
183
  try:
@@ -99,78 +185,34 @@ def _revert_cells(app, dirty_cells):
99
185
  app.Calculation = _XL_CALC_MANUAL
100
186
  except Exception:
101
187
  pass
188
+ try:
189
+ app.ScreenUpdating = False
190
+ except Exception:
191
+ pass
102
192
 
103
193
  for sheet_name, cells in _group_by_sheet(dirty_cells).items():
104
194
  try:
105
195
  ws = app.Worksheets(sheet_name)
106
196
  for cell in cells:
107
- addr = cell['address']
197
+ addr = cell.get('address', '')
198
+ if not addr:
199
+ continue
108
200
  try:
109
201
  if cell.get('oldFormula'):
110
202
  ws.Range(addr).Formula = cell['oldFormula']
111
203
  else:
112
- ws.Range(addr).Value = cell['oldValue']
204
+ ws.Range(addr).Value = cell.get('oldValue')
113
205
  except Exception as e:
114
- xl_log(f"_revert_cells({sheet_name}!{addr}): {e}")
206
+ xl_log(f"revert_cells({sheet_name}!{addr}): {e}")
115
207
  except Exception as e:
116
- xl_log(f"_revert_cells({sheet_name}): {e}")
208
+ xl_log(f"revert_cells({sheet_name}): {e}")
117
209
 
210
+ try:
211
+ app.ScreenUpdating = True
212
+ except Exception:
213
+ pass
118
214
  if orig_calc is not None:
119
215
  try:
120
216
  app.Calculation = orig_calc
121
217
  except Exception:
122
218
  pass
123
-
124
-
125
- def highlight_and_approve(app, dirty_cells):
126
- """Highlight changed cells with CF, show MessageBox for user approval.
127
-
128
- Returns True if approved, False if rejected.
129
- On rejection, all changes are reverted from dirty_cells' oldValue/oldFormula.
130
- CF highlighting is always cleaned up before returning.
131
- """
132
- if not dirty_cells:
133
- return True
134
-
135
- by_sheet = _group_by_sheet(dirty_cells)
136
- cf_state = []
137
-
138
- try:
139
- # Phase 1: Apply CF highlights
140
- try:
141
- app.ScreenUpdating = False
142
- except Exception:
143
- pass
144
- cf_state = _apply_cf_highlights(app, by_sheet)
145
- try:
146
- app.ScreenUpdating = True
147
- except Exception:
148
- pass
149
-
150
- # Phase 2: MessageBox — modal to Excel window
151
- n = len(dirty_cells)
152
- sheet_summary = ", ".join(
153
- f"{name} ({len(cells)})" for name, cells in by_sheet.items()
154
- )
155
- msg = (
156
- f"ShortcutXL modified {n} cell{'s' if n != 1 else ''} "
157
- f"in: {sheet_summary}\n\n"
158
- "Keep these changes?"
159
- )
160
- try:
161
- hwnd = app.Hwnd
162
- except Exception:
163
- hwnd = 0
164
- result = ctypes.windll.user32.MessageBoxW(
165
- hwnd, msg, "ShortcutXL", _MB_YESNO | _MB_ICONQUESTION
166
- )
167
- approved = (result == _IDYES)
168
-
169
- # Phase 3: Revert if rejected
170
- if not approved:
171
- _revert_cells(app, dirty_cells)
172
-
173
- return approved
174
-
175
- finally:
176
- _cleanup_cf(cf_state)
@@ -0,0 +1,150 @@
1
+ """HTTP entry point — bridges run_managed to the C HTTP server.
2
+
3
+ C calls dispatch_request(body_json) for every POST /exec.
4
+ This function always returns valid JSON. C never builds JSON.
5
+
6
+ Two request shapes:
7
+ {"code": "..."} → exec_and_respond
8
+ {"control": "revert"|"cleanup", "cfTxId": "..."} → handle_control
9
+ """
10
+
11
+ import io
12
+ import json
13
+ import sys
14
+ import traceback
15
+
16
+ from shortcut_xl._com import xl_app
17
+ from shortcut_xl._diff_highlight import (
18
+ cleanup_by_tx,
19
+ highlight_changes,
20
+ revert_by_tx,
21
+ )
22
+ from shortcut_xl._managed import run_managed
23
+ from shortcut_xl._threading import _run_on_main
24
+
25
+ _MAX_DIFF_JSON_CELLS = 50
26
+
27
+
28
+ def _format_cell_value(v):
29
+ """Format a cell value for JSON display."""
30
+ if v is None:
31
+ return 'empty'
32
+ if isinstance(v, bool):
33
+ return 'TRUE' if v else 'FALSE'
34
+ if isinstance(v, int):
35
+ return v
36
+ if isinstance(v, str):
37
+ return 'empty' if len(v) == 0 else v
38
+ try:
39
+ f = float(v)
40
+ # Only convert to int if it fits exactly (avoids precision loss for large floats)
41
+ i = int(f)
42
+ return i if f == i and abs(i) <= 2**53 else round(f, 10)
43
+ except (TypeError, ValueError):
44
+ return str(v)
45
+
46
+
47
+ def _build_diff(dirty):
48
+ """Build structured diff payload for the TUI approval component."""
49
+ cells = []
50
+ for c in dirty[:_MAX_DIFF_JSON_CELLS]:
51
+ entry = {
52
+ 'cell': f"{c.get('sheet', '')}!{c.get('address', '')}",
53
+ 'before': _format_cell_value(c.get('oldValue')),
54
+ 'after': _format_cell_value(c.get('value')),
55
+ }
56
+ if c.get('oldFormula'):
57
+ entry['oldFormula'] = c['oldFormula']
58
+ if c.get('formula'):
59
+ entry['formula'] = c['formula']
60
+ cells.append(entry)
61
+ return {'cells': cells, 'total': len(dirty)}
62
+
63
+
64
+ def _json_default(obj):
65
+ """Fallback serializer for non-standard types (e.g. datetime)."""
66
+ try:
67
+ return str(obj)
68
+ except Exception:
69
+ return None
70
+
71
+
72
+ def dispatch_request(body_json):
73
+ """Single entry point for all POST /exec requests.
74
+
75
+ Always returns a valid JSON string. C sends this verbatim.
76
+ """
77
+ try:
78
+ request = json.loads(body_json)
79
+ except (json.JSONDecodeError, TypeError) as e:
80
+ return json.dumps({'ok': False, 'error': f'Invalid JSON: {e}'})
81
+
82
+ if not isinstance(request, dict):
83
+ return json.dumps({'ok': False, 'error': "request must be a JSON object"})
84
+
85
+ if 'code' in request:
86
+ return exec_and_respond(request['code'], auto_approve=request.get('autoApprove', False))
87
+ elif 'control' in request:
88
+ return handle_control(request)
89
+ else:
90
+ return json.dumps({'ok': False, 'error': "request must have 'code' or 'control' field"})
91
+
92
+
93
+ def exec_and_respond(code_str, *, auto_approve=False):
94
+ """Execute user code and return a JSON response string.
95
+
96
+ Owns stdout/stderr capture, calls run_managed (pure), applies
97
+ CF highlights, and builds the response. Always returns valid JSON.
98
+
99
+ When *auto_approve* is True the caller already knows it will accept
100
+ any changes, so we skip CF highlighting entirely — no highlights to
101
+ apply, no revert data to store, no cleanup round-trip needed.
102
+ """
103
+ sio = io.StringIO()
104
+ old_out, old_err = sys.stdout, sys.stderr
105
+ sys.stdout = sys.stderr = sio
106
+ try:
107
+ dirty = run_managed(code_str)
108
+ output = sio.getvalue()
109
+
110
+ response = {'ok': True, 'output': output}
111
+ if dirty:
112
+ response['diff'] = _build_diff(dirty)
113
+ # Only apply CF highlights when the user may review changes.
114
+ if not auto_approve:
115
+ cf_tx_id = _run_on_main(lambda: highlight_changes(xl_app(), dirty))
116
+ response['cfTxId'] = cf_tx_id
117
+ return json.dumps(response, default=_json_default)
118
+ except Exception:
119
+ output = sio.getvalue()
120
+ tb = traceback.format_exc()
121
+ error = f"{output}{tb}" if output else tb
122
+ return json.dumps({'ok': False, 'error': error})
123
+ finally:
124
+ sys.stdout, sys.stderr = old_out, old_err
125
+
126
+
127
+ def handle_control(request):
128
+ """Handle control commands (revert, cleanup).
129
+
130
+ Revert data is stored server-side in _diff_highlight, keyed by cfTxId.
131
+ TS only sends the ID — no dirty cell data round-trips through HTTP.
132
+ """
133
+ action = request.get('control')
134
+ cf_tx_id = request.get('cfTxId')
135
+
136
+ if action == 'revert':
137
+ if cf_tx_id is None:
138
+ return json.dumps({'ok': False, 'error': "revert requires cfTxId"})
139
+ n = _run_on_main(lambda: revert_by_tx(xl_app(), cf_tx_id))
140
+ if n == 0:
141
+ return json.dumps({'ok': True, 'output': 'Nothing to revert (transaction not found or already cleaned up).'})
142
+ return json.dumps({'ok': True, 'output': f"Reverted {n} cell{'s' if n != 1 else ''}."})
143
+
144
+ elif action == 'cleanup':
145
+ if cf_tx_id is not None:
146
+ _run_on_main(lambda: cleanup_by_tx(cf_tx_id))
147
+ return json.dumps({'ok': True, 'output': ''})
148
+
149
+ else:
150
+ return json.dumps({'ok': False, 'error': f"Unknown control action: {action}"})
@@ -1,7 +1,7 @@
1
1
  """Logging utility for ShortcutXL."""
2
2
 
3
- import os
4
3
  import datetime
4
+ import os
5
5
 
6
6
 
7
7
  def xl_log(msg):
@@ -1,17 +1,21 @@
1
1
  """run_managed — execute user code against Excel on the main thread."""
2
2
 
3
3
  import threading
4
- from shortcut_xl._log import xl_log
4
+
5
5
  from shortcut_xl._com import xl_app
6
+ from shortcut_xl._log import xl_log
6
7
  from shortcut_xl._threading import _run_on_main
7
8
  from shortcut_xl._tracking import DirtyTracker, TrackedApp
8
9
  from shortcut_xl.api.format import format_cell_diff
9
- from shortcut_xl.api.workbook import Workbook
10
- from shortcut_xl.api.worksheet import Worksheet
11
10
  from shortcut_xl.api.utils.helpers import (
11
+ address_to_index,
12
+ col_index,
13
+ col_letter,
12
14
  hex_to_bgr,
13
- index_to_address, address_to_index, col_letter, col_index,
15
+ index_to_address,
14
16
  )
17
+ from shortcut_xl.api.workbook import Workbook
18
+ from shortcut_xl.api.worksheet import Worksheet
15
19
 
16
20
  # Excel xlCalculation enum values
17
21
  _XL_CALCULATION_MANUAL = -4135
@@ -34,10 +38,11 @@ def run_managed(code_str):
34
38
 
35
39
  Suspends Calculation/Events/Alerts, runs code with a tracking
36
40
  proxy, collects dirty cells, then restores Excel state.
37
- Prints a human-readable diff summary of any cell changes to stdout.
38
41
 
39
- All COM calls happen directly on the main thread — no cross-apartment
40
- marshaling overhead.
42
+ Returns the list of dirty cells. Caller (_exec_entry) is responsible
43
+ for CF highlighting, JSON response building, and stdout capture.
44
+ The text summary is still printed to stdout (captured by _exec_entry)
45
+ so the agent sees what changed in the tool result output.
41
46
  """
42
47
  code = compile(code_str, '<excel_exec>', 'exec')
43
48
 
@@ -92,13 +97,14 @@ def run_managed(code_str):
92
97
  app.Calculation = _XL_CALCULATION_AUTOMATIC
93
98
  except Exception:
94
99
  pass
100
+
95
101
  return dirty
96
102
 
97
103
  try:
98
104
  dirty = _run_on_main(_work)
99
105
  except Exception as e:
100
- # Strip infrastructure traceback so PyRun_SimpleString only prints
101
- # the exception type + message, not the full frame chain.
106
+ # Strip infrastructure traceback _exec_entry formats it via
107
+ # traceback.format_exc(), so the extra frames are noise.
102
108
  e.__traceback__ = None
103
109
  raise
104
110
  summary = format_cell_diff(dirty)
@@ -6,12 +6,12 @@ This matches the pattern used by PyXLL, Excel-DNA, and xlwings:
6
6
  background threads enqueue work, a timer on the main thread drains it.
7
7
  """
8
8
 
9
- import threading
10
- import queue
11
9
  import ctypes
10
+ import queue
11
+ import threading
12
12
  from ctypes import wintypes
13
- from shortcut_xl._log import xl_log
14
13
 
14
+ from shortcut_xl._log import xl_log
15
15
 
16
16
  # COM error code: Excel is busy (VBA_E_IGNORE = 0x800AC472)
17
17
  _VBA_E_IGNORE = -2146777998
@@ -44,6 +44,7 @@ def _is_excel_busy(e):
44
44
 
45
45
  # Persist main thread ID in builtins so it survives hot-reload.
46
46
  import builtins
47
+
47
48
  _main_thread_id = getattr(builtins, '_shortcutxl_main_thread_id', None)
48
49
  if _main_thread_id is None:
49
50
  _main_thread_id = threading.current_thread().ident
@@ -7,9 +7,9 @@ sheets) not O(workbook), and the agent gets raw COM objects for everything
7
7
  below the Worksheet level — zero overhead on Range reads/writes.
8
8
  """
9
9
 
10
- from shortcut_xl.api.utils.helpers import col_letter
11
- from shortcut_xl.api.utils.com_utils import normalize_2d
12
10
  from shortcut_xl._log import xl_log
11
+ from shortcut_xl.api.utils.com_utils import normalize_2d
12
+ from shortcut_xl.api.utils.helpers import col_letter
13
13
 
14
14
 
15
15
  class _SheetSnapshot:
@@ -69,6 +69,9 @@ def _diff_snapshots(sheet_name, before, after):
69
69
  formula = _get_formula(after, ri, ci)
70
70
  if formula:
71
71
  entry['formula'] = formula
72
+ old_formula = _get_formula(before, ri, ci)
73
+ if old_formula:
74
+ entry['oldFormula'] = old_formula
72
75
  changes.append(entry)
73
76
  return changes
74
77
 
@@ -105,6 +108,9 @@ def _diff_snapshots(sheet_name, before, after):
105
108
  formula = _get_formula_abs(after, row, col)
106
109
  if formula:
107
110
  entry['formula'] = formula
111
+ old_formula = _get_formula_abs(before, row, col)
112
+ if old_formula:
113
+ entry['oldFormula'] = old_formula
108
114
  changes.append(entry)
109
115
  return changes
110
116
 
@@ -1,4 +1,4 @@
1
1
  """API layer — Workbook/Worksheet wrappers and supporting modules."""
2
2
 
3
- from shortcut_xl.api.workbook import Workbook
4
- from shortcut_xl.api.worksheet import Worksheet
3
+ from shortcut_xl.api.workbook import Workbook as Workbook
4
+ from shortcut_xl.api.worksheet import Worksheet as Worksheet
@@ -5,14 +5,19 @@ with Good / Issues breakdown and row sampling.
5
5
  """
6
6
 
7
7
  import re
8
- from shortcut_xl.api.utils.numerical import NUMERIC_TYPES
8
+
9
9
  from shortcut_xl.api.categorize import (
10
+ GOOD,
11
+ HARDCODED_NUMBER,
12
+ HARDCODED_NUMBER_IN_FORMULA,
13
+ INVALID_FORMULA,
14
+ LARGE_PERCENTAGE,
10
15
  categorize_cells,
11
- GOOD, LARGE_PERCENTAGE, HARDCODED_NUMBER,
12
- HARDCODED_NUMBER_IN_FORMULA, INVALID_FORMULA,
13
- is_com_error, com_error_to_str,
16
+ com_error_to_str,
17
+ is_com_error,
14
18
  )
15
19
  from shortcut_xl.api.utils.helpers import col_letter
20
+ from shortcut_xl.api.utils.numerical import NUMERIC_TYPES
16
21
 
17
22
  # Display constants
18
23
  MAX_ROWS_TO_SHOW = 10
@@ -165,7 +170,7 @@ def _format_row(sheet, row, entries, indent=2, show_issues=True, show_count=True
165
170
  lines.append(f'{ind}{sheet}!Row {row + 1} ({col_range}){count_suffix}')
166
171
 
167
172
  samples = _sample_first_last(entries, MAX_SAMPLES_PER_ROW)
168
- for cell, issues, _col in samples:
173
+ for cell, _issues, _col in samples:
169
174
  fmt = cell.get('numberFormat')
170
175
  old_str = format_value(cell.get('oldValue'), fmt)
171
176
  new_str = _display_value(cell)
@@ -9,11 +9,11 @@ Replicates the TypeScript processCellRange / RangeFormatter logic:
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
- from shortcut_xl.api.utils.helpers import index_to_address
13
12
  from shortcut_xl.api.style import read_style
14
- from shortcut_xl.api.utils.numerical import is_numerical, NUMERIC_TYPES
13
+ from shortcut_xl.api.utils.helpers import index_to_address
14
+ from shortcut_xl.api.utils.numerical import NUMERIC_TYPES, is_numerical
15
15
  from shortcut_xl.api.utils.ranges import consolidate_ranges
16
- from shortcut_xl.api.utils.style_utils import get_style_key, get_style_description
16
+ from shortcut_xl.api.utils.style_utils import get_style_description, get_style_key
17
17
 
18
18
  # ---------------------------------------------------------------------------
19
19
  # Constants (match TypeScript range-processing/types.ts)
@@ -349,8 +349,8 @@ def format_cell_range(
349
349
  5. Context from cells to the left
350
350
  6. Context from cells above
351
351
  """
352
+ from shortcut_xl.api.utils.com_utils import make_range, normalize_2d
352
353
  from shortcut_xl.api.utils.helpers import parse_range
353
- from shortcut_xl.api.utils.com_utils import normalize_2d, make_range
354
354
 
355
355
  sr, sc, er, ec = parse_range(range_addr)
356
356
  rows = er - sr + 1
@@ -5,17 +5,14 @@ Output formats match the SpreadJS equivalents.
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
- from typing import Any
9
-
8
+ from shortcut_xl.api.categorize import com_error_to_str, is_com_error
9
+ from shortcut_xl.api.named_ranges import get_named_range_info
10
10
  from shortcut_xl.api.utils.helpers import (
11
- col_letter,
11
+ extract_sheet_and_range,
12
12
  index_to_address,
13
13
  is_error_value,
14
14
  parse_range,
15
- extract_sheet_and_range,
16
15
  )
17
- from shortcut_xl.api.categorize import is_com_error, com_error_to_str
18
- from shortcut_xl.api.named_ranges import get_named_range_info
19
16
  from shortcut_xl.api.worksheet import Worksheet
20
17
 
21
18
  # Error check constants (match SpreadJS error-checker.ts)
@@ -263,8 +260,6 @@ class Workbook:
263
260
  rng.Value = com_error_to_str(data)
264
261
  return
265
262
  # Multi-cell range
266
- rows = rng.Rows.Count
267
- cols = rng.Columns.Count
268
263
  for r_idx, row in enumerate(data):
269
264
  if not isinstance(row, tuple):
270
265
  row = (row,)