shortcutxl 0.2.14 → 0.2.15

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.
package/README.md CHANGED
@@ -1,13 +1,33 @@
1
1
  # ShortcutXL
2
2
 
3
- An AI agent that lives on your computer and has Excel superpowers. Made by the Shortcut team [Shortcut](https://shortcut.ai).
3
+ An AI agent that lives on your computer and has Excel superpowers. Made by the [Shortcut](https://shortcut.ai) team.
4
+
5
+ ## Install
6
+
7
+ ### 1. Open Command Prompt or PowerShell and install Node.js
8
+
9
+ ```bash
10
+ winget install OpenJS.NodeJS.LTS
11
+ ```
12
+
13
+ ### 2. Install ShortcutXL
4
14
 
5
15
  ```bash
6
16
  npm install -g shortcutxl
17
+ ```
18
+
19
+ ### 3. Launch ShortcutXL
20
+
21
+ Type `shortcut` in your terminal:
22
+
23
+ ```bash
7
24
  shortcut
8
25
  ```
9
26
 
10
- > **Important:** Install globally with `-g`. Do not use `npm install shortcutxl` without it.
27
+ ## Requirements
28
+
29
+ - **Windows 10/11** with **Excel 2016+** (64-bit)
30
+ - **Node.js >= 20**
11
31
 
12
32
  ## Capabilities
13
33
 
@@ -19,8 +39,3 @@ shortcut
19
39
  - **Extensible** — Integrate any API or data source by adding a skill file or a custom tool extension.
20
40
  - **User-defined functions (UDFs)** — Custom Excel formulas powered by Python for live data, calculations, or database queries.
21
41
  - **External data connections** — ODBC, OLE DB, QueryTables, Power Query.
22
-
23
- ## Prerequisites
24
-
25
- - **Windows 10/11** with **Excel 2016+** (64-bit)
26
- - **Node.js >= 20** — If missing: `winget install OpenJS.NodeJS.LTS`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shortcutxl",
3
- "version": "0.2.14",
3
+ "version": "0.2.15",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist/",
@@ -4,8 +4,8 @@ C calls dispatch_request(body_json) for every POST /exec.
4
4
  This function always returns valid JSON. C never builds JSON.
5
5
 
6
6
  Two request shapes:
7
- {"code": "..."} → exec_and_respond
8
- {"control": "revert"|"cleanup", "cfTxId": "..."} → handle_control
7
+ {"code": "..."} → exec_and_respond
8
+ {"control": "revert"|"cleanup"|"navigate", ...} → handle_control
9
9
  """
10
10
 
11
11
  import io
@@ -20,7 +20,9 @@ from shortcut_xl._diff_highlight import (
20
20
  revert_by_tx,
21
21
  )
22
22
  from shortcut_xl._managed import run_managed
23
+ from shortcut_xl._navigate import clear_navigate_cursor, navigate_to_cell
23
24
  from shortcut_xl._threading import _run_on_main
25
+ from shortcut_xl.api.utils.helpers import address_to_index
24
26
 
25
27
  _MAX_DIFF_JSON_CELLS = 50
26
28
 
@@ -44,12 +46,41 @@ def _format_cell_value(v):
44
46
  return str(v)
45
47
 
46
48
 
49
+ def _cell_sort_key(c):
50
+ """Sort key: (sheet, row, col) using address_to_index."""
51
+ addr = c.get('address', '')
52
+ try:
53
+ row, col = address_to_index(addr)
54
+ except (ValueError, AttributeError):
55
+ row, col = 0, 0
56
+ return (c.get('sheet', ''), row, col)
57
+
58
+
59
+ def _sort_dirty(dirty):
60
+ """Sort dirty cells by (sheet, row, column) for row-wise display.
61
+
62
+ Applied once before both _build_diff and highlight_changes so the
63
+ entire pipeline sees a consistent order.
64
+ """
65
+ return sorted(dirty, key=_cell_sort_key)
66
+
67
+
68
+ def _cell_ref(c):
69
+ """Build a full cell reference string from a dirty cell dict."""
70
+ wb = c.get('workbook', '')
71
+ prefix = f"[{wb}]" if wb else ''
72
+ return f"{prefix}{c.get('sheet', '')}!{c.get('address', '')}"
73
+
74
+
47
75
  def _build_diff(dirty):
48
- """Build structured diff payload for the TUI approval component."""
76
+ """Build structured diff payload for the TUI approval component.
77
+
78
+ Expects *dirty* to be pre-sorted by _sort_dirty. One row per cell.
79
+ """
49
80
  cells = []
50
81
  for c in dirty[:_MAX_DIFF_JSON_CELLS]:
51
82
  entry = {
52
- 'cell': f"{c.get('sheet', '')}!{c.get('address', '')}",
83
+ 'address': _cell_ref(c),
53
84
  'before': _format_cell_value(c.get('oldValue')),
54
85
  'after': _format_cell_value(c.get('value')),
55
86
  }
@@ -109,6 +140,7 @@ def exec_and_respond(code_str, *, auto_approve=False):
109
140
 
110
141
  response = {'ok': True, 'output': output}
111
142
  if dirty:
143
+ dirty = _sort_dirty(dirty)
112
144
  response['diff'] = _build_diff(dirty)
113
145
  # Only apply CF highlights when the user may review changes.
114
146
  if not auto_approve:
@@ -136,12 +168,21 @@ def handle_control(request):
136
168
  if action == 'revert':
137
169
  if cf_tx_id is None:
138
170
  return json.dumps({'ok': False, 'error': "revert requires cfTxId"})
171
+ _run_on_main(clear_navigate_cursor)
139
172
  n = _run_on_main(lambda: revert_by_tx(xl_app(), cf_tx_id))
140
173
  if n == 0:
141
174
  return json.dumps({'ok': True, 'output': 'Nothing to revert (transaction not found or already cleaned up).'})
142
175
  return json.dumps({'ok': True, 'output': f"Reverted {n} cell{'s' if n != 1 else ''}."})
143
176
 
177
+ elif action == 'navigate':
178
+ cell_ref = request.get('cellRef', '')
179
+ if not cell_ref:
180
+ return json.dumps({'ok': False, 'error': "navigate requires cellRef"})
181
+ ok = _run_on_main(lambda: navigate_to_cell(xl_app(), cell_ref))
182
+ return json.dumps({'ok': ok, 'output': '' if ok else f"Could not navigate to {cell_ref}"})
183
+
144
184
  elif action == 'cleanup':
185
+ _run_on_main(clear_navigate_cursor)
145
186
  if cf_tx_id is not None:
146
187
  _run_on_main(lambda: cleanup_by_tx(cf_tx_id))
147
188
  return json.dumps({'ok': True, 'output': ''})
@@ -0,0 +1,115 @@
1
+ """Navigate Excel to a specific cell — used during diff review.
2
+
3
+ Highlights the cursor cell with a colored border via direct Range
4
+ formatting. Visible even when Excel doesn't have focus (unlike
5
+ .Select() which only shows when Excel is foreground).
6
+
7
+ Thread safety: called via _run_on_main, serialized by s_exec_lock.
8
+ """
9
+
10
+ from shortcut_xl._log import xl_log
11
+ from shortcut_xl.api.utils.helpers import extract_sheet_and_range
12
+
13
+ # Excel COM constants
14
+ _XL_EDGE_LEFT = 7
15
+ _XL_EDGE_TOP = 8
16
+ _XL_EDGE_BOTTOM = 9
17
+ _XL_EDGE_RIGHT = 10
18
+ _XL_CONTINUOUS = 1
19
+ _XL_LINE_STYLE_NONE = -4142
20
+ _XL_MEDIUM = -4138
21
+
22
+ _CURSOR_BLUE = 0xFF0000 # BGR — distinct from yellow change highlights
23
+ _BORDER_EDGES = (_XL_EDGE_LEFT, _XL_EDGE_TOP, _XL_EDGE_BOTTOM, _XL_EDGE_RIGHT)
24
+
25
+ # Current cursor state — at most one cell highlighted at a time.
26
+ _cursor_ws = None # COM Worksheet of the current cursor cell
27
+ _cursor_address = None # e.g. "B5"
28
+
29
+
30
+ def parse_cell_ref(cell_ref):
31
+ """Parse a cell reference into (workbook, sheet, address).
32
+
33
+ Supports single cells and ranges:
34
+ "[Book1]Sheet1!A1" → ("Book1", "Sheet1", "A1")
35
+ "Sheet1!A1:E1" → (None, "Sheet1", "A1:E1")
36
+
37
+ Returns (None, None, None) if the reference is invalid.
38
+ """
39
+ rest = cell_ref
40
+ workbook = None
41
+
42
+ if rest.startswith('['):
43
+ close = rest.find(']')
44
+ if close <= 1: # missing or empty workbook name
45
+ return None, None, None
46
+ workbook = rest[1:close]
47
+ rest = rest[close + 1:]
48
+
49
+ sheet, address = extract_sheet_and_range(rest)
50
+ if not sheet or not address:
51
+ return None, None, None
52
+
53
+ return workbook, sheet, address
54
+
55
+
56
+ def _resolve_worksheet(app, workbook, sheet):
57
+ """Get the COM Worksheet object for the given workbook/sheet names."""
58
+ if workbook:
59
+ return app.Workbooks(workbook).Worksheets(sheet)
60
+ return app.Worksheets(sheet)
61
+
62
+
63
+ def _set_border(rng, color, line_style, weight):
64
+ """Apply border styling to all four edges of a range."""
65
+ for edge in _BORDER_EDGES:
66
+ b = rng.Borders(edge)
67
+ b.LineStyle = line_style
68
+ if line_style != _XL_LINE_STYLE_NONE:
69
+ b.Color = color
70
+ b.Weight = weight
71
+
72
+
73
+ def _clear_cursor():
74
+ """Remove the border from the current cursor cell, if any."""
75
+ global _cursor_ws, _cursor_address
76
+ if _cursor_ws is None or _cursor_address is None:
77
+ return
78
+ try:
79
+ _set_border(_cursor_ws.Range(_cursor_address), 0, _XL_LINE_STYLE_NONE, 0)
80
+ except Exception as e:
81
+ xl_log(f"_clear_cursor({_cursor_address}): {e}")
82
+ _cursor_ws = None
83
+ _cursor_address = None
84
+
85
+
86
+ def navigate_to_cell(app, cell_ref):
87
+ """Highlight the cell with a colored border.
88
+
89
+ *cell_ref* is "[Book1]Sheet1!A1" or "Sheet1!A1".
90
+ Clears the previous cursor border before applying the new one.
91
+ Returns True on success, False on failure.
92
+ """
93
+ global _cursor_ws, _cursor_address
94
+
95
+ workbook, sheet, address = parse_cell_ref(cell_ref)
96
+ if not sheet or not address:
97
+ return False
98
+
99
+ _clear_cursor()
100
+
101
+ try:
102
+ ws = _resolve_worksheet(app, workbook, sheet)
103
+ rng = ws.Range(address)
104
+ _set_border(rng, _CURSOR_BLUE, _XL_CONTINUOUS, _XL_MEDIUM)
105
+ _cursor_ws = ws
106
+ _cursor_address = address
107
+ return True
108
+ except Exception as e:
109
+ xl_log(f"navigate_to_cell({cell_ref}): {e}")
110
+ return False
111
+
112
+
113
+ def clear_navigate_cursor():
114
+ """Remove the cursor border. Called on approval accept/reject."""
115
+ _clear_cursor()
@@ -142,8 +142,14 @@ class DirtyTracker:
142
142
  try:
143
143
  after = _SheetSnapshot(ws)
144
144
  changes = _diff_snapshots(name, before, after)
145
- # Enrich with numberFormat (can't bulk-read mixed ranges)
145
+ # Enrich with workbook name + numberFormat
146
+ try:
147
+ wb_name = ws.Parent.Name
148
+ except Exception:
149
+ wb_name = None
146
150
  for entry in changes:
151
+ if wb_name:
152
+ entry['workbook'] = wb_name
147
153
  try:
148
154
  fmt = ws.Range(entry['address']).NumberFormat
149
155
  if fmt and fmt != 'General':