playwright-repl 0.2.0 → 0.2.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.2.1 — Ghost Completion
4
+
5
+ **2026-02-17**
6
+
7
+ ### Features
8
+
9
+ - **Ghost completion**: Fish-shell style inline suggestions — type a prefix and see dimmed suggestion text after the cursor
10
+ - **Tab** cycles through matches (e.g., `go` → goto, go-back, go-forward)
11
+ - **Right Arrow** accepts the current suggestion
12
+ - **Tab on empty line** cycles through all commands
13
+ - Aliases excluded from ghost suggestions (still work when typed)
14
+
15
+ ### Removed
16
+
17
+ - Removed readline's built-in Tab completer (replaced entirely by ghost completion)
18
+
19
+ ---
20
+
3
21
  ## v0.2.0 — MCP Server
4
22
 
5
23
  **2026-02-16**
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Ghost completion demo — standalone test for inline suggestions.
4
+ *
5
+ * Run: node examples/ghost-completion-demo.mjs
6
+ *
7
+ * Type a prefix (e.g. "go") and see dimmed suggestion text.
8
+ * Tab cycles through matches, Right Arrow accepts.
9
+ */
10
+
11
+ import readline from 'node:readline';
12
+
13
+ const COMMANDS = [
14
+ 'click', 'check', 'close', 'console', 'cookie-get', 'cookie-list',
15
+ 'dblclick', 'drag', 'eval', 'fill', 'goto', 'go-back', 'go-forward',
16
+ 'hover', 'network', 'open', 'press', 'reload', 'screenshot', 'select',
17
+ 'snapshot', 'type', 'uncheck', 'upload',
18
+ '.help', '.aliases', '.status', '.exit',
19
+ ];
20
+
21
+ const rl = readline.createInterface({
22
+ input: process.stdin,
23
+ output: process.stdout,
24
+ prompt: '\x1b[36mpw>\x1b[0m ',
25
+ });
26
+
27
+ // ─── Ghost completion via _ttyWrite ─────────────────────────────────────────
28
+
29
+ let ghost = '';
30
+ let matches = [];
31
+ let matchIdx = 0;
32
+
33
+ function getMatches(input) {
34
+ if (input.length > 0 && !input.includes(' ')) {
35
+ return COMMANDS.filter(cmd => cmd.startsWith(input) && cmd !== input);
36
+ }
37
+ return [];
38
+ }
39
+
40
+ function renderGhost(suffix) {
41
+ ghost = suffix;
42
+ rl.output.write(`\x1b[2m${ghost}\x1b[0m\x1b[${ghost.length}D`);
43
+ }
44
+
45
+ const origTtyWrite = rl._ttyWrite.bind(rl);
46
+ rl._ttyWrite = function (s, key) {
47
+ if (ghost && key) {
48
+ // Right-arrow-at-end accepts ghost suggestion
49
+ if (key.name === 'right' && rl.cursor === rl.line.length) {
50
+ const text = ghost;
51
+ rl.output.write('\x1b[K');
52
+ ghost = '';
53
+ matches = [];
54
+ rl._insertString(text);
55
+ return;
56
+ }
57
+
58
+ // Tab cycles through matches (or accepts if only one)
59
+ if (key.name === 'tab') {
60
+ if (matches.length > 1) {
61
+ rl.output.write('\x1b[K');
62
+ matchIdx = (matchIdx + 1) % matches.length;
63
+ const input = rl.line || '';
64
+ renderGhost(matches[matchIdx].slice(input.length));
65
+ return;
66
+ }
67
+ // Single match — accept it
68
+ const text = ghost;
69
+ rl.output.write('\x1b[K');
70
+ ghost = '';
71
+ matches = [];
72
+ rl._insertString(text);
73
+ return;
74
+ }
75
+ }
76
+
77
+ // Tab on empty input — show all commands as ghost suggestions
78
+ if (key && key.name === 'tab') {
79
+ if ((rl.line || '') === '') {
80
+ matches = COMMANDS;
81
+ matchIdx = 0;
82
+ renderGhost(matches[0]);
83
+ }
84
+ return;
85
+ }
86
+
87
+ // Clear existing ghost text before readline processes the key
88
+ if (ghost) {
89
+ rl.output.write('\x1b[K');
90
+ ghost = '';
91
+ }
92
+
93
+ // Let readline handle the key normally
94
+ origTtyWrite(s, key);
95
+
96
+ // Render new ghost text if cursor is at end of line
97
+ const input = rl.line || '';
98
+ matches = getMatches(input);
99
+ matchIdx = 0;
100
+ if (matches.length > 0 && rl.cursor === rl.line.length) {
101
+ renderGhost(matches[0].slice(input.length));
102
+ }
103
+ };
104
+
105
+ // ─── REPL loop ──────────────────────────────────────────────────────────────
106
+
107
+ console.log('Ghost completion demo — Tab cycles matches, Right Arrow accepts\n');
108
+ rl.prompt();
109
+
110
+ rl.on('line', (line) => {
111
+ if (line.trim() === '.exit') {
112
+ rl.close();
113
+ return;
114
+ }
115
+ console.log(` → ${line.trim() || '(empty)'}`);
116
+ rl.prompt();
117
+ });
118
+
119
+ rl.on('close', () => {
120
+ console.log('\nBye!');
121
+ process.exit(0);
122
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "playwright-repl",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Interactive REPL for Playwright browser automation — keyword-driven testing from your terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Completion data — builds the list of items for dropdown autocomplete.
3
+ *
4
+ * Sources: COMMANDS from resolve.mjs, ALIASES from parser.mjs,
5
+ * plus REPL meta-commands (.help, .exit, etc.).
6
+ */
7
+
8
+ import { COMMANDS } from './resolve.mjs';
9
+ import { ALIASES } from './parser.mjs';
10
+
11
+ // ─── Meta-commands ───────────────────────────────────────────────────────────
12
+
13
+ const META_COMMANDS = [
14
+ { cmd: '.help', desc: 'Show available commands' },
15
+ { cmd: '.aliases', desc: 'Show command aliases' },
16
+ { cmd: '.status', desc: 'Show connection status' },
17
+ { cmd: '.reconnect', desc: 'Reconnect to daemon' },
18
+ { cmd: '.record', desc: 'Start recording commands' },
19
+ { cmd: '.save', desc: 'Stop recording and save' },
20
+ { cmd: '.pause', desc: 'Pause/resume recording' },
21
+ { cmd: '.discard', desc: 'Discard current recording' },
22
+ { cmd: '.replay', desc: 'Replay a recorded session' },
23
+ { cmd: '.exit', desc: 'Exit REPL' },
24
+ ];
25
+
26
+ // ─── Build completion items ──────────────────────────────────────────────────
27
+
28
+ /**
29
+ * Returns a sorted array of `{ cmd, desc }` for all completable items:
30
+ * commands, aliases (with "→ target" description), and meta-commands.
31
+ */
32
+ export function buildCompletionItems() {
33
+ const items = [];
34
+
35
+ // Primary commands
36
+ for (const [name, info] of Object.entries(COMMANDS)) {
37
+ items.push({ cmd: name, desc: info.desc });
38
+ }
39
+
40
+ // Aliases — show "→ target" as description
41
+ for (const [alias, target] of Object.entries(ALIASES)) {
42
+ items.push({ cmd: alias, desc: `→ ${target}` });
43
+ }
44
+
45
+ // Meta-commands
46
+ items.push(...META_COMMANDS);
47
+
48
+ items.sort((a, b) => a.cmd.localeCompare(b.cmd));
49
+ return items;
50
+ }
package/src/index.mjs CHANGED
@@ -1,15 +1,16 @@
1
- /**
2
- * playwright-repl — public API
3
- *
4
- * Usage as CLI:
5
- * npx playwright-repl [options]
6
- *
7
- * Usage as library:
8
- * import { DaemonConnection, parseInput, SessionRecorder } from 'playwright-repl';
9
- */
10
-
11
- export { DaemonConnection } from './connection.mjs';
12
- export { parseInput, ALIASES, ALL_COMMANDS } from './parser.mjs';
13
- export { SessionRecorder, SessionPlayer } from './recorder.mjs';
14
- export { socketPath, isDaemonRunning, startDaemon, findWorkspaceDir } from './workspace.mjs';
15
- export { startRepl } from './repl.mjs';
1
+ /**
2
+ * playwright-repl — public API
3
+ *
4
+ * Usage as CLI:
5
+ * npx playwright-repl [options]
6
+ *
7
+ * Usage as library:
8
+ * import { DaemonConnection, parseInput, SessionRecorder } from 'playwright-repl';
9
+ */
10
+
11
+ export { DaemonConnection } from './connection.mjs';
12
+ export { parseInput, ALIASES, ALL_COMMANDS } from './parser.mjs';
13
+ export { SessionRecorder, SessionPlayer } from './recorder.mjs';
14
+ export { socketPath, isDaemonRunning, startDaemon, findWorkspaceDir } from './workspace.mjs';
15
+ export { startRepl } from './repl.mjs';
16
+ export { buildCompletionItems } from './completion-data.mjs';
package/src/repl.mjs CHANGED
@@ -9,11 +9,12 @@ import path from 'node:path';
9
9
  import fs from 'node:fs';
10
10
  import { execSync } from 'node:child_process';
11
11
 
12
- import { replVersion, COMMANDS } from './resolve.mjs';
12
+ import { replVersion } from './resolve.mjs';
13
13
  import { DaemonConnection } from './connection.mjs';
14
14
  import { socketPath, daemonProfilesDir, isDaemonRunning, startDaemon } from './workspace.mjs';
15
15
  import { parseInput, ALIASES, ALL_COMMANDS } from './parser.mjs';
16
16
  import { SessionManager } from './recorder.mjs';
17
+ import { buildCompletionItems } from './completion-data.mjs';
17
18
  import { c } from './colors.mjs';
18
19
 
19
20
  // ─── Verify commands → run-code translation ─────────────────────────────────
@@ -518,26 +519,98 @@ export function promptStr(ctx) {
518
519
  return `${prefix}${c.cyan}pw>${c.reset} `;
519
520
  }
520
521
 
521
- // ─── Tab completer ──────────────────────────────────────────────────────────
522
-
523
- export function completer(line) {
524
- const parts = line.split(/\s+/);
525
- if (parts.length <= 1) {
526
- const prefix = parts[0] || '';
527
- const allNames = [...ALL_COMMANDS, ...Object.keys(ALIASES)];
528
- const metas = ['.help', '.aliases', '.status', '.reconnect', '.exit',
529
- '.record', '.save', '.replay', '.pause', '.discard'];
530
- const hits = [...allNames, ...metas].filter(n => n.startsWith(prefix));
531
- return [hits.length ? hits : allNames, prefix];
522
+ // ─── Ghost completion (inline suggestion) ───────────────────────────────────
523
+
524
+ /**
525
+ * Attaches ghost-text completion to a readline interface.
526
+ * Shows dimmed inline suggestion after the cursor; Tab or Right Arrow accepts it.
527
+ *
528
+ * Uses _ttyWrite wrapper instead of _writeToOutput because Node 22+ optimizes
529
+ * single-character appends and doesn't always trigger a full line refresh.
530
+ *
531
+ * @param {readline.Interface} rl
532
+ * @param {Array<{cmd: string, desc: string}>} items - from buildCompletionItems()
533
+ */
534
+ function attachGhostCompletion(rl, items) {
535
+ if (!process.stdin.isTTY) return; // no ghost text for piped input
536
+
537
+ const cmds = items.filter(i => !i.desc.startsWith('→')).map(i => i.cmd);
538
+ let ghost = '';
539
+ let matches = []; // all matching commands for current input
540
+ let matchIdx = 0; // which match is currently shown
541
+
542
+ function getMatches(input) {
543
+ if (input.length > 0 && !input.includes(' ')) {
544
+ return cmds.filter(cmd => cmd.startsWith(input) && cmd !== input);
545
+ }
546
+ return [];
532
547
  }
533
- const cmd = ALIASES[parts[0]] || parts[0];
534
- const helpText = COMMANDS[cmd]?.options || [];
535
- const lastPart = parts[parts.length - 1];
536
- if (lastPart.startsWith('--')) {
537
- const hits = helpText.filter(o => o.startsWith(lastPart));
538
- return [hits, lastPart];
548
+
549
+ function renderGhost(suffix) {
550
+ ghost = suffix;
551
+ rl.output.write(`\x1b[2m${ghost}\x1b[0m\x1b[${ghost.length}D`);
539
552
  }
540
- return [[], line];
553
+
554
+ const origTtyWrite = rl._ttyWrite.bind(rl);
555
+ rl._ttyWrite = function (s, key) {
556
+ if (ghost && key) {
557
+ // Right-arrow-at-end accepts ghost suggestion
558
+ if (key.name === 'right' && rl.cursor === rl.line.length) {
559
+ const text = ghost;
560
+ rl.output.write('\x1b[K');
561
+ ghost = '';
562
+ matches = [];
563
+ rl._insertString(text);
564
+ return;
565
+ }
566
+
567
+ // Tab cycles through matches
568
+ if (key.name === 'tab' && matches.length > 1) {
569
+ rl.output.write('\x1b[K');
570
+ matchIdx = (matchIdx + 1) % matches.length;
571
+ const input = rl.line || '';
572
+ renderGhost(matches[matchIdx].slice(input.length));
573
+ return;
574
+ }
575
+
576
+ // Tab with single match accepts it
577
+ if (key.name === 'tab' && matches.length === 1) {
578
+ const text = ghost;
579
+ rl.output.write('\x1b[K');
580
+ ghost = '';
581
+ matches = [];
582
+ rl._insertString(text);
583
+ return;
584
+ }
585
+ }
586
+
587
+ // Tab on empty input — show all commands as ghost suggestions
588
+ if (key && key.name === 'tab') {
589
+ if ((rl.line || '') === '') {
590
+ matches = cmds;
591
+ matchIdx = 0;
592
+ renderGhost(matches[0]);
593
+ }
594
+ return;
595
+ }
596
+
597
+ // Clear existing ghost text before readline processes the key
598
+ if (ghost) {
599
+ rl.output.write('\x1b[K');
600
+ ghost = '';
601
+ }
602
+
603
+ // Let readline handle the key normally
604
+ origTtyWrite(s, key);
605
+
606
+ // Render new ghost text if cursor is at end of line
607
+ const input = rl.line || '';
608
+ matches = getMatches(input);
609
+ matchIdx = 0;
610
+ if (matches.length > 0 && rl.cursor === rl.line.length) {
611
+ renderGhost(matches[0].slice(input.length));
612
+ }
613
+ };
541
614
  }
542
615
 
543
616
  // ─── REPL ────────────────────────────────────────────────────────────────────
@@ -585,7 +658,6 @@ export async function startRepl(opts = {}) {
585
658
  output: process.stdout,
586
659
  prompt: promptStr(ctx),
587
660
  historySize: 500,
588
- completer,
589
661
  });
590
662
  ctx.rl = rl;
591
663
 
@@ -594,6 +666,8 @@ export async function startRepl(opts = {}) {
594
666
  for (const line of hist) rl.history.push(line);
595
667
  } catch {}
596
668
 
669
+ attachGhostCompletion(rl, buildCompletionItems());
670
+
597
671
  // ─── Start ───────────────────────────────────────────────────────
598
672
 
599
673
  if (opts.replay) {