shortcutxl 0.2.14 → 0.2.16

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/dist/cli.js CHANGED
@@ -14,6 +14,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
14
14
  const agentRoot = resolve(__dirname, '..');
15
15
  dotenv.config({ path: resolve(agentRoot, '.env.development') });
16
16
  process.title = 'shortcut';
17
+ // Clear terminal (scrollback + visible area) for a clean slate on launch.
18
+ process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
17
19
  const { main } = await import('./main.js');
18
20
  main(process.argv.slice(2));
19
21
  //# sourceMappingURL=cli.js.map
@@ -45,11 +45,6 @@ export function buildSystemPrompt(options = {}) {
45
45
  prompt += `## ${filePath}\n\n${content}\n\n`;
46
46
  }
47
47
  }
48
- // Append skills section (only if read tool is available)
49
- const customPromptHasRead = !selectedTools || selectedTools.includes(READ);
50
- if (customPromptHasRead && skills.length > 0) {
51
- prompt += formatSkillsForPrompt(skills);
52
- }
53
48
  // Add date/time and working directory last
54
49
  prompt += `\n\nCurrent date and time: ${dateTime}`;
55
50
  prompt += `\nCurrent working directory: ${resolvedCwd}`;
@@ -2,15 +2,16 @@
2
2
  * Action-mode system prompt for the Shortcut agent.
3
3
  *
4
4
  * This is the normal post-installation prompt. Passed as `customPrompt` to
5
- * the core buildSystemPrompt(), which automatically appends: project context
6
- * files, skills, date/time, and cwd.
5
+ * the core buildSystemPrompt(), which appends only: project context files,
6
+ * date/time, and cwd. Skills are loaded and injected here directly.
7
7
  *
8
8
  * The prompt does NOT embed runtime-discovered paths (xllDir, modulesPath).
9
9
  * Those are shown in the TUI status line. The agent discovers them dynamically
10
10
  * via the health check endpoint or excel_exec.
11
11
  */
12
12
  import { join, resolve } from 'path';
13
- import { getAgentDir, getInstalledMarkerPath, getPackageDir, getSessionsDir } from '../../config.js';
13
+ import { getAgentDir, getBundledSkillsDir, getInstalledMarkerPath, getPackageDir, getSessionsDir } from '../../config.js';
14
+ import { loadSkills } from '../../core/skills.js';
14
15
  import { EXCEL_HTTP_URL } from '../constants.js';
15
16
  import { EXCEL_COM_API_GUIDELINES } from './api.js';
16
17
  import { CODING_GUIDELINES, COMMUNICATION_GUIDELINES, DATA_FORMATTING_GUIDELINES, EXPLORATION_GUIDELINES, NOTES_COMMENTS_GUIDELINES, NUMBER_FORMAT_REFERENCE } from './shared.js';
@@ -66,10 +67,15 @@ For any extension requests, ask the user which approach fits their need, or reco
66
67
  const INSTALLATION = (markerPath) => `\
67
68
  ## Installation
68
69
  The installed marker file is at \`${markerPath}\`. If the user needs to re-run installation (e.g. something is broken), delete this file and use switch_mode to switch to "installation" mode`;
69
- const DOCS = (paths) => `\
70
+ const SELF_REFERENCE = (paths) => `\
71
+ ======================
72
+ ## SELF REFERENCE
73
+ ======================
74
+
70
75
  ## Key Directories
71
76
  - Package directory (shipped code, read-only): ${paths.packageDir}
72
77
  - Agent directory (user config, state, XLL binaries): ${paths.agentDir}
78
+ - User skills (create new skills here): ${paths.userSkillsDir}
73
79
  - Sessions (prior conversation JSONL transcripts): ${paths.sessionsDir}
74
80
  - Temp directory (dump temporary/scratch files here, e.g. exported Excel files): ${paths.tempDir}
75
81
 
@@ -86,33 +92,56 @@ Read these for introspection, customization, debugging of Shortcut itself, its S
86
92
  - Examples: ${paths.examples} (extensions, custom tools, SDK)
87
93
  - Topics: extensions (docs/extensions.md, examples/extensions/), themes (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), TUI (docs/tui.md), keybindings (docs/keybindings.md), SDK (docs/sdk.md), custom providers (docs/custom-provider.md), models (docs/models.md), packages (docs/packages.md)
88
94
  - Always read Shortcut .md files completely and follow links to related docs`;
95
+ const SKILLS = (skills, userSkillsDir) => {
96
+ const visible = skills.filter((s) => !s.disableModelInvocation);
97
+ const lines = [
98
+ '======================',
99
+ '## SKILLS',
100
+ '======================',
101
+ 'The following skills provide specialized instructions for specific tasks.',
102
+ 'When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md).',
103
+ '',
104
+ `To create a new user skill: ${userSkillsDir}/<skill-name>/SKILL.md`,
105
+ 'Format:',
106
+ ' ---',
107
+ ' name: <skill-name> # must match the parent directory name',
108
+ ' description: "..." # one-line summary shown here; max 1024 chars',
109
+ ' ---',
110
+ ' <skill body — clear and concise instructions the agent reads when the skill is invoked>',
111
+ ''
112
+ ];
113
+ for (const skill of visible) {
114
+ lines.push(`- ${skill.name} (${skill.filePath}): ${skill.description}`);
115
+ }
116
+ return lines.join('\n');
117
+ };
89
118
  // ---------------------------------------------------------------------------
90
- // Assembly — resolves runtime paths, joins sections
119
+ // Assembly — resolves runtime paths, loads skills, joins sections
91
120
  // ---------------------------------------------------------------------------
92
121
  export function buildActionPrompt() {
122
+ const agentDir = getAgentDir();
93
123
  const agentDocsDir = resolve(join(getPackageDir(), 'agent-docs'));
94
124
  const installedMarker = getInstalledMarkerPath();
95
- return [
125
+ const userSkillsDir = join(agentDir, 'skills');
126
+ const { skills } = loadSkills({ agentDir, skillPaths: [getBundledSkillsDir()] });
127
+ const skillsSection = SKILLS(skills, userSkillsDir);
128
+ const sections = [
96
129
  CORE(),
97
130
  INSTALLATION(installedMarker),
98
- EXPLORATION_GUIDELINES,
99
- DATA_FORMATTING_GUIDELINES,
100
- NUMBER_FORMAT_REFERENCE,
101
- NOTES_COMMENTS_GUIDELINES,
102
- CODING_GUIDELINES,
103
- COMMUNICATION_GUIDELINES,
104
- DOCS({
131
+ SELF_REFERENCE({
105
132
  packageDir: getPackageDir(),
106
- agentDir: getAgentDir(),
133
+ agentDir,
134
+ userSkillsDir: join(agentDir, 'skills'),
107
135
  sessionsDir: getSessionsDir(),
108
- tempDir: join(getAgentDir(), 'temp'),
136
+ tempDir: join(agentDir, 'temp'),
109
137
  xllSpec: join(agentDocsDir, 'xll-spec.md'),
110
138
  xllSkill: join(agentDocsDir, 'xll-skill.md'),
111
139
  readme: join(agentDocsDir, 'README.md'),
112
140
  docs: join(agentDocsDir, 'docs'),
113
141
  examples: join(agentDocsDir, 'examples')
114
- }),
115
- EXCEL_COM_API_GUIDELINES
116
- ].join('\n\n');
142
+ })
143
+ ];
144
+ sections.push(skillsSection, COMMUNICATION_GUIDELINES, CODING_GUIDELINES, EXPLORATION_GUIDELINES, DATA_FORMATTING_GUIDELINES, NUMBER_FORMAT_REFERENCE, NOTES_COMMENTS_GUIDELINES, EXCEL_COM_API_GUIDELINES);
145
+ return sections.join('\n\n');
117
146
  }
118
147
  //# sourceMappingURL=action.js.map
@@ -42,6 +42,7 @@ function padVisible(text, targetWidth) {
42
42
  export class ExcelApprovalComponent extends Container {
43
43
  selectedButton = 0;
44
44
  scrollOffset = 0;
45
+ cursorRow = 0;
45
46
  diff;
46
47
  theme;
47
48
  onDone;
@@ -63,7 +64,8 @@ export class ExcelApprovalComponent extends Container {
63
64
  this.tableContainer = new Container();
64
65
  this.addChild(this.tableContainer);
65
66
  if (diff.total > diff.cells.length) {
66
- this.addChild(new Text(theme.fg('dim', ` ... and ${diff.total - diff.cells.length} more cells`), 0, 0));
67
+ const hidden = diff.total - diff.cells.length;
68
+ this.addChild(new Text(theme.fg('dim', ` ... ${hidden} more cell${hidden !== 1 ? 's' : ''} not shown`), 0, 0));
67
69
  }
68
70
  this.addChild(new Spacer(1));
69
71
  this.buttonContainer = new Container();
@@ -77,51 +79,58 @@ export class ExcelApprovalComponent extends Container {
77
79
  const cells = this.diff.cells;
78
80
  const visible = cells.slice(this.scrollOffset, this.scrollOffset + MAX_VISIBLE_ROWS);
79
81
  // Compute column widths from visible data
80
- const colWidths = { cell: 4, before: 6, after: 5, formula: 7 };
82
+ const colWidths = { addr: 7, before: 6, after: 5, detail: 7 };
81
83
  for (const c of visible) {
82
- colWidths.cell = Math.max(colWidths.cell, String(c.cell).length);
84
+ colWidths.addr = Math.max(colWidths.addr, String(c.address).length);
83
85
  colWidths.before = Math.max(colWidths.before, String(c.before).length);
84
86
  colWidths.after = Math.max(colWidths.after, String(c.after).length);
85
- const formulaText = c.formula || c.oldFormula || '';
86
- colWidths.formula = Math.max(colWidths.formula, formulaText.length);
87
+ colWidths.detail = Math.max(colWidths.detail, (c.formula || c.oldFormula || '').length);
87
88
  }
88
- colWidths.cell = Math.min(colWidths.cell, 20);
89
+ colWidths.addr = Math.min(colWidths.addr, 24);
89
90
  colWidths.before = Math.min(colWidths.before, 16);
90
91
  colWidths.after = Math.min(colWidths.after, 16);
91
- colWidths.formula = Math.min(colWidths.formula, 30);
92
+ colWidths.detail = Math.min(colWidths.detail, 30);
92
93
  // Header
93
94
  const header = ' ' +
94
- this.theme.fg('dim', padVisible('Cell', colWidths.cell) +
95
+ this.theme.fg('dim', padVisible('Address', colWidths.addr) +
95
96
  ' ' +
96
97
  padVisible('Before', colWidths.before) +
97
98
  ' ' +
98
99
  padVisible('After', colWidths.after) +
99
100
  ' ' +
100
- padVisible('Formula', colWidths.formula));
101
+ padVisible('Detail', colWidths.detail));
101
102
  this.tableContainer.addChild(new Text(header, 0, 0));
102
- // Rows
103
- for (const c of visible) {
104
- const cellStr = truncateToWidth(String(c.cell), colWidths.cell, '..', true);
103
+ for (let idx = 0; idx < visible.length; idx++) {
104
+ const c = visible[idx];
105
+ const absIdx = this.scrollOffset + idx;
106
+ const isCursor = absIdx === this.cursorRow;
107
+ const marker = isCursor ? '▸ ' : ' ';
108
+ const addrStr = truncateToWidth(String(c.address), colWidths.addr, '..', true);
105
109
  const beforeStr = truncateToWidth(String(c.before), colWidths.before, '..', true);
106
110
  const afterStr = truncateToWidth(String(c.after), colWidths.after, '..', true);
107
- const formulaStr = c.formula
108
- ? truncateToWidth(c.formula, colWidths.formula, '..', true)
109
- : c.oldFormula
110
- ? truncateToWidth(this.theme.fg('dim', c.oldFormula), colWidths.formula, '..', true)
111
- : padVisible(this.theme.fg('dim', 'hardcoded'), colWidths.formula);
112
- const row = ' ' +
113
- this.theme.fg('text', padVisible(cellStr, colWidths.cell)) +
111
+ let detailStr;
112
+ if (c.formula) {
113
+ detailStr = truncateToWidth(c.formula, colWidths.detail, '..', true);
114
+ }
115
+ else if (c.oldFormula) {
116
+ detailStr = truncateToWidth(this.theme.fg('dim', c.oldFormula), colWidths.detail, '..', true);
117
+ }
118
+ else {
119
+ detailStr = padVisible(this.theme.fg('dim', 'hardcoded'), colWidths.detail);
120
+ }
121
+ const row = marker +
122
+ this.theme.fg(isCursor ? 'accent' : 'text', padVisible(addrStr, colWidths.addr)) +
114
123
  ' ' +
115
124
  this.theme.fg(c.before !== 'empty' ? 'error' : 'dim', padVisible(beforeStr, colWidths.before)) +
116
125
  ' ' +
117
126
  this.theme.fg('accent', padVisible(afterStr, colWidths.after)) +
118
127
  ' ' +
119
- formulaStr;
128
+ detailStr;
120
129
  this.tableContainer.addChild(new Text(row, 0, 0));
121
130
  }
122
131
  if (cells.length > MAX_VISIBLE_ROWS) {
123
132
  const scrollInfo = ` ${this.scrollOffset + 1}-${Math.min(this.scrollOffset + MAX_VISIBLE_ROWS, cells.length)} of ${cells.length}`;
124
- this.tableContainer.addChild(new Text(this.theme.fg('dim', scrollInfo + ' (\u2191\u2193 scroll)'), 0, 0));
133
+ this.tableContainer.addChild(new Text(this.theme.fg('dim', scrollInfo + ' (\u2191\u2193 navigate)'), 0, 0));
125
134
  }
126
135
  }
127
136
  updateButtons() {
@@ -145,29 +154,37 @@ export class ExcelApprovalComponent extends Container {
145
154
  }
146
155
  }
147
156
  this.buttonContainer.addChild(new Text(' ' + parts.join(' '), 0, 0));
148
- this.buttonContainer.addChild(new Text(this.theme.fg('dim', ' \u2190\u2192 navigate enter select esc cancel'), 0, 0));
157
+ this.buttonContainer.addChild(new Text(this.theme.fg('dim', ' \u2191\u2193 navigate \u2190\u2192 select enter confirm esc cancel'), 0, 0));
158
+ }
159
+ moveCursor(delta) {
160
+ const maxRow = this.diff.cells.length - 1;
161
+ const next = Math.max(0, Math.min(maxRow, this.cursorRow + delta));
162
+ if (next === this.cursorRow)
163
+ return;
164
+ this.cursorRow = next;
165
+ if (this.cursorRow < this.scrollOffset) {
166
+ this.scrollOffset = this.cursorRow;
167
+ }
168
+ else if (this.cursorRow >= this.scrollOffset + MAX_VISIBLE_ROWS) {
169
+ this.scrollOffset = this.cursorRow - MAX_VISIBLE_ROWS + 1;
170
+ }
171
+ this.updateTable();
149
172
  }
150
173
  handleInput(keyData) {
151
174
  const kb = getEditorKeybindings();
152
- if (keyData === '\x1b[D' || keyData === 'h') {
175
+ if (kb.matches(keyData, 'cursorLeft') || keyData === 'h') {
153
176
  this.selectedButton = Math.max(0, this.selectedButton - 1);
154
177
  this.updateButtons();
155
178
  }
156
- else if (keyData === '\x1b[C' || keyData === 'l') {
179
+ else if (kb.matches(keyData, 'cursorRight') || keyData === 'l') {
157
180
  this.selectedButton = Math.min(BUTTONS.length - 1, this.selectedButton + 1);
158
181
  this.updateButtons();
159
182
  }
160
- else if (keyData === '\x1b[A' || keyData === 'k') {
161
- if (this.scrollOffset > 0) {
162
- this.scrollOffset--;
163
- this.updateTable();
164
- }
183
+ else if (kb.matches(keyData, 'selectUp') || keyData === 'k') {
184
+ this.moveCursor(-1);
165
185
  }
166
- else if (keyData === '\x1b[B' || keyData === 'j') {
167
- if (this.scrollOffset + MAX_VISIBLE_ROWS < this.diff.cells.length) {
168
- this.scrollOffset++;
169
- this.updateTable();
170
- }
186
+ else if (kb.matches(keyData, 'selectDown') || keyData === 'j') {
187
+ this.moveCursor(1);
171
188
  }
172
189
  else if (kb.matches(keyData, 'selectConfirm') || keyData === '\n' || keyData === '\r') {
173
190
  this.onDone(BUTTONS[this.selectedButton]);
@@ -68,8 +68,8 @@ export function createSwitchModeTool(session) {
68
68
  };
69
69
  }
70
70
  const s = session();
71
- s.setBaseSystemPrompt(target.systemPrompt);
72
71
  s.setActiveToolsByName([...target.tools]);
72
+ s.setBaseSystemPrompt(target.systemPrompt);
73
73
  currentModeRef = target.name;
74
74
  // Snapshot the new system prompt + tools into the session trace so the
75
75
  // JSONL reflects the mode switch (otherwise only the initial prompt is recorded).
@@ -1603,10 +1603,12 @@ export class InteractiveMode {
1603
1603
  // No image — check if clipboard contains a file path
1604
1604
  if (this.tryAttachClipboardFilePath()) {
1605
1605
  this.ui.requestRender();
1606
+ return;
1606
1607
  }
1608
+ this.showStatus('Nothing to attach — clipboard has no image or file path');
1607
1609
  }
1608
- catch {
1609
- // Silently ignore clipboard errors (may not have permission, etc.)
1610
+ catch (err) {
1611
+ this.showError(`Clipboard paste failed: ${err instanceof Error ? err.message : String(err)}`);
1610
1612
  }
1611
1613
  }
1612
1614
  /**
@@ -83,6 +83,36 @@ function runCommand(command, args, options) {
83
83
  : Buffer.from(result.stdout ?? '', typeof result.stdout === 'string' ? 'utf-8' : undefined);
84
84
  return { ok: true, stdout };
85
85
  }
86
+ /**
87
+ * PowerShell fallback for Windows image reading.
88
+ * Handles BMP/DIB format (Snipping Tool, Win+Shift+S) and cases where the
89
+ * native @mariozechner/clipboard module is unavailable or returns hasImage()=false.
90
+ * Saves clipboard image as PNG via System.Windows.Forms and returns base64-encoded bytes.
91
+ */
92
+ function readClipboardImageViaPowerShell() {
93
+ const result = runCommand('powershell.exe', [
94
+ '-NoProfile',
95
+ '-Command',
96
+ 'Add-Type -AssemblyName System.Windows.Forms; ' +
97
+ '$img = [System.Windows.Forms.Clipboard]::GetImage(); ' +
98
+ 'if ($img -eq $null) { exit 1 }; ' +
99
+ '$ms = New-Object System.IO.MemoryStream; ' +
100
+ '$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); ' +
101
+ '[System.Convert]::ToBase64String($ms.ToArray())'
102
+ ], { timeoutMs: 8000 });
103
+ if (!result.ok || result.stdout.length === 0)
104
+ return null;
105
+ try {
106
+ const base64 = result.stdout.toString('utf-8').trim();
107
+ const bytes = Buffer.from(base64, 'base64');
108
+ if (bytes.length === 0)
109
+ return null;
110
+ return { bytes, mimeType: 'image/png' };
111
+ }
112
+ catch {
113
+ return null;
114
+ }
115
+ }
86
116
  function readClipboardImageViaWlPaste() {
87
117
  const list = runCommand('wl-paste', ['--list-types'], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS });
88
118
  if (!list.ok) {
@@ -137,8 +167,22 @@ export async function readClipboardImage(options) {
137
167
  if (platform === 'linux' && isWaylandSession(env)) {
138
168
  image = readClipboardImageViaWlPaste() ?? readClipboardImageViaXclip();
139
169
  }
170
+ else if (platform === 'win32') {
171
+ // Try native module first (fast), then PowerShell fallback.
172
+ // PowerShell catches BMP/DIB format (Snipping Tool, Win+Shift+S) which
173
+ // hasImage() misses, and also works when the native module fails to load.
174
+ if (clipboard?.hasImage()) {
175
+ const imageData = await clipboard.getImageBinary();
176
+ if (imageData && imageData.length > 0) {
177
+ const bytes = imageData instanceof Uint8Array ? imageData : Uint8Array.from(imageData);
178
+ image = { bytes, mimeType: 'image/png' };
179
+ }
180
+ }
181
+ image ??= readClipboardImageViaPowerShell();
182
+ }
140
183
  else {
141
- if (!clipboard || !clipboard.hasImage()) {
184
+ // macOS and other platforms
185
+ if (!clipboard?.hasImage()) {
142
186
  return null;
143
187
  }
144
188
  const imageData = await clipboard.getImageBinary();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shortcutxl",
3
- "version": "0.2.14",
3
+ "version": "0.2.16",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist/",
@@ -33,6 +33,7 @@
33
33
  "format:check": "prettier --check 'src/**/*.{ts,tsx,js,json}'",
34
34
  "test": "vitest --run",
35
35
  "test:e2e": "vitest --run --config vitest.e2e.config.ts",
36
+ "release": "node -e \"const v=require('./package.json').version;const t='shortcutxl/v'+v;require('child_process').execSync('git tag '+t,{stdio:'inherit'});require('child_process').execSync('git push origin '+t,{stdio:'inherit'});console.log('Tagged and pushed '+t)\"",
36
37
  "test:watch": "vitest",
37
38
  "knip": "knip",
38
39
  "lint:python": "cd .. && uvx ruff check modules/ tests/",
@@ -71,7 +71,6 @@ def debug_grid_test():
71
71
 
72
72
  def _do(app):
73
73
  sheet = app.ActiveSheet
74
- top = sheet.Range("Z5").Cells(1, 1)
75
74
  sheet.Range("Z5:AD9").Value = grid
76
75
 
77
76
  shortcut_xl.xl_batch(_do)
@@ -107,7 +106,7 @@ def debug_grid_tuple_test():
107
106
  sheet.Range("Z5:AD9").Value = grid
108
107
 
109
108
  shortcut_xl.xl_batch(_do)
110
- xl_log(f"debug_grid_tuple_test: wrote tuple grid to Z5:AD9")
109
+ xl_log("debug_grid_tuple_test: wrote tuple grid to Z5:AD9")
111
110
  return "Wrote tuple-of-tuples 5x5 to Z5:AD9"
112
111
  except Exception as e:
113
112
  xl_log(f"debug_grid_tuple_test FAILED: {e}")
@@ -207,9 +206,7 @@ def debug_frame_ascii():
207
206
  row = []
208
207
  for c in range(WIDTH):
209
208
  ch = "." # visible empty (not "")
210
- if c == 0 and paddle_l <= r < paddle_l + PADDLE_H:
211
- ch = "|"
212
- elif c == WIDTH - 1 and paddle_r <= r < paddle_r + PADDLE_H:
209
+ if c == 0 and paddle_l <= r < paddle_l + PADDLE_H or c == WIDTH - 1 and paddle_r <= r < paddle_r + PADDLE_H:
213
210
  ch = "|"
214
211
  elif r == ball_y and c == ball_x:
215
212
  ch = "O"