This commit is contained in:
2026-07-26 14:02:37 +07:00
parent bc56546b49
commit 367ebc1c7f
171 changed files with 4617 additions and 10402 deletions
+1
View File
@@ -333,6 +333,7 @@ ${chalk.bold("Examples:")}
${APP_NAME} --export session.jsonl output.html
${chalk.bold("Environment Variables:")}
ANTHROPIC_AUTH_TOKEN - Anthropic bearer auth token
ANTHROPIC_API_KEY - Anthropic Claude API key
ANTHROPIC_OAUTH_TOKEN - Anthropic OAuth token (alternative to API key)
ANT_LING_API_KEY - Ant Ling API key
+12 -10
View File
@@ -176,7 +176,9 @@ export type AgentSessionEvent =
source: "compaction";
reason: "manual" | "threshold" | "overflow";
}
| { type: "summarization_retry_finished" };
| { type: "summarization_retry_finished" }
| { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }
| { type: "bash_execution_update"; id?: string; delta: string };
/** Listener function for agent session events */
export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
@@ -403,7 +405,7 @@ export class AgentSession {
}
private async _getRequiredRequestAuth(model: Model<any>): Promise<{
apiKey: string;
apiKey?: string;
headers?: Record<string, string>;
env?: Record<string, string>;
}> {
@@ -417,7 +419,7 @@ export class AgentSession {
}
throw error;
}
if (result?.auth.apiKey) {
if (result && (result.auth.apiKey || result.auth.headers)) {
return {
apiKey: result.auth.apiKey,
headers: withoutDeletedHeaders(result.auth.headers),
@@ -2055,11 +2057,7 @@ export class AgentSession {
let headers: Record<string, string> | undefined;
let env: Record<string, string> | undefined;
if (this.agent.streamFunction === streamSimple) {
const authResult = await this._modelRuntime.getAuth(this.model);
if (!authResult?.auth.apiKey) return false;
apiKey = authResult.auth.apiKey;
headers = withoutDeletedHeaders(authResult.auth.headers);
env = authResult.env;
({ apiKey, headers, env } = await this._getRequiredRequestAuth(this.model));
} else {
({ apiKey, headers, env } = await this._getSummarizationRequestAuth(this.model));
}
@@ -2760,12 +2758,13 @@ export class AgentSession {
* @param command The bash command to execute
* @param onChunk Optional streaming callback for output
* @param options.excludeFromContext If true, command output won't be sent to LLM (!! prefix)
* @param options.id Optional identifier included in bash execution update events
* @param options.operations Custom BashOperations for remote execution
*/
async executeBash(
command: string,
onChunk?: (chunk: string) => void,
options?: { excludeFromContext?: boolean; operations?: BashOperations },
options?: { excludeFromContext?: boolean; id?: string; operations?: BashOperations },
): Promise<BashResult> {
this._bashAbortController = new AbortController();
@@ -2780,7 +2779,10 @@ export class AgentSession {
this.sessionManager.getCwd(),
options?.operations ?? createLocalBashOperations({ shellPath }),
{
onChunk,
onChunk: (delta) => {
onChunk?.(delta);
this._emit({ type: "bash_execution_update", id: options?.id, delta });
},
signal: this._bashAbortController.signal,
},
);
@@ -19,6 +19,7 @@ import type {
Api,
AssistantMessageEvent,
AssistantMessageEventStream,
ConstrainedSamplingConfig,
Context,
ImageContent,
Model,
@@ -452,6 +453,8 @@ export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = un
promptGuidelines?: string[];
/** Parameter schema (TypeBox) */
parameters: TParams;
/** Optional provider-side constrained sampling request for this tool. Set false to explicitly disable it, equivalent to leaving it undefined. */
constrainedSampling?: false | ConstrainedSamplingConfig;
/** Controls whether ToolExecutionComponent renders the standard colored shell or the tool renders its own framing. */
renderShell?: "default" | "self";
@@ -1126,6 +1129,8 @@ export interface SessionBeforeTreeResult {
export interface MessageRenderOptions {
expanded: boolean;
/** Horizontal padding configured by the outputPad setting. */
outputPad: number;
}
export interface EntryRenderOptions {
@@ -97,6 +97,7 @@ const OpenAICompletionsCompatSchema = Type.Object({
cacheControlFormat: Type.Optional(Type.Literal("anthropic")),
openRouterRouting: Type.Optional(OpenRouterRoutingSchema),
vercelGatewayRouting: Type.Optional(VercelGatewayRoutingSchema),
supportsOpenAIGrammarTools: Type.Optional(Type.Boolean()),
supportsStrictMode: Type.Optional(Type.Boolean()),
sendSessionAffinityHeaders: Type.Optional(Type.Boolean()),
deferredToolsMode: Type.Optional(Type.Literal("kimi")),
@@ -112,6 +113,8 @@ const OpenAIResponsesCompatSchema = Type.Object({
Type.Union([Type.Literal("openai"), Type.Literal("openai-nosession"), Type.Literal("openrouter")]),
),
supportsLongCacheRetention: Type.Optional(Type.Boolean()),
supportsStrictMode: Type.Optional(Type.Boolean()),
supportsOpenAIGrammarTools: Type.Optional(Type.Boolean()),
supportsToolSearch: Type.Optional(Type.Boolean()),
});
@@ -120,7 +123,10 @@ const AnthropicMessagesCompatSchema = Type.Object({
supportsLongCacheRetention: Type.Optional(Type.Boolean()),
sendSessionAffinityHeaders: Type.Optional(Type.Boolean()),
supportsCacheControlOnTools: Type.Optional(Type.Boolean()),
supportsTemperature: Type.Optional(Type.Boolean()),
forceAdaptiveThinking: Type.Optional(Type.Boolean()),
allowEmptySignature: Type.Optional(Type.Boolean()),
supportsStrictTools: Type.Optional(Type.Boolean()),
supportsToolReferences: Type.Optional(Type.Boolean()),
});
@@ -25,8 +25,8 @@ export class ModelRegistry {
}
/** Reload models.json asynchronously. Await before making synchronous registry reads. */
refresh(): Promise<void> {
return this.runtime.reloadConfig();
async refresh(): Promise<void> {
await this.runtime.refresh();
}
getError(): string | undefined {
@@ -260,6 +260,7 @@ export function parseModelPattern(
*/
export interface ModelScopeDiagnostic {
type: "warning";
code: "no-match" | "invalid-thinking-level";
message: string;
pattern: string;
}
@@ -293,6 +294,14 @@ export async function resolveModelScopeWithDiagnostics(
}
}
const exactMatch = findExactModelReferenceMatch(globPattern, availableModels);
if (exactMatch) {
if (!scopedModels.find((sm) => modelsAreEqual(sm.model, exactMatch))) {
scopedModels.push({ model: exactMatch, thinkingLevel });
}
continue;
}
// Match against "provider/modelId" format OR just model ID
// This allows "*sonnet*" to match without requiring "anthropic/*sonnet*"
const matchingModels = availableModels.filter((m) => {
@@ -301,7 +310,12 @@ export async function resolveModelScopeWithDiagnostics(
});
if (matchingModels.length === 0) {
diagnostics.push({ type: "warning", message: `No models match pattern "${pattern}"`, pattern });
diagnostics.push({
type: "warning",
code: "no-match",
message: `No models match pattern "${pattern}"`,
pattern,
});
continue;
}
@@ -316,11 +330,16 @@ export async function resolveModelScopeWithDiagnostics(
const { model, thinkingLevel, warning } = parseModelPattern(pattern, availableModels);
if (warning) {
diagnostics.push({ type: "warning", message: warning, pattern });
diagnostics.push({ type: "warning", code: "invalid-thinking-level", message: warning, pattern });
}
if (!model) {
diagnostics.push({ type: "warning", message: `No models match pattern "${pattern}"`, pattern });
diagnostics.push({
type: "warning",
code: "no-match",
message: `No models match pattern "${pattern}"`,
pattern,
});
continue;
}
@@ -140,18 +140,13 @@ export class ModelRuntime implements Models {
(modelsPath
? new FileModelsStore(options.modelsStorePath ?? join(dirname(modelsPath), "models-store.json"))
: new InMemoryCodingAgentModelsStore());
const builtinModelDataGeneratedAt = builtinProviderCatalog.getBuiltinModelDataGeneratedAt();
const providers = builtinProviderCatalog
.builtinProviders()
.map((provider) =>
provider.id === "radius"
? provider
: withRemoteCatalog(
provider,
options.catalogBaseUrl,
builtinProviderCatalog.getBuiltinModelDataUrl(
provider.id as builtinProviderCatalog.BuiltinProvider,
),
),
: withRemoteCatalog(provider, options.catalogBaseUrl, builtinModelDataGeneratedAt),
);
const runtime = new ModelRuntime(
credentials,
@@ -518,14 +513,10 @@ export class ModelRuntime implements Models {
await this.refresh({ allowNetwork: this.modelNetworkEnabled });
}
async reloadConfig(): Promise<void> {
async refresh(options: ModelsRefreshOptions = {}): Promise<ModelsRefreshResult> {
this.config = await ModelConfig.load(this.modelsPath);
this.configureRadiusProviders();
this.rebuildProviders();
await this.refresh({ allowNetwork: this.modelNetworkEnabled });
}
async refresh(options: ModelsRefreshOptions = {}): Promise<ModelsRefreshResult> {
const refreshOptions = {
...options,
allowNetwork: options.allowNetwork ?? this.modelNetworkEnabled,
@@ -1,4 +1,3 @@
import { stat } from "node:fs/promises";
import type { Api, Model, ModelsStoreEntry, Provider } from "@earendil-works/pi-ai";
import { VERSION } from "../config.ts";
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
@@ -32,13 +31,10 @@ function parseCatalog(providerId: string, value: unknown): Model<Api>[] {
function remoteModels(
entry: ModelsStoreEntry | undefined,
localLastModified: number | undefined,
localGeneratedAt: number | undefined,
): readonly Model<Api>[] {
if (!entry) return [];
if (
localLastModified !== undefined &&
(entry.lastModified === undefined || entry.lastModified <= localLastModified)
) {
if (localGeneratedAt !== undefined && (entry.lastModified === undefined || entry.lastModified <= localGeneratedAt)) {
return [];
}
return entry.models;
@@ -48,7 +44,7 @@ function remoteModels(
export function withRemoteCatalog(
provider: Provider,
catalogBaseUrl: string = DEFAULT_CATALOG_BASE_URL,
localCatalogUrl?: URL,
localGeneratedAt?: number,
): Provider {
let dynamicModels: readonly Model<Api>[] = [];
let inflightRefresh: Promise<void> | undefined;
@@ -59,16 +55,8 @@ export function withRemoteCatalog(
refreshModels: (context) => {
inflightRefresh ??= (async () => {
try {
const localLastModified = localCatalogUrl
? await stat(localCatalogUrl).then(
(value) => value.mtimeMs,
() => undefined,
)
: undefined;
const stored = await context.store.read();
dynamicModels = remoteModels(stored, localLastModified).filter(
(model) => model.provider === provider.id,
);
dynamicModels = remoteModels(stored, localGeneratedAt).filter((model) => model.provider === provider.id);
if (!context.allowNetwork || context.signal?.aborted) return;
if (
!context.force &&
@@ -79,21 +67,38 @@ export function withRemoteCatalog(
return;
}
// Only revalidate when a cached body backs the validator, so a 304 can never
// leave the overlay empty.
const validator = stored?.models.length ? stored.etag : undefined;
const url = new URL(`/api/models/providers/${encodeURIComponent(provider.id)}`, catalogBaseUrl);
const response = await fetch(url, {
headers: {
accept: "application/json",
"User-Agent": getPiUserAgent(VERSION),
...(validator ? { "if-none-match": validator } : {}),
},
signal: context.signal,
});
if (context.signal?.aborted) return;
const checkedAt = Date.now();
// Unchanged: dynamicModels already holds the stored overlay, so only the
// freshness window moves.
if (response.status === 304 && stored) {
await context.store.write({ ...stored, checkedAt });
return;
}
if (response.status === 404 || response.status === 501) {
await context.store.write({ ...(stored ?? { models: [] }), checkedAt, lastModified: 0 });
await context.store.write({
...(stored ?? { models: [] }),
checkedAt,
lastModified: 0,
etag: undefined,
});
return;
}
if (!response.ok) {
// Transient failure: the cached body and its validator stay valid, so keep the
// etag and let the next refresh revalidate instead of downloading the catalog.
await context.store.write({ ...(stored ?? { models: [] }), checkedAt });
throw new Error(`Model catalog request failed for ${provider.id}: ${response.status}`);
}
@@ -104,8 +109,9 @@ export function withRemoteCatalog(
models: refreshed,
checkedAt,
lastModified: Number.isNaN(lastModified) ? 0 : lastModified,
etag: response.headers.get("etag") ?? undefined,
};
dynamicModels = remoteModels(entry, localLastModified);
dynamicModels = remoteModels(entry, localGeneratedAt);
await context.store.write(entry);
} finally {
inflightRefresh = undefined;
@@ -70,6 +70,9 @@ function loadContextFileFromDir(dir: string): { path: string; content: string }
const filePath = join(dir, filename);
if (existsSync(filePath)) {
try {
if (!statSync(filePath).isFile()) {
continue;
}
return {
path: filePath,
content: readFileSync(filePath, "utf-8"),
@@ -11,6 +11,7 @@ export function wrapToolDefinition<TDetails = unknown>(
label: definition.label,
description: definition.description,
parameters: definition.parameters,
constrainedSampling: definition.constrainedSampling,
prepareArguments: definition.prepareArguments,
executionMode: definition.executionMode,
execute: (toolCallId, params, signal, onUpdate, ctx?: ExtensionContext) =>
@@ -38,6 +39,7 @@ export function createToolDefinitionFromAgentTool(tool: AgentTool<any>): ToolDef
label: tool.label,
description: tool.description,
parameters: tool.parameters as any,
constrainedSampling: tool.constrainedSampling,
prepareArguments: tool.prepareArguments,
executionMode: tool.executionMode,
execute: async (toolCallId, params, signal, onUpdate) => tool.execute(toolCallId, params, signal, onUpdate),
@@ -12,8 +12,6 @@ import { LlamaClient, type LlamaModelInfo, llamaInferenceUrl, normalizeLlamaServ
export const LLAMA_PROVIDER_ID = "llama.cpp";
export const DEFAULT_LLAMA_SERVER_URL = "http://127.0.0.1:8080";
const DEFAULT_MAX_TOKENS = 16384;
function credentialServerUrl(credential: ApiKeyCredential | undefined): string | undefined {
const value = credential?.env?.LLAMA_BASE_URL;
return typeof value === "string" && value.trim() ? normalizeLlamaServerUrl(value) : undefined;
@@ -40,7 +38,7 @@ function toPiModel(model: LlamaModelInfo, serverUrl: string): Model<"openai-comp
input: model.architecture?.input_modalities?.includes("image") ? ["text", "image"] : ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow,
maxTokens: Math.min(DEFAULT_MAX_TOKENS, contextWindow),
maxTokens: contextWindow,
compat: {
supportsStore: false,
supportsDeveloperRole: false,
@@ -113,11 +111,20 @@ export function createLlamaProvider(): LlamaProviderController {
},
getModels: () => models,
refreshModels: async (context: RefreshModelsContext): Promise<void> => {
const stored = await context.store.read();
if (stored) {
models = stored.models.filter(
(model): model is Model<"openai-completions"> =>
model.provider === LLAMA_PROVIDER_ID && model.api === "openai-completions",
);
}
if (!context.allowNetwork || context.signal?.aborted || context.credential?.type !== "api_key") return;
const serverUrl = credentialServerUrl(context.credential);
if (!serverUrl) return;
const catalog = await new LlamaClient(serverUrl, context.credential.key).list({ signal: context.signal });
setCatalog(catalog, serverUrl);
if (!context.signal?.aborted) await context.store.write({ models, checkedAt: Date.now() });
},
stream: (model, context, options) => stream(model, context, options as ProviderStreamOptions | undefined),
streamSimple: (model, context, options) => streamSimple(model, context, options),
@@ -16,16 +16,19 @@ export class CustomMessageComponent extends Container {
private customComponent?: Component;
private markdownTheme: MarkdownTheme;
private _expanded = false;
private outputPad: number;
constructor(
message: CustomMessage<unknown>,
customRenderer?: MessageRenderer,
markdownTheme: MarkdownTheme = getMarkdownTheme(),
outputPad = 1,
) {
super();
this.message = message;
this.customRenderer = customRenderer;
this.markdownTheme = markdownTheme;
this.outputPad = outputPad;
this.addChild(new Spacer(1));
@@ -42,6 +45,13 @@ export class CustomMessageComponent extends Container {
}
}
setOutputPad(outputPad: number): void {
if (this.outputPad !== outputPad) {
this.outputPad = outputPad;
this.rebuild();
}
}
override invalidate(): void {
super.invalidate();
this.rebuild();
@@ -58,7 +68,11 @@ export class CustomMessageComponent extends Container {
// Try custom renderer first - it handles its own styling
if (this.customRenderer) {
try {
const component = this.customRenderer(this.message, { expanded: this._expanded }, theme);
const component = this.customRenderer(
this.message,
{ expanded: this._expanded, outputPad: this.outputPad },
theme,
);
if (component) {
// Custom renderer provides its own styled component
this.customComponent = component;
@@ -36,7 +36,7 @@ function enableAll(enabledIds: EnabledIds, allIds: string[], targetIds?: string[
for (const id of targets) {
if (!result.includes(id)) result.push(id);
}
return result.length === allIds.length ? null : result;
return result.length === allIds.length && result.every((id) => allIds.includes(id)) ? null : result;
}
function clearAll(enabledIds: EnabledIds, allIds: string[], targetIds?: string[]): EnabledIds {
@@ -67,7 +67,7 @@ function getSortedIds(enabledIds: EnabledIds, allIds: string[]): string[] {
interface ModelItem {
fullId: string;
model: Model<any>;
model: Model<any> | undefined;
enabled: boolean;
}
@@ -152,20 +152,20 @@ export class ScopedModelsSelectorComponent extends Container implements Focusabl
}
private buildItems(): ModelItem[] {
// Filter out IDs that no longer have a corresponding model (e.g., after logout)
return getSortedIds(this.enabledIds, this.allIds)
.filter((id) => this.modelsById.has(id))
.map((id) => ({
fullId: id,
model: this.modelsById.get(id)!,
enabled: isEnabled(this.enabledIds, id),
}));
return getSortedIds(this.enabledIds, this.allIds).map((id) => ({
fullId: id,
model: this.modelsById.get(id),
enabled: isEnabled(this.enabledIds, id),
}));
}
private getFooterText(): string {
const enabledCount = this.enabledIds?.length ?? this.allIds.length;
const enabledCount = this.enabledIds?.filter((id) => this.modelsById.has(id)).length ?? this.allIds.length;
const unavailableCount = this.enabledIds?.filter((id) => !this.modelsById.has(id)).length ?? 0;
const allEnabled = this.enabledIds === null;
const countText = allEnabled ? "all enabled" : `${enabledCount}/${this.allIds.length} enabled`;
const countText = allEnabled
? "all enabled"
: `${enabledCount}/${this.allIds.length} enabled${unavailableCount ? ` · ${unavailableCount} unavailable` : ""}`;
const parts = [
`${keyText("tui.select.confirm")} toggle`,
`${keyText("app.models.enableAll")} all`,
@@ -184,8 +184,10 @@ export class ScopedModelsSelectorComponent extends Container implements Focusabl
const query = this.searchInput.getValue();
const items = this.buildItems();
this.filteredItems = query
? fuzzyFilter(items, query, (i) =>
getModelSearchText({ id: i.model.id, provider: i.model.provider, name: i.model.name }),
? fuzzyFilter(items, query, (item) =>
item.model
? getModelSearchText({ id: item.model.id, provider: item.model.provider, name: item.model.name })
: item.fullId,
)
: items;
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredItems.length - 1));
@@ -216,9 +218,16 @@ export class ScopedModelsSelectorComponent extends Container implements Focusabl
const item = this.filteredItems[i]!;
const isSelected = i === this.selectedIndex;
const prefix = isSelected ? theme.fg("accent", "→ ") : " ";
const modelText = isSelected ? theme.fg("accent", item.model.id) : item.model.id;
const providerBadge = theme.fg("muted", ` [${item.model.provider}]`);
const status = allEnabled ? "" : item.enabled ? theme.fg("success", " ✓") : theme.fg("dim", " ✗");
const id = item.model?.id ?? item.fullId;
const modelText = isSelected ? theme.fg("accent", id) : id;
const providerBadge = theme.fg("muted", item.model ? ` [${item.model.provider}]` : " [unavailable]");
const status = item.model
? allEnabled
? ""
: item.enabled
? theme.fg("success", " ✓")
: theme.fg("dim", " ✗")
: theme.fg("dim", " ✗");
this.listContainer.addChild(new Text(`${prefix}${modelText}${providerBadge}${status}`, 0, 0));
}
@@ -232,7 +241,13 @@ export class ScopedModelsSelectorComponent extends Container implements Focusabl
if (this.filteredItems.length > 0) {
const selected = this.filteredItems[this.selectedIndex];
this.listContainer.addChild(new Spacer(1));
this.listContainer.addChild(new Text(theme.fg("muted", ` Model Name: ${selected.model.name}`), 0, 0));
this.listContainer.addChild(
new Text(
theme.fg("muted", ` ${selected.model ? `Model Name: ${selected.model.name}` : "Model unavailable"}`),
0,
0,
),
);
}
}
@@ -310,7 +325,7 @@ export class ScopedModelsSelectorComponent extends Container implements Focusabl
// Toggle provider of current item
if (kb.matches(data, "app.models.toggleProvider")) {
const item = this.filteredItems[this.selectedIndex];
if (item) {
if (item?.model) {
const provider = item.model.provider;
const providerIds = this.allIds.filter((id) => this.modelsById.get(id)!.provider === provider);
const allEnabled = providerIds.every((id) => isEnabled(this.enabledIds, id));
@@ -76,7 +76,12 @@ import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/
import { configureHttpDispatcher, formatHttpIdleTimeoutMs } from "../../core/http-dispatcher.ts";
import { type AppKeybinding, KeybindingsManager } from "../../core/keybindings.ts";
import { createCompactionSummaryMessage } from "../../core/messages.ts";
import { defaultModelPerProvider, findExactModelReferenceMatch, resolveModelScope } from "../../core/model-resolver.ts";
import {
defaultModelPerProvider,
findExactModelReferenceMatch,
resolveModelScope,
resolveModelScopeWithDiagnostics,
} from "../../core/model-resolver.ts";
import { DefaultPackageManager } from "../../core/package-manager.ts";
import type { ResourceDiagnostic } from "../../core/resource-loader.ts";
import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.ts";
@@ -2985,6 +2990,10 @@ export class InteractiveMode {
this.ui.requestRender();
break;
case "bash_execution_update":
// The bash execution callback handles TUI output rendering.
break;
case "tool_execution_start": {
let component = this.pendingTools.get(event.toolCallId);
if (!component) {
@@ -3233,7 +3242,12 @@ export class InteractiveMode {
case "custom": {
if (message.display) {
const renderer = this.session.extensionRunner.getMessageRenderer(message.customType);
const component = new CustomMessageComponent(message, renderer, this.getMarkdownThemeWithSettings());
const component = new CustomMessageComponent(
message,
renderer,
this.getMarkdownThemeWithSettings(),
this.outputPad,
);
component.setExpanded(this.toolOutputExpanded);
this.chatContainer.addChild(component);
}
@@ -4248,7 +4262,11 @@ export class InteractiveMode {
this.outputPad = padding;
if (this.streamingComponent || this.session.isStreaming) {
for (const child of this.chatContainer.children) {
if (child instanceof AssistantMessageComponent || child instanceof UserMessageComponent) {
if (
child instanceof AssistantMessageComponent ||
child instanceof CustomMessageComponent ||
child instanceof UserMessageComponent
) {
child.setOutputPad(padding);
}
}
@@ -4459,14 +4477,20 @@ export class InteractiveMode {
// Get all available models
await this.session.modelRuntime.refresh();
const allModels = [...(await this.session.modelRuntime.getAvailable())];
const allModelIds = new Set(allModels.map((model) => `${model.provider}/${model.id}`));
const configuredPatterns = this.settingsManager.getEnabledModels();
const sessionScopedModels = this.session.scopedModels;
if (allModels.length === 0) {
if (allModels.length === 0 && !configuredPatterns?.length && sessionScopedModels.length === 0) {
this.showStatus("No models available");
return;
}
const configuredScope = configuredPatterns?.length
? await resolveModelScopeWithDiagnostics(configuredPatterns, this.session.modelRuntime)
: undefined;
// Check if session has scoped models (from previous session-only changes or CLI --models)
const sessionScopedModels = this.session.scopedModels;
const hasSessionScope = sessionScopedModels.length > 0;
// Build enabled model IDs from session state or settings
@@ -4475,19 +4499,25 @@ export class InteractiveMode {
if (hasSessionScope) {
// Use current session's scoped models
currentEnabledIds = sessionScopedModels.map((scoped) => `${scoped.model.provider}/${scoped.model.id}`);
} else {
// Fall back to settings
const patterns = this.settingsManager.getEnabledModels();
if (patterns !== undefined && patterns.length > 0) {
const scopedModels = await resolveModelScope(patterns, this.session.modelRuntime);
currentEnabledIds = scopedModels.map((scoped) => `${scoped.model.provider}/${scoped.model.id}`);
}
} else if (configuredScope) {
currentEnabledIds = configuredScope.scopedModels.map(
(scoped) => `${scoped.model.provider}/${scoped.model.id}`,
);
}
for (const diagnostic of configuredScope?.diagnostics ?? []) {
if (diagnostic.code !== "no-match") continue;
currentEnabledIds ??= [];
if (!currentEnabledIds.includes(diagnostic.pattern)) currentEnabledIds.push(diagnostic.pattern);
}
// Helper to update session's scoped models (session-only, no persist)
const updateSessionModels = async (enabledIds: string[] | null) => {
currentEnabledIds = enabledIds === null ? null : [...enabledIds];
if (enabledIds && enabledIds.length > 0 && enabledIds.length < allModels.length) {
const hasEnabledAvailableModel = enabledIds?.some((id) => allModelIds.has(id)) ?? false;
const allAvailableModelsEnabled =
enabledIds !== null && [...allModelIds].every((id) => enabledIds.includes(id));
if (enabledIds && hasEnabledAvailableModel && !allAvailableModelsEnabled) {
const newScopedModels = await resolveModelScope(enabledIds, this.session.modelRuntime);
this.session.setScopedModels(
newScopedModels.map((sm) => ({
@@ -4515,10 +4545,11 @@ export class InteractiveMode {
},
onPersist: (enabledIds) => {
// Persist to settings
const newPatterns =
enabledIds === null || enabledIds.length === allModels.length
? undefined // All enabled = clear filter
: enabledIds;
const allEnabled =
enabledIds !== null &&
enabledIds.length === allModels.length &&
enabledIds.every((id) => allModelIds.has(id));
const newPatterns = enabledIds === null || allEnabled ? undefined : enabledIds;
this.settingsManager.setEnabledModels(newPatterns ? [...newPatterns] : undefined);
this.showStatus("Model selection saved to settings");
},
@@ -558,6 +558,7 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
case "bash": {
const result = await session.executeBash(command.command, undefined, {
excludeFromContext: command.excludeFromContext,
id,
});
return success(id, "bash", result);
}
+18 -8
View File
@@ -104,15 +104,25 @@ export async function copyToClipboard(text: string): Promise<void> {
try {
// Verify wl-copy exists (spawn errors are async and won't be caught)
execSync("which wl-copy", { stdio: "ignore" });
// wl-copy with execSync hangs due to fork behavior; use spawn instead
const proc = spawn("wl-copy", [], { stdio: ["pipe", "ignore", "ignore"] });
proc.stdin.on("error", () => {
// Ignore EPIPE errors if wl-copy exits early
// wl-copy with execSync hangs due to fork behavior; use spawn instead.
// Await the exit code and only claim success on a clean exit, so a
// failed wl-copy falls through to the xclip/OSC 52 fallbacks.
const wlCopyExit = await new Promise<number>((resolve) => {
const proc = spawn("wl-copy", [], { stdio: ["pipe", "ignore", "ignore"] });
proc.on("error", () => resolve(1));
proc.on("close", (code) => resolve(code ?? 1));
proc.stdin.on("error", () => {
// Ignore EPIPE errors if wl-copy exits early
});
proc.stdin.write(text);
proc.stdin.end();
});
proc.stdin.write(text);
proc.stdin.end();
proc.unref();
copied = true;
if (wlCopyExit === 0) {
copied = true;
} else if (hasX11Display) {
copyToX11Clipboard(options);
copied = true;
}
} catch {
if (hasX11Display) {
copyToX11Clipboard(options);