Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ Only update the `Status` field — do not modify any other frontmatter or prompt

<!-- BEGIN:REPO:current-state -->
## Current State
Branch: `refactor/adopt-string-width`
In-progress: Nothing. PR #139 (refactor #137, adopt string-width) open, auto-merge enabled.
Branch: `fix/lone-surrogate-sanitisation`
In-progress: Nothing. PR #143 (fix #141, lone surrogate sanitisation) open, auto-merge enabled.
<!-- END:REPO:current-state -->

<!-- BEGIN:REPO:architecture -->
Expand Down
8 changes: 8 additions & 0 deletions .claude/sessions/2026-03-28.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,11 @@
- Decisions: Label `enhancement` chosen as adoption of a library is an improvement, not a pure bug fix.
- Next: Await PR #139 auto-merge.
- Violations: None

### 22:42 - fix/lone-surrogate-sanitisation (#141)

- Did: Extracted `sanitiseLoneSurrogates` to `src/sanitise.ts`, applied at the top of `submit()` in `ClaudeCli.ts`, added warning log when replacement occurs, added 4 unit tests. Created PR #143 with Closes #141, auto-merge enabled.
- Files: `src/ClaudeCli.ts`, `src/sanitise.ts`, `test/sanitise.spec.ts`
- Decisions: Sanitisation extracted to a separate module so it can be unit tested. Warning uses `this.term.info` with yellow ANSI, matching the existing config warning pattern.
- Next: Await PR #143 auto-merge.
- Violations: None
7 changes: 6 additions & 1 deletion src/ClaudeCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { UsageProvider } from './providers/UsageProvider.js';
import { SdkResult } from './SdkResult.js';
import { SessionManager } from './SessionManager.js';
import { SystemPromptBuilder } from './SystemPromptBuilder.js';
import { sanitiseLoneSurrogates } from './sanitise.js';
import { QuerySession } from './session.js';
import { Terminal } from './terminal.js';
import { type ContextUsage, readLastTodoWrite, type TodoItem, UsageTracker } from './UsageTracker.js';
Expand Down Expand Up @@ -293,7 +294,11 @@ export class ClaudeCli {
}

private async submit(override?: string): Promise<void> {
const text = override ?? getText(this.editor);
const rawText = override ?? getText(this.editor);
const text = sanitiseLoneSurrogates(rawText);
if (text !== rawText) {
this.term.info('\x1b[33m[warning] Input contained lone surrogates; replaced with \uFFFD\x1b[0m');
}
if (!text.trim()) {
return;
}
Expand Down
5 changes: 5 additions & 0 deletions src/sanitise.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const LONE_SURROGATE_RE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;

export function sanitiseLoneSurrogates(s: string): string {
return s.replace(LONE_SURROGATE_RE, '\uFFFD');
}
28 changes: 28 additions & 0 deletions test/sanitise.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { sanitiseLoneSurrogates } from '../src/sanitise.js';

describe('sanitiseLoneSurrogates', () => {
it('replaces lone high surrogate', () => {
const actual = sanitiseLoneSurrogates('hello \uD83C world');
const expected = 'hello \uFFFD world';
expect(actual).toBe(expected);
});

it('replaces lone low surrogate', () => {
const actual = sanitiseLoneSurrogates('hello \uDF4C world');
const expected = 'hello \uFFFD world';
expect(actual).toBe(expected);
});

it('preserves valid surrogate pair', () => {
const actual = sanitiseLoneSurrogates('hello \uD83C\uDF4C world');
const expected = 'hello \uD83C\uDF4C world';
expect(actual).toBe(expected);
});

it('replaces lone high surrogate but preserves valid pair', () => {
const actual = sanitiseLoneSurrogates('\uD83C\uDF4C test \uD83C');
const expected = '\uD83C\uDF4C test \uFFFD';
expect(actual).toBe(expected);
});
});
Loading