codex rules
This commit is contained in:
@@ -14,9 +14,9 @@ import { complete, type Message } from "@mariozechner/pi-ai";
|
||||
import { getModel } from "@mariozechner/pi-ai";
|
||||
import type { ExtensionAPI, SessionEntry } from "@mariozechner/pi-coding-agent";
|
||||
import {
|
||||
BorderedLoader,
|
||||
convertToLlm,
|
||||
serializeConversation,
|
||||
BorderedLoader,
|
||||
convertToLlm,
|
||||
serializeConversation,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
@@ -43,306 +43,306 @@ Output ONLY the session name, nothing else.`;
|
||||
const AUTO_NAME_MODEL = getModel("opencode-go", "minimax-m2.5");
|
||||
|
||||
// Number of messages before auto-naming kicks in
|
||||
const AUTO_NAME_THRESHOLD = 2;
|
||||
const AUTO_NAME_THRESHOLD = 1;
|
||||
|
||||
// Debug log file
|
||||
const LOG_FILE = path.join(os.homedir(), ".pi", "session-name-debug.log");
|
||||
|
||||
function log(message: string) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const entry = `[${timestamp}] ${message}\n`;
|
||||
try {
|
||||
fs.appendFileSync(LOG_FILE, entry);
|
||||
} catch (e) {
|
||||
// ignore write errors
|
||||
}
|
||||
const timestamp = new Date().toISOString();
|
||||
const entry = `[${timestamp}] ${message}\n`;
|
||||
try {
|
||||
fs.appendFileSync(LOG_FILE, entry);
|
||||
} catch (e) {
|
||||
// ignore write errors
|
||||
}
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
// Track if we've already attempted auto-naming for this session
|
||||
let autoNamedAttempted = false;
|
||||
export default function(pi: ExtensionAPI) {
|
||||
// Track if we've already attempted auto-naming for this session
|
||||
let autoNamedAttempted = false;
|
||||
|
||||
// Listen for agent_end to auto-name sessions (non-blocking)
|
||||
pi.on("agent_end", (_event, ctx) => {
|
||||
log("=== agent_end triggered ===");
|
||||
log(`hasUI: ${ctx.hasUI}`);
|
||||
log(`current session name: ${pi.getSessionName()}`);
|
||||
log(`autoNamedAttempted: ${autoNamedAttempted}`);
|
||||
// Listen for agent_end to auto-name sessions (non-blocking)
|
||||
pi.on("agent_end", (_event, ctx) => {
|
||||
log("=== agent_end triggered ===");
|
||||
log(`hasUI: ${ctx.hasUI}`);
|
||||
log(`current session name: ${pi.getSessionName()}`);
|
||||
log(`autoNamedAttempted: ${autoNamedAttempted}`);
|
||||
|
||||
// Skip if already has a name or already attempted
|
||||
if (pi.getSessionName() || autoNamedAttempted) {
|
||||
log("Skipping: already has name or already attempted");
|
||||
return;
|
||||
}
|
||||
// Skip if already has a name or already attempted
|
||||
if (pi.getSessionName() || autoNamedAttempted) {
|
||||
log("Skipping: already has name or already attempted");
|
||||
return;
|
||||
}
|
||||
|
||||
// Count user messages in the branch
|
||||
const branch = ctx.sessionManager.getBranch();
|
||||
const userMessages = branch.filter(
|
||||
(entry): entry is SessionEntry & { type: "message" } =>
|
||||
entry.type === "message" && entry.message.role === "user",
|
||||
);
|
||||
// Count user messages in the branch
|
||||
const branch = ctx.sessionManager.getBranch();
|
||||
const userMessages = branch.filter(
|
||||
(entry): entry is SessionEntry & { type: "message" } =>
|
||||
entry.type === "message" && entry.message.role === "user",
|
||||
);
|
||||
|
||||
log(`Total entries in branch: ${branch.length}`);
|
||||
log(`User messages: ${userMessages.length}`);
|
||||
log(`Threshold: ${AUTO_NAME_THRESHOLD}`);
|
||||
log(`Total entries in branch: ${branch.length}`);
|
||||
log(`User messages: ${userMessages.length}`);
|
||||
log(`Threshold: ${AUTO_NAME_THRESHOLD}`);
|
||||
|
||||
// Only auto-name after threshold is reached
|
||||
if (userMessages.length < AUTO_NAME_THRESHOLD) {
|
||||
log("Skipping: below threshold");
|
||||
return;
|
||||
}
|
||||
// Only auto-name after threshold is reached
|
||||
if (userMessages.length < AUTO_NAME_THRESHOLD) {
|
||||
log("Skipping: below threshold");
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as attempted so we don't try again
|
||||
autoNamedAttempted = true;
|
||||
log("Threshold reached, attempting auto-name");
|
||||
// Mark as attempted so we don't try again
|
||||
autoNamedAttempted = true;
|
||||
log("Threshold reached, attempting auto-name");
|
||||
|
||||
// Only auto-name in interactive mode
|
||||
if (!ctx.hasUI) {
|
||||
log("Skipping: no UI (non-interactive mode)");
|
||||
return;
|
||||
}
|
||||
// Only auto-name in interactive mode
|
||||
if (!ctx.hasUI) {
|
||||
log("Skipping: no UI (non-interactive mode)");
|
||||
return;
|
||||
}
|
||||
|
||||
// Gather conversation context
|
||||
const messages = branch
|
||||
.filter(
|
||||
(entry): entry is SessionEntry & { type: "message" } =>
|
||||
entry.type === "message",
|
||||
)
|
||||
.map((entry) => entry.message);
|
||||
// Gather conversation context
|
||||
const messages = branch
|
||||
.filter(
|
||||
(entry): entry is SessionEntry & { type: "message" } =>
|
||||
entry.type === "message",
|
||||
)
|
||||
.map((entry) => entry.message);
|
||||
|
||||
log(`Total messages to analyze: ${messages.length}`);
|
||||
log(`Total messages to analyze: ${messages.length}`);
|
||||
|
||||
if (messages.length === 0) {
|
||||
log("No messages found, aborting");
|
||||
return;
|
||||
}
|
||||
if (messages.length === 0) {
|
||||
log("No messages found, aborting");
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert to LLM format and serialize
|
||||
const llmMessages = convertToLlm(messages);
|
||||
const conversationText = serializeConversation(llmMessages);
|
||||
// Convert to LLM format and serialize
|
||||
const llmMessages = convertToLlm(messages);
|
||||
const conversationText = serializeConversation(llmMessages);
|
||||
|
||||
log(`Conversation text length: ${conversationText.length}`);
|
||||
log(`Conversation text length: ${conversationText.length}`);
|
||||
|
||||
// Truncate if too long (keep costs low)
|
||||
const maxChars = 4000;
|
||||
const truncatedText =
|
||||
conversationText.length > maxChars
|
||||
? conversationText.slice(0, maxChars) + "\n..."
|
||||
: conversationText;
|
||||
// Truncate if too long (keep costs low)
|
||||
const maxChars = 4000;
|
||||
const truncatedText =
|
||||
conversationText.length > maxChars
|
||||
? conversationText.slice(0, maxChars) + "\n..."
|
||||
: conversationText;
|
||||
|
||||
log(`Truncated text length: ${truncatedText.length}`);
|
||||
log("Starting background auto-name...");
|
||||
log(`Truncated text length: ${truncatedText.length}`);
|
||||
log("Starting background auto-name...");
|
||||
|
||||
// Fire-and-forget: run auto-naming in background without blocking
|
||||
const doAutoName = async () => {
|
||||
const apiKey = await ctx.modelRegistry.getApiKey(AUTO_NAME_MODEL);
|
||||
log(`Got API key: ${apiKey ? "yes" : "no"}`);
|
||||
// Fire-and-forget: run auto-naming in background without blocking
|
||||
const doAutoName = async () => {
|
||||
const apiKey = await ctx.modelRegistry.getApiKey(AUTO_NAME_MODEL);
|
||||
log(`Got API key: ${apiKey ? "yes" : "no"}`);
|
||||
|
||||
if (!apiKey) {
|
||||
log("No API key available, aborting");
|
||||
return;
|
||||
}
|
||||
if (!apiKey) {
|
||||
log("No API key available, aborting");
|
||||
return;
|
||||
}
|
||||
|
||||
const userMessage: Message = {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `## Conversation History\n\n${truncatedText}\n\nGenerate a concise session name for this conversation.`,
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const userMessage: Message = {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `## Conversation History\n\n${truncatedText}\n\nGenerate a concise session name for this conversation.`,
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const response = await complete(
|
||||
AUTO_NAME_MODEL,
|
||||
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
|
||||
{ apiKey },
|
||||
);
|
||||
const response = await complete(
|
||||
AUTO_NAME_MODEL,
|
||||
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
|
||||
{ apiKey },
|
||||
);
|
||||
|
||||
log(`Response received, stopReason: ${response.stopReason}`);
|
||||
log(`Response received, stopReason: ${response.stopReason}`);
|
||||
|
||||
if (response.stopReason === "aborted") {
|
||||
log("Request was aborted");
|
||||
return;
|
||||
}
|
||||
if (response.stopReason === "aborted") {
|
||||
log("Request was aborted");
|
||||
return;
|
||||
}
|
||||
|
||||
const name = response.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text.trim())
|
||||
.join(" ")
|
||||
.replace(/^[\"']|[\"']$/g, ""); // Remove surrounding quotes
|
||||
const name = response.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text.trim())
|
||||
.join(" ")
|
||||
.replace(/^[\"']|[\"']$/g, ""); // Remove surrounding quotes
|
||||
|
||||
log(`Generated name: "${name}"`);
|
||||
log(`Generated name: "${name}"`);
|
||||
|
||||
// Clean up the generated name
|
||||
const cleanName = name
|
||||
.replace(/\n/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 50); // Max 50 chars
|
||||
// Clean up the generated name
|
||||
const cleanName = name
|
||||
.replace(/\n/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 50); // Max 50 chars
|
||||
|
||||
log(`Cleaned name: "${cleanName}"`);
|
||||
log(`Cleaned name: "${cleanName}"`);
|
||||
|
||||
if (cleanName) {
|
||||
pi.setSessionName(cleanName);
|
||||
ctx.ui.notify(`Auto-named: ${cleanName}`, "info");
|
||||
log(`Successfully set session name to: ${cleanName}`);
|
||||
} else {
|
||||
log("Cleaned name was empty, not setting");
|
||||
}
|
||||
};
|
||||
if (cleanName) {
|
||||
pi.setSessionName(cleanName);
|
||||
ctx.ui.notify(`Auto-named: ${cleanName}`, "info");
|
||||
log(`Successfully set session name to: ${cleanName}`);
|
||||
} else {
|
||||
log("Cleaned name was empty, not setting");
|
||||
}
|
||||
};
|
||||
|
||||
// Run in background without awaiting - don't block the agent
|
||||
doAutoName().catch((err) => {
|
||||
log(`ERROR: ${err}`);
|
||||
console.error("Auto-naming failed:", err);
|
||||
});
|
||||
});
|
||||
// Run in background without awaiting - don't block the agent
|
||||
doAutoName().catch((err) => {
|
||||
log(`ERROR: ${err}`);
|
||||
console.error("Auto-naming failed:", err);
|
||||
});
|
||||
});
|
||||
|
||||
// Reset flag on new session
|
||||
pi.on("session_start", () => {
|
||||
log("=== session_start ===");
|
||||
autoNamedAttempted = false;
|
||||
log("Reset autoNamedAttempted to false");
|
||||
});
|
||||
// Reset flag on new session
|
||||
pi.on("session_start", () => {
|
||||
log("=== session_start ===");
|
||||
autoNamedAttempted = false;
|
||||
log("Reset autoNamedAttempted to false");
|
||||
});
|
||||
|
||||
pi.on("session_switch", () => {
|
||||
log("=== session_switch ===");
|
||||
autoNamedAttempted = false;
|
||||
log("Reset autoNamedAttempted to false");
|
||||
});
|
||||
pi.on("session_switch", () => {
|
||||
log("=== session_switch ===");
|
||||
autoNamedAttempted = false;
|
||||
log("Reset autoNamedAttempted to false");
|
||||
});
|
||||
|
||||
// Manual command for setting/getting session name
|
||||
pi.registerCommand("session-name", {
|
||||
description:
|
||||
"Set, show, or auto-generate session name (usage: /session-name [name] or /session-name --auto)",
|
||||
handler: async (args, ctx) => {
|
||||
const trimmedArgs = args.trim();
|
||||
// Manual command for setting/getting session name
|
||||
pi.registerCommand("session-name", {
|
||||
description:
|
||||
"Set, show, or auto-generate session name (usage: /session-name [name] or /session-name --auto)",
|
||||
handler: async (args, ctx) => {
|
||||
const trimmedArgs = args.trim();
|
||||
|
||||
// Show current name if no args
|
||||
if (!trimmedArgs) {
|
||||
const current = pi.getSessionName();
|
||||
ctx.ui.notify(
|
||||
current ? `Session: ${current}` : "No session name set",
|
||||
"info",
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Show current name if no args
|
||||
if (!trimmedArgs) {
|
||||
const current = pi.getSessionName();
|
||||
ctx.ui.notify(
|
||||
current ? `Session: ${current}` : "No session name set",
|
||||
"info",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-generate name using AI
|
||||
if (trimmedArgs === "--auto" || trimmedArgs === "-a") {
|
||||
if (!ctx.hasUI) {
|
||||
ctx.ui.notify("Auto-naming requires interactive mode", "error");
|
||||
return;
|
||||
}
|
||||
// Auto-generate name using AI
|
||||
if (trimmedArgs === "--auto" || trimmedArgs === "-a") {
|
||||
if (!ctx.hasUI) {
|
||||
ctx.ui.notify("Auto-naming requires interactive mode", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Gather conversation context
|
||||
const branch = ctx.sessionManager.getBranch();
|
||||
const messages = branch
|
||||
.filter(
|
||||
(entry): entry is SessionEntry & { type: "message" } =>
|
||||
entry.type === "message",
|
||||
)
|
||||
.map((entry) => entry.message);
|
||||
// Gather conversation context
|
||||
const branch = ctx.sessionManager.getBranch();
|
||||
const messages = branch
|
||||
.filter(
|
||||
(entry): entry is SessionEntry & { type: "message" } =>
|
||||
entry.type === "message",
|
||||
)
|
||||
.map((entry) => entry.message);
|
||||
|
||||
if (messages.length === 0) {
|
||||
ctx.ui.notify("No conversation to analyze", "error");
|
||||
return;
|
||||
}
|
||||
if (messages.length === 0) {
|
||||
ctx.ui.notify("No conversation to analyze", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert to LLM format and serialize
|
||||
const llmMessages = convertToLlm(messages);
|
||||
const conversationText = serializeConversation(llmMessages);
|
||||
// Convert to LLM format and serialize
|
||||
const llmMessages = convertToLlm(messages);
|
||||
const conversationText = serializeConversation(llmMessages);
|
||||
|
||||
// Truncate if too long (keep costs low)
|
||||
const maxChars = 4000;
|
||||
const truncatedText =
|
||||
conversationText.length > maxChars
|
||||
? conversationText.slice(0, maxChars) + "\n..."
|
||||
: conversationText;
|
||||
// Truncate if too long (keep costs low)
|
||||
const maxChars = 4000;
|
||||
const truncatedText =
|
||||
conversationText.length > maxChars
|
||||
? conversationText.slice(0, maxChars) + "\n..."
|
||||
: conversationText;
|
||||
|
||||
// Generate name with loader UI
|
||||
const result = await ctx.ui.custom<string | null>(
|
||||
(tui, theme, _kb, done) => {
|
||||
const loader = new BorderedLoader(
|
||||
tui,
|
||||
theme,
|
||||
"Generating session name...",
|
||||
);
|
||||
loader.onAbort = () => done(null);
|
||||
// Generate name with loader UI
|
||||
const result = await ctx.ui.custom<string | null>(
|
||||
(tui, theme, _kb, done) => {
|
||||
const loader = new BorderedLoader(
|
||||
tui,
|
||||
theme,
|
||||
"Generating session name...",
|
||||
);
|
||||
loader.onAbort = () => done(null);
|
||||
|
||||
const doGenerate = async () => {
|
||||
const apiKey = await ctx.modelRegistry.getApiKey(AUTO_NAME_MODEL);
|
||||
const doGenerate = async () => {
|
||||
const apiKey = await ctx.modelRegistry.getApiKey(AUTO_NAME_MODEL);
|
||||
|
||||
const userMessage: Message = {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `## Conversation History\n\n${truncatedText}\n\nGenerate a concise session name for this conversation.`,
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const userMessage: Message = {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `## Conversation History\n\n${truncatedText}\n\nGenerate a concise session name for this conversation.`,
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const response = await complete(
|
||||
AUTO_NAME_MODEL,
|
||||
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
|
||||
{ apiKey, signal: loader.signal },
|
||||
);
|
||||
const response = await complete(
|
||||
AUTO_NAME_MODEL,
|
||||
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
|
||||
{ apiKey, signal: loader.signal },
|
||||
);
|
||||
|
||||
if (response.stopReason === "aborted") {
|
||||
return null;
|
||||
}
|
||||
if (response.stopReason === "aborted") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const name = response.content
|
||||
.filter(
|
||||
(c): c is { type: "text"; text: string } => c.type === "text",
|
||||
)
|
||||
.map((c) => c.text.trim())
|
||||
.join(" ")
|
||||
.replace(/^[\"']|[\"']$/g, ""); // Remove surrounding quotes
|
||||
const name = response.content
|
||||
.filter(
|
||||
(c): c is { type: "text"; text: string } => c.type === "text",
|
||||
)
|
||||
.map((c) => c.text.trim())
|
||||
.join(" ")
|
||||
.replace(/^[\"']|[\"']$/g, ""); // Remove surrounding quotes
|
||||
|
||||
return name;
|
||||
};
|
||||
return name;
|
||||
};
|
||||
|
||||
doGenerate()
|
||||
.then(done)
|
||||
.catch((err) => {
|
||||
console.error("Auto-naming failed:", err);
|
||||
done(null);
|
||||
});
|
||||
doGenerate()
|
||||
.then(done)
|
||||
.catch((err) => {
|
||||
console.error("Auto-naming failed:", err);
|
||||
done(null);
|
||||
});
|
||||
|
||||
return loader;
|
||||
},
|
||||
);
|
||||
return loader;
|
||||
},
|
||||
);
|
||||
|
||||
if (result === null) {
|
||||
ctx.ui.notify("Auto-naming cancelled", "info");
|
||||
return;
|
||||
}
|
||||
if (result === null) {
|
||||
ctx.ui.notify("Auto-naming cancelled", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up the generated name
|
||||
const cleanName = result
|
||||
.replace(/\n/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 50); // Max 50 chars
|
||||
// Clean up the generated name
|
||||
const cleanName = result
|
||||
.replace(/\n/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 50); // Max 50 chars
|
||||
|
||||
if (!cleanName) {
|
||||
ctx.ui.notify("Failed to generate name", "error");
|
||||
return;
|
||||
}
|
||||
if (!cleanName) {
|
||||
ctx.ui.notify("Failed to generate name", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
pi.setSessionName(cleanName);
|
||||
ctx.ui.notify(`Session auto-named: ${cleanName}`, "info");
|
||||
return;
|
||||
}
|
||||
pi.setSessionName(cleanName);
|
||||
ctx.ui.notify(`Session auto-named: ${cleanName}`, "info");
|
||||
return;
|
||||
}
|
||||
|
||||
// Manual naming
|
||||
pi.setSessionName(trimmedArgs);
|
||||
ctx.ui.notify(`Session named: ${trimmedArgs}`, "info");
|
||||
},
|
||||
});
|
||||
// Manual naming
|
||||
pi.setSessionName(trimmedArgs);
|
||||
ctx.ui.notify(`Session named: ${trimmedArgs}`, "info");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,622 @@
|
||||
import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
|
||||
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
||||
import * as fs from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
type ProviderName = "anthropic" | "codex" | "gemini" | "opencode-go";
|
||||
|
||||
interface RateWindow {
|
||||
label: string;
|
||||
usedPercent: number;
|
||||
resetDescription?: string;
|
||||
resetAt?: string;
|
||||
}
|
||||
|
||||
interface UsageSnapshot {
|
||||
provider: ProviderName;
|
||||
displayName: string;
|
||||
windows: RateWindow[];
|
||||
error?: string;
|
||||
fetchedAt: number;
|
||||
lastSuccessAt?: number;
|
||||
fromCache?: boolean;
|
||||
}
|
||||
|
||||
interface ProviderCache {
|
||||
usage?: UsageSnapshot;
|
||||
lastSuccessAt?: number;
|
||||
}
|
||||
|
||||
interface CodexRateWindow {
|
||||
reset_at?: number;
|
||||
limit_window_seconds?: number;
|
||||
used_percent?: number;
|
||||
}
|
||||
|
||||
interface CodexRateLimit {
|
||||
primary_window?: CodexRateWindow;
|
||||
secondary_window?: CodexRateWindow;
|
||||
}
|
||||
|
||||
interface PiAuthShape {
|
||||
anthropic?: { access?: string };
|
||||
"google-gemini-cli"?: { access?: string };
|
||||
"openai-codex"?: { access?: string; accountId?: string };
|
||||
}
|
||||
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const REFRESH_MS = 2 * 60 * 1000;
|
||||
const PROVIDER_ORDER: ProviderName[] = ["anthropic", "codex", "gemini", "opencode-go"];
|
||||
const SHORTCUT_TOGGLE = "ctrl+alt+b";
|
||||
const showToggleState = async (ctx: ExtensionContext, next: boolean, refresh: () => Promise<void>) => {
|
||||
if (!next) {
|
||||
ctx.ui.setWidget("sub-bar-local", undefined);
|
||||
ctx.ui.notify("sub bar hidden", "info");
|
||||
return;
|
||||
}
|
||||
ctx.ui.notify("sub bar shown", "info");
|
||||
await refresh();
|
||||
};
|
||||
|
||||
const OPENCODE_CONFIG_FILE = join(homedir(), ".config", "opencode", "opencode-go-usage.json");
|
||||
const PI_AUTH_FILE = join(homedir(), ".pi", "agent", "auth.json");
|
||||
|
||||
function readJsonFile<T>(path: string): T | undefined {
|
||||
try {
|
||||
if (!fs.existsSync(path)) return undefined;
|
||||
const content = fs.readFileSync(path, "utf-8");
|
||||
return JSON.parse(content) as T;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function clampPercent(value: number | undefined): number {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) return 0;
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
|
||||
function formatDuration(seconds?: number): string | undefined {
|
||||
if (typeof seconds !== "number" || seconds <= 0) return undefined;
|
||||
if (seconds < 60) return `${Math.max(1, Math.round(seconds))}s`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
|
||||
if (seconds < 86400) {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
||||
}
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
return h > 0 ? `${d}d ${h}h` : `${d}d`;
|
||||
}
|
||||
|
||||
function formatResetDate(resetIso?: string): string | undefined {
|
||||
if (!resetIso) return undefined;
|
||||
const resetDate = new Date(resetIso);
|
||||
if (Number.isNaN(resetDate.getTime())) return undefined;
|
||||
const diffSec = Math.max(0, Math.floor((resetDate.getTime() - Date.now()) / 1000));
|
||||
return formatDuration(diffSec);
|
||||
}
|
||||
|
||||
function getErrorAge(lastSuccessAt?: number): string | undefined {
|
||||
if (!lastSuccessAt) return undefined;
|
||||
const diffSec = Math.floor((Date.now() - lastSuccessAt) / 1000);
|
||||
return formatDuration(diffSec);
|
||||
}
|
||||
|
||||
function getPiAuth(): PiAuthShape {
|
||||
return readJsonFile<PiAuthShape>(PI_AUTH_FILE) ?? {};
|
||||
}
|
||||
|
||||
function loadAnthropicToken(): string | undefined {
|
||||
const env = process.env.ANTHROPIC_OAUTH_TOKEN?.trim();
|
||||
if (env) return env;
|
||||
return getPiAuth().anthropic?.access;
|
||||
}
|
||||
|
||||
function loadGeminiToken(): string | undefined {
|
||||
const env = (
|
||||
process.env.GOOGLE_GEMINI_CLI_OAUTH_TOKEN ||
|
||||
process.env.GOOGLE_GEMINI_CLI_ACCESS_TOKEN ||
|
||||
process.env.GEMINI_OAUTH_TOKEN
|
||||
)?.trim();
|
||||
if (env) return env;
|
||||
return getPiAuth()["google-gemini-cli"]?.access;
|
||||
}
|
||||
|
||||
function loadCodexCredentials(): { accessToken?: string; accountId?: string } {
|
||||
const envToken = (
|
||||
process.env.OPENAI_CODEX_OAUTH_TOKEN ||
|
||||
process.env.OPENAI_CODEX_ACCESS_TOKEN ||
|
||||
process.env.CODEX_OAUTH_TOKEN ||
|
||||
process.env.CODEX_ACCESS_TOKEN
|
||||
)?.trim();
|
||||
const envAccountId = (process.env.OPENAI_CODEX_ACCOUNT_ID || process.env.CHATGPT_ACCOUNT_ID)?.trim();
|
||||
if (envToken) {
|
||||
return { accessToken: envToken, accountId: envAccountId || undefined };
|
||||
}
|
||||
const auth = getPiAuth()["openai-codex"];
|
||||
if (!auth?.access) return {};
|
||||
return { accessToken: auth.access, accountId: auth.accountId };
|
||||
}
|
||||
|
||||
function loadOpenCodeGoCredentials(): { workspaceId?: string; authCookie?: string } {
|
||||
const envWorkspaceId = process.env.OPENCODE_GO_WORKSPACE_ID?.trim();
|
||||
const envAuthCookie = process.env.OPENCODE_GO_AUTH_COOKIE?.trim();
|
||||
if (envWorkspaceId && envAuthCookie) {
|
||||
return { workspaceId: envWorkspaceId, authCookie: envAuthCookie };
|
||||
}
|
||||
const config = readJsonFile<{ workspaceId?: string; authCookie?: string }>(OPENCODE_CONFIG_FILE);
|
||||
return {
|
||||
workspaceId: config?.workspaceId?.trim(),
|
||||
authCookie: config?.authCookie?.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function modelToProvider(modelProvider?: string): ProviderName | undefined {
|
||||
const id = (modelProvider ?? "").toLowerCase();
|
||||
if (id.includes("opencode-go")) return "opencode-go";
|
||||
if (id.includes("anthropic")) return "anthropic";
|
||||
if (id.includes("gemini") || id.includes("google")) return "gemini";
|
||||
if (id.includes("codex") || id.includes("openai")) return "codex";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function pushCodexWindow(windows: RateWindow[], label: string, window?: CodexRateWindow): void {
|
||||
if (!window) return;
|
||||
const resetIso = typeof window.reset_at === "number" ? new Date(window.reset_at * 1000).toISOString() : undefined;
|
||||
windows.push({
|
||||
label,
|
||||
usedPercent: clampPercent(window.used_percent),
|
||||
resetAt: resetIso,
|
||||
});
|
||||
}
|
||||
|
||||
function barForPercent(theme: Theme, usedPercent: number, width = 8): string {
|
||||
const safeWidth = Math.max(1, width);
|
||||
const filled = Math.round((clampPercent(usedPercent) / 100) * safeWidth);
|
||||
const empty = Math.max(0, safeWidth - filled);
|
||||
const color = usedPercent >= 85 ? "error" : usedPercent >= 60 ? "warning" : "success";
|
||||
return `${theme.fg(color, "─".repeat(filled))}${theme.fg("dim", "─".repeat(empty))}`;
|
||||
}
|
||||
|
||||
function padToWidth(text: string, width: number): string {
|
||||
const w = visibleWidth(text);
|
||||
if (w >= width) return truncateToWidth(text, width);
|
||||
return text + " ".repeat(width - w);
|
||||
}
|
||||
|
||||
function buildColumnWidths(totalWidth: number, columns: number): number[] {
|
||||
if (columns <= 0) return [];
|
||||
const base = Math.max(1, Math.floor(totalWidth / columns));
|
||||
let remainder = Math.max(0, totalWidth - base * columns);
|
||||
const widths: number[] = [];
|
||||
for (let i = 0; i < columns; i++) {
|
||||
const extra = remainder > 0 ? 1 : 0;
|
||||
widths.push(base + extra);
|
||||
if (remainder > 0) remainder--;
|
||||
}
|
||||
return widths;
|
||||
}
|
||||
|
||||
function formatUsageTwoLines(theme: Theme, usage: UsageSnapshot, width: number): { top: string; bottom?: string } {
|
||||
const provider = theme.bold(theme.fg("accent", usage.displayName));
|
||||
if (usage.error && usage.windows.length === 0) {
|
||||
const age = getErrorAge(usage.lastSuccessAt);
|
||||
const stale = age ? ` (stale ${age})` : "";
|
||||
return { top: truncateToWidth(`${provider} ${theme.fg("warning", `⚠ ${usage.error}${stale}`)}`, width) };
|
||||
}
|
||||
if (usage.windows.length === 0) {
|
||||
return { top: truncateToWidth(provider, width) };
|
||||
}
|
||||
|
||||
const prefix = `${provider} ${theme.fg("dim", "•")} `;
|
||||
const prefixWidth = Math.min(visibleWidth(prefix), Math.max(0, width - 1));
|
||||
const contentWidth = Math.max(1, width - prefixWidth);
|
||||
const gap = " ";
|
||||
const gapWidth = visibleWidth(gap);
|
||||
const windowsCount = usage.windows.length;
|
||||
const totalGapWidth = Math.max(0, (windowsCount - 1) * gapWidth);
|
||||
const columnsArea = Math.max(1, contentWidth - totalGapWidth);
|
||||
const colWidths = buildColumnWidths(columnsArea, windowsCount);
|
||||
|
||||
const topCols = usage.windows.map((window, index) => {
|
||||
const colWidth = colWidths[index] ?? 1;
|
||||
const pct = clampPercent(window.usedPercent);
|
||||
const resetRaw = formatResetDate(window.resetAt) ?? window.resetDescription ?? "";
|
||||
const left = `${window.label} ${pct}%`;
|
||||
const right = resetRaw;
|
||||
if (!right) return padToWidth(theme.fg("text", left), colWidth);
|
||||
const leftWidth = visibleWidth(left);
|
||||
const rightWidth = visibleWidth(right);
|
||||
if (leftWidth + 1 + rightWidth <= colWidth) {
|
||||
const spaces = " ".repeat(colWidth - leftWidth - rightWidth);
|
||||
return `${theme.fg("text", left)}${spaces}${theme.fg("dim", right)}`;
|
||||
}
|
||||
const compact = `${left} ${right}`;
|
||||
return padToWidth(theme.fg("text", truncateToWidth(compact, colWidth)), colWidth);
|
||||
});
|
||||
|
||||
const bottomCols = usage.windows.map((window, index) => {
|
||||
const colWidth = colWidths[index] ?? 1;
|
||||
const pct = clampPercent(window.usedPercent);
|
||||
return barForPercent(theme, pct, colWidth);
|
||||
});
|
||||
|
||||
const top = prefix + topCols.join(gap);
|
||||
const bottomPrefix = " ".repeat(prefixWidth);
|
||||
const bottom = bottomPrefix + bottomCols.join(gap);
|
||||
|
||||
return {
|
||||
top: padToWidth(top, width),
|
||||
bottom: padToWidth(bottom, width),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchAnthropicUsage(): Promise<UsageSnapshot> {
|
||||
const token = loadAnthropicToken();
|
||||
if (!token) throw new Error("missing anthropic oauth token");
|
||||
|
||||
const response = await fetch("https://api.anthropic.com/api/oauth/usage", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
},
|
||||
});
|
||||
if (!response.ok) throw new Error(`anthropic http ${response.status}`);
|
||||
|
||||
const data = (await response.json()) as {
|
||||
five_hour?: { utilization?: number; resets_at?: string };
|
||||
seven_day?: { utilization?: number; resets_at?: string };
|
||||
extra_usage?: { utilization?: number; used_credits?: number; monthly_limit?: number; is_enabled?: boolean };
|
||||
};
|
||||
|
||||
const windows: RateWindow[] = [];
|
||||
if (data.five_hour?.utilization !== undefined) {
|
||||
windows.push({ label: "5h", usedPercent: clampPercent(data.five_hour.utilization), resetAt: data.five_hour.resets_at });
|
||||
}
|
||||
if (data.seven_day?.utilization !== undefined) {
|
||||
windows.push({ label: "Week", usedPercent: clampPercent(data.seven_day.utilization), resetAt: data.seven_day.resets_at });
|
||||
}
|
||||
if (data.extra_usage?.is_enabled && data.extra_usage.utilization !== undefined) {
|
||||
const used = data.extra_usage.used_credits ?? 0;
|
||||
const limit = data.extra_usage.monthly_limit;
|
||||
const label = limit && limit > 0 ? `Extra ${used}/${limit}` : `Extra ${used}`;
|
||||
windows.push({ label, usedPercent: clampPercent(data.extra_usage.utilization) });
|
||||
}
|
||||
if (windows.length === 0) throw new Error("no anthropic usage windows");
|
||||
|
||||
const now = Date.now();
|
||||
return {
|
||||
provider: "anthropic",
|
||||
displayName: "Claude Plan",
|
||||
windows,
|
||||
fetchedAt: now,
|
||||
lastSuccessAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchCodexUsage(): Promise<UsageSnapshot> {
|
||||
const { accessToken, accountId } = loadCodexCredentials();
|
||||
if (!accessToken) throw new Error("missing codex oauth token");
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: "application/json",
|
||||
};
|
||||
if (accountId) headers["ChatGPT-Account-Id"] = accountId;
|
||||
|
||||
const response = await fetch("https://chatgpt.com/backend-api/wham/usage", { headers });
|
||||
if (!response.ok) throw new Error(`codex http ${response.status}`);
|
||||
|
||||
const data = (await response.json()) as {
|
||||
rate_limit?: CodexRateLimit;
|
||||
};
|
||||
|
||||
const windows: RateWindow[] = [];
|
||||
pushCodexWindow(windows, "3h", data.rate_limit?.primary_window);
|
||||
pushCodexWindow(windows, "Day", data.rate_limit?.secondary_window);
|
||||
if (windows.length === 0) throw new Error("no codex usage windows");
|
||||
|
||||
const now = Date.now();
|
||||
return {
|
||||
provider: "codex",
|
||||
displayName: "Codex Plan",
|
||||
windows,
|
||||
fetchedAt: now,
|
||||
lastSuccessAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchGeminiUsage(): Promise<UsageSnapshot> {
|
||||
const token = loadGeminiToken();
|
||||
if (!token) throw new Error("missing gemini oauth token");
|
||||
|
||||
const response = await fetch("https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: "{}",
|
||||
});
|
||||
if (!response.ok) throw new Error(`gemini http ${response.status}`);
|
||||
|
||||
const data = (await response.json()) as {
|
||||
buckets?: Array<{ modelId?: string; remainingFraction?: number }>;
|
||||
};
|
||||
|
||||
let minPro = 1;
|
||||
let minFlash = 1;
|
||||
let hasPro = false;
|
||||
let hasFlash = false;
|
||||
for (const bucket of data.buckets ?? []) {
|
||||
const id = bucket.modelId?.toLowerCase() ?? "";
|
||||
const remaining = typeof bucket.remainingFraction === "number" ? bucket.remainingFraction : 1;
|
||||
if (id.includes("pro")) {
|
||||
hasPro = true;
|
||||
minPro = Math.min(minPro, remaining);
|
||||
}
|
||||
if (id.includes("flash")) {
|
||||
hasFlash = true;
|
||||
minFlash = Math.min(minFlash, remaining);
|
||||
}
|
||||
}
|
||||
|
||||
const windows: RateWindow[] = [];
|
||||
if (hasPro) windows.push({ label: "Pro", usedPercent: clampPercent((1 - minPro) * 100) });
|
||||
if (hasFlash) windows.push({ label: "Flash", usedPercent: clampPercent((1 - minFlash) * 100) });
|
||||
if (windows.length === 0) throw new Error("no gemini usage windows");
|
||||
|
||||
const now = Date.now();
|
||||
return {
|
||||
provider: "gemini",
|
||||
displayName: "Gemini Plan",
|
||||
windows,
|
||||
fetchedAt: now,
|
||||
lastSuccessAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
function parseInlineObject(raw: string): Record<string, unknown> {
|
||||
const normalized = raw.replace(/([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)(\s*:)/g, "$1\"$2\"$3");
|
||||
return JSON.parse(normalized) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function fetchOpenCodeGoUsage(): Promise<UsageSnapshot> {
|
||||
const { workspaceId, authCookie } = loadOpenCodeGoCredentials();
|
||||
if (!workspaceId || !authCookie) throw new Error(`missing ${OPENCODE_CONFIG_FILE} credentials`);
|
||||
if (!/^wrk_[a-zA-Z0-9]+$/.test(workspaceId)) throw new Error("invalid workspace id format");
|
||||
|
||||
const url = `https://opencode.ai/workspace/${encodeURIComponent(workspaceId)}/go`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
Cookie: `auth=${authCookie}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) throw new Error(`opencode-go http ${response.status}`);
|
||||
|
||||
const html = await response.text();
|
||||
const patterns: Record<string, RegExp> = {
|
||||
Rolling: /rollingUsage:\$R\[\d+\]=(\{[^}]+\})/,
|
||||
Weekly: /weeklyUsage:\$R\[\d+\]=(\{[^}]+\})/,
|
||||
Monthly: /monthlyUsage:\$R\[\d+\]=(\{[^}]+\})/,
|
||||
};
|
||||
|
||||
const windows: RateWindow[] = [];
|
||||
for (const [label, pattern] of Object.entries(patterns)) {
|
||||
const match = html.match(pattern);
|
||||
if (!match?.[1]) continue;
|
||||
try {
|
||||
const parsed = parseInlineObject(match[1]);
|
||||
const usagePercent = clampPercent(Number(parsed.usagePercent));
|
||||
const resetSec = Number(parsed.resetInSec);
|
||||
const resetDescription = formatDuration(Number.isFinite(resetSec) ? resetSec : undefined);
|
||||
const resetAt = Number.isFinite(resetSec) && resetSec > 0
|
||||
? new Date(Date.now() + resetSec * 1000).toISOString()
|
||||
: undefined;
|
||||
windows.push({
|
||||
label,
|
||||
usedPercent: usagePercent,
|
||||
resetDescription,
|
||||
resetAt,
|
||||
});
|
||||
} catch {
|
||||
// Ignore malformed chunks and keep parsing others
|
||||
}
|
||||
}
|
||||
|
||||
if (windows.length === 0) throw new Error("could not parse opencode-go usage from page");
|
||||
|
||||
const now = Date.now();
|
||||
return {
|
||||
provider: "opencode-go",
|
||||
displayName: "OpenCode Go",
|
||||
windows,
|
||||
fetchedAt: now,
|
||||
lastSuccessAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchUsage(provider: ProviderName): Promise<UsageSnapshot> {
|
||||
switch (provider) {
|
||||
case "anthropic":
|
||||
return fetchAnthropicUsage();
|
||||
case "codex":
|
||||
return fetchCodexUsage();
|
||||
case "gemini":
|
||||
return fetchGeminiUsage();
|
||||
case "opencode-go":
|
||||
return fetchOpenCodeGoUsage();
|
||||
}
|
||||
}
|
||||
|
||||
export default function createSubBarLocal(pi: ExtensionAPI) {
|
||||
let lastCtx: ExtensionContext | undefined;
|
||||
let activeProvider: ProviderName | "auto" = "auto";
|
||||
let widgetEnabled = true;
|
||||
let refreshTimer: NodeJS.Timeout | undefined;
|
||||
const cache: Partial<Record<ProviderName, ProviderCache>> = {};
|
||||
|
||||
function getSelectedProvider(ctx?: ExtensionContext): ProviderName | undefined {
|
||||
if (activeProvider !== "auto") return activeProvider;
|
||||
return modelToProvider(ctx?.model?.provider);
|
||||
}
|
||||
|
||||
function render(ctx: ExtensionContext): void {
|
||||
if (!ctx.hasUI) return;
|
||||
if (!widgetEnabled) {
|
||||
ctx.ui.setWidget("sub-bar-local", undefined);
|
||||
return;
|
||||
}
|
||||
const provider = getSelectedProvider(ctx);
|
||||
if (!provider) {
|
||||
ctx.ui.setWidget("sub-bar-local", undefined);
|
||||
return;
|
||||
}
|
||||
const snapshot = cache[provider]?.usage;
|
||||
|
||||
const setWidgetWithPlacement = (ctx.ui as unknown as { setWidget: (...args: unknown[]) => void }).setWidget;
|
||||
setWidgetWithPlacement(
|
||||
"sub-bar-local",
|
||||
(_tui: unknown, theme: Theme) => ({
|
||||
render(width: number) {
|
||||
const safeWidth = Math.max(1, width);
|
||||
const topDivider = theme.fg("dim", "─".repeat(safeWidth));
|
||||
if (!snapshot) {
|
||||
const loading = truncateToWidth(theme.fg("dim", `sub bar loading ${provider} usage...`), safeWidth);
|
||||
return [topDivider, padToWidth(loading, safeWidth)];
|
||||
}
|
||||
const lines = formatUsageTwoLines(theme, snapshot, safeWidth);
|
||||
const output = [topDivider, lines.top];
|
||||
if (lines.bottom) output.push(lines.bottom);
|
||||
return output;
|
||||
},
|
||||
invalidate() {},
|
||||
}),
|
||||
{ placement: "aboveEditor" },
|
||||
);
|
||||
}
|
||||
|
||||
async function refreshCurrent(ctx: ExtensionContext, force = false): Promise<void> {
|
||||
const provider = getSelectedProvider(ctx);
|
||||
if (!provider) {
|
||||
ctx.ui.setWidget("sub-bar-local", undefined);
|
||||
return;
|
||||
}
|
||||
const cached = cache[provider]?.usage;
|
||||
if (!force && cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
||||
render(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const fresh = await fetchUsage(provider);
|
||||
cache[provider] = {
|
||||
usage: {
|
||||
...fresh,
|
||||
fromCache: false,
|
||||
lastSuccessAt: Date.now(),
|
||||
},
|
||||
lastSuccessAt: Date.now(),
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "fetch failed";
|
||||
const fallback = cache[provider]?.usage;
|
||||
cache[provider] = {
|
||||
usage: {
|
||||
provider,
|
||||
displayName: fallback?.displayName ?? provider,
|
||||
windows: fallback?.windows ?? [],
|
||||
error: message,
|
||||
fetchedAt: Date.now(),
|
||||
lastSuccessAt: cache[provider]?.lastSuccessAt,
|
||||
fromCache: Boolean(fallback),
|
||||
},
|
||||
lastSuccessAt: cache[provider]?.lastSuccessAt,
|
||||
};
|
||||
}
|
||||
|
||||
render(ctx);
|
||||
}
|
||||
|
||||
function startRefreshLoop(ctx: ExtensionContext): void {
|
||||
if (refreshTimer) clearInterval(refreshTimer);
|
||||
refreshTimer = setInterval(() => {
|
||||
if (!lastCtx) return;
|
||||
void refreshCurrent(lastCtx);
|
||||
}, REFRESH_MS);
|
||||
refreshTimer.unref?.();
|
||||
void refreshCurrent(ctx, true);
|
||||
}
|
||||
|
||||
pi.registerCommand("sub:refresh", {
|
||||
description: "Refresh sub bar usage now",
|
||||
handler: async (_args, ctx) => {
|
||||
await refreshCurrent(ctx, true);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerCommand("sub:provider", {
|
||||
description: "Set sub bar provider (auto|anthropic|codex|gemini|opencode-go)",
|
||||
handler: async (args, ctx) => {
|
||||
const raw = String(args ?? "").trim().toLowerCase();
|
||||
if (!raw || raw === "auto") {
|
||||
activeProvider = "auto";
|
||||
ctx.ui.notify("sub bar provider: auto", "info");
|
||||
await refreshCurrent(ctx, true);
|
||||
return;
|
||||
}
|
||||
if (PROVIDER_ORDER.includes(raw as ProviderName)) {
|
||||
activeProvider = raw as ProviderName;
|
||||
ctx.ui.notify(`sub bar provider: ${activeProvider}`, "info");
|
||||
await refreshCurrent(ctx, true);
|
||||
return;
|
||||
}
|
||||
ctx.ui.notify("invalid provider. use: auto|anthropic|codex|gemini|opencode-go", "warning");
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerCommand("sub:toggle", {
|
||||
description: "Toggle sub bar on/off",
|
||||
handler: async (_args, ctx) => {
|
||||
widgetEnabled = !widgetEnabled;
|
||||
await showToggleState(ctx, widgetEnabled, async () => refreshCurrent(ctx, true));
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerShortcut(SHORTCUT_TOGGLE as import("@mariozechner/pi-tui").KeyId, {
|
||||
description: "Toggle sub bar on/off",
|
||||
handler: async (ctx) => {
|
||||
widgetEnabled = !widgetEnabled;
|
||||
await showToggleState(ctx, widgetEnabled, async () => refreshCurrent(ctx, true));
|
||||
},
|
||||
});
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
lastCtx = ctx;
|
||||
if (!ctx.hasUI) return;
|
||||
render(ctx);
|
||||
startRefreshLoop(ctx);
|
||||
});
|
||||
|
||||
pi.on("model_select", async (_event, ctx) => {
|
||||
lastCtx = ctx;
|
||||
if (!ctx.hasUI) return;
|
||||
render(ctx);
|
||||
if (activeProvider === "auto") {
|
||||
await refreshCurrent(ctx, true);
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", async () => {
|
||||
lastCtx = undefined;
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
refreshTimer = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user