Merge remote-tracking branch 'origin/main'
# Conflicts: # packages/ai/CHANGELOG.md
This commit is contained in:
@@ -22,6 +22,7 @@
|
|||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed Amazon Bedrock endpoint resolution to honor scoped `AWS_PROFILE` values.
|
- Fixed Amazon Bedrock endpoint resolution to honor scoped `AWS_PROFILE` values.
|
||||||
|
- Fixed OpenAI Codex Responses WebSocket sessions to reconnect once when OpenAI's connection limit is reached before output starts ([#5973](https://github.com/earendil-works/pi/issues/5973)).
|
||||||
- Fixed OpenCode Go GLM-5.2 metadata to expose `xhigh` reasoning and send `reasoning_effort: "max"` ([#5967](https://github.com/earendil-works/pi/issues/5967)).
|
- Fixed OpenCode Go GLM-5.2 metadata to expose `xhigh` reasoning and send `reasoning_effort: "max"` ([#5967](https://github.com/earendil-works/pi/issues/5967)).
|
||||||
- Fixed Claude Fable 5 thinking-off requests to omit Anthropic's unsupported `thinking.type: "disabled"` payload ([#5567](https://github.com/earendil-works/pi/pull/5567) by [@tmustier](https://github.com/tmustier)).
|
- Fixed Claude Fable 5 thinking-off requests to omit Anthropic's unsupported `thinking.type: "disabled"` payload ([#5567](https://github.com/earendil-works/pi/pull/5567) by [@tmustier](https://github.com/tmustier)).
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ const DEFAULT_SSE_HEADER_TIMEOUT_MS = 20_000;
|
|||||||
const DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS = 15_000;
|
const DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS = 15_000;
|
||||||
const CODEX_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode"]);
|
const CODEX_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode"]);
|
||||||
const WEBSOCKET_MESSAGE_TOO_BIG_CLOSE_CODE = 1009;
|
const WEBSOCKET_MESSAGE_TOO_BIG_CLOSE_CODE = 1009;
|
||||||
|
const WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE = "websocket_connection_limit_reached";
|
||||||
|
|
||||||
const CODEX_RESPONSE_STATUSES = new Set<CodexResponseStatus>([
|
const CODEX_RESPONSE_STATUSES = new Set<CodexResponseStatus>([
|
||||||
"completed",
|
"completed",
|
||||||
@@ -253,6 +254,9 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons
|
|||||||
|
|
||||||
if (transport !== "sse" && !websocketDisabledForSession) {
|
if (transport !== "sse" && !websocketDisabledForSession) {
|
||||||
let websocketStarted = false;
|
let websocketStarted = false;
|
||||||
|
let retriedWebSocketConnectionLimit = false;
|
||||||
|
while (true) {
|
||||||
|
websocketStarted = false;
|
||||||
try {
|
try {
|
||||||
await processWebSocketStream(
|
await processWebSocketStream(
|
||||||
resolveCodexWebSocketUrl(model.baseUrl),
|
resolveCodexWebSocketUrl(model.baseUrl),
|
||||||
@@ -281,7 +285,12 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons
|
|||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const aborted = options?.signal?.aborted;
|
const aborted = options?.signal?.aborted;
|
||||||
if (aborted || isCodexNonTransportError(error)) {
|
const connectionLimitBeforeStart = !websocketStarted && isWebSocketConnectionLimitReachedError(error);
|
||||||
|
if (!aborted && connectionLimitBeforeStart && !retriedWebSocketConnectionLimit) {
|
||||||
|
retriedWebSocketConnectionLimit = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (aborted || (isCodexNonTransportError(error) && !connectionLimitBeforeStart)) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
appendAssistantMessageDiagnostic(
|
appendAssistantMessageDiagnostic(
|
||||||
@@ -299,6 +308,8 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
recordWebSocketSseFallback(options?.sessionId);
|
recordWebSocketSseFallback(options?.sessionId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -582,16 +593,32 @@ function isCodexNonTransportError(error: unknown): boolean {
|
|||||||
return error instanceof CodexApiError || error instanceof CodexProtocolError;
|
return error instanceof CodexApiError || error instanceof CodexProtocolError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isWebSocketConnectionLimitReachedError(error: unknown): boolean {
|
||||||
|
return error instanceof CodexApiError && error.code === WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractCodexEventError(event: Record<string, unknown>): { code?: string; message?: string } {
|
||||||
|
const nested = event.error && typeof event.error === "object" ? (event.error as Record<string, unknown>) : undefined;
|
||||||
|
return {
|
||||||
|
code: typeof event.code === "string" ? event.code : typeof nested?.code === "string" ? nested.code : undefined,
|
||||||
|
message:
|
||||||
|
typeof event.message === "string"
|
||||||
|
? event.message
|
||||||
|
: typeof nested?.message === "string"
|
||||||
|
? nested.message
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function* mapCodexEvents(events: AsyncIterable<Record<string, unknown>>): AsyncGenerator<ResponseStreamEvent> {
|
async function* mapCodexEvents(events: AsyncIterable<Record<string, unknown>>): AsyncGenerator<ResponseStreamEvent> {
|
||||||
for await (const event of events) {
|
for await (const event of events) {
|
||||||
const type = typeof event.type === "string" ? event.type : undefined;
|
const type = typeof event.type === "string" ? event.type : undefined;
|
||||||
if (!type) continue;
|
if (!type) continue;
|
||||||
|
|
||||||
if (type === "error") {
|
if (type === "error") {
|
||||||
const code = (event as { code?: string }).code || "";
|
const { code, message } = extractCodexEventError(event);
|
||||||
const message = (event as { message?: string }).message || "";
|
|
||||||
throw new CodexApiError(`Codex error: ${message || code || JSON.stringify(event)}`, {
|
throw new CodexApiError(`Codex error: ${message || code || JSON.stringify(event)}`, {
|
||||||
code: code || undefined,
|
code,
|
||||||
payload: event,
|
payload: event,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,11 @@ describe.sequential("OAuthAuth adapters", () => {
|
|||||||
it("github-copilot refresh preserves the enterprise domain", async () => {
|
it("github-copilot refresh preserves the enterprise domain", async () => {
|
||||||
const fetchedUrls: string[] = [];
|
const fetchedUrls: string[] = [];
|
||||||
const fetchMock = vi.fn(async (input: unknown) => {
|
const fetchMock = vi.fn(async (input: unknown) => {
|
||||||
fetchedUrls.push(typeof input === "string" ? input : String(input));
|
const url = typeof input === "string" ? input : String(input);
|
||||||
|
fetchedUrls.push(url);
|
||||||
|
if (url.endsWith("/models")) {
|
||||||
|
return jsonResponse({ data: [] });
|
||||||
|
}
|
||||||
return jsonResponse({ token: "new-token", expires_at: 9999999999 });
|
return jsonResponse({ token: "new-token", expires_at: 9999999999 });
|
||||||
});
|
});
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|||||||
@@ -1195,6 +1195,68 @@ describe("openai-codex streaming", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reconnects once when the websocket connection limit is reached before output starts", async () => {
|
||||||
|
const token = mockToken();
|
||||||
|
let connections = 0;
|
||||||
|
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
class MockWebSocket extends EventTarget {
|
||||||
|
private readonly limitReached = connections++ === 0;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
queueMicrotask(() => this.dispatchEvent(new Event("open")));
|
||||||
|
}
|
||||||
|
|
||||||
|
send(): void {
|
||||||
|
const event = this.limitReached
|
||||||
|
? { type: "error", error: { code: "websocket_connection_limit_reached" } }
|
||||||
|
: {
|
||||||
|
type: "response.completed",
|
||||||
|
response: {
|
||||||
|
id: "resp_1",
|
||||||
|
status: "completed",
|
||||||
|
usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
queueMicrotask(() => {
|
||||||
|
this.dispatchEvent(Object.assign(new Event("message"), { data: JSON.stringify(event) }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {}
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||||
|
|
||||||
|
const model: Model<"openai-codex-responses"> = {
|
||||||
|
id: "gpt-5.1-codex",
|
||||||
|
name: "GPT-5.1 Codex",
|
||||||
|
api: "openai-codex-responses",
|
||||||
|
provider: "openai-codex",
|
||||||
|
baseUrl: "https://chatgpt.com/backend-api",
|
||||||
|
reasoning: true,
|
||||||
|
input: ["text"],
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
contextWindow: 400000,
|
||||||
|
maxTokens: 128000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await streamOpenAICodexResponses(
|
||||||
|
model,
|
||||||
|
{ systemPrompt: "", messages: [] },
|
||||||
|
{
|
||||||
|
apiKey: token,
|
||||||
|
},
|
||||||
|
).result();
|
||||||
|
|
||||||
|
expect(result.stopReason).toBe("stop");
|
||||||
|
expect(connections).toBe(2);
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("falls back to SSE when a websocket is idle before the first event", async () => {
|
it("falls back to SSE when a websocket is idle before the first event", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const token = mockToken();
|
const token = mockToken();
|
||||||
|
|||||||
@@ -12,6 +12,11 @@
|
|||||||
|
|
||||||
- Added an experimental first-time setup flow behind `PI_EXPERIMENTAL=1` that asks for a dark/light theme choice (preselecting the detected appearance) and opt-in analytics data sharing on first launch with the default agent directory; opting in stores a `trackingId` in `settings.json`.
|
- Added an experimental first-time setup flow behind `PI_EXPERIMENTAL=1` that asks for a dark/light theme choice (preselecting the detected appearance) and opt-in analytics data sharing on first launch with the default agent directory; opting in stores a `trackingId` in `settings.json`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed `pi --resume` to load user package themes and resolve automatic light/dark theme settings.
|
||||||
|
- Fixed `models.json` custom providers so stored credentials can satisfy auth without a redundant provider-level `apiKey` ([#5953](https://github.com/earendil-works/pi/issues/5953)).
|
||||||
|
|
||||||
## [0.79.10] - 2026-06-22
|
## [0.79.10] - 2026-06-22
|
||||||
|
|
||||||
### New Features
|
### New Features
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ For local models (Ollama, LM Studio, vLLM), only `id` is required per model:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The `apiKey` is required but Ollama ignores it, so any value works.
|
The `apiKey` value is a placeholder because Ollama ignores it. pi still treats models as requiring auth before they appear in `/model`, so keyless local servers should keep a dummy value, save a key for that provider with `/login`, or pass `--api-key` when selecting the model.
|
||||||
|
|
||||||
Some OpenAI-compatible servers do not understand the `developer` role used for reasoning-capable models. For those providers, set `compat.supportsDeveloperRole` to `false` so pi sends the system prompt as a `system` message instead. If the server also does not support `reasoning_effort`, set `compat.supportsReasoningEffort` to `false` too.
|
Some OpenAI-compatible servers do not understand the `developer` role used for reasoning-capable models. For those providers, set `compat.supportsDeveloperRole` to `false` so pi sends the system prompt as a `system` message instead. If the server also does not support `reasoning_effort`, set `compat.supportsReasoningEffort` to `false` too.
|
||||||
|
|
||||||
@@ -135,12 +135,14 @@ Set `api` at provider level (default for all models) or model level (override pe
|
|||||||
|-------|-------------|
|
|-------|-------------|
|
||||||
| `baseUrl` | API endpoint URL |
|
| `baseUrl` | API endpoint URL |
|
||||||
| `api` | API type (see above) |
|
| `api` | API type (see above) |
|
||||||
| `apiKey` | API key (see value resolution below) |
|
| `apiKey` | Optional API key config (see value resolution below). Omit it when auth is provided by `/login`/`auth.json` or CLI `--api-key`. |
|
||||||
| `headers` | Custom headers (see value resolution below) |
|
| `headers` | Custom headers (see value resolution below) |
|
||||||
| `authHeader` | Set `true` to add `Authorization: Bearer <apiKey>` automatically |
|
| `authHeader` | Set `true` to add `Authorization: Bearer <apiKey>` automatically |
|
||||||
| `models` | Array of model configurations |
|
| `models` | Array of model configurations |
|
||||||
| `modelOverrides` | Per-model overrides for built-in models on this provider |
|
| `modelOverrides` | Per-model overrides for built-in models on this provider |
|
||||||
|
|
||||||
|
For providers with `models`, non-built-in provider configs need `baseUrl` and an `api` value at either provider or model level. `apiKey` is not required to load the file: models become available when auth is configured through `/login`/`auth.json`, CLI `--api-key`, or provider `apiKey`. If no auth is configured, the models load but stay unavailable in `/model` and `--list-models`.
|
||||||
|
|
||||||
### Value Resolution
|
### Value Resolution
|
||||||
|
|
||||||
The `apiKey` and `headers` fields support command execution, environment interpolation, and literals:
|
The `apiKey` and `headers` fields support command execution, environment interpolation, and literals:
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
* TUI session selector for --resume flag
|
* TUI session selector for --resume flag
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ProcessTerminal, setKeybindings, TUI } from "@earendil-works/pi-tui";
|
import { setKeybindings } from "@earendil-works/pi-tui";
|
||||||
import { KeybindingsManager } from "../core/keybindings.ts";
|
import { KeybindingsManager } from "../core/keybindings.ts";
|
||||||
import type { SessionInfo, SessionListProgress } from "../core/session-manager.ts";
|
import type { SessionInfo, SessionListProgress } from "../core/session-manager.ts";
|
||||||
|
import type { SettingsManager } from "../core/settings-manager.ts";
|
||||||
import { SessionSelectorComponent } from "../modes/interactive/components/session-selector.ts";
|
import { SessionSelectorComponent } from "../modes/interactive/components/session-selector.ts";
|
||||||
|
import { createStartupTui, startStartupTui } from "./startup-ui.ts";
|
||||||
|
|
||||||
type SessionsLoader = (onProgress?: SessionListProgress) => Promise<SessionInfo[]>;
|
type SessionsLoader = (onProgress?: SessionListProgress) => Promise<SessionInfo[]>;
|
||||||
|
|
||||||
@@ -13,9 +15,10 @@ type SessionsLoader = (onProgress?: SessionListProgress) => Promise<SessionInfo[
|
|||||||
export async function selectSession(
|
export async function selectSession(
|
||||||
currentSessionsLoader: SessionsLoader,
|
currentSessionsLoader: SessionsLoader,
|
||||||
allSessionsLoader: SessionsLoader,
|
allSessionsLoader: SessionsLoader,
|
||||||
|
settingsManager: SettingsManager,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
|
const ui = await createStartupTui(settingsManager);
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const ui = new TUI(new ProcessTerminal());
|
|
||||||
const keybindings = KeybindingsManager.create();
|
const keybindings = KeybindingsManager.create();
|
||||||
setKeybindings(keybindings);
|
setKeybindings(keybindings);
|
||||||
let resolved = false;
|
let resolved = false;
|
||||||
@@ -47,6 +50,6 @@ export async function selectSession(
|
|||||||
|
|
||||||
ui.addChild(selector);
|
ui.addChild(selector);
|
||||||
ui.setFocus(selector.getSessionList());
|
ui.setFocus(selector.getSessionList());
|
||||||
ui.start();
|
startStartupTui(ui, settingsManager);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,27 @@
|
|||||||
import { ProcessTerminal, setKeybindings, TUI } from "@earendil-works/pi-tui";
|
import { ProcessTerminal, setKeybindings, TUI } from "@earendil-works/pi-tui";
|
||||||
import { existsSync } from "fs";
|
import { existsSync } from "fs";
|
||||||
import { APP_NAME, CONFIG_DIR_NAME, ENV_AGENT_DIR, getSettingsPath, PACKAGE_NAME } from "../config.ts";
|
import { APP_NAME, CONFIG_DIR_NAME, ENV_AGENT_DIR, getAgentDir, getSettingsPath, PACKAGE_NAME } from "../config.ts";
|
||||||
import { areExperimentalFeaturesEnabled } from "../core/experimental.ts";
|
import { areExperimentalFeaturesEnabled } from "../core/experimental.ts";
|
||||||
import { KeybindingsManager } from "../core/keybindings.ts";
|
import { KeybindingsManager } from "../core/keybindings.ts";
|
||||||
import type { SettingsManager } from "../core/settings-manager.ts";
|
import { DefaultPackageManager, type ResolvedResource } from "../core/package-manager.ts";
|
||||||
|
import { SettingsManager } from "../core/settings-manager.ts";
|
||||||
import { ExtensionInputComponent } from "../modes/interactive/components/extension-input.ts";
|
import { ExtensionInputComponent } from "../modes/interactive/components/extension-input.ts";
|
||||||
import { ExtensionSelectorComponent } from "../modes/interactive/components/extension-selector.ts";
|
import { ExtensionSelectorComponent } from "../modes/interactive/components/extension-selector.ts";
|
||||||
import {
|
import {
|
||||||
FirstTimeSetupComponent,
|
FirstTimeSetupComponent,
|
||||||
type FirstTimeSetupResult,
|
type FirstTimeSetupResult,
|
||||||
} from "../modes/interactive/components/first-time-setup.ts";
|
} from "../modes/interactive/components/first-time-setup.ts";
|
||||||
import { detectTerminalBackgroundTheme, initTheme, setTheme } from "../modes/interactive/theme/theme.ts";
|
import {
|
||||||
|
detectTerminalBackgroundFromEnv,
|
||||||
|
detectTerminalThemeForAuto,
|
||||||
|
initTheme,
|
||||||
|
loadThemeFromPath,
|
||||||
|
parseAutoThemeSetting,
|
||||||
|
resolveThemeSetting,
|
||||||
|
setRegisteredThemes,
|
||||||
|
setTheme,
|
||||||
|
type Theme,
|
||||||
|
} from "../modes/interactive/theme/theme.ts";
|
||||||
|
|
||||||
const OFFICIAL_PACKAGE_NAME = "@earendil-works/pi-coding-agent";
|
const OFFICIAL_PACKAGE_NAME = "@earendil-works/pi-coding-agent";
|
||||||
const OFFICIAL_APP_NAME = "pi";
|
const OFFICIAL_APP_NAME = "pi";
|
||||||
@@ -30,14 +41,64 @@ function isOfficialDistribution({ packageName, appName, configDirName }: Distrib
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createStartupTui(settingsManager: SettingsManager): TUI {
|
function loadThemes(resources: ResolvedResource[]): Theme[] {
|
||||||
initTheme(settingsManager.getTheme());
|
const themes: Theme[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const resource of resources) {
|
||||||
|
if (!resource.enabled) continue;
|
||||||
|
try {
|
||||||
|
const loadedTheme = loadThemeFromPath(resource.path);
|
||||||
|
if (loadedTheme.name) {
|
||||||
|
if (seen.has(loadedTheme.name)) continue;
|
||||||
|
seen.add(loadedTheme.name);
|
||||||
|
}
|
||||||
|
themes.push(loadedTheme);
|
||||||
|
} catch {
|
||||||
|
// Startup prompts should not fail because a theme is broken. The normal
|
||||||
|
// resource loader reports theme diagnostics later in startup.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return themes;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStartupThemes(settingsManager: SettingsManager): Promise<Theme[]> {
|
||||||
|
const globalSettingsManager = SettingsManager.inMemory(settingsManager.getGlobalSettings(), {
|
||||||
|
projectTrusted: false,
|
||||||
|
});
|
||||||
|
const packageManager = new DefaultPackageManager({
|
||||||
|
cwd: process.cwd(),
|
||||||
|
agentDir: getAgentDir(),
|
||||||
|
settingsManager: globalSettingsManager,
|
||||||
|
});
|
||||||
|
const resolvedPaths = await packageManager.resolve(async () => "skip");
|
||||||
|
return loadThemes(resolvedPaths.themes);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createStartupTui(settingsManager: SettingsManager): Promise<TUI> {
|
||||||
|
setRegisteredThemes(await loadStartupThemes(settingsManager));
|
||||||
|
const terminalTheme = detectTerminalBackgroundFromEnv().theme;
|
||||||
|
initTheme(resolveThemeSetting(settingsManager.getThemeSetting(), terminalTheme) ?? terminalTheme);
|
||||||
setKeybindings(KeybindingsManager.create());
|
setKeybindings(KeybindingsManager.create());
|
||||||
const ui = new TUI(new ProcessTerminal(), settingsManager.getShowHardwareCursor());
|
const ui = new TUI(new ProcessTerminal(), settingsManager.getShowHardwareCursor());
|
||||||
ui.setClearOnShrink(settingsManager.getClearOnShrink());
|
ui.setClearOnShrink(settingsManager.getClearOnShrink());
|
||||||
return ui;
|
return ui;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function startStartupTui(ui: TUI, settingsManager: SettingsManager): void {
|
||||||
|
ui.start();
|
||||||
|
void applyDetectedStartupTheme(ui, settingsManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyDetectedStartupTheme(ui: TUI, settingsManager: SettingsManager): Promise<void> {
|
||||||
|
const themeSetting = settingsManager.getThemeSetting();
|
||||||
|
if (themeSetting && !parseAutoThemeSetting(themeSetting)) return;
|
||||||
|
|
||||||
|
const terminalTheme = await detectTerminalThemeForAuto({ ui, timeoutMs: 100 });
|
||||||
|
setTheme(resolveThemeSetting(themeSetting, terminalTheme) ?? terminalTheme);
|
||||||
|
ui.invalidate();
|
||||||
|
ui.requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
async function clearStartupTui(ui: TUI): Promise<void> {
|
async function clearStartupTui(ui: TUI): Promise<void> {
|
||||||
ui.clear();
|
ui.clear();
|
||||||
ui.requestRender();
|
ui.requestRender();
|
||||||
@@ -75,9 +136,8 @@ export async function showStartupSelector<T>(
|
|||||||
title: string,
|
title: string,
|
||||||
options: Array<{ label: string; value: T }>,
|
options: Array<{ label: string; value: T }>,
|
||||||
): Promise<T | undefined> {
|
): Promise<T | undefined> {
|
||||||
|
const ui = await createStartupTui(settingsManager);
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const ui = createStartupTui(settingsManager);
|
|
||||||
|
|
||||||
let settled = false;
|
let settled = false;
|
||||||
const finish = async (result: T | undefined) => {
|
const finish = async (result: T | undefined) => {
|
||||||
if (settled) {
|
if (settled) {
|
||||||
@@ -98,15 +158,14 @@ export async function showStartupSelector<T>(
|
|||||||
);
|
);
|
||||||
ui.addChild(selector);
|
ui.addChild(selector);
|
||||||
ui.setFocus(selector);
|
ui.setFocus(selector);
|
||||||
ui.start();
|
startStartupTui(ui, settingsManager);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Show the first-time setup dialog and persist the result */
|
/** Show the first-time setup dialog and persist the result */
|
||||||
export async function showFirstTimeSetup(settingsManager: SettingsManager): Promise<void> {
|
export async function showFirstTimeSetup(settingsManager: SettingsManager): Promise<void> {
|
||||||
|
const ui = await createStartupTui(settingsManager);
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const ui = createStartupTui(settingsManager);
|
|
||||||
|
|
||||||
let settled = false;
|
let settled = false;
|
||||||
const finish = async (result: FirstTimeSetupResult | undefined) => {
|
const finish = async (result: FirstTimeSetupResult | undefined) => {
|
||||||
if (settled) {
|
if (settled) {
|
||||||
@@ -125,10 +184,10 @@ export async function showFirstTimeSetup(settingsManager: SettingsManager): Prom
|
|||||||
|
|
||||||
const showSetup = async () => {
|
const showSetup = async () => {
|
||||||
ui.start();
|
ui.start();
|
||||||
const detection = await detectTerminalBackgroundTheme({ ui, timeoutMs: 100 });
|
const detectedTheme = await detectTerminalThemeForAuto({ ui, timeoutMs: 100 });
|
||||||
setTheme(detection.theme);
|
setTheme(detectedTheme);
|
||||||
const component = new FirstTimeSetupComponent({
|
const component = new FirstTimeSetupComponent({
|
||||||
detectedTheme: detection.theme,
|
detectedTheme,
|
||||||
onThemePreview: (themeName) => {
|
onThemePreview: (themeName) => {
|
||||||
setTheme(themeName);
|
setTheme(themeName);
|
||||||
ui.requestRender();
|
ui.requestRender();
|
||||||
@@ -150,9 +209,8 @@ export async function showStartupInput(
|
|||||||
title: string,
|
title: string,
|
||||||
placeholder?: string,
|
placeholder?: string,
|
||||||
): Promise<string | undefined> {
|
): Promise<string | undefined> {
|
||||||
|
const ui = await createStartupTui(settingsManager);
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const ui = createStartupTui(settingsManager);
|
|
||||||
|
|
||||||
let settled = false;
|
let settled = false;
|
||||||
const finish = async (result: string | undefined) => {
|
const finish = async (result: string | undefined) => {
|
||||||
if (settled) {
|
if (settled) {
|
||||||
@@ -176,6 +234,6 @@ export async function showStartupInput(
|
|||||||
);
|
);
|
||||||
ui.addChild(input);
|
ui.addChild(input);
|
||||||
ui.setFocus(input);
|
ui.setFocus(input);
|
||||||
ui.start();
|
startStartupTui(ui, settingsManager);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -546,13 +546,11 @@ export class ModelRegistry {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (!isBuiltIn) {
|
} else if (!isBuiltIn) {
|
||||||
// Non-built-in providers with custom models require endpoint + auth.
|
// Non-built-in providers with custom models require an endpoint.
|
||||||
|
// Auth can come from auth.json, --api-key, or provider request config.
|
||||||
if (!providerConfig.baseUrl) {
|
if (!providerConfig.baseUrl) {
|
||||||
throw new Error(`Provider ${providerName}: "baseUrl" is required when defining custom models.`);
|
throw new Error(`Provider ${providerName}: "baseUrl" is required when defining custom models.`);
|
||||||
}
|
}
|
||||||
if (!providerConfig.apiKey) {
|
|
||||||
throw new Error(`Provider ${providerName}: "apiKey" is required when defining custom models.`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Built-in providers with custom models: baseUrl/apiKey/api are optional,
|
// Built-in providers with custom models: baseUrl/apiKey/api are optional,
|
||||||
// inherited from built-in models. Auth comes from env vars / auth storage.
|
// inherited from built-in models. Auth comes from env vars / auth storage.
|
||||||
|
|||||||
@@ -334,11 +334,11 @@ export class SettingsManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Create an in-memory SettingsManager (no file I/O) */
|
/** Create an in-memory SettingsManager (no file I/O) */
|
||||||
static inMemory(settings: Partial<Settings> = {}): SettingsManager {
|
static inMemory(settings: Partial<Settings> = {}, options: SettingsManagerCreateOptions = {}): SettingsManager {
|
||||||
const storage = new InMemorySettingsStorage();
|
const storage = new InMemorySettingsStorage();
|
||||||
const initialSettings = SettingsManager.migrateSettings(structuredClone(settings) as Record<string, unknown>);
|
const initialSettings = SettingsManager.migrateSettings(structuredClone(settings) as Record<string, unknown>);
|
||||||
storage.withLock("global", () => JSON.stringify(initialSettings, null, 2));
|
storage.withLock("global", () => JSON.stringify(initialSettings, null, 2));
|
||||||
return SettingsManager.fromStorage(storage);
|
return SettingsManager.fromStorage(storage, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static loadFromStorage(storage: SettingsStorage, scope: SettingsScope, projectTrusted = true): Settings {
|
private static loadFromStorage(storage: SettingsStorage, scope: SettingsScope, projectTrusted = true): Settings {
|
||||||
|
|||||||
@@ -308,11 +308,11 @@ async function createSessionManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.resume) {
|
if (parsed.resume) {
|
||||||
initTheme(settingsManager.getTheme(), true);
|
|
||||||
try {
|
try {
|
||||||
const selectedPath = await selectSession(
|
const selectedPath = await selectSession(
|
||||||
(onProgress) => SessionManager.list(cwd, sessionDir, onProgress),
|
(onProgress) => SessionManager.list(cwd, sessionDir, onProgress),
|
||||||
(onProgress) => SessionManager.listAll(sessionDir, onProgress),
|
(onProgress) => SessionManager.listAll(sessionDir, onProgress),
|
||||||
|
settingsManager,
|
||||||
);
|
);
|
||||||
if (!selectedPath) {
|
if (!selectedPath) {
|
||||||
console.log(chalk.dim("No session selected"));
|
console.log(chalk.dim("No session selected"));
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { SettingsManager } from "../../../core/settings-manager.ts";
|
|||||||
import {
|
import {
|
||||||
detectTerminalBackgroundFromEnv,
|
detectTerminalBackgroundFromEnv,
|
||||||
detectTerminalBackgroundTheme,
|
detectTerminalBackgroundTheme,
|
||||||
|
detectTerminalThemeForAuto,
|
||||||
initTheme,
|
initTheme,
|
||||||
parseAutoThemeSetting,
|
parseAutoThemeSetting,
|
||||||
resolveThemeSetting,
|
resolveThemeSetting,
|
||||||
@@ -37,7 +38,7 @@ export class InteractiveThemeController {
|
|||||||
const themeSetting = this.settingsManager.getThemeSetting();
|
const themeSetting = this.settingsManager.getThemeSetting();
|
||||||
const autoTheme = parseAutoThemeSetting(themeSetting);
|
const autoTheme = parseAutoThemeSetting(themeSetting);
|
||||||
if (autoTheme) {
|
if (autoTheme) {
|
||||||
this.terminalTheme = await this.detectTerminalThemeForAuto();
|
this.terminalTheme = await detectTerminalThemeForAuto({ ui: this.ui, timeoutMs: 100 });
|
||||||
this.setAutoSync(true);
|
this.setAutoSync(true);
|
||||||
this.applyThemeName(this.terminalTheme === "light" ? autoTheme.lightTheme : autoTheme.darkTheme, true);
|
this.applyThemeName(this.terminalTheme === "light" ? autoTheme.lightTheme : autoTheme.darkTheme, true);
|
||||||
return;
|
return;
|
||||||
@@ -109,16 +110,6 @@ export class InteractiveThemeController {
|
|||||||
this.ui.setTerminalColorSchemeNotifications(enabled);
|
this.ui.setTerminalColorSchemeNotifications(enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async detectTerminalThemeForAuto(): Promise<TerminalTheme> {
|
|
||||||
try {
|
|
||||||
const colorScheme = await this.ui.queryTerminalColorScheme({ timeoutMs: 100 });
|
|
||||||
if (colorScheme) return colorScheme;
|
|
||||||
} catch {
|
|
||||||
// Fall back to OSC 11 / COLORFGBG detection when color-scheme DSR is unsupported.
|
|
||||||
}
|
|
||||||
return (await detectTerminalBackgroundTheme({ ui: this.ui, timeoutMs: 100 })).theme;
|
|
||||||
}
|
|
||||||
|
|
||||||
private applyTerminalTheme(terminalTheme: TerminalTheme): void {
|
private applyTerminalTheme(terminalTheme: TerminalTheme): void {
|
||||||
if (!this.autoSyncEnabled) return;
|
if (!this.autoSyncEnabled) return;
|
||||||
this.terminalTheme = terminalTheme;
|
this.terminalTheme = terminalTheme;
|
||||||
|
|||||||
@@ -680,11 +680,20 @@ export interface TerminalBackgroundThemeDetector {
|
|||||||
queryTerminalBackgroundColor({ timeoutMs }: { timeoutMs: number }): Promise<RgbColor | undefined>;
|
queryTerminalBackgroundColor({ timeoutMs }: { timeoutMs: number }): Promise<RgbColor | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TerminalAutoThemeDetector extends TerminalBackgroundThemeDetector {
|
||||||
|
queryTerminalColorScheme?({ timeoutMs }: { timeoutMs: number }): Promise<TerminalTheme | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TerminalBackgroundThemeDetectionOptions extends TerminalThemeDetectionOptions {
|
export interface TerminalBackgroundThemeDetectionOptions extends TerminalThemeDetectionOptions {
|
||||||
ui: TerminalBackgroundThemeDetector;
|
ui: TerminalBackgroundThemeDetector;
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TerminalAutoThemeDetectionOptions extends TerminalThemeDetectionOptions {
|
||||||
|
ui: TerminalAutoThemeDetector;
|
||||||
|
timeoutMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
function getColorFgBgBackgroundIndex(colorfgbg: string): number | undefined {
|
function getColorFgBgBackgroundIndex(colorfgbg: string): number | undefined {
|
||||||
const parts = colorfgbg.split(";");
|
const parts = colorfgbg.split(";");
|
||||||
for (let i = parts.length - 1; i >= 0; i--) {
|
for (let i = parts.length - 1; i >= 0; i--) {
|
||||||
@@ -755,6 +764,20 @@ export async function detectTerminalBackgroundTheme({
|
|||||||
return detectTerminalBackgroundFromEnv({ env });
|
return detectTerminalBackgroundFromEnv({ env });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function detectTerminalThemeForAuto({
|
||||||
|
ui,
|
||||||
|
timeoutMs,
|
||||||
|
env,
|
||||||
|
}: TerminalAutoThemeDetectionOptions): Promise<TerminalTheme> {
|
||||||
|
try {
|
||||||
|
const colorScheme = await ui.queryTerminalColorScheme?.({ timeoutMs });
|
||||||
|
if (colorScheme) return colorScheme;
|
||||||
|
} catch {
|
||||||
|
// Fall back to OSC 11 / COLORFGBG detection when color-scheme DSR is unsupported.
|
||||||
|
}
|
||||||
|
return (await detectTerminalBackgroundTheme({ ui, timeoutMs, env })).theme;
|
||||||
|
}
|
||||||
|
|
||||||
export function getDefaultTheme(): string {
|
export function getDefaultTheme(): string {
|
||||||
return detectTerminalBackgroundFromEnv().theme;
|
return detectTerminalBackgroundFromEnv().theme;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -246,9 +246,10 @@ describe("ModelRegistry", () => {
|
|||||||
expect(model?.baseUrl).toBe("https://openrouter.ai/api/v1");
|
expect(model?.baseUrl).toBe("https://openrouter.ai/api/v1");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("non-built-in provider custom models still require baseUrl and apiKey", () => {
|
test("non-built-in provider custom models still require baseUrl", () => {
|
||||||
writeRawModelsJson({
|
writeRawModelsJson({
|
||||||
"my-custom-provider": {
|
"my-custom-provider": {
|
||||||
|
apiKey: "test-key",
|
||||||
models: [
|
models: [
|
||||||
{
|
{
|
||||||
id: "my-model",
|
id: "my-model",
|
||||||
|
|||||||
Reference in New Issue
Block a user