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
3 changes: 2 additions & 1 deletion apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
import {
detectComposerTrigger,
replaceTextRange,
serializeComposerMentionPath,
type ComposerTrigger,
} from "@t3tools/shared/composerTrigger";
import { TextInputWrapper } from "expo-paste-input";
Expand Down Expand Up @@ -408,7 +409,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer

let replacement = "";
if (item.type === "path") {
replacement = `@${item.path} `;
replacement = `@${serializeComposerMentionPath(item.path)} `;
} else if (item.type === "skill") {
replacement = `$${item.skill.name} `;
} else if (item.type === "slash-command") {
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/ComposerPromptEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin";
import { type ServerProviderSkill } from "@t3tools/contracts";
import { serializeComposerMentionPath } from "@t3tools/shared/composerTrigger";
import {
$applyNodeReplacement,
$createRangeSelection,
Expand Down Expand Up @@ -198,7 +199,7 @@ class ComposerMentionNode extends DecoratorNode<React.ReactElement> {
}

override getTextContent(): string {
return `@${this.__path}`;
return `@${serializeComposerMentionPath(this.__path)}`;
}

override isInline(): true {
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
} from "@t3tools/contracts";
import { serializeComposerMentionPath } from "@t3tools/shared/composerTrigger";
import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
import {
memo,
Expand Down Expand Up @@ -1471,7 +1472,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
const { snapshot, trigger } = resolveActiveComposerTrigger();
if (!trigger) return;
if (item.type === "path") {
const replacement = `@${item.path} `;
const replacement = `@${serializeComposerMentionPath(item.path)} `;
const replacementRangeEnd = extendReplacementRangeForTrailingSpace(
snapshot.value,
trigger.rangeEnd,
Expand Down
26 changes: 26 additions & 0 deletions apps/web/src/composer-editor-mentions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,22 @@ describe("splitPromptIntoComposerSegments", () => {
]);
});

it("splits quoted mention tokens containing whitespace", () => {
expect(splitPromptIntoComposerSegments('Inspect @"My File.md" please')).toEqual([
{ type: "text", text: "Inspect " },
{ type: "mention", path: "My File.md" },
{ type: "text", text: " please" },
]);
});

it("unescapes quoted mention token content", () => {
expect(splitPromptIntoComposerSegments('Inspect @"docs/My \\"File\\".md" please')).toEqual([
{ type: "text", text: "Inspect " },
{ type: "mention", path: 'docs/My "File".md' },
{ type: "text", text: " please" },
]);
});

it("splits skill tokens followed by whitespace into skill segments", () => {
expect(splitPromptIntoComposerSegments("Use $review-follow-up please")).toEqual([
{ type: "text", text: "Use " },
Expand Down Expand Up @@ -125,4 +141,14 @@ describe("selectionTouchesMentionBoundary", () => {
),
).toBe(true);
});

it("returns true when selection includes whitespace after a quoted mention", () => {
expect(
selectionTouchesMentionBoundary(
'hi @"My File.md" there',
'hi @"My File.md"'.length,
'hi @"My File.md" there'.length,
),
).toBe(true);
});
});
30 changes: 19 additions & 11 deletions apps/web/src/composer-editor-mentions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ export type ComposerPromptSegment =
context: TerminalContextDraft | null;
};

const MENTION_TOKEN_REGEX = /(^|\s)@([^\s@]+)(?=\s)/g;
const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s)/g;
const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g;

function rangeIncludesIndex(start: number, end: number, index: number): boolean {
return start <= index && index < end;
Expand Down Expand Up @@ -52,13 +52,18 @@ type InlineTokenMatch =
end: number;
};

function collectInlineTokenMatches(text: string): InlineTokenMatch[] {
const matches: InlineTokenMatch[] = [];
type MentionTokenMatch = Extract<InlineTokenMatch, { type: "mention" }>;

function collectMentionTokenMatches(text: string): MentionTokenMatch[] {
const matches: MentionTokenMatch[] = [];

for (const match of text.matchAll(MENTION_TOKEN_REGEX)) {
const fullMatch = match[0];
const prefix = match[1] ?? "";
const path = match[2] ?? "";
const quotedPath = match[2];
const unquotedPath = match[3];
const path =
quotedPath !== undefined ? quotedPath.replace(/\\(.)/g, "$1") : (unquotedPath ?? "");
const matchIndex = match.index ?? 0;
const start = matchIndex + prefix.length;
const end = start + fullMatch.length - prefix.length;
Expand All @@ -67,6 +72,12 @@ function collectInlineTokenMatches(text: string): InlineTokenMatch[] {
}
}

return matches;
}

function collectInlineTokenMatches(text: string): InlineTokenMatch[] {
const matches: InlineTokenMatch[] = collectMentionTokenMatches(text);

for (const match of text.matchAll(SKILL_TOKEN_REGEX)) {
const fullMatch = match[0];
const prefix = match[1] ?? "";
Expand Down Expand Up @@ -148,10 +159,10 @@ function forEachPromptTextSlice(

function forEachMentionMatch(
prompt: string,
visitor: (match: RegExpMatchArray, promptOffset: number) => boolean | void,
visitor: (match: MentionTokenMatch, promptOffset: number) => boolean | void,
): boolean {
return forEachPromptTextSlice(prompt, (text, promptOffset) => {
for (const match of text.matchAll(MENTION_TOKEN_REGEX)) {
for (const match of collectMentionTokenMatches(text)) {
if (visitor(match, promptOffset) === true) {
return true;
}
Expand Down Expand Up @@ -203,11 +214,8 @@ export function selectionTouchesMentionBoundary(
}

return forEachMentionMatch(prompt, (match, promptOffset) => {
const fullMatch = match[0];
const prefix = match[1] ?? "";
const matchIndex = match.index ?? 0;
const mentionStart = promptOffset + matchIndex + prefix.length;
const mentionEnd = mentionStart + fullMatch.length - prefix.length;
const mentionStart = promptOffset + match.start;
const mentionEnd = promptOffset + match.end;
const beforeMentionIndex = mentionStart - 1;
const afterMentionIndex = mentionEnd;

Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/composer-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,16 @@ describe("expandCollapsedComposerCursor", () => {
);
});

it("maps collapsed quoted mention cursor to expanded text cursor", () => {
const text = 'what is in @"My File.md" please';
const collapsedCursorAfterMention = "what is in ".length + 2;
const expandedCursorAfterMention = 'what is in @"My File.md" '.length;

expect(expandCollapsedComposerCursor(text, collapsedCursorAfterMention)).toBe(
expandedCursorAfterMention,
);
});

it("allows path trigger detection to close after selecting a mention", () => {
const text = "what's in my @AGENTS.md ";
const collapsedCursorAfterMention = "what's in my ".length + 2;
Expand Down Expand Up @@ -191,6 +201,16 @@ describe("collapseExpandedComposerCursor", () => {
);
});

it("maps expanded quoted mention cursor back to collapsed cursor", () => {
const text = 'what is in @"My File.md" please';
const collapsedCursorAfterMention = "what is in ".length + 2;
const expandedCursorAfterMention = 'what is in @"My File.md" '.length;

expect(collapseExpandedComposerCursor(text, expandedCursorAfterMention)).toBe(
collapsedCursorAfterMention,
);
});

it("keeps replacement cursors aligned when another mention already exists earlier", () => {
const text = "open @AGENTS.md then @src/index.ts ";
const expandedCursor = text.length;
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/composer-logic.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { serializeComposerMentionPath } from "@t3tools/shared/composerTrigger";
import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";
import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext";

Expand Down Expand Up @@ -54,7 +55,7 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number)

for (const segment of segments) {
if (segment.type === "mention") {
const expandedLength = segment.path.length + 1;
const expandedLength = serializeComposerMentionPath(segment.path).length + 1;
Comment thread
cursor[bot] marked this conversation as resolved.
if (remaining <= 1) {
return expandedCursor + (remaining === 0 ? 0 : expandedLength);
}
Expand Down Expand Up @@ -142,7 +143,7 @@ export function collapseExpandedComposerCursor(text: string, cursorInput: number

for (const segment of segments) {
if (segment.type === "mention") {
const expandedLength = segment.path.length + 1;
const expandedLength = serializeComposerMentionPath(segment.path).length + 1;
if (remaining === 0) {
return collapsedCursor;
}
Expand Down
17 changes: 17 additions & 0 deletions packages/shared/src/composerTrigger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";

import { serializeComposerMentionPath } from "./composerTrigger.ts";

describe("serializeComposerMentionPath", () => {
it("keeps simple mention paths unquoted", () => {
expect(serializeComposerMentionPath("src/index.ts")).toBe("src/index.ts");
});

it("quotes mention paths containing whitespace", () => {
expect(serializeComposerMentionPath("docs/My File.md")).toBe('"docs/My File.md"');
});

it("escapes quoted mention path content", () => {
expect(serializeComposerMentionPath('docs/My "File".md')).toBe('"docs/My \\"File\\".md"');
});
});
9 changes: 9 additions & 0 deletions packages/shared/src/composerTrigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ export interface ComposerTrigger {
rangeEnd: number;
}

const SIMPLE_MENTION_PATH_REGEX = /^[^\s@"\\]+$/;

export function serializeComposerMentionPath(path: string): string {
if (SIMPLE_MENTION_PATH_REGEX.test(path)) {
return path;
}
return `"${path.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
}

function clampCursor(text: string, cursor: number): number {
if (!Number.isFinite(cursor)) return text.length;
return Math.max(0, Math.min(text.length, Math.floor(cursor)));
Expand Down
Loading