claude-remote-cli 2.15.3 → 2.15.5

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.
@@ -11,7 +11,7 @@
11
11
  <meta name="apple-mobile-web-app-capable" content="yes" />
12
12
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
13
13
  <meta name="theme-color" content="#1a1a1a" />
14
- <script type="module" crossorigin src="/assets/index-D_AXCfe5.js"></script>
14
+ <script type="module" crossorigin src="/assets/index-6brRnAUY.js"></script>
15
15
  <link rel="stylesheet" crossorigin href="/assets/index-t15zfL9Q.css">
16
16
  </head>
17
17
  <body>
@@ -0,0 +1,107 @@
1
+ export function codepointCount(str) {
2
+ let count = 0;
3
+ for (let i = 0; i < str.length; i++) {
4
+ count++;
5
+ if (str.charCodeAt(i) >= 0xd800 && str.charCodeAt(i) <= 0xdbff)
6
+ i++;
7
+ }
8
+ return count;
9
+ }
10
+ export function commonPrefixLength(a, b) {
11
+ let len = 0;
12
+ while (len < a.length && len < b.length && a[len] === b[len])
13
+ len++;
14
+ return len;
15
+ }
16
+ const DEL = '\x7f';
17
+ function makeBackspaces(count) {
18
+ let s = '';
19
+ for (let i = 0; i < count; i++)
20
+ s += DEL;
21
+ return s;
22
+ }
23
+ function handleInsert(intent, currentValue) {
24
+ const { rangeStart, rangeEnd, data } = intent;
25
+ if (rangeStart !== null && rangeEnd !== null && rangeStart !== rangeEnd) {
26
+ // Non-collapsed range = autocorrect replacement
27
+ const replaced = intent.valueBefore.slice(rangeStart, rangeEnd);
28
+ const charsToDelete = codepointCount(replaced);
29
+ const payload = makeBackspaces(charsToDelete) + (data ?? '');
30
+ return { payload };
31
+ }
32
+ if (data) {
33
+ // Detect bad cursor-0 autocorrect: keyboard lost cursor position
34
+ // and prepended data at position 0 instead of replacing a word.
35
+ if (data.length > 1 && intent.cursorBefore === 0 &&
36
+ intent.valueBefore.length > 0 &&
37
+ currentValue === data + intent.valueBefore) {
38
+ const charsToDelete = codepointCount(intent.valueBefore);
39
+ const payload = makeBackspaces(charsToDelete) + data;
40
+ return { payload, newInputValue: data };
41
+ }
42
+ // Collapsed range = normal character insertion
43
+ return { payload: data };
44
+ }
45
+ // No data and no range — fall back to diff
46
+ return handleFallbackDiff(intent, currentValue);
47
+ }
48
+ function handleDelete(intent, currentValue) {
49
+ const { rangeStart, rangeEnd, valueBefore } = intent;
50
+ if (rangeStart !== null && rangeEnd !== null) {
51
+ const deleted = valueBefore.slice(rangeStart, rangeEnd);
52
+ const charsToDelete = codepointCount(deleted);
53
+ return { payload: makeBackspaces(charsToDelete) };
54
+ }
55
+ // No range info — diff to figure out how many chars were deleted
56
+ const deleted = valueBefore.length - currentValue.length;
57
+ const charsToDelete = Math.max(1, deleted);
58
+ return { payload: makeBackspaces(charsToDelete) };
59
+ }
60
+ function handleReplacement(intent, currentValue) {
61
+ const { rangeStart, rangeEnd, data, valueBefore } = intent;
62
+ if (rangeStart !== null && rangeEnd !== null) {
63
+ const replaced = valueBefore.slice(rangeStart, rangeEnd);
64
+ const charsToDelete = codepointCount(replaced);
65
+ const payload = makeBackspaces(charsToDelete) + (data ?? '');
66
+ return { payload };
67
+ }
68
+ return handleFallbackDiff(intent, currentValue);
69
+ }
70
+ function handlePaste(intent, currentValue) {
71
+ const commonLen = commonPrefixLength(intent.valueBefore, currentValue);
72
+ const pasted = currentValue.slice(commonLen);
73
+ return { payload: pasted };
74
+ }
75
+ function handleFallbackDiff(intent, currentValue) {
76
+ const valueBefore = intent.valueBefore || '';
77
+ if (currentValue === valueBefore) {
78
+ return { payload: '' };
79
+ }
80
+ const commonLen = commonPrefixLength(valueBefore, currentValue);
81
+ const deletedSlice = valueBefore.slice(commonLen);
82
+ const charsToDelete = codepointCount(deletedSlice);
83
+ const newChars = currentValue.slice(commonLen);
84
+ const payload = makeBackspaces(charsToDelete) + newChars;
85
+ return { payload };
86
+ }
87
+ export function processIntent(intent, currentValue) {
88
+ switch (intent.type) {
89
+ case 'insertText':
90
+ return handleInsert(intent, currentValue);
91
+ case 'deleteContentBackward':
92
+ case 'deleteContentForward':
93
+ case 'deleteWordBackward':
94
+ case 'deleteWordForward':
95
+ case 'deleteSoftLineBackward':
96
+ case 'deleteSoftLineForward':
97
+ case 'deleteBySoftwareKeyboard':
98
+ return handleDelete(intent, currentValue);
99
+ case 'insertReplacementText':
100
+ return handleReplacement(intent, currentValue);
101
+ case 'insertFromPaste':
102
+ case 'insertFromDrop':
103
+ return handlePaste(intent, currentValue);
104
+ default:
105
+ return handleFallbackDiff(intent, currentValue);
106
+ }
107
+ }
@@ -0,0 +1,163 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { readFileSync, readdirSync } from 'node:fs';
4
+ import { join, dirname } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { processIntent } from '../server/mobile-input-pipeline.js';
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const FIXTURES_DIR = join(__dirname, '..', '..', 'test', 'fixtures', 'mobile-input');
9
+ function loadFixture(filename) {
10
+ const raw = readFileSync(join(FIXTURES_DIR, filename), 'utf-8');
11
+ return JSON.parse(raw);
12
+ }
13
+ function replayFixture(fixture) {
14
+ let totalPayload = '';
15
+ for (const step of fixture.events) {
16
+ const intent = {
17
+ type: step.inputType,
18
+ data: step.data,
19
+ rangeStart: step.rangeStart,
20
+ rangeEnd: step.rangeEnd,
21
+ valueBefore: step.valueBefore,
22
+ cursorBefore: step.cursorBefore,
23
+ };
24
+ const result = processIntent(intent, step.valueAfter);
25
+ totalPayload += result.payload;
26
+ }
27
+ return totalPayload;
28
+ }
29
+ function assertReplacementNotLost(payload, expectedReplacement, fixtureName) {
30
+ assert.ok(payload.includes(expectedReplacement), `[${fixtureName}] Payload contains only backspaces — replacement text "${expectedReplacement}" was lost. Got: ${JSON.stringify(payload)}`);
31
+ }
32
+ // ── Fixture replay tests ─────────────────────────────────────────────
33
+ describe('mobile-input-pipeline: fixture replay', () => {
34
+ const fixtureFiles = readdirSync(FIXTURES_DIR).filter(f => f.endsWith('.json'));
35
+ for (const file of fixtureFiles) {
36
+ const fixture = loadFixture(file);
37
+ it(`${fixture.name}: ${fixture.description}`, () => {
38
+ const actualPayload = replayFixture(fixture);
39
+ assert.strictEqual(actualPayload, fixture.expectedPayload, `Payload mismatch for fixture "${fixture.name}". ` +
40
+ `Expected: ${JSON.stringify(fixture.expectedPayload)}, ` +
41
+ `Got: ${JSON.stringify(actualPayload)}`);
42
+ });
43
+ }
44
+ });
45
+ // ── Autocorrect invariant tests ──────────────────────────────────────
46
+ describe('mobile-input-pipeline: autocorrect always includes replacement text', () => {
47
+ it('Gboard range replacement includes replacement text', () => {
48
+ const fixture = loadFixture('gboard-autocorrect-range.json');
49
+ const payload = replayFixture(fixture);
50
+ assertReplacementNotLost(payload, 'the', fixture.name);
51
+ });
52
+ it('Gboard cursor-0 recovery includes replacement text', () => {
53
+ const fixture = loadFixture('gboard-autocorrect-cursor0.json');
54
+ const payload = replayFixture(fixture);
55
+ assertReplacementNotLost(payload, 'the', fixture.name);
56
+ });
57
+ it('iOS insertReplacementText includes replacement text', () => {
58
+ const fixture = loadFixture('ios-replacement-text.json');
59
+ const payload = replayFixture(fixture);
60
+ assertReplacementNotLost(payload, 'the', fixture.name);
61
+ });
62
+ it('multi-word buffer: only target word deleted, replacement inserted', () => {
63
+ const fixture = loadFixture('gboard-autocorrect-multi-word.json');
64
+ const payload = replayFixture(fixture);
65
+ assertReplacementNotLost(payload, 'the', fixture.name);
66
+ const backspaceCount = (payload.match(/\x7f/g) ?? []).length;
67
+ assert.strictEqual(backspaceCount, 3, `Expected 3 backspaces (for "teh") but got ${backspaceCount} — ` +
68
+ `pipeline is deleting more than the target word`);
69
+ });
70
+ });
71
+ // ── processIntent unit tests ─────────────────────────────────────────
72
+ describe('mobile-input-pipeline: processIntent', () => {
73
+ it('normal character insertion sends data directly', () => {
74
+ const result = processIntent({
75
+ type: 'insertText', data: 'a',
76
+ rangeStart: 5, rangeEnd: 5,
77
+ valueBefore: 'hello', cursorBefore: 5,
78
+ }, 'helloa');
79
+ assert.strictEqual(result.payload, 'a');
80
+ assert.strictEqual(result.newInputValue, undefined);
81
+ });
82
+ it('autocorrect with range sends backspaces + replacement', () => {
83
+ const result = processIntent({
84
+ type: 'insertText', data: 'the',
85
+ rangeStart: 0, rangeEnd: 3,
86
+ valueBefore: 'teh', cursorBefore: 3,
87
+ }, 'the');
88
+ assert.strictEqual(result.payload, '\x7f\x7f\x7fthe');
89
+ });
90
+ it('cursor-0 recovery sets newInputValue', () => {
91
+ const result = processIntent({
92
+ type: 'insertText', data: 'the',
93
+ rangeStart: null, rangeEnd: null,
94
+ valueBefore: 'teh', cursorBefore: 0,
95
+ }, 'theteh');
96
+ assert.strictEqual(result.payload, '\x7f\x7f\x7fthe');
97
+ assert.strictEqual(result.newInputValue, 'the');
98
+ });
99
+ it('deleteContentBackward with range', () => {
100
+ const result = processIntent({
101
+ type: 'deleteContentBackward', data: null,
102
+ rangeStart: 4, rangeEnd: 5,
103
+ valueBefore: 'hello', cursorBefore: 5,
104
+ }, 'hell');
105
+ assert.strictEqual(result.payload, '\x7f');
106
+ });
107
+ it('deleteWordBackward with range sends correct backspace count', () => {
108
+ const result = processIntent({
109
+ type: 'deleteWordBackward', data: null,
110
+ rangeStart: 6, rangeEnd: 11,
111
+ valueBefore: 'hello world', cursorBefore: 11,
112
+ }, 'hello ');
113
+ assert.strictEqual(result.payload, '\x7f\x7f\x7f\x7f\x7f');
114
+ });
115
+ it('deleteContentBackward without range falls back to diff', () => {
116
+ const result = processIntent({
117
+ type: 'deleteContentBackward', data: null,
118
+ rangeStart: null, rangeEnd: null,
119
+ valueBefore: 'hello', cursorBefore: 5,
120
+ }, 'hell');
121
+ assert.strictEqual(result.payload, '\x7f');
122
+ });
123
+ it('insertReplacementText sends backspaces + replacement', () => {
124
+ const result = processIntent({
125
+ type: 'insertReplacementText', data: 'the',
126
+ rangeStart: 0, rangeEnd: 3,
127
+ valueBefore: 'teh', cursorBefore: 3,
128
+ }, 'the');
129
+ assert.strictEqual(result.payload, '\x7f\x7f\x7fthe');
130
+ });
131
+ it('insertFromPaste uses diff to extract pasted text', () => {
132
+ const result = processIntent({
133
+ type: 'insertFromPaste', data: null,
134
+ rangeStart: null, rangeEnd: null,
135
+ valueBefore: 'hello', cursorBefore: 5,
136
+ }, 'hello world');
137
+ assert.strictEqual(result.payload, ' world');
138
+ });
139
+ it('unknown inputType falls back to diff', () => {
140
+ const result = processIntent({
141
+ type: 'insertFromYank', data: null,
142
+ rangeStart: null, rangeEnd: null,
143
+ valueBefore: 'hllo', cursorBefore: 1,
144
+ }, 'hello');
145
+ assert.strictEqual(result.payload, '\x7f\x7f\x7fello');
146
+ });
147
+ it('empty payload for no-op diff', () => {
148
+ const result = processIntent({
149
+ type: 'insertText', data: null,
150
+ rangeStart: null, rangeEnd: null,
151
+ valueBefore: 'hello', cursorBefore: 5,
152
+ }, 'hello');
153
+ assert.strictEqual(result.payload, '');
154
+ });
155
+ it('handles emoji codepoints correctly in autocorrect range', () => {
156
+ const result = processIntent({
157
+ type: 'insertText', data: 'smile',
158
+ rangeStart: 0, rangeEnd: 2,
159
+ valueBefore: '😊', cursorBefore: 2,
160
+ }, 'smile');
161
+ assert.strictEqual(result.payload, '\x7fsmile');
162
+ });
163
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote-cli",
3
- "version": "2.15.3",
3
+ "version": "2.15.5",
4
4
  "description": "Remote web interface for Claude Code CLI sessions",
5
5
  "type": "module",
6
6
  "main": "dist/server/index.js",