new_pull
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user