diff --git a/src/lib/context/manager.ts b/src/lib/context/manager.ts index 5aa4b128d..2add94c72 100644 --- a/src/lib/context/manager.ts +++ b/src/lib/context/manager.ts @@ -18,10 +18,9 @@ import { consola } from "consola" import type { EndpointType } from "~/lib/history/store" +import { recordAcceptedRequest, recordSettledRequest } from "~/lib/request-telemetry" import { state } from "~/lib/state" - import { notifyActiveRequestChanged } from "~/lib/ws" -import { recordAcceptedRequest, recordSettledRequest } from "~/lib/request-telemetry" import type { HistoryEntryData, RequestContext, RequestContextEventData, RequestState } from "./request" @@ -58,6 +57,9 @@ export interface RequestContextManager { /** Unsubscribe from context events */ off(event: "change", listener: (event: RequestContextEvent) => void): void + /** Abort all active requests (Esc key cancellation) */ + abortAll(): void + /** Start periodic cleanup of stale active contexts */ startReaper(): void @@ -267,6 +269,12 @@ export function createRequestContextManager(): RequestContextManager { listeners.delete(listener) }, + abortAll() { + for (const ctx of activeContexts.values()) { + ctx.abort() + } + }, + startReaper, stopReaper, _runReaperOnce: runReaperOnce, diff --git a/src/lib/context/request.ts b/src/lib/context/request.ts index 81d28540f..9d4878e6c 100644 --- a/src/lib/context/request.ts +++ b/src/lib/context/request.ts @@ -62,6 +62,8 @@ export function createRequestContext(opts: { const startTime = Date.now() const onEvent = opts.onEvent + const _abortController = new AbortController() + // Mutable internal state let _state: RequestState = "pending" let _sessionId = opts.sessionId @@ -135,6 +137,16 @@ export function createRequestContext(opts: { return _warningMessages }, + get abortSignal() { + return _abortController.signal + }, + + abort() { + if (settled) return + _abortController.abort() + ctx.fail(_originalRequest?.model ?? "unknown", new Error("Request cancelled by user")) + }, + setSessionId(sessionId: string | undefined) { _sessionId = sessionId }, diff --git a/src/lib/context/types.ts b/src/lib/context/types.ts index 3d47040dd..db5cf1e55 100644 --- a/src/lib/context/types.ts +++ b/src/lib/context/types.ts @@ -201,6 +201,11 @@ export interface RequestContext { readonly queueWaitMs: number readonly warningMessages: ReadonlyArray + /** AbortSignal that fires when abort() is called (server-side user cancellation) */ + readonly abortSignal: AbortSignal + /** Abort this request immediately and mark it as failed with "cancelled by user" */ + abort(): void + setSessionId(sessionId: string | undefined): void setOriginalRequest(req: OriginalRequest): void setPipelineInfo(info: PipelineInfo): void diff --git a/src/lib/tui/index.ts b/src/lib/tui/index.ts index b89c1aca9..530945901 100644 --- a/src/lib/tui/index.ts +++ b/src/lib/tui/index.ts @@ -1,5 +1,6 @@ /** TUI module exports */ +export { cleanupKeyboardHandler, initKeyboardHandler } from "./keyboard" export { tuiMiddleware } from "./middleware" export { tuiLogger } from "./tracker" export type { RequestOutcome } from "./tracker" diff --git a/src/lib/tui/keyboard.ts b/src/lib/tui/keyboard.ts new file mode 100644 index 000000000..81585052b --- /dev/null +++ b/src/lib/tui/keyboard.ts @@ -0,0 +1,47 @@ +/** Terminal keyboard handler — Esc cancels all active requests */ + +import consola from "consola" + +import { getRequestContextManager } from "~/lib/context/manager" + +let active = false + +function onKeypress(key: string): void { + if (key === "\x1b") { + // Escape — cancel all active requests + const manager = getRequestContextManager() + const requests = manager.getAll() + if (requests.length === 0) return + + const count = requests.length + consola.info(`[tui] Cancelling ${count} active request${count > 1 ? "s" : ""}`) + manager.abortAll() + } else if (key === "\x03") { + // Ctrl+C — raw mode intercepts it, re-emit as SIGINT + process.emit("SIGINT") + } +} + +/** Start listening for Esc key to cancel active requests. No-op if stdin is not a TTY. */ +export function initKeyboardHandler(): void { + if (!process.stdin.isTTY) return + if (active) return + active = true + + process.stdin.setRawMode(true) + process.stdin.resume() + process.stdin.setEncoding("utf8") + process.stdin.on("data", onKeypress) +} + +/** Stop keyboard handler and restore stdin to normal mode. */ +export function cleanupKeyboardHandler(): void { + if (!active) return + active = false + + process.stdin.removeListener("data", onKeypress) + if (process.stdin.isTTY) { + process.stdin.setRawMode(false) + } + process.stdin.pause() +} diff --git a/src/routes/chat-completions/handler.ts b/src/routes/chat-completions/handler.ts index 5453b14ce..6fef432ff 100644 --- a/src/routes/chat-completions/handler.ts +++ b/src/routes/chat-completions/handler.ts @@ -451,7 +451,7 @@ async function handleStreamingResponse(opts: StreamingOptions) { const iterator = response[Symbol.asyncIterator]() for (;;) { - const abortSignal = combineAbortSignals(getShutdownSignal(), clientAbortSignal) + const abortSignal = combineAbortSignals(getShutdownSignal(), clientAbortSignal, reqCtx.abortSignal) const result = await raceIteratorNext(iterator.next(), { idleTimeoutMs, abortSignal }) if (result === STREAM_ABORTED) break diff --git a/src/routes/messages/handler.ts b/src/routes/messages/handler.ts index 13a0bc0a5..f0a7c6a4b 100644 --- a/src/routes/messages/handler.ts +++ b/src/routes/messages/handler.ts @@ -48,7 +48,7 @@ import { createDeferredToolRetryStrategy } from "~/lib/request/strategies/deferr import { createNetworkRetryStrategy } from "~/lib/request/strategies/network-retry" import { createTokenRefreshStrategy } from "~/lib/request/strategies/token-refresh" import { state } from "~/lib/state" -import { StreamIdleTimeoutError } from "~/lib/stream" +import { StreamIdleTimeoutError, combineAbortSignals } from "~/lib/stream" import { processAnthropicSystem } from "~/lib/system-prompt" import { tuiLogger } from "~/lib/tui" @@ -134,7 +134,12 @@ export async function handleMessages(c: Context) { // ============================================================================ // Handle completion using direct Anthropic API (no translation needed) -async function handleDirectAnthropicCompletion(c: Context, anthropicPayload: MessagesPayload, reqCtx: RequestContext, preprocessInfo: PreprocessInfo) { +async function handleDirectAnthropicCompletion( + c: Context, + anthropicPayload: MessagesPayload, + reqCtx: RequestContext, + preprocessInfo: PreprocessInfo, +) { consola.debug("Using direct Anthropic API path for model:", anthropicPayload.model) // Find model for auto-truncate and usage adjustment @@ -192,7 +197,8 @@ async function handleDirectAnthropicCompletion(c: Context, anthropicPayload: Mes format: "anthropic-messages", }) }, - })), + }), + ), logPayloadSize: (p) => logPayloadSizeInfoAnthropic(p, selectedModel), } @@ -282,7 +288,7 @@ async function handleDirectAnthropicCompletion(c: Context, anthropicPayload: Mes response: response as AsyncIterable, anthropicPayload: effectivePayload, reqCtx, - clientAbortSignal: clientAbort.signal, + clientAbortSignal: combineAbortSignals(clientAbort.signal, reqCtx.abortSignal), }) }) } diff --git a/src/routes/responses/handler.ts b/src/routes/responses/handler.ts index 08da5d0ac..4c6ee5575 100644 --- a/src/routes/responses/handler.ts +++ b/src/routes/responses/handler.ts @@ -192,7 +192,7 @@ async function handleDirectResponses(opts: ResponsesHandlerOptions) { const iterator = (response as AsyncIterable)[Symbol.asyncIterator]() for (;;) { - const abortSignal = combineAbortSignals(getShutdownSignal(), clientAbort.signal) + const abortSignal = combineAbortSignals(getShutdownSignal(), clientAbort.signal, reqCtx.abortSignal) const result = await raceIteratorNext(iterator.next(), { idleTimeoutMs, abortSignal }) if (result === STREAM_ABORTED) break diff --git a/src/start.ts b/src/start.ts index c53c7766d..3e6a5d01a 100644 --- a/src/start.ts +++ b/src/start.ts @@ -24,7 +24,7 @@ import { startServer } from "./lib/serve" import { setServerInstance, setupShutdownHandlers, waitForShutdown } from "./lib/shutdown" import { setCliState, setServerStartTime, state } from "./lib/state" import { initTokenManagers } from "./lib/token" -import { initTuiLogger } from "./lib/tui" +import { initTuiLogger, initKeyboardHandler, cleanupKeyboardHandler } from "./lib/tui" import { createWebSocketAdapter, setConnectedDataFactory } from "./lib/ws" import { registerWsRoutes } from "./routes" import { normalizeExternalUiUrl } from "./routes/ui/route" @@ -274,6 +274,7 @@ export async function runServer(options: RunServerOptions): Promise { // so the handler has access to the server instance when closing. setServerInstance(serverInstance) setupShutdownHandlers() + initKeyboardHandler() // Inject the single shared WebSocket upgrade handler into Node.js HTTP server (no-op under Bun) if (wsAdapter.injectWebSocket && serverInstance.nodeServer) { @@ -286,6 +287,7 @@ export async function runServer(options: RunServerOptions): Promise { // process.exit(0) in main.ts (needed for one-shot commands). await waitForShutdown() } finally { + cleanupKeyboardHandler() stopModelRefreshLoop() } } diff --git a/tests/e2e-ui/vuetify-history.pw.ts b/tests/e2e-ui/vuetify-history.pw.ts index a5891159c..9e33c8b8f 100644 --- a/tests/e2e-ui/vuetify-history.pw.ts +++ b/tests/e2e-ui/vuetify-history.pw.ts @@ -3,6 +3,21 @@ import { test, expect } from "@playwright/test" import { ensureServerRunning, uiUrl } from "./helpers" import { createHistoryUiScenario, installHistoryUiMocks } from "./history-mocks" +// Minimal DOM type stubs for Playwright evaluate() callbacks (browser context). +// DOM lib is excluded from tsconfig to avoid conflicts with Bun types. + +type HTMLElement = { + scrollTop: number + scrollHeight: number + clientHeight: number + className: string + getBoundingClientRect(): { left: number; top: number; width: number; height: number } + querySelectorAll(selector: string): ArrayLike +} + +type HTMLDivElement = HTMLElement +declare function getComputedStyle(el: unknown): { overflowY: string } + test.beforeAll(ensureServerRunning) test.describe("Vuetify History And Activity", () => { @@ -81,11 +96,12 @@ test.describe("Vuetify History And Activity", () => { const nestedVerticalScrollables = await detailCard.evaluate((node) => { return Array.from(node.querySelectorAll("*")) .filter((element) => { - const style = getComputedStyle(element) + const el = element as HTMLElement + const style = getComputedStyle(el) const canScroll = style.overflowY === "auto" || style.overflowY === "scroll" - return canScroll && element.scrollHeight > Number(element.clientHeight) + 8 + return canScroll && el.scrollHeight > el.clientHeight + 8 }) - .map((element) => element.className) + .map((element) => (element as HTMLElement).className) }) expect(nestedVerticalScrollables).toContain("detail-body") @@ -142,8 +158,8 @@ test.describe("Vuetify History And Activity", () => { const topmostTarget = await rawJsonCard.evaluate((node) => { const elementNode = node as HTMLDivElement const rect = elementNode.getBoundingClientRect() - const x = Number(rect.left) + Number(rect.width) / 2 - const y = Number(rect.top) + Math.min(120, Number(rect.height) / 2) + const x = rect.left + rect.width / 2 + const y = rect.top + Math.min(120, rect.height / 2) const doc = ( globalThis as unknown as { document: {