mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-22 09:12:44 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e791ab3df7 | ||
|
|
f521abfb24 | ||
|
|
b89ebbede6 | ||
|
|
aec01ea605 |
@@ -61,6 +61,8 @@ Core direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`code
|
||||
|
||||
Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code.
|
||||
|
||||
Keep the curated Claude catalog: `supportedModels()` has historically omitted older versions and carries less metadata. Discovery is only an opt-in visibility gate, not a replacement catalog or an entitlement guarantee. Claude Code v2.1.197 added `resolvedModel` to disambiguate alias rows; a bare family alias does not establish access to a particular version. Explicit user-configured models must remain selectable when discovery fails or omits them.
|
||||
|
||||
Paseo tools are not implemented as MCP tools internally. They live in a shared tool catalog under `packages/server/src/server/agent/tools/`; MCP is only the fallback adapter. The daemon resolves `agents.providers.<provider>.paseoTools` by the exact provider ID. The catalog policy belongs to the caller: it filters the tools exposed to the current agent. When that agent calls `create_agent`, the child receives the policy for the child provider ID; the caller's policy is not inherited.
|
||||
|
||||
A provider that can register runtime tools directly should set `supportsNativePaseoTools: true` and consume the already-filtered `launchContext.paseoTools` in `createSession`/`resumeSession`. When native tools are present, `AgentManager` strips the internal Paseo MCP server from the provider launch config so the provider does not receive the same tools twice. Providers that only know MCP should keep `supportsMcpServers: true` and let the daemon inject `/mcp/agents`; the MCP server builds the same policy-filtered catalog for that caller. Filtering is enforced at catalog registration in both paths. Browser tools remain subject to the daemon browser-tools setting and browser-host availability.
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Native iOS text geometry regression
|
||||
|
||||
Run on the Namespace Mac with a fresh simulator development build. Use the PID
|
||||
returned by `xcrun simctl launch <your-task-simulator-UDID> sh.paseo.debug`.
|
||||
Attach only to your task's app, while no device replay is running:
|
||||
|
||||
```sh
|
||||
PASEO_NATIVE_TEXT_REFLOW_PID=12345 \
|
||||
PASEO_NATIVE_TEXT_REFLOW_LOG=/Volumes/devbox/workspace/native-text-reflow.log \
|
||||
bash packages/app/e2e/mobile/native-text-reflow/ios.sh
|
||||
```
|
||||
|
||||
The probe constructs the installed `RNUITextView`, fills its real UIKit text view,
|
||||
and resizes its content container from 500 to 240 and back to 500 points. It
|
||||
asserts the inner frame follows both widths and the same selection survives.
|
||||
It then recycles the view the way Fabric's pool does and remounts it 30 points
|
||||
wider and at the same size; the inner frame must still match the container.
|
||||
A zeroed frame on recycle comes back 30 or 0 points wide. This assertion checks
|
||||
the intermediate recycled geometry; it does not reproduce visible clipping in a
|
||||
mounted Fabric paragraph or exercise the subsequent `drawRect` call.
|
||||
It uses public UIView/UITextView operations, without mocking React Native.
|
||||
LLDB detaches after the probe. An assertion failure or evaluation error fails the
|
||||
shell command; the log distinguishes them.
|
||||
|
||||
This covers native container resizing and selection retention. It does not cover
|
||||
Fabric event delivery, chat row height, reader anchoring, or the on-screen copy
|
||||
menu. Those require the real chat journey: read an older multiline response,
|
||||
open/close Explorer, resize/rotate, inspect complete paragraphs and bullets,
|
||||
retain expanded/collapsed details, and check a stationary reader's position.
|
||||
Accessibility exposes full text even when glyphs are clipped, so a text wait
|
||||
alone does not verify that journey.
|
||||
@@ -0,0 +1,76 @@
|
||||
import lldb
|
||||
|
||||
|
||||
def __lldb_init_module(debugger, _internal_dict):
|
||||
process = debugger.GetSelectedTarget().GetProcess()
|
||||
main_threads = [thread for thread in process if thread.GetQueueName() == "com.apple.main-thread"]
|
||||
if len(main_threads) != 1 or not process.SetSelectedThread(main_threads[0]):
|
||||
raise RuntimeError("Could not select the UIKit main thread")
|
||||
debugger.HandleCommand("expression -l objc++ -- @import UIKit")
|
||||
frame = process.GetSelectedThread().GetSelectedFrame()
|
||||
options = lldb.SBExpressionOptions()
|
||||
options.SetLanguage(lldb.eLanguageTypeObjC_plus_plus)
|
||||
is_main_thread = frame.EvaluateExpression("(BOOL)[NSThread isMainThread]", options)
|
||||
if is_main_thread.GetError().Fail() or is_main_thread.GetValueAsUnsigned() != 1:
|
||||
raise RuntimeError("Refusing to run UIKit probe outside the main thread")
|
||||
result = frame.EvaluateExpression(
|
||||
r'''({
|
||||
UIView *host = (UIView *)[[NSClassFromString(@"RNUITextView") alloc] init];
|
||||
auto hostFrame = host.frame;
|
||||
hostFrame.size.width = 500;
|
||||
hostFrame.size.height = 200;
|
||||
host.frame = hostFrame;
|
||||
UIView *container = (UIView *)[host performSelector:@selector(contentView)];
|
||||
container.frame = host.bounds;
|
||||
UITextView *text = nil;
|
||||
for (UIView *child in host.subviews) {
|
||||
if ([child isKindOfClass:UITextView.class]) text = (UITextView *)child;
|
||||
for (UIView *nested in child.subviews) {
|
||||
if ([nested isKindOfClass:UITextView.class]) text = (UITextView *)nested;
|
||||
}
|
||||
}
|
||||
text.frame = container.bounds;
|
||||
text.font = [UIFont systemFontOfSize:16];
|
||||
text.text = @"A realistic multiline paragraph must reflow when Explorer narrows the available chat width, then fill the restored width without replacing the text view or discarding a selection.";
|
||||
auto range = text.selectedRange;
|
||||
range.location = 12;
|
||||
range.length = 9;
|
||||
text.selectedRange = range;
|
||||
auto frame = container.frame;
|
||||
frame.size.width = 240;
|
||||
container.frame = frame;
|
||||
[container layoutIfNeeded];
|
||||
CGFloat narrow = text.frame.size.width;
|
||||
auto selection = text.selectedRange;
|
||||
frame.size.width = 500;
|
||||
container.frame = frame;
|
||||
[container layoutIfNeeded];
|
||||
CGFloat wide = text.frame.size.width;
|
||||
auto keptSelection = text.selectedRange;
|
||||
// Fabric recycles the view: the container keeps its size in the pool,
|
||||
// and the next paragraph mounts it 30 points wider (a list-item paragraph
|
||||
// reused by a full-width one) or at the same size.
|
||||
[host performSelector:@selector(prepareForRecycle)];
|
||||
frame.size.width = 530;
|
||||
container.frame = frame;
|
||||
[container layoutIfNeeded];
|
||||
CGFloat reusedWider = text.frame.size.width;
|
||||
[host performSelector:@selector(prepareForRecycle)];
|
||||
container.frame = frame;
|
||||
[container layoutIfNeeded];
|
||||
CGFloat reusedSame = text.frame.size.width;
|
||||
BOOL ok = text != nil && narrow == 240 && wide == 500 &&
|
||||
reusedWider == 530 && reusedSame == 530 &&
|
||||
selection.location == keptSelection.location &&
|
||||
selection.length == keptSelection.length &&
|
||||
selection.location == 12 && selection.length == 9;
|
||||
[NSString stringWithFormat:@"NATIVE_REFLOW_%@ narrow=%.0f wide=%.0f recycled=%.0f,%.0f selection=%lu:%lu",
|
||||
ok ? @"PASS" : @"FAIL", narrow, wide, reusedWider, reusedSame,
|
||||
(unsigned long)keptSelection.location,
|
||||
(unsigned long)keptSelection.length];
|
||||
})''',
|
||||
options,
|
||||
)
|
||||
if result.GetError().Fail():
|
||||
raise RuntimeError(result.GetError().GetCString())
|
||||
print(result.GetObjectDescription())
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
: "${PASEO_NATIVE_TEXT_REFLOW_PID:?Set the PID of your task-owned Paseo iOS simulator app}"
|
||||
: "${PASEO_NATIVE_TEXT_REFLOW_LOG:?Set the test output log path}"
|
||||
|
||||
if [[ "$(ps -p "$PASEO_NATIVE_TEXT_REFLOW_PID" -o comm=)" != *PaseoDebug.app/PaseoDebug ]]; then
|
||||
echo "Expected a running PaseoDebug simulator app" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
probe_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
xcrun lldb --batch -p "$PASEO_NATIVE_TEXT_REFLOW_PID" \
|
||||
-o "command script import $probe_dir/ios.py" \
|
||||
-o "process detach" -o quit > "$PASEO_NATIVE_TEXT_REFLOW_LOG" 2>&1
|
||||
|
||||
grep '^NATIVE_REFLOW_' "$PASEO_NATIVE_TEXT_REFLOW_LOG"
|
||||
grep -q '^NATIVE_REFLOW_PASS narrow=240 wide=500 recycled=530,530 selection=12:9$' "$PASEO_NATIVE_TEXT_REFLOW_LOG"
|
||||
@@ -7,9 +7,9 @@ export interface ProviderListItem {
|
||||
provider: ProviderSnapshotEntry["provider"];
|
||||
label: string;
|
||||
status: string;
|
||||
enabled: boolean;
|
||||
enabled: "Enabled" | "Disabled";
|
||||
defaultMode: string;
|
||||
modes: Array<{ id: string; label: string }>;
|
||||
modes: string;
|
||||
}
|
||||
|
||||
/** Schema for provider ls output */
|
||||
@@ -28,13 +28,9 @@ export const providerLsSchema: OutputSchema<ProviderListItem> = {
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
{ header: "ENABLED", field: (item) => (item.enabled ? "Enabled" : "Disabled"), width: 10 },
|
||||
{ header: "ENABLED", field: "enabled", width: 10 },
|
||||
{ header: "DEFAULT MODE", field: "defaultMode", width: 14 },
|
||||
{
|
||||
header: "MODES",
|
||||
field: (item) => item.modes.map((mode) => mode.label).join(", "),
|
||||
width: 30,
|
||||
},
|
||||
{ header: "MODES", field: "modes", width: 30 },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -58,9 +54,9 @@ export async function runLsCommand(
|
||||
provider: entry.provider,
|
||||
label: entry.label ?? entry.provider,
|
||||
status: entry.status === "ready" ? "available" : entry.status,
|
||||
enabled: entry.enabled,
|
||||
enabled: !entry.enabled ? "Disabled" : "Enabled",
|
||||
defaultMode: entry.defaultModeId ?? "default",
|
||||
modes: (entry.modes ?? []).map(({ id, label }) => ({ id, label })),
|
||||
modes: (entry.modes ?? []).map((mode) => mode.label).join(", "),
|
||||
})),
|
||||
schema: providerLsSchema,
|
||||
};
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
*/
|
||||
|
||||
import assert from "node:assert";
|
||||
import YAML from "yaml";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
@@ -44,8 +43,7 @@ interface ProviderListRow {
|
||||
provider: string;
|
||||
label: string;
|
||||
status: string;
|
||||
enabled: boolean;
|
||||
modes: Array<{ id: string; label: string }>;
|
||||
enabled: string;
|
||||
}
|
||||
|
||||
interface ProviderDiagnostic {
|
||||
@@ -248,7 +246,6 @@ try {
|
||||
// Test 3: provider ls --json outputs valid JSON
|
||||
{
|
||||
console.log("Test 3: provider ls --json outputs valid JSON");
|
||||
await runProviderModelsJson("codex");
|
||||
const result = await ctx.paseo(["provider", "ls", "--json"]);
|
||||
assert.strictEqual(result.exitCode, 0, "should exit 0");
|
||||
const data = JSON.parse(result.stdout.trim());
|
||||
@@ -267,33 +264,15 @@ try {
|
||||
"should include opencode",
|
||||
);
|
||||
const rows = data as ProviderListRow[];
|
||||
const codex = rows.find((p) => p.provider === "codex");
|
||||
assert(codex, "should include codex");
|
||||
assert(Array.isArray(codex.modes), "mode choices should be structured, not display text");
|
||||
assert(
|
||||
codex.modes.some((mode) => mode.id === "full-access" && mode.label === "Full Access"),
|
||||
"mode choices should include an ID usable with run --mode and its display label",
|
||||
);
|
||||
assert(
|
||||
codex.modes.every((mode) => mode.label.length > 0),
|
||||
"modes should keep display labels",
|
||||
);
|
||||
for (const provider of ["claude", "codex", "opencode"] as const) {
|
||||
const row = rows.find((p) => p.provider === provider);
|
||||
assert(row, `should include ${provider}`);
|
||||
assert.strictEqual(row.enabled, true, `${provider} should report enabled`);
|
||||
assert.strictEqual(row.enabled, "Enabled", `${provider} should report Enabled`);
|
||||
}
|
||||
|
||||
const omp = rows.find((p) => p.provider === "omp");
|
||||
assert(omp, "should include omp");
|
||||
assert.strictEqual(omp.enabled, false, "omp should report disabled by default");
|
||||
const yamlResult = await ctx.paseo(["provider", "ls", "--format", "yaml"]);
|
||||
assert.strictEqual(yamlResult.exitCode, 0, "YAML provider listing should exit 0");
|
||||
const yamlRows = YAML.parse(yamlResult.stdout) as ProviderListRow[];
|
||||
const yamlCodex = yamlRows.find((p) => p.provider === "codex");
|
||||
assert(yamlCodex, "YAML should include codex");
|
||||
assert.strictEqual(yamlCodex.enabled, true, "YAML should preserve boolean enabled state");
|
||||
assert.deepStrictEqual(yamlCodex.modes, codex.modes, "YAML should preserve the same mode IDs");
|
||||
assert.strictEqual(omp.enabled, "Disabled", "omp should report Disabled by default");
|
||||
console.log("✓ provider ls --json outputs valid JSON\n");
|
||||
}
|
||||
|
||||
@@ -326,11 +305,11 @@ try {
|
||||
const data = JSON.parse(result.stdout.trim()) as ProviderListRow[];
|
||||
const claude = data.find((p) => p.provider === "claude");
|
||||
assert(claude, "disabled claude provider should stay in provider ls");
|
||||
assert.strictEqual(claude.enabled, false, "disabled provider should report false");
|
||||
assert.strictEqual(claude.enabled, "Disabled", "disabled provider should report Disabled");
|
||||
|
||||
const opencode = data.find((p) => p.provider === "opencode");
|
||||
assert(opencode, "enabled opencode provider should stay in provider ls");
|
||||
assert.strictEqual(opencode.enabled, true, "enabled provider should report true");
|
||||
assert.strictEqual(opencode.enabled, "Enabled", "enabled provider should report Enabled");
|
||||
|
||||
const modelsResult = await runPaseoCli(disabledCtx, ["provider", "models", "claude"]);
|
||||
assert.notStrictEqual(
|
||||
@@ -385,6 +364,10 @@ try {
|
||||
ids.every((id) => id.startsWith("gpt-")),
|
||||
"all codex model IDs should be from the gpt family",
|
||||
);
|
||||
assert(
|
||||
ids.some((id) => id.includes("codex")),
|
||||
"codex model list should include at least one codex-optimized model",
|
||||
);
|
||||
assert(
|
||||
data.every((m) => m.model && m.id && m.description),
|
||||
"every codex model should have model, id, and description fields",
|
||||
|
||||
@@ -426,6 +426,14 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
try {
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
discoverModels: async () => [
|
||||
{
|
||||
value: "mythos",
|
||||
resolvedModel: "claude-mythos-5-1",
|
||||
displayName: "Mythos",
|
||||
description: "",
|
||||
},
|
||||
],
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
resolveVersion: async () => "2.1.219",
|
||||
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: emptyConfigDir } },
|
||||
@@ -438,6 +446,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
|
||||
expect(models.map((m) => m.id)).toEqual([
|
||||
"claude-opus-5",
|
||||
"claude-mythos-5-1",
|
||||
"claude-fable-5-1",
|
||||
"claude-fable-5",
|
||||
"claude-fable-5[1m]",
|
||||
@@ -472,6 +481,14 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
try {
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
discoverModels: async () => [
|
||||
{
|
||||
value: "mythos",
|
||||
resolvedModel: "claude-mythos-5-1",
|
||||
displayName: "Mythos",
|
||||
description: "",
|
||||
},
|
||||
],
|
||||
resolveVersion: async () => {
|
||||
throw new Error("unrecognized version output");
|
||||
},
|
||||
@@ -495,6 +512,14 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
try {
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
discoverModels: async () => [
|
||||
{
|
||||
value: "mythos",
|
||||
resolvedModel: "claude-mythos-5-1",
|
||||
displayName: "Mythos",
|
||||
description: "",
|
||||
},
|
||||
],
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
resolveVersion: async () => "2.1.293",
|
||||
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: emptyConfigDir } },
|
||||
@@ -509,6 +534,14 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
};
|
||||
|
||||
expect(getThinkingIds("claude-opus-5")).toContain("ultracode");
|
||||
expect(getThinkingIds("claude-mythos-5-1")).toEqual([
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
"ultracode",
|
||||
]);
|
||||
expect(getThinkingIds("claude-fable-5-1")).toContain("ultracode");
|
||||
expect(getThinkingIds("claude-fable-5")).toContain("ultracode");
|
||||
expect(getThinkingIds("claude-opus-4-8[1m]")).toContain("ultracode");
|
||||
@@ -1232,6 +1265,7 @@ describe("ClaudeAgentSession features", () => {
|
||||
|
||||
test.each([
|
||||
["supported model", "claude-opus-4-8", { type: "disabled" }, undefined],
|
||||
["Mythos 5.1", "claude-mythos-5-1", { type: "adaptive", display: "summarized" }, "high"],
|
||||
["Haiku 5.5", "claude-haiku-5-5", { type: "disabled" }, undefined],
|
||||
["unsupported model", "claude-fable-5", { type: "adaptive", display: "summarized" }, "high"],
|
||||
["custom model", "openrouter/anthropic/claude-opus-4-8", undefined, undefined],
|
||||
|
||||
@@ -76,6 +76,7 @@ import {
|
||||
type ClaudeProviderOptions,
|
||||
} from "./options.js";
|
||||
import { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
|
||||
import { discoverClaudeModels } from "./model-discovery.js";
|
||||
import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js";
|
||||
import {
|
||||
realClaudeRewindSdk,
|
||||
@@ -412,6 +413,7 @@ export interface ClaudeContentChunk {
|
||||
}
|
||||
|
||||
interface ClaudeAgentClientOptions {
|
||||
discoverModels?: typeof discoverClaudeModels;
|
||||
defaults?: { agents?: Record<string, AgentDefinition> };
|
||||
logger: Logger;
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
@@ -1514,6 +1516,7 @@ export function readEventIdentifiers(message: SDKMessage): EventIdentifiers {
|
||||
}
|
||||
|
||||
export class ClaudeAgentClient implements AgentClient {
|
||||
private readonly discoverModels: typeof discoverClaudeModels;
|
||||
readonly provider = "claude" as const;
|
||||
readonly capabilities = CLAUDE_CAPABILITIES;
|
||||
|
||||
@@ -1526,6 +1529,7 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
private readonly rewindSdk: ClaudeRewindSdk;
|
||||
|
||||
constructor(options: ClaudeAgentClientOptions) {
|
||||
this.discoverModels = options.discoverModels ?? discoverClaudeModels;
|
||||
this.defaults = options.defaults;
|
||||
this.logger = options.logger.child({ module: "agent", provider: "claude" });
|
||||
this.runtimeSettings = options.runtimeSettings;
|
||||
@@ -1608,8 +1612,35 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
this.logger.warn({ err: error }, "Failed to resolve Claude Code version for model catalog");
|
||||
}
|
||||
const env = this.buildProviderEnv();
|
||||
let discoveredModelIds = new Set<string>();
|
||||
try {
|
||||
const advertisedModels = await runProviderRefreshActivity(context, "models", () =>
|
||||
this.discoverModels({
|
||||
resolveBinary: this.resolveBinary,
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
env,
|
||||
signal: context?.signal,
|
||||
}),
|
||||
);
|
||||
discoveredModelIds = new Set(
|
||||
advertisedModels
|
||||
.map((model) => normalizeClaudeRuntimeModelId(model.resolvedModel ?? model.value))
|
||||
.filter((id): id is string => id !== null),
|
||||
);
|
||||
} catch (error) {
|
||||
context?.signal.throwIfAborted();
|
||||
this.logger.warn(
|
||||
{ err: error },
|
||||
"Claude model discovery failed; omitting discovery-required models",
|
||||
);
|
||||
}
|
||||
const models = await runProviderRefreshActivity(context, "settings", () =>
|
||||
getClaudeModelsWithSettings(this.logger, claudeConfigDir(env), claudeCodeVersion),
|
||||
getClaudeModelsWithSettings(
|
||||
this.logger,
|
||||
claudeConfigDir(env),
|
||||
claudeCodeVersion,
|
||||
discoveredModelIds,
|
||||
),
|
||||
);
|
||||
const modeCatalog = claudeModeCatalog(env);
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import os from "node:os";
|
||||
import type { ModelInfo, Query, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { raceProviderRefreshAbort } from "../../provider-refresh-deadline.js";
|
||||
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
|
||||
import { terminateWithTreeKill } from "../../../../utils/tree-kill.js";
|
||||
import { claudeQuery, type ClaudeQueryInput, type ClaudeQueryContext } from "./query.js";
|
||||
|
||||
interface ClaudeModelDiscoveryOptions {
|
||||
resolveBinary: () => Promise<string>;
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
env: NodeJS.ProcessEnv;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
type OpenDiscoveryQuery = (
|
||||
input: ClaudeQueryInput,
|
||||
context: ClaudeQueryContext,
|
||||
) => Pick<Query, "supportedModels" | "close">;
|
||||
|
||||
// The curated catalog survives a failed or slow probe. This process never receives
|
||||
// a prompt and must not persist a session or launch user MCP servers/hooks.
|
||||
export async function discoverClaudeModels(
|
||||
options: ClaudeModelDiscoveryOptions,
|
||||
openQuery: OpenDiscoveryQuery = claudeQuery,
|
||||
): Promise<ModelInfo[]> {
|
||||
const controller = new AbortController();
|
||||
const signal = options.signal
|
||||
? AbortSignal.any([options.signal, controller.signal])
|
||||
: controller.signal;
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(new Error("Claude model discovery timed out")),
|
||||
5_000,
|
||||
);
|
||||
let endInput!: () => void;
|
||||
const ended = new Promise<void>((resolve) => {
|
||||
endInput = resolve;
|
||||
});
|
||||
const input: AsyncIterable<SDKUserMessage> = {
|
||||
[Symbol.asyncIterator]() {
|
||||
return {
|
||||
next: async () => {
|
||||
await ended;
|
||||
return { done: true, value: undefined };
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
let query: ReturnType<OpenDiscoveryQuery> | undefined;
|
||||
let child: ChildProcess | undefined;
|
||||
try {
|
||||
signal.throwIfAborted();
|
||||
const binary = await raceProviderRefreshAbort(signal, options.resolveBinary());
|
||||
query = openQuery(
|
||||
{
|
||||
prompt: input,
|
||||
options: {
|
||||
pathToClaudeCodeExecutable: binary,
|
||||
cwd: os.homedir(),
|
||||
env: options.env,
|
||||
persistSession: false,
|
||||
tools: [],
|
||||
mcpServers: {},
|
||||
strictMcpConfig: true,
|
||||
settingSources: ["user"],
|
||||
settings: { disableAllHooks: true },
|
||||
abortController: controller,
|
||||
},
|
||||
},
|
||||
{
|
||||
runtimeSettings: options.runtimeSettings,
|
||||
onChildProcess: (process) => {
|
||||
child = process;
|
||||
},
|
||||
},
|
||||
);
|
||||
return await raceProviderRefreshAbort(signal, query.supportedModels());
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
endInput();
|
||||
try {
|
||||
query?.close();
|
||||
} finally {
|
||||
if (child) {
|
||||
await terminateWithTreeKill(child, { gracefulTimeoutMs: 200, forceTimeoutMs: 200 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ interface ClaudeModelManifestEntry {
|
||||
description: string;
|
||||
defaultPriority?: number;
|
||||
minimumClaudeCodeVersion?: string;
|
||||
requiresDiscovery?: boolean;
|
||||
contextWindowMaxTokens?: number;
|
||||
effortLevels?: readonly ClaudeEffortLevel[];
|
||||
defaultThinkingOptionId?: ClaudeEffortLevel;
|
||||
@@ -57,6 +58,14 @@ export const CLAUDE_MODEL_MANIFEST = [
|
||||
supportsThinkingDisabled: true,
|
||||
supportsFastMode: true,
|
||||
},
|
||||
{
|
||||
id: "claude-mythos-5-1",
|
||||
label: "Mythos 5.1",
|
||||
requiresDiscovery: true,
|
||||
description: "Mythos 5.1 · Requires verified access",
|
||||
contextWindowMaxTokens: 1_000_000,
|
||||
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
|
||||
},
|
||||
{
|
||||
id: "claude-fable-5-1",
|
||||
label: "Fable 5.1",
|
||||
@@ -217,9 +226,16 @@ function buildThinkingOptions(
|
||||
return options;
|
||||
}
|
||||
|
||||
export function getClaudeManifestModels(claudeCodeVersion?: string): AgentModelDefinition[] {
|
||||
export function getClaudeManifestModels(
|
||||
claudeCodeVersion?: string,
|
||||
discoveredModelIds?: ReadonlySet<string>,
|
||||
): AgentModelDefinition[] {
|
||||
const availableModels: readonly ClaudeModelManifestEntry[] = CLAUDE_MODEL_MANIFEST.filter(
|
||||
(model) => isModelAvailableInClaudeCode(model, claudeCodeVersion),
|
||||
(model) =>
|
||||
isModelAvailableInClaudeCode(model, claudeCodeVersion) &&
|
||||
(discoveredModelIds === undefined ||
|
||||
!("requiresDiscovery" in model && model.requiresDiscovery) ||
|
||||
discoveredModelIds.has(model.id)),
|
||||
);
|
||||
const defaultModel = availableModels.reduce<ClaudeModelManifestEntry | undefined>(
|
||||
(selected, candidate) =>
|
||||
@@ -359,7 +375,7 @@ export function normalizeClaudeManifestModelId(value: string | null | undefined)
|
||||
}
|
||||
|
||||
const singleSegmentMatch = trimmed.match(
|
||||
/^(?:claude[-_ ])?(fable|opus|sonnet|haiku)[-_ ]+(\d+)(?:\[1m\])?(?:[-_ ]+\d{8})?(?:\[1m\])?$/i,
|
||||
/^(?:claude[-_ ])?(mythos|fable|opus|sonnet|haiku)[-_ ]+(\d+)(?:\[1m\])?(?:[-_ ]+\d{8})?(?:\[1m\])?$/i,
|
||||
);
|
||||
if (singleSegmentMatch) {
|
||||
return normalizeSingleSegmentClaudeModelId(
|
||||
@@ -370,7 +386,7 @@ export function normalizeClaudeManifestModelId(value: string | null | undefined)
|
||||
}
|
||||
|
||||
const runtimeMatch = trimmed.match(
|
||||
/^(?:claude[-_ ])?(fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(?:\[1m\])?(?:[-_ ]+\d{8})?(?:\[1m\])?$/i,
|
||||
/^(?:claude[-_ ])?(mythos|fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(?:\[1m\])?(?:[-_ ]+\d{8})?(?:\[1m\])?$/i,
|
||||
);
|
||||
if (!runtimeMatch) {
|
||||
return null;
|
||||
@@ -403,7 +419,7 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined):
|
||||
}
|
||||
|
||||
const runtimeMatch = trimmed.match(
|
||||
/claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i,
|
||||
/claude[-_ ](mythos|fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i,
|
||||
);
|
||||
if (runtimeMatch) {
|
||||
const normalizedModelId = normalizeMajorMinorClaudeModelId(
|
||||
@@ -418,7 +434,7 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined):
|
||||
}
|
||||
|
||||
const singleSegmentMatch = trimmed.match(
|
||||
/claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)(\[1m\])?/i,
|
||||
/claude[-_ ](mythos|fable|opus|sonnet|haiku)[-_ ]+(\d+)(\[1m\])?/i,
|
||||
);
|
||||
if (!singleSegmentMatch) {
|
||||
return null;
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
parseClaudeCodeVersion,
|
||||
resolveClaudeDisabledThinkingForModel,
|
||||
} from "./model-manifest.js";
|
||||
import { discoverClaudeModels } from "./model-discovery.js";
|
||||
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { findClaudeModel, getClaudeModels, normalizeClaudeRuntimeModelId } from "./models.js";
|
||||
|
||||
const createdClaudeConfigDirs: string[] = [];
|
||||
@@ -43,6 +45,14 @@ function createCatalogClient(claudeCodeVersion = "2.1.293"): ClaudeAgentClient {
|
||||
return new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
resolveVersion: async () => claudeCodeVersion,
|
||||
discoverModels: async () => [
|
||||
{
|
||||
value: "mythos",
|
||||
resolvedModel: "claude-mythos-5-1",
|
||||
displayName: "Mythos",
|
||||
description: "",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -52,6 +62,7 @@ describe("getClaudeModels", () => {
|
||||
expect(models.map((m) => m.id)).toEqual([
|
||||
"claude-opus-5-5",
|
||||
"claude-opus-5",
|
||||
"claude-mythos-5-1",
|
||||
"claude-fable-5-1",
|
||||
"claude-fable-5",
|
||||
"claude-fable-5[1m]",
|
||||
@@ -87,6 +98,7 @@ describe("getClaudeModels", () => {
|
||||
new Map([
|
||||
["claude-opus-5-5", 1_000_000],
|
||||
["claude-opus-5", 1_000_000],
|
||||
["claude-mythos-5-1", 1_000_000],
|
||||
["claude-fable-5-1", 1_000_000],
|
||||
["claude-fable-5", 1_000_000],
|
||||
["claude-fable-5[1m]", 1_000_000],
|
||||
@@ -221,6 +233,118 @@ describe("getClaudeModels", () => {
|
||||
});
|
||||
|
||||
describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
it.each([
|
||||
{ value: "mythos", resolvedModel: "claude-mythos-5-1", expected: true },
|
||||
{ value: "claude-mythos-5-1", expected: true },
|
||||
{ value: "claude-mythos-5-1[1m]", expected: true },
|
||||
{ value: "global.anthropic.claude-mythos-5-1", expected: true },
|
||||
{ value: "mythos", expected: false },
|
||||
{ value: "mythos", resolvedModel: "claude-mythos-5-2", expected: false },
|
||||
{ value: "claude-mythos-5-1", resolvedModel: "claude-fable-5-1", expected: false },
|
||||
])(
|
||||
"uses advertised identity $value / $resolvedModel to gate Mythos",
|
||||
async ({ value, resolvedModel, expected }) => {
|
||||
const configDir = await createClaudeConfigDir({});
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
resolveVersion: async () => "2.1.293",
|
||||
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } },
|
||||
discoverModels: async () => [
|
||||
{ value, resolvedModel, displayName: "Untrusted label", description: "" },
|
||||
],
|
||||
});
|
||||
const { models } = await client.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: configDir,
|
||||
force: false,
|
||||
});
|
||||
expect(models.find((model) => model.id === "claude-mythos-5-1")).toEqual(
|
||||
expected ? findClaudeModel("claude-mythos-5-1") : undefined,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves the curated catalog and explicitly configured models after discovery failure", async () => {
|
||||
const configDir = await createClaudeConfigDir({ model: "claude-mythos-5-1" });
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
resolveVersion: async () => "2.1.293",
|
||||
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } },
|
||||
discoverModels: async () => {
|
||||
throw new Error("discovery unavailable");
|
||||
},
|
||||
});
|
||||
const { models } = await client.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: configDir,
|
||||
force: false,
|
||||
});
|
||||
expect(models.filter((model) => model.id !== "claude-mythos-5-1")).toEqual(
|
||||
getClaudeModels("2.1.293").filter((model) => model.id !== "claude-mythos-5-1"),
|
||||
);
|
||||
expect(models.find((model) => model.id === "claude-mythos-5-1")).toEqual({
|
||||
...findClaudeModel("claude-mythos-5-1"),
|
||||
label: "claude-mythos-5-1",
|
||||
description: "From Claude settings.json model",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits unconfirmed Mythos after a failed probe and retries on refresh", async () => {
|
||||
const configDir = await createClaudeConfigDir({});
|
||||
let fail = true;
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
resolveVersion: async () => "2.1.293",
|
||||
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } },
|
||||
discoverModels: async () => {
|
||||
if (fail) throw new Error("discovery unavailable");
|
||||
return [
|
||||
{
|
||||
value: "mythos",
|
||||
resolvedModel: "claude-mythos-5-1",
|
||||
displayName: "Mythos",
|
||||
description: "",
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
const options = { scope: "workspace" as const, cwd: configDir, force: true };
|
||||
expect(
|
||||
(await client.fetchCatalog(options)).models.some((model) => model.id === "claude-mythos-5-1"),
|
||||
).toBe(false);
|
||||
fail = false;
|
||||
expect(
|
||||
(await client.fetchCatalog(options)).models.find((model) => model.id === "claude-mythos-5-1"),
|
||||
).toEqual(findClaudeModel("claude-mythos-5-1"));
|
||||
});
|
||||
|
||||
it("keeps the curated catalog when discovery does not advertise Mythos", async () => {
|
||||
const configDir = await createClaudeConfigDir({});
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
resolveVersion: async () => "2.1.293",
|
||||
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } },
|
||||
discoverModels: async () => [
|
||||
{
|
||||
value: "opus",
|
||||
resolvedModel: "claude-opus-5-5",
|
||||
displayName: "Opus",
|
||||
description: "Latest",
|
||||
},
|
||||
],
|
||||
});
|
||||
const { models } = await client.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: configDir,
|
||||
force: false,
|
||||
});
|
||||
expect(models.map((model) => model.id)).toEqual(
|
||||
getClaudeModels("2.1.293")
|
||||
.filter((model) => model.id !== "claude-mythos-5-1")
|
||||
.map((model) => model.id),
|
||||
);
|
||||
});
|
||||
|
||||
it("appends concrete models from Claude settings.json", async () => {
|
||||
const configDir = await createClaudeConfigDir({
|
||||
model: "us.anthropic.claude-opus-4-7[1m]",
|
||||
@@ -298,6 +422,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
resolveVersion: async () => "2.1.280",
|
||||
discoverModels: async () => [],
|
||||
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: providerConfigDir } },
|
||||
});
|
||||
|
||||
@@ -724,3 +849,124 @@ describe("claudeManifestModelSupportsFastMode", () => {
|
||||
expect(claudeManifestModelSupportsFastMode("claude-fable-5-1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Claude Mythos 5.1 catalog", () => {
|
||||
it("offers one 1M model with high default effort and always-on thinking", () => {
|
||||
const models = getClaudeModels().filter((model) => model.id.startsWith("claude-mythos"));
|
||||
expect(models).toHaveLength(1);
|
||||
expect(models[0]).toMatchObject({
|
||||
id: "claude-mythos-5-1",
|
||||
label: "Mythos 5.1",
|
||||
contextWindowMaxTokens: 1_000_000,
|
||||
defaultThinkingOptionId: "high",
|
||||
});
|
||||
expect(models[0].thinkingOptions?.map((option) => option.id)).toEqual([
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
CLAUDE_ULTRACODE_THINKING_OPTION_ID,
|
||||
]);
|
||||
expect(
|
||||
models[0].thinkingOptions?.filter((option) => option.isDefault).map((option) => option.id),
|
||||
).toEqual(["high"]);
|
||||
expect(resolveClaudeDisabledThinkingForModel("claude-mythos-5-1")).toEqual({
|
||||
supported: false,
|
||||
fallbackThinkingOptionId: "high",
|
||||
});
|
||||
expect(claudeManifestModelSupportsFastMode("claude-mythos-5-1")).toBe(false);
|
||||
expect(getClaudeModels().find((model) => model.isDefault)?.id).toBe("claude-opus-5-5");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"claude-mythos-5-1[1m]",
|
||||
"claude-mythos-5-1-20260901",
|
||||
"claude-mythos-5-1-20260901[1m]",
|
||||
"Mythos 5.1",
|
||||
])("resolves %s to the single catalog entry", (id) => {
|
||||
expect(findClaudeModel(id)?.id).toBe("claude-mythos-5-1");
|
||||
});
|
||||
|
||||
it.each(["anthropic/claude-mythos-5-1", "us.anthropic.claude-mythos-5-1-v1:0"])(
|
||||
"normalizes runtime identity without granting first-party capabilities: %s",
|
||||
(id) => {
|
||||
expect(normalizeClaudeRuntimeModelId(id)).toBe("claude-mythos-5-1");
|
||||
expect(normalizeClaudeManifestModelId(id)).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("Claude model discovery process", () => {
|
||||
it("does not send a prompt or persist a session and closes after discovery", async () => {
|
||||
let closed = false;
|
||||
let promptEnded: Promise<IteratorResult<unknown>> | undefined;
|
||||
const advertised: ModelInfo[] = [
|
||||
{
|
||||
value: "mythos",
|
||||
resolvedModel: "claude-mythos-5-1",
|
||||
displayName: "Mythos",
|
||||
description: "",
|
||||
},
|
||||
];
|
||||
const models = await discoverClaudeModels(
|
||||
{ resolveBinary: async () => "/claude", env: { CLAUDE_CONFIG_DIR: "/provider-config" } },
|
||||
(input) => {
|
||||
expect(input.options).toMatchObject({
|
||||
pathToClaudeCodeExecutable: "/claude",
|
||||
env: { CLAUDE_CONFIG_DIR: "/provider-config" },
|
||||
persistSession: false,
|
||||
tools: [],
|
||||
mcpServers: {},
|
||||
strictMcpConfig: true,
|
||||
settingSources: ["user"],
|
||||
settings: { disableAllHooks: true },
|
||||
});
|
||||
if (typeof input.prompt === "string") throw new Error("unexpected inference prompt");
|
||||
promptEnded = input.prompt[Symbol.asyncIterator]().next();
|
||||
return {
|
||||
supportedModels: async () => advertised,
|
||||
close: () => {
|
||||
closed = true;
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
expect(models).toEqual(advertised);
|
||||
expect(closed).toBe(true);
|
||||
await expect(promptEnded).resolves.toEqual({ done: true, value: undefined });
|
||||
});
|
||||
|
||||
it("times out and closes an unresponsive discovery process", async () => {
|
||||
let closed = false;
|
||||
await expect(
|
||||
discoverClaudeModels({ resolveBinary: async () => "/claude", env: {} }, () => ({
|
||||
supportedModels: () => new Promise(() => {}),
|
||||
close: () => {
|
||||
closed = true;
|
||||
},
|
||||
})),
|
||||
).rejects.toThrow("Claude model discovery timed out");
|
||||
expect(closed).toBe(true);
|
||||
}, 10_000);
|
||||
|
||||
it("closes a stalled probe when its refresh is canceled", async () => {
|
||||
const controller = new AbortController();
|
||||
const failure = new Error("refresh canceled");
|
||||
let closed = false;
|
||||
const result = discoverClaudeModels(
|
||||
{ resolveBinary: async () => "/claude", env: {}, signal: controller.signal },
|
||||
() => ({
|
||||
supportedModels: () => {
|
||||
controller.abort(failure);
|
||||
return new Promise(() => {});
|
||||
},
|
||||
close: () => {
|
||||
closed = true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
await expect(result).rejects.toBe(failure);
|
||||
expect(closed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,8 +52,9 @@ export async function getClaudeModelsWithSettings(
|
||||
logger: Logger,
|
||||
configDir: string,
|
||||
claudeCodeVersion?: string,
|
||||
discoveredModelIds: ReadonlySet<string> = new Set(),
|
||||
): Promise<AgentModelDefinition[]> {
|
||||
const hardcodedModels = getClaudeModels(claudeCodeVersion);
|
||||
const hardcodedModels = getClaudeManifestModels(claudeCodeVersion, discoveredModelIds);
|
||||
const settingsModels = await readClaudeSettingsModels(logger, configDir);
|
||||
if (settingsModels.length === 0) {
|
||||
return hardcodedModels;
|
||||
@@ -70,7 +71,10 @@ export async function getClaudeModelsWithSettings(
|
||||
}
|
||||
continue;
|
||||
}
|
||||
models.push(model);
|
||||
const manifestModel = getClaudeModels(claudeCodeVersion).find(
|
||||
(candidate) => candidate.id === model.id,
|
||||
);
|
||||
models.push({ ...manifestModel, ...model });
|
||||
}
|
||||
|
||||
return models;
|
||||
|
||||
@@ -422,12 +422,12 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => {
|
||||
api: {
|
||||
type: "service",
|
||||
command:
|
||||
"node -e \"const fs=require('fs'); fs.writeFileSync('api-env.json', JSON.stringify(process.env)); setTimeout(()=>{}, 30000)\"",
|
||||
"node -e \"const fs=require('fs'); fs.writeFileSync('api-env.json.tmp', JSON.stringify(process.env)); fs.renameSync('api-env.json.tmp', 'api-env.json'); setTimeout(()=>{}, 30000)\"",
|
||||
},
|
||||
web: {
|
||||
type: "service",
|
||||
command:
|
||||
"node -e \"const fs=require('fs'); fs.writeFileSync('web-env.json', JSON.stringify(process.env)); setTimeout(()=>{}, 30000)\"",
|
||||
"node -e \"const fs=require('fs'); fs.writeFileSync('web-env.json.tmp', JSON.stringify(process.env)); fs.renameSync('web-env.json.tmp', 'web-env.json'); setTimeout(()=>{}, 30000)\"",
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
diff --git a/node_modules/react-native-uitextview/ios/RNUITextView.mm b/node_modules/react-native-uitextview/ios/RNUITextView.mm
|
||||
index d0c31ed..d1605ea 100644
|
||||
--- a/node_modules/react-native-uitextview/ios/RNUITextView.mm
|
||||
+++ b/node_modules/react-native-uitextview/ios/RNUITextView.mm
|
||||
@@ -22,6 +22,7 @@ using namespace facebook::react;
|
||||
RNUITextViewShadowNode::ConcreteState::Shared _state;
|
||||
UITapGestureRecognizer * _outsideTapRecognizer;
|
||||
BOOL _suppressSelectionChange;
|
||||
+ CGRect _lastTextLayoutFrame;
|
||||
}
|
||||
|
||||
+ (ComponentDescriptorProvider)componentDescriptorProvider
|
||||
@@ -47,7 +48,8 @@ using namespace facebook::react;
|
||||
_textView.delegate = self;
|
||||
// Must match RCTTextLayoutManager, which measures with usesFontLeading = NO.
|
||||
_textView.layoutManager.usesFontLeading = NO;
|
||||
- [self addSubview:_textView];
|
||||
+ _textView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
|
||||
+ [_view addSubview:_textView];
|
||||
|
||||
const auto longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self
|
||||
action:@selector(handleLongPressIfNecessary:)];
|
||||
@@ -91,19 +93,21 @@ using namespace facebook::react;
|
||||
[super prepareForRecycle];
|
||||
_state.reset();
|
||||
|
||||
- // Reset the frame to zero so that when it properly lays out on the next use
|
||||
- _textView.frame = CGRectZero;
|
||||
+ // Keep the text view on the content container's bounds. The container keeps
|
||||
+ // its size while the view waits in the recycle pool, and autoresizing only
|
||||
+ // applies the size delta on reuse, so a zeroed text view would come back as
|
||||
+ // wide as that delta (zero, or a two-word column).
|
||||
+ _textView.frame = _view.bounds;
|
||||
_textView.attributedText = nil;
|
||||
+ _lastTextLayoutFrame = CGRectZero;
|
||||
}
|
||||
|
||||
- (void)layoutSubviews
|
||||
{
|
||||
[super layoutSubviews];
|
||||
- // _textView's frame is assigned inside drawRect, which only fires when
|
||||
- // state changes. Trigger a redraw whenever the host frame moves out from
|
||||
- // under it (rotation, parent relayout) so the text view resizes and
|
||||
- // onTextLayout re-fires with the new line wrapping.
|
||||
- if (!CGRectEqualToRect(_textView.frame, _view.frame)) {
|
||||
+ // Autoresizing updates UIKit immediately; report the new line layout when
|
||||
+ // the content container's bounds change.
|
||||
+ if (!CGRectEqualToRect(_lastTextLayoutFrame, _view.bounds)) {
|
||||
[self setNeedsDisplay];
|
||||
}
|
||||
}
|
||||
@@ -124,10 +128,16 @@ using namespace facebook::react;
|
||||
// entirely when nothing actually changed so a JS-side state update made in
|
||||
// response to onSelectionChange doesn't deselect what the user is selecting.
|
||||
const BOOL textChanged = ![_textView.attributedText isEqualToAttributedString:convertedAttrString];
|
||||
- const BOOL frameChanged = !CGRectEqualToRect(_textView.frame, _view.frame);
|
||||
- if (!textChanged && !frameChanged) {
|
||||
+ const BOOL frameChanged = !CGRectEqualToRect(_textView.frame, _view.bounds);
|
||||
+ const BOOL layoutChanged = !CGRectEqualToRect(_lastTextLayoutFrame, _view.bounds);
|
||||
+ if (!textChanged && !frameChanged && !layoutChanged) {
|
||||
return;
|
||||
}
|
||||
+ // Size the text view before handing it the text, so UIKit lays the text out
|
||||
+ // at the container width instead of a stale width.
|
||||
+ if (frameChanged) {
|
||||
+ _textView.frame = _view.bounds;
|
||||
+ }
|
||||
if (textChanged) {
|
||||
// Reassigning attributedText clears any active selection. Save it and
|
||||
// restore after, while suppressing the synthetic textViewDidChangeSelection
|
||||
@@ -141,9 +151,6 @@ using namespace facebook::react;
|
||||
}
|
||||
_suppressSelectionChange = NO;
|
||||
}
|
||||
- if (frameChanged) {
|
||||
- _textView.frame = _view.frame;
|
||||
- }
|
||||
|
||||
__block std::vector<std::string> lines;
|
||||
const int maxLines = props.numberOfLines;
|
||||
@@ -162,6 +169,7 @@ using namespace facebook::react;
|
||||
}
|
||||
}];
|
||||
|
||||
+ _lastTextLayoutFrame = _view.bounds;
|
||||
if (_eventEmitter != nullptr) {
|
||||
std::dynamic_pointer_cast<const facebook::react::RNUITextViewEventEmitter>(_eventEmitter)
|
||||
->onTextLayout(facebook::react::RNUITextViewEventEmitter::OnTextLayout{static_cast<int>(self.tag), lines});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { afterEach, beforeEach, expect, test } from "vitest";
|
||||
import { discover, fetchUsage } from "./usage.js";
|
||||
|
||||
@@ -43,7 +44,6 @@ async function logins(options: Parameters<typeof discover>[1]) {
|
||||
}
|
||||
async function database(path: string) {
|
||||
await mkdir((await import("node:path")).dirname(path), { recursive: true });
|
||||
const { DatabaseSync } = await import("node:sqlite");
|
||||
const db = new DatabaseSync(path);
|
||||
db.exec(
|
||||
"CREATE TABLE auth_credentials (id INTEGER PRIMARY KEY, provider TEXT, credential_type TEXT, data TEXT, disabled_cause TEXT)",
|
||||
@@ -248,7 +248,6 @@ test("OMP fetch re-reads its row after rotation and skips a newly disabled login
|
||||
const path = join(home, ".omp", "agent", "agent.db");
|
||||
await database(path);
|
||||
const [input] = await logins(lookup());
|
||||
const { DatabaseSync } = await import("node:sqlite");
|
||||
const db = new DatabaseSync(path);
|
||||
db.prepare("UPDATE auth_credentials SET data = ? WHERE id = 1").run(
|
||||
JSON.stringify({ access: "fixture-rotated-omp", accountId: "omp-account", expires: 2000 }),
|
||||
|
||||
@@ -12,6 +12,10 @@ const patchedPackages = [
|
||||
nodeModulesPath: "node_modules/react-native-markdown-display",
|
||||
patchPrefix: "react-native-markdown-display+",
|
||||
},
|
||||
{
|
||||
nodeModulesPath: "node_modules/react-native-uitextview",
|
||||
patchPrefix: "react-native-uitextview+",
|
||||
},
|
||||
// Remove after react-native-unistyles ships
|
||||
// https://github.com/jpudysz/react-native-unistyles/pull/1203.
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user