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
13 changes: 9 additions & 4 deletions apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export function nativeMarkdownContextCopyRanges(
const source = reference
? formatComposerContextReference({ ...reference, label: run.text })
: run.skillName
? `$${run.skillName}`
? (run.sourceText ?? `$${run.skillName}`)
: run.fileIcon && run.href
? (run.sourceText ?? `[${run.text}](<${run.href}>)`)
: null;
Expand Down Expand Up @@ -267,6 +267,7 @@ function sameRunStyle(left: NativeMarkdownTextRun, right: NativeMarkdownTextRun)
left.externalHost === right.externalHost &&
left.fileIcon === right.fileIcon &&
left.skillName === right.skillName &&
left.sourceText === right.sourceText &&
left.skillLabel === right.skillLabel &&
left.role === right.role &&
left.headingLevel === right.headingLevel &&
Expand Down Expand Up @@ -319,7 +320,7 @@ function appendRun(
}

const SKILL_TOKEN_REGEX =
/(^|\s)\$(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/g;
/(^|\s)\p{Sc}(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/gu;

function formatSkillLabel(skill: SelectableMarkdownSkill): string {
const displayName = skill.displayName?.trim();
Expand All @@ -336,6 +337,7 @@ function formatSkillLabel(skill: SelectableMarkdownSkill): string {
function decorateSkillRuns(
runs: ReadonlyArray<NativeMarkdownTextRun>,
skills: ReadonlyArray<SelectableMarkdownSkill>,
allowUnicodeSkillAliases: boolean,
): ReadonlyArray<NativeMarkdownTextRun> {
if (skills.length === 0) {
return runs;
Expand All @@ -359,13 +361,15 @@ function decorateSkillRuns(
continue;
}
const start = (match.index ?? 0) + prefix.length;
const end = start + name.length + 1;
const end = (match.index ?? 0) + match[0].length;
if (!allowUnicodeSkillAliases && run.text[start] !== "$") continue;
if (start > cursor) {
decorated.push({ ...run, text: run.text.slice(cursor, start) });
}
decorated.push({
...run,
text: run.text.slice(start, end),
sourceText: run.text.slice(start, end),
skillName: name,
skillLabel: formatSkillLabel(skill),
});
Expand Down Expand Up @@ -949,6 +953,7 @@ export function nativeMarkdownChunkSpacing(
export function nativeMarkdownDocumentRuns(
node: MarkdownNode,
skills: ReadonlyArray<SelectableMarkdownSkill> = [],
allowUnicodeSkillAliases = false,
): ReadonlyArray<NativeMarkdownTextRun> {
const runs = appendDocumentBlock([], node);
while (runs.length > 0) {
Expand All @@ -964,5 +969,5 @@ export function nativeMarkdownDocumentRuns(
runs[lastIndex] = { ...last, text };
}
}
return decorateMentionRuns(decorateSkillRuns(runs, skills));
return decorateMentionRuns(decorateSkillRuns(runs, skills, allowUnicodeSkillAliases));
}
5 changes: 3 additions & 2 deletions apps/mobile/src/components/ComposerEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,9 @@ export function ComposerEditor({
const selectedReference = selected
? collectComposerContextReferences(selected.source)[0]
: undefined;
const selectedSkill = selected?.source.startsWith("$")
? props.skills?.find((skill) => skill.name === selected.source.slice(1))
const selectedSkillName = selected?.source.match(/^\p{Sc}(.+)$/u)?.[1];
const selectedSkill = selectedSkillName
? props.skills?.find((skill) => skill.name === selectedSkillName)
: undefined;
const record = draft.context?.records.find(
(entry) => entry.contextId === selectedReference?.contextId,
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { supportsUnicodeSkillAliases } from "@t3tools/client-runtime/providerSkills";
import { shouldHandleUsageLimitsCommand } from "@t3tools/shared/usageLimits";
import { useAtomValue } from "@effect/atom-react";
import * as Cause from "effect/Cause";
Expand Down Expand Up @@ -1265,6 +1266,7 @@ export function NewTaskDraftScreen(props: {
const promptEditor = (
<>
<ComposerEditor
allowUnicodeSkillAliases={supportsUnicodeSkillAliases(flow.selectedProviderStatus?.driver)}
draftKey={flow.draftKey}
environmentId={selectedProject.environmentId}
onOpenAttachment={openDraftDocument}
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { supportsUnicodeSkillAliases } from "@t3tools/client-runtime/providerSkills";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { useAtomValue } from "@effect/atom-react";
import type { ContextWindowSnapshot } from "@t3tools/client-runtime/state/context-window";
Expand Down Expand Up @@ -1788,6 +1789,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
the surface's own paddingLeft. */}
<View className={isExpanded ? "px-[14px]" : "min-w-0 flex-1"}>
<ComposerEditor
allowUnicodeSkillAliases={supportsUnicodeSkillAliases(
selectedProviderStatus?.driver,
)}
draftKey={composerOwnerKey}
environmentId={props.environmentId}
onOpenMention={(path) => {
Expand Down
18 changes: 18 additions & 0 deletions apps/mobile/src/features/threads/use-composer-command-menu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,24 @@ describe("buildComposerCommandItems path results", () => {
});

describe("composerCommandReplacement", () => {
it.each(["$", "€", "£", "₹", "𑿝"])(
"preserves the %s trigger prefix when selecting a native skill",
(prefix) => {
expect(
composerCommandReplacement(
{
id: "skill:review-t3-upstream",
type: "skill",
skill: skill({ name: "review-t3-upstream" }),
label: "Review T3 Upstream",
description: "",
},
`${prefix}review`,
),
).toBe(`${prefix}review-t3-upstream `);
},
);

it("writes a markdown link for a path and a skill token for a skill", () => {
expect(
composerCommandReplacement({
Expand Down
20 changes: 15 additions & 5 deletions apps/mobile/src/features/threads/use-composer-command-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
getProviderSkillsForSlashMenu,
getProviderSlashCommandsForSlashMenu,
isProviderSkillUserInvocable,
supportsUnicodeSkillAliases,
} from "@t3tools/client-runtime/providerSkills";
import type { ComposerPathSearchEntry } from "@t3tools/client-runtime/state/threads";
import {
Expand Down Expand Up @@ -212,7 +213,7 @@ export function buildComposerCommandItems({
(selectedProviderStatus?.skills ?? []).filter(isProviderSkillUserInvocable),
);
const normalizedQuery = normalizeSearchQuery(trigger.query, {
trimLeadingPattern: /^\$+/,
trimLeadingPattern: /^\p{Sc}+/u,
});

if (!normalizedQuery) {
Expand Down Expand Up @@ -312,12 +313,13 @@ export function buildComposerCommandItems({
/** The text a selected item writes over its trigger range, or `null` for mode switches. */
export function composerCommandReplacement(
item: Exclude<ComposerCommandItem, { type: "pull-request" }>,
originalToken?: string,
): string | null {
switch (item.type) {
case "path":
return `${serializeComposerFileLink(item.path)} `;
case "skill":
return `$${item.skill.name} `;
return `${/^\p{Sc}/u.exec(originalToken ?? "")?.[0] ?? "$"}${item.skill.name} `;
case "slash-command":
return item.command === "plan" || item.command === "default" ? null : `/${item.command} `;
case "provider-slash-command":
Expand Down Expand Up @@ -467,8 +469,13 @@ export function useComposerCommandMenu({
if (!enabled || selection.start !== selection.end) {
return null;
}
return detectComposerTrigger(draftMessage, selection.end);
}, [draftMessage, enabled, selection]);
return detectComposerTrigger(
draftMessage,
selection.end,
undefined,
supportsUnicodeSkillAliases(selectedProviderStatus?.driver),
);
}, [draftMessage, enabled, selection, selectedProviderStatus?.driver]);

const pathSearch = useComposerPathSearch({
environmentId,
Expand Down Expand Up @@ -554,7 +561,10 @@ export function useComposerCommandMenu({
return;
}

const replacement = composerCommandReplacement(item);
const replacement = composerCommandReplacement(
item,
draftMessage.slice(trigger.rangeStart, trigger.rangeEnd),
);
const result = replaceTextRange(
draftMessage,
trigger.rangeStart,
Expand Down
69 changes: 51 additions & 18 deletions apps/mobile/src/lib/nativeMarkdownText.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,27 +307,56 @@ describe("nativeMarkdownDocumentRuns", () => {
]);
});

it("decorates known skill references as selectable skill links", () => {
const node: MarkdownNode = {
type: "document",
children: [
it.each(["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"])(
"decorates %s skill references as selectable skill links",
(prefix) => {
const node: MarkdownNode = {
type: "document",
children: [
{
type: "paragraph",
children: [{ type: "text", content: `Use ${prefix}ui for this.` }],
},
],
};

expect(nativeMarkdownDocumentRuns(node, [{ name: "ui", displayName: "UI" }], true)).toEqual([
{ text: "Use ", role: "body" },
{
type: "paragraph",
children: [{ type: "text", content: "Use $ui for this." }],
text: `${prefix}ui`,
role: "body",
sourceText: `${prefix}ui`,
skillName: "ui",
skillLabel: "UI",
},
],
{ text: " for this.", role: "body" },
]);
},
);

it("keeps Unicode aliases literal in history without provider identity", () => {
const node: MarkdownNode = {
type: "document",
children: [{ type: "paragraph", children: [{ type: "text", content: "Use 𑿝ui and $ui" }] }],
};
const runs = nativeMarkdownDocumentRuns(node, [{ name: "ui" }]);
expect(runs.some((run) => run.skillName === "ui" && run.text === "𑿝ui")).toBe(false);
expect(runs.some((run) => run.skillName === "ui" && run.text === "$ui")).toBe(true);
expect(runs.map((run) => run.text).join("")).toBe("Use 𑿝ui and $ui");
});

expect(nativeMarkdownDocumentRuns(node, [{ name: "ui", displayName: "UI" }])).toEqual([
{ text: "Use ", role: "body" },
{
text: "$ui",
role: "body",
skillName: "ui",
skillLabel: "UI",
},
{ text: " for this.", role: "body" },
]);
it("copies a decorated astral currency alias without replacing its prefix", () => {
const node: MarkdownNode = {
type: "document",
children: [{ type: "paragraph", children: [{ type: "text", content: "Use 𑿝ui now" }] }],
};
const skillRun = nativeMarkdownDocumentRuns(node, [{ name: "ui" }], true).find(
(run) => run.skillName === "ui",
);
expect(skillRun).toBeDefined();
expect(
nativeMarkdownContextCopyRanges([{ run: skillRun!, text: "\uFFFC", inlineImageLength: 0 }]),
).toEqual([{ start: 0, end: 1, text: "𑿝ui" }]);
});

it("decorates known skill references that begin with a digit", () => {
Expand All @@ -346,6 +375,7 @@ describe("nativeMarkdownDocumentRuns", () => {
{
text: "$2spec",
role: "body",
sourceText: "$2spec",
skillName: "2spec",
skillLabel: "2Spec",
},
Expand All @@ -364,9 +394,12 @@ describe("nativeMarkdownDocumentRuns", () => {
],
};

expect(nativeMarkdownDocumentRuns(node, [{ name: "ui", displayName: "UI" }])).toContainEqual({
expect(
nativeMarkdownDocumentRuns(node, [{ name: "ui", displayName: "UI" }], true),
).toContainEqual({
text: "$ui",
role: "body",
sourceText: "$ui",
skillName: "ui",
skillLabel: "UI",
});
Expand Down
19 changes: 17 additions & 2 deletions apps/mobile/src/native/T3ComposerEditor.ios.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,16 @@ export function ComposerEditor({
// first controlled payload must be a non-echo so a restored draft (or a
// recycled native view) is applied rather than skipped.
const nativeEventSnapshotsRef = useRef<ComposerNativeEventSnapshot[]>([]);
const confirmedTokensRef = useRef(collectComposerInlineTokens(props.value));
const confirmedTokensRef = useRef(
collectComposerInlineTokens(props.value, {
allowUnicodeSkillAliases: props.allowUnicodeSkillAliases ?? false,
unicodeSkillNames: new Set(
skills
.filter((skill) => skill.enabled !== false && skill.userInvocable !== false)
.map((skill) => skill.name),
),
}),
);
const bodyText = useScaledTextRole("body");
const theme = useUniwindTheme();
const fontFamily = useFontFamily("regular");
Expand All @@ -145,6 +154,12 @@ export function ComposerEditor({
const tokensJson = useMemo(() => {
const tokens = collectComposerInlineTokens(props.value, {
preserveTrailingFrom: confirmedTokensRef.current,
allowUnicodeSkillAliases: props.allowUnicodeSkillAliases ?? false,
unicodeSkillNames: new Set(
skills
.filter((skill) => skill.enabled !== false && skill.userInvocable !== false)
.map((skill) => skill.name),
),
});
confirmedTokensRef.current = tokens;
return JSON.stringify(
Expand Down Expand Up @@ -178,7 +193,7 @@ export function ComposerEditor({
};
}),
);
}, [props.value, props.context, skillLabels]);
}, [props.value, props.context, props.allowUnicodeSkillAliases, skillLabels, skills]);
// Every render resolves against the snapshot history, so a render whose
// (value, selection) lags the acknowledged native state is stamped behind
// the native revision and rejected by the editor instead of re-applying a
Expand Down
19 changes: 17 additions & 2 deletions apps/mobile/src/native/T3ComposerEditor.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,16 @@ export function ComposerEditor({
// first controlled payload must be a non-echo so a restored draft (or a
// recycled native view) is applied rather than skipped.
const nativeEventSnapshotsRef = useRef<ComposerNativeEventSnapshot[]>([]);
const [initialConfirmedTokens] = useState(() => collectComposerInlineTokens(props.value));
const [initialConfirmedTokens] = useState(() =>
collectComposerInlineTokens(props.value, {
allowUnicodeSkillAliases: props.allowUnicodeSkillAliases ?? false,
unicodeSkillNames: new Set(
skills
.filter((skill) => skill.enabled !== false && skill.userInvocable !== false)
.map((skill) => skill.name),
),
}),
);
const confirmedTokensRef = useRef(initialConfirmedTokens);
const theme = useUniwindTheme();
const handlePaste = useNativePaste((uris) => onPasteImages?.(uris));
Expand All @@ -147,6 +156,12 @@ export function ComposerEditor({
const tokensJson = useMemo(() => {
const tokens = collectComposerInlineTokens(props.value, {
preserveTrailingFrom: confirmedTokensRef.current,
allowUnicodeSkillAliases: props.allowUnicodeSkillAliases ?? false,
unicodeSkillNames: new Set(
skills
.filter((skill) => skill.enabled !== false && skill.userInvocable !== false)
.map((skill) => skill.name),
),
});
confirmedTokensRef.current = tokens;
return JSON.stringify(
Expand Down Expand Up @@ -180,7 +195,7 @@ export function ComposerEditor({
};
}),
);
}, [props.value, props.context, skillLabels]);
}, [props.value, props.context, props.allowUnicodeSkillAliases, skillLabels, skills]);
// Every render resolves against the snapshot history, so a render whose
// (value, selection) lags the acknowledged native state is stamped behind
// the native revision and rejected by the editor instead of re-applying a
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/src/native/T3ComposerEditor.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface ComposerEditorHandle {
export interface ComposerEditorProps {
readonly ref?: Ref<ComposerEditorHandle>;
readonly value: string;
readonly allowUnicodeSkillAliases?: boolean;
readonly context?: OrchestrationMessageContext;
readonly clipboardFragment?: string;
readonly onPasteContext?: (clipboard: {
Expand All @@ -25,7 +26,7 @@ export interface ComposerEditorProps {
}) => void;
readonly skills?: ReadonlyArray<
Pick<ServerProviderSkill, "name" | "displayName" | "shortDescription" | "description"> &
Partial<Pick<ServerProviderSkill, "path">>
Partial<Pick<ServerProviderSkill, "path" | "enabled" | "userInvocable">>
>;
readonly selection?: ComposerEditorSelection;
readonly placeholder?: string;
Expand Down
Loading
Loading