Skip to content

Commit 6ff00fe

Browse files
sheehanmunimclaude
andcommitted
merge(upstream): sync 2 commits from pingdotgg/t3code main (Claude skills dispatch, bounded session lookups)
ClaudeAdapter keeps the fork's computer-history context block ahead of upstream's skill dispatch split, and loads it beside upstream's per-send skill discovery. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J764QWzvXpEUTgDPn5ahfo
2 parents 73635f2 + ea71a19 commit 6ff00fe

18 files changed

Lines changed: 1151 additions & 99 deletions

‎apps/mobile/src/features/threads/use-composer-command-menu.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
import {
1313
dedupeProviderSkillsByName,
1414
getProviderSkillsForSlashMenu,
15+
isProviderSkillUserInvocable,
1516
} from "@t3tools/client-runtime/providerSkills";
1617
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1718

@@ -114,8 +115,14 @@ export function useComposerCommandMenu({
114115
(item.command === "model" || onUpdateInteractionMode !== undefined),
115116
);
116117

118+
// A provider expands a slash command only when it opens the whole
119+
// message; elsewhere it arrives as literal text. Built-ins apply
120+
// locally and skills insert a `$` mention the server dispatches from
121+
// any position, so only provider commands are position-gated.
117122
const providerCommands: ComposerCommandItem[] = [];
118-
for (const command of selectedProviderStatus?.slashCommands ?? []) {
123+
const expandableCommands =
124+
trigger.rangeStart === 0 ? (selectedProviderStatus?.slashCommands ?? []) : [];
125+
for (const command of expandableCommands) {
119126
if (!command.name.toLowerCase().includes(q)) continue;
120127
// Codex feedback uploads an existing thread's session and logs.
121128
if (
@@ -149,7 +156,7 @@ export function useComposerCommandMenu({
149156

150157
if (trigger.kind === "skill") {
151158
const enabledSkills = dedupeProviderSkillsByName(
152-
(selectedProviderStatus?.skills ?? []).filter((skill) => skill.enabled),
159+
(selectedProviderStatus?.skills ?? []).filter(isProviderSkillUserInvocable),
153160
);
154161
const normalizedQuery = normalizeSearchQuery(trigger.query, {
155162
trimLeadingPattern: /^\$+/,
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { describe, expect, it } from "vite-plus/test";
2+
3+
import { planClaudeSkillDispatch } from "./ClaudeSkillDispatch.ts";
4+
5+
const SKILLS = new Set(["implement", "review", "re-release-version"]);
6+
7+
describe("planClaudeSkillDispatch", () => {
8+
it("leaves a prompt without a known skill untouched", () => {
9+
expect(planClaudeSkillDispatch("fix the build", SKILLS)).toBeUndefined();
10+
// Not a discovered skill, so it stays prose rather than becoming a command.
11+
expect(planClaudeSkillDispatch("echo $HOME then $unknown", SKILLS)).toBeUndefined();
12+
});
13+
14+
it("moves a mid-prompt mention into a trailing slash command", () => {
15+
expect(planClaudeSkillDispatch("ok, now $implement all the tickets", SKILLS)).toEqual({
16+
leadingText: "ok, now",
17+
commandText: "/implement all the tickets",
18+
skillName: "implement",
19+
});
20+
});
21+
22+
it("keeps a mention that opens the prompt as a single command block", () => {
23+
expect(planClaudeSkillDispatch("$review\nfocus on auth", SKILLS)).toEqual({
24+
leadingText: undefined,
25+
commandText: "/review\nfocus on auth",
26+
skillName: "review",
27+
});
28+
});
29+
30+
it("dispatches the last mention and rewrites earlier ones inline", () => {
31+
expect(planClaudeSkillDispatch("$review the diff, then $implement the fixes", SKILLS)).toEqual({
32+
leadingText: "/review the diff, then",
33+
commandText: "/implement the fixes",
34+
skillName: "implement",
35+
});
36+
});
37+
38+
it("ignores a dollar token glued to other text", () => {
39+
expect(planClaudeSkillDispatch("cost is 5$implement", SKILLS)).toBeUndefined();
40+
});
41+
});
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* ClaudeSkillDispatch — turns `$skill` mentions in a composer prompt into the
3+
* slash invocation Claude Code actually runs.
4+
*
5+
* The composer inserts `$name` for every provider. Codex parses that natively;
6+
* Claude Code does not, and treats it as prose. Claude Code's only user-side
7+
* invocation is a text block whose first character is `/`: the harness
8+
* expands `/name args` into the SKILL.md body, and every character after the
9+
* name (newlines included) arrives as `ARGUMENTS`. Verified against the CLI in
10+
* stream-json mode, which is what the Agent SDK uses:
11+
*
12+
* - The check runs on the LAST text block of the message. Earlier text
13+
* blocks are preserved verbatim, and image blocks may sit before it.
14+
* - Leading whitespace, or a `/name` that starts a later line of the same
15+
* block, is literal text.
16+
* - Only one skill expands per message; a second `/x` becomes argument text
17+
* (anthropics/claude-code#87113). The model still starts the rest through
18+
* its Skill tool when it reads `/name` in the prompt, so earlier mentions
19+
* are rewritten to `/name` inline.
20+
*
21+
* So one mention anywhere in the prompt becomes a guaranteed invocation, and
22+
* the user's text on either side is kept in order.
23+
*
24+
* @module provider/Drivers/ClaudeSkillDispatch
25+
*/
26+
27+
/**
28+
* Same token shape the composer and timeline chips recognise
29+
* (`packages/shared/src/composerInlineTokens.ts`), so a rendered chip and a
30+
* dispatched skill are always the same set.
31+
*/
32+
const SKILL_MENTION_PATTERN = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g;
33+
34+
export interface ClaudeSkillDispatch {
35+
/** Text before the dispatched mention, or `undefined` when it opens the prompt. */
36+
readonly leadingText: string | undefined;
37+
/** `/name` plus the trailing text, ready to be the message's last text block. */
38+
readonly commandText: string;
39+
readonly skillName: string;
40+
}
41+
42+
/**
43+
* Split `prompt` around the last `$skill` mention that names a known skill.
44+
* Returns `undefined` when there is nothing to dispatch, in which case the
45+
* prompt should go out unchanged. Mentions that do not match a discovered
46+
* skill stay literal: a `$HOME` in prose must not become a command.
47+
*/
48+
export function planClaudeSkillDispatch(
49+
prompt: string,
50+
skillNames: ReadonlySet<string>,
51+
): ClaudeSkillDispatch | undefined {
52+
const mentions = [...prompt.matchAll(SKILL_MENTION_PATTERN)].flatMap((match) => {
53+
const name = match[2] ?? "";
54+
if (!skillNames.has(name)) return [];
55+
const start = (match.index ?? 0) + (match[1]?.length ?? 0);
56+
return [{ name, start, end: start + name.length + 1 }];
57+
});
58+
const last = mentions.at(-1);
59+
if (!last) {
60+
return undefined;
61+
}
62+
63+
const leading = prompt.slice(0, last.start);
64+
const trailing = prompt.slice(last.end);
65+
const leadingWithInlineSlashes = mentions
66+
.slice(0, -1)
67+
.reduceRight(
68+
(text, mention) => `${text.slice(0, mention.start)}/${text.slice(mention.start + 1)}`,
69+
leading,
70+
)
71+
.trimEnd();
72+
73+
return {
74+
leadingText: leadingWithInlineSlashes.length > 0 ? leadingWithInlineSlashes : undefined,
75+
commandText: `/${last.name}${trailing}`.trimEnd(),
76+
skillName: last.name,
77+
};
78+
}

0 commit comments

Comments
 (0)