Files
oh-my-pi/packages/coding-agent/src/modes/controllers/live-command-controller.ts
T
can1357 17735c7786 feat: added x-oai-attestation header for ChatGPT-OAuth Codex requests
- Add `CodexAttestationProvider` hook and `getCodexAttestationHeader` resolver that gates on ChatGPT-OAuth credentials.
- Integrate attestation into WebSocket transport, SSE event stream, and OpenAI compaction requests.
- Wire `setCodexAttestationProvider(generateCodexAttestation)` in model-registry init for OAuth sessions.
2026-07-24 09:17:00 +02:00

256 lines
8.2 KiB
TypeScript

import type { AssistantMessage } from "@oh-my-pi/pi-ai";
import { logger } from "@oh-my-pi/pi-utils";
import { LiveSessionController, type LiveTranscript } from "../../live/controller";
import { LIVE_MODEL } from "../../live/protocol";
import { LiveVisualizer } from "../../live/visualizer";
import { vocalizer } from "../../tts/vocalizer";
import type { AssistantMessageComponent } from "../components/assistant-message";
import type { CustomEditor } from "../components/custom-editor";
import { theme } from "../theme/theme";
import type { InteractiveModeContext } from "../types";
import { createAssistantMessageComponent } from "../utils/interactive-context-helpers";
const ANIMATION_INTERVAL_MS = 80;
const LIVE_MESSAGE_USAGE: AssistantMessage["usage"] = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
function errorFrom(cause: unknown): Error {
return cause instanceof Error ? cause : new Error(String(cause));
}
/** Owns the editor-replacing visualizer and realtime session lifecycle for `/live`. */
export class LiveCommandController {
readonly #ctx: InteractiveModeContext;
#session: LiveSessionController | undefined;
#settling: Promise<void> | undefined;
#visualizer: LiveVisualizer | undefined;
#detachedEditor: CustomEditor | undefined;
#animationInterval: NodeJS.Timeout | undefined;
#previousShowHardwareCursor: boolean | undefined;
#previousUseTerminalCursor: boolean | undefined;
#resumeVocalizer: (() => void) | undefined;
#assistantTranscriptComponent: AssistantMessageComponent | undefined;
#assistantTranscriptTurn = 0;
#assistantTranscriptStartedAt = 0;
constructor(ctx: InteractiveModeContext) {
this.#ctx = ctx;
}
/** Whether a live session is connected, connecting, or closing. */
get active(): boolean {
return this.#session !== undefined || this.#settling !== undefined;
}
/** Start live mode, or stop the currently active session. */
async handleCommand(): Promise<void> {
if (this.#session) {
await this.stop();
return;
}
if (this.#settling) await this.#settling;
await this.#start();
}
/** Stop the active live session and restore the editor. */
async stop(): Promise<void> {
const session = this.#session;
if (!session) {
if (this.#settling) await this.#settling;
return;
}
try {
await session.stop();
} catch (cause) {
this.#finish(session, errorFrom(cause));
} finally {
this.#finish(session);
}
}
/** Release UI resources during synchronous InteractiveMode teardown. */
dispose(): void {
const session = this.#session;
if (session) {
this.#finish(session);
void session.stop().catch(cause => {
logger.debug("Live session teardown failed", { error: errorFrom(cause).message });
});
} else {
this.#restoreEditor();
}
}
async #start(): Promise<void> {
this.#assistantTranscriptTurn = 0;
this.#assistantTranscriptStartedAt = 0;
const visualizer = new LiveVisualizer({
onStop: () => {
void this.stop().catch(cause => this.#ctx.showError(errorFrom(cause).message));
},
onToggleMute: () => this.#session?.toggleMute(),
});
this.#mountVisualizer(visualizer);
let session: LiveSessionController;
session = new LiveSessionController({
session: this.#ctx.session,
extractAssistantText: message => this.#ctx.extractAssistantText(message),
callbacks: {
onPhase: phase => {
if (this.#visualizer !== visualizer) return;
visualizer.setPhase(phase);
this.#ctx.ui.requestComponentRender(visualizer);
},
onLevels: input => {
if (this.#visualizer !== visualizer) return;
visualizer.setInputLevel(input);
this.#ctx.ui.requestComponentRender(visualizer);
},
onTranscript: transcript => {
if (this.#visualizer !== visualizer) return;
if (!transcript) {
visualizer.clearTranscript();
this.#ctx.ui.requestComponentRender(visualizer);
} else if (transcript.role === "user") {
visualizer.setTranscript(transcript.text);
this.#ctx.ui.requestComponentRender(visualizer);
} else {
this.#presentAssistantTranscript(transcript);
}
},
onTerminal: error => this.#finish(session, error),
},
});
this.#session = session;
try {
await session.start();
} catch (cause) {
if (this.#session === session) {
await session.stop();
this.#finish(session, errorFrom(cause));
}
}
}
#presentAssistantTranscript(transcript: LiveTranscript): void {
if (
transcript.turn < this.#assistantTranscriptTurn ||
(transcript.turn === this.#assistantTranscriptTurn && !this.#assistantTranscriptComponent)
) {
return;
}
if (transcript.turn > this.#assistantTranscriptTurn) {
this.#finalizeAssistantTranscript();
this.#assistantTranscriptTurn = transcript.turn;
}
let component = this.#assistantTranscriptComponent;
if (!component) {
component = createAssistantMessageComponent(this.#ctx);
component.setTextColorTransform(text => theme.fg("borderAccent", text));
this.#assistantTranscriptComponent = component;
this.#assistantTranscriptStartedAt = Date.now();
}
const message: AssistantMessage = {
role: "assistant",
content: [{ type: "text", text: transcript.text }],
api: "openai-codex-responses",
provider: "openai-codex",
model: LIVE_MODEL,
usage: { ...LIVE_MESSAGE_USAGE },
stopReason: "stop",
timestamp: this.#assistantTranscriptStartedAt,
};
component.updateContent(message, { transient: !transcript.final });
if (transcript.final) {
component.markTranscriptBlockFinalized();
this.#assistantTranscriptComponent = undefined;
this.#assistantTranscriptStartedAt = 0;
}
if (!this.#ctx.chatContainer.children.includes(component)) {
this.#ctx.present(component);
} else {
this.#ctx.ui.requestComponentRender(component);
}
}
#finalizeAssistantTranscript(): void {
const component = this.#assistantTranscriptComponent;
if (!component) return;
component.markTranscriptBlockFinalized();
this.#assistantTranscriptComponent = undefined;
this.#assistantTranscriptStartedAt = 0;
this.#ctx.ui.requestComponentRender(component);
}
#mountVisualizer(visualizer: LiveVisualizer): void {
this.#visualizer = visualizer;
this.#detachedEditor = this.#ctx.editor;
this.#previousShowHardwareCursor = this.#ctx.ui.getShowHardwareCursor();
this.#previousUseTerminalCursor = this.#ctx.editor.getUseTerminalCursor();
this.#ctx.ui.setShowHardwareCursor(false);
this.#ctx.editor.setUseTerminalCursor(false);
this.#ctx.editorContainer.clear();
this.#ctx.editorContainer.addChild(visualizer);
this.#ctx.ui.setFocus(visualizer);
this.#resumeVocalizer = vocalizer.suspend();
let frame = 0;
this.#animationInterval = setInterval(() => {
if (this.#visualizer !== visualizer) return;
frame += 1;
visualizer.setFrame(frame);
this.#ctx.ui.requestComponentRender(visualizer);
}, ANIMATION_INTERVAL_MS);
this.#ctx.ui.requestRender();
}
#finish(session: LiveSessionController, error?: Error): void {
if (this.#session !== session) return;
this.#session = undefined;
this.#restoreEditor();
if (error) this.#ctx.showError(error.message);
const settling = session.stop().catch(cause => {
logger.debug("Live session cleanup failed", { error: errorFrom(cause).message });
});
this.#settling = settling;
void settling.finally(() => {
if (this.#settling === settling) this.#settling = undefined;
});
}
#restoreEditor(): void {
this.#finalizeAssistantTranscript();
if (this.#animationInterval) {
clearInterval(this.#animationInterval);
this.#animationInterval = undefined;
}
this.#resumeVocalizer?.();
this.#resumeVocalizer = undefined;
const editor = this.#detachedEditor;
this.#detachedEditor = undefined;
this.#visualizer = undefined;
if (!editor) return;
this.#ctx.editorContainer.clear();
this.#ctx.editorContainer.addChild(editor);
if (this.#previousShowHardwareCursor !== undefined) {
this.#ctx.ui.setShowHardwareCursor(this.#previousShowHardwareCursor);
}
if (this.#previousUseTerminalCursor !== undefined) {
editor.setUseTerminalCursor(this.#previousUseTerminalCursor);
}
this.#previousShowHardwareCursor = undefined;
this.#previousUseTerminalCursor = undefined;
this.#ctx.ui.setFocus(editor);
this.#ctx.ui.requestRender();
}
}