diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index e97d8f4ac..1ab62572d 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -702,9 +702,9 @@ describe("Generate E2E Tests", () => { ); describe.skipIf(!hasCloudflareAiGatewayCredentials() || !process.env.ANTHROPIC_API_KEY)( - "Cloudflare AI Gateway → Anthropic BYOK (claude-sonnet-4-5 via /anthropic messages)", + "Cloudflare AI Gateway → Anthropic BYOK (claude-fable-5 via /anthropic messages)", () => { - const llm = getModel("cloudflare-ai-gateway", "claude-sonnet-4-5"); + const llm = getModel("cloudflare-ai-gateway", "claude-fable-5"); const options = { headers: { Authorization: `Bearer ${process.env.ANTHROPIC_API_KEY}` } }; const thinkingOptions = { ...options, diff --git a/packages/coding-agent/docs/terminal-setup.md b/packages/coding-agent/docs/terminal-setup.md index 17675b99e..363549523 100644 --- a/packages/coding-agent/docs/terminal-setup.md +++ b/packages/coding-agent/docs/terminal-setup.md @@ -2,6 +2,16 @@ Diffex uses the [Kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/) for reliable modifier key detection. Most modern terminals support this protocol, but some require configuration. +## Terminal focus and tmux + +Diffex uses terminal focus reporting to pause cursor animation when its terminal window or pane is not active. When reliable focus reporting is unavailable, the placeholder cursor remains static. To receive pane focus changes inside tmux and keep the focused-pane animation, add this to `~/.tmux.conf`: + +```tmux +set -g focus-events on +``` + +Detach and reattach existing tmux clients after changing this option. + ## Kitty, iTerm2 Work out of the box. diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 1caf30889..59af4bd68 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -448,7 +448,13 @@ export class Editor implements Component, Focusable { if (this.cursorBlinkTimer) return; this.cursorBlinkTimer = setTimeout(() => { this.cursorBlinkTimer = undefined; - if (!this.focused || !this.isEditorEmpty() || this.placeholder.length === 0 || this.autocompleteState) { + if ( + !this.focused || + !this.tui.isTerminalFocused() || + !this.isEditorEmpty() || + this.placeholder.length === 0 || + this.autocompleteState + ) { this.cursorBlinkVisible = true; return; } @@ -584,7 +590,11 @@ export class Editor implements Component, Focusable { // autocomplete (e.g. slash-command menu) is visible. const emitCursorMarker = this.focused; this.updateCursorBlink( - this.focused && this.isEditorEmpty() && this.placeholder.length > 0 && !this.autocompleteState, + this.focused && + this.tui.isTerminalFocused() && + this.isEditorEmpty() && + this.placeholder.length > 0 && + !this.autocompleteState, ); for (let visibleLineIndex = 0; visibleLineIndex < visibleLines.length; visibleLineIndex++) { diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index ddaed6ee9..889ad45e4 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process"; import * as fs from "node:fs"; import { createRequire } from "node:module"; import * as path from "node:path"; @@ -54,6 +55,29 @@ export function normalizeAppleTerminalInput(data: string, isAppleTerminal: boole return normalizeNativeShiftEnterInput(data, isAppleTerminal, isShiftPressed); } +function probeTmuxFocusEvents(): boolean { + try { + return ( + execFileSync("tmux", ["show-options", "-gv", "focus-events"], { + encoding: "utf8", + timeout: 250, + stdio: ["ignore", "pipe", "ignore"], + }).trim() === "on" + ); + } catch { + return false; + } +} + +export function detectTerminalFocusReportingSupport( + environment: NodeJS.ProcessEnv = process.env, + tmuxFocusEventsEnabled: () => boolean = probeTmuxFocusEvents, +): boolean { + const term = environment.TERM?.toLowerCase() ?? ""; + if (!environment.TMUX && !term.startsWith("tmux")) return true; + return tmuxFocusEventsEnabled(); +} + /** * Minimal terminal interface for TUI */ @@ -82,6 +106,9 @@ export interface Terminal { // Whether Kitty keyboard protocol is active get kittyProtocolActive(): boolean; + // Whether terminal focus changes can be reported reliably + readonly focusReportingSupported?: boolean; + // Cursor positioning (relative to current position) moveBy(lines: number): void; // Move cursor up (negative) or down (positive) by N lines @@ -110,6 +137,7 @@ export class ProcessTerminal implements Terminal { private resizeHandler?: () => void; private _kittyProtocolActive = false; private _modifyOtherKeysActive = false; + readonly focusReportingSupported = detectTerminalFocusReportingSupport(); private keyboardProtocolPushed = false; private keyboardProtocolNegotiationBuffer = ""; private keyboardProtocolBufferFlushTimer?: ReturnType; diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index fa7063e4c..e799663c7 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -45,11 +45,9 @@ const ENTER_ALT_SCREEN = "\x1b[?1049h"; const EXIT_ALT_SCREEN = "\x1b[?1049l"; const DISABLE_AUTOWRAP = "\x1b[?7l"; const ENABLE_AUTOWRAP = "\x1b[?7h"; -const ENABLE_BUTTON_MOTION_MOUSE = "\x1b[?1000h\x1b[?1002h\x1b[?1004h\x1b[?1006h"; -const ENABLE_ALL_MOTION_MOUSE = "\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1004h\x1b[?1006h"; -const DISABLE_MOUSE = "\x1b[?1006l\x1b[?1004l\x1b[?1003l\x1b[?1002l\x1b[?1000l"; -const FOCUS_IN = "\x1b[I"; -const FOCUS_OUT = "\x1b[O"; +const ENABLE_BUTTON_MOTION_MOUSE = "\x1b[?1000h\x1b[?1002h\x1b[?1006h"; +const ENABLE_ALL_MOTION_MOUSE = "\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h"; +const DISABLE_MOUSE = "\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l"; const BEGIN_SYNCHRONIZED_OUTPUT = "\x1b[?2026h"; const END_SYNCHRONIZED_OUTPUT = "\x1b[?2026l"; const OSC133_ZONE_PREFIX = /^(?:\x1b\]133;[ABC](?:\x07|\x1b\\))+/; @@ -249,6 +247,24 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { ); } + protected override onTerminalFocusChanged(focused: boolean): void { + if (focused) return; + const hadActiveSelection = this.selectionPressActive; + this.selectionPressActive = false; + this.stopSelectionAutoScroll(); + this.stopScrollbarHover(); + this.stopScrollbarDrag(); + this.pressedUrl = undefined; + this.selectionDragged = false; + if (hadActiveSelection) { + this.selectionAnchor = undefined; + this.selectionFocus = undefined; + this.selectionGranularity = "character"; + this.selectionInitialRange = undefined; + } + this.lastClick = undefined; + } + protected override beforeTerminalStop(_options: TuiStopOptions): void { this.stopSelectionAutoScroll(); this.selectionPressActive = false; @@ -383,26 +399,6 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { } private handleViewportInput(data: string): { consume?: boolean } | undefined { - if (data === FOCUS_OUT) { - const hadActiveSelection = this.selectionPressActive; - this.selectionPressActive = false; - this.stopSelectionAutoScroll(); - this.stopScrollbarHover(); - this.stopScrollbarDrag(); - this.pressedUrl = undefined; - this.selectionDragged = false; - if (hadActiveSelection) { - this.selectionAnchor = undefined; - this.selectionFocus = undefined; - this.selectionGranularity = "character"; - this.selectionInitialRange = undefined; - } - this.lastClick = undefined; - this.requestRender(); - return { consume: true }; - } - if (data === FOCUS_IN) return { consume: true }; - const wheelEvent = this.parseWheelEvent(data); if (wheelEvent) { this.routeWheel(wheelEvent); @@ -1032,7 +1028,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { if (cursorPos) { buffer += `\x1b[${cursorPos.row + 1};${Math.min(width, cursorPos.col) + 1}H`; - buffer += this.getShowHardwareCursor() ? "\x1b[?25h" : "\x1b[?25l"; + buffer += this.getShowHardwareCursor() && this.isTerminalFocused() ? "\x1b[?25h" : "\x1b[?25l"; } else { buffer += "\x1b[?25l"; } diff --git a/packages/tui/src/tui-main-screen.ts b/packages/tui/src/tui-main-screen.ts index 28f56e220..29e97c92d 100644 --- a/packages/tui/src/tui-main-screen.ts +++ b/packages/tui/src/tui-main-screen.ts @@ -577,7 +577,7 @@ export class TuiMainScreen extends TuiBase implements TUI { } this.hardwareCursorRow = targetRow; - if (this.getShowHardwareCursor()) { + if (this.getShowHardwareCursor() && this.isTerminalFocused()) { this.terminal.showCursor(); } else { this.terminal.hideCursor(); diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index ed2226b5c..54565963f 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -248,6 +248,10 @@ export class Container implements Component { * TUI - Main class for managing terminal UI with differential rendering */ const SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07"; +const ENABLE_FOCUS_REPORTING = "\x1b[?1004h"; +const DISABLE_FOCUS_REPORTING = "\x1b[?1004l"; +const FOCUS_IN = "\x1b[I"; +const FOCUS_OUT = "\x1b[O"; /** Composite overlay content into a terminal line at a fixed column. */ export function compositeTuiLine( @@ -299,6 +303,7 @@ export interface TUI extends Component { clear(): void; getShowHardwareCursor(): boolean; setShowHardwareCursor(enabled: boolean): void; + isTerminalFocused(): boolean; getClearOnShrink(): boolean; setClearOnShrink(enabled: boolean): void; setFocus(component: Component | null): void; @@ -343,6 +348,7 @@ export abstract class TuiBase extends Container implements TUI { private static readonly MIN_RENDER_INTERVAL_MS = 16; private showHardwareCursor = process.env.DIFFEX_HARDWARE_CURSOR === "1"; private clearOnShrink = process.env.DIFFEX_CLEAR_ON_SHRINK === "1"; + private terminalFocused = true; protected fullRedrawCount = 0; protected stopped = false; private pendingOsc11BackgroundReplies = 0; @@ -363,6 +369,7 @@ export abstract class TuiBase extends Container implements TUI { constructor(terminal: Terminal, showHardwareCursor?: boolean, logDirectory?: string) { super(); this.terminal = terminal; + this.terminalFocused = terminal.focusReportingSupported !== false; this.logDirectory = logDirectory ?? process.env.DIFFEX_CODING_AGENT_DIR ?? path.join(os.homedir(), ".diffex", "agent"); if (showHardwareCursor !== undefined) { @@ -378,6 +385,8 @@ export abstract class TuiBase extends Container implements TUI { protected afterTerminalStart(): void {} + protected onTerminalFocusChanged(_focused: boolean): void {} + protected beforeTerminalStop(_options: TuiStopOptions): void {} protected afterTerminalStop(_options: TuiStopOptions): void {} @@ -399,6 +408,10 @@ export abstract class TuiBase extends Container implements TUI { this.requestRender(); } + isTerminalFocused(): boolean { + return this.terminalFocused; + } + getClearOnShrink(): boolean { return this.clearOnShrink; } @@ -691,11 +704,13 @@ export abstract class TuiBase extends Container implements TUI { start(): void { this.stopped = false; + this.terminalFocused = this.terminal.focusReportingSupported !== false; this.beforeTerminalStart(); this.terminal.start( (data) => this.handleTerminalInput(data), () => this.requestRender(), ); + this.terminal.write(ENABLE_FOCUS_REPORTING); this.afterTerminalStart(); this.terminal.hideCursor(); if (this.terminalColorSchemeNotificationsEnabled) { @@ -746,6 +761,7 @@ export abstract class TuiBase extends Container implements TUI { stop(options: TuiStopOptions = {}): void { this.stopped = true; this.cancelRenderTimer(); + this.terminal.write(DISABLE_FOCUS_REPORTING); if (this.terminalColorSchemeNotificationsEnabled) { this.terminal.write("\x1b[?2031l"); } @@ -818,6 +834,9 @@ export abstract class TuiBase extends Container implements TUI { } private handleTerminalInput(data: string): void { + if (this.consumeTerminalFocusEvent(data)) { + return; + } if (this.consumeOsc11BackgroundResponse(data)) { return; } @@ -895,6 +914,17 @@ export abstract class TuiBase extends Container implements TUI { } } + private consumeTerminalFocusEvent(data: string): boolean { + if (data !== FOCUS_IN && data !== FOCUS_OUT) return false; + const focused = data === FOCUS_IN; + if (this.terminalFocused !== focused) { + this.terminalFocused = focused; + this.onTerminalFocusChanged(focused); + this.requestRender(); + } + return true; + } + private consumeOsc11BackgroundResponse(data: string): boolean { if (this.pendingOsc11BackgroundReplies <= 0) { return false; diff --git a/packages/tui/test/editor-placeholder-cursor.test.ts b/packages/tui/test/editor-placeholder-cursor.test.ts index 3e38cc921..fcee6a2fe 100644 --- a/packages/tui/test/editor-placeholder-cursor.test.ts +++ b/packages/tui/test/editor-placeholder-cursor.test.ts @@ -5,10 +5,11 @@ import { Editor } from "../src/components/editor.ts"; import { CURSOR_MARKER, type TUI } from "../src/tui.ts"; import { defaultEditorTheme } from "./test-themes.ts"; -function createTestTui(requestRender: () => void = () => {}): TUI { +function createTestTui(requestRender: () => void = () => {}, isTerminalFocused: () => boolean = () => true): TUI { return { terminal: { rows: 24 }, requestRender, + isTerminalFocused, } as unknown as TUI; } @@ -71,6 +72,52 @@ describe("Editor placeholder cursor", () => { } }); + it("keeps the cursor visible without blinking while the terminal is unfocused", () => { + mock.timers.enable({ apis: ["setTimeout"] }); + let renderRequests = 0; + let terminalFocused = true; + const editor = new Editor( + createTestTui( + () => { + renderRequests++; + }, + () => terminalFocused, + ), + defaultEditorTheme, + { + prompt: "> ", + placeholder: "Ask Diffex to do something for you.", + placeholderStyle: (text) => `\x1b[90m${text}\x1b[39m`, + }, + ); + editor.focused = true; + + try { + assert.ok(editor.render(80)[1]!.includes("\x1b[7mA\x1b[0m")); + mock.timers.tick(500); + assert.strictEqual(renderRequests, 1); + assert.ok(!editor.render(80)[1]!.includes("\x1b[7mA\x1b[0m")); + + terminalFocused = false; + mock.timers.tick(500); + assert.strictEqual(renderRequests, 1); + assert.ok(editor.render(80)[1]!.includes("\x1b[7mA\x1b[0m")); + mock.timers.tick(1000); + assert.strictEqual(renderRequests, 1); + assert.ok(editor.render(80)[1]!.includes("\x1b[7mA\x1b[0m")); + + terminalFocused = true; + editor.render(80); + mock.timers.tick(500); + assert.strictEqual(renderRequests, 2); + assert.ok(!editor.render(80)[1]!.includes("\x1b[7mA\x1b[0m")); + } finally { + editor.focused = false; + editor.render(80); + mock.timers.reset(); + } + }); + it("keeps typed and wrapped text to the right of the prompt", () => { const editor = new Editor(createTestTui(), defaultEditorTheme, { prompt: "> " }); editor.setText("123456789"); diff --git a/packages/tui/test/terminal.test.ts b/packages/tui/test/terminal.test.ts index 06c751dbc..725951e2f 100644 --- a/packages/tui/test/terminal.test.ts +++ b/packages/tui/test/terminal.test.ts @@ -1,7 +1,12 @@ import assert from "node:assert"; import { describe, it, mock } from "node:test"; import { setKittyProtocolActive } from "../src/keys.ts"; -import { normalizeAppleTerminalInput, normalizeNativeShiftEnterInput, ProcessTerminal } from "../src/terminal.ts"; +import { + detectTerminalFocusReportingSupport, + normalizeAppleTerminalInput, + normalizeNativeShiftEnterInput, + ProcessTerminal, +} from "../src/terminal.ts"; describe("normalizeNativeShiftEnterInput", () => { it("rewrites Return to CSI-u Shift+Enter when native Shift detection is enabled and Shift is pressed", () => { @@ -41,6 +46,26 @@ describe("normalizeAppleTerminalInput", () => { }); }); +describe("detectTerminalFocusReportingSupport", () => { + it("uses terminal focus reporting outside tmux", () => { + assert.strictEqual( + detectTerminalFocusReportingSupport({ TERM: "xterm-256color" }, () => false), + true, + ); + }); + + it("requires tmux focus events inside tmux", () => { + assert.strictEqual( + detectTerminalFocusReportingSupport({ TMUX: "/tmp/tmux/default,1,0" }, () => false), + false, + ); + assert.strictEqual( + detectTerminalFocusReportingSupport({ TMUX: "/tmp/tmux/default,1,0" }, () => true), + true, + ); + }); +}); + describe("ProcessTerminal Kitty keyboard protocol negotiation", () => { type NegotiationHarness = { terminal: ProcessTerminal; diff --git a/packages/tui/test/tui-focus.test.ts b/packages/tui/test/tui-focus.test.ts new file mode 100644 index 000000000..c7cc672b4 --- /dev/null +++ b/packages/tui/test/tui-focus.test.ts @@ -0,0 +1,106 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { type Component, CURSOR_MARKER } from "../src/tui.ts"; +import { TuiAltScreen } from "../src/tui-alt-screen.ts"; +import { TuiMainScreen } from "../src/tui-main-screen.ts"; +import { VirtualTerminal } from "./virtual-terminal.ts"; + +class InputRecorder implements Component { + readonly inputs: string[] = []; + + render(): string[] { + return [""]; + } + + handleInput(data: string): void { + this.inputs.push(data); + } + + invalidate(): void {} +} + +class RecordingTerminal extends VirtualTerminal { + readonly writes: string[] = []; + hardwareCursorVisible = false; + + override write(data: string): void { + this.writes.push(data); + super.write(data); + } + + override hideCursor(): void { + this.hardwareCursorVisible = false; + super.hideCursor(); + } + + override showCursor(): void { + this.hardwareCursorVisible = true; + super.showCursor(); + } +} + +class CursorComponent implements Component { + render(): string[] { + return [`${CURSOR_MARKER}\x1b[7m \x1b[0m`]; + } + + invalidate(): void {} +} + +class FocusUnsupportedTerminal extends RecordingTerminal { + override readonly focusReportingSupported = false; +} + +describe("TUI terminal focus", () => { + it("enables focus reporting and consumes focus changes", () => { + const terminal = new RecordingTerminal(); + const tui = new TuiMainScreen(terminal); + const recorder = new InputRecorder(); + tui.setFocus(recorder); + + tui.start(); + assert.ok(terminal.writes.includes("\x1b[?1004h")); + assert.strictEqual(tui.isTerminalFocused(), true); + + terminal.sendInput("\x1b[O"); + assert.strictEqual(tui.isTerminalFocused(), false); + assert.deepStrictEqual(recorder.inputs, []); + + terminal.sendInput("\x1b[I"); + assert.strictEqual(tui.isTerminalFocused(), true); + assert.deepStrictEqual(recorder.inputs, []); + + terminal.sendInput("a"); + assert.deepStrictEqual(recorder.inputs, ["a"]); + + tui.stop(); + assert.ok(terminal.writes.includes("\x1b[?1004l")); + }); + + it("defaults to a static software cursor when focus reporting is unavailable", () => { + const terminal = new FocusUnsupportedTerminal(); + const tui = new TuiMainScreen(terminal, true); + tui.addChild(new CursorComponent()); + + tui.start(); + tui.renderNow(); + assert.strictEqual(tui.isTerminalFocused(), false); + assert.strictEqual(terminal.hardwareCursorVisible, false); + + terminal.sendInput("\x1b[I"); + tui.renderNow(); + assert.strictEqual(tui.isTerminalFocused(), true); + assert.strictEqual(terminal.hardwareCursorVisible, true); + tui.stop(); + }); + + it("reports focus in alternate-screen mode when mouse tracking is disabled", () => { + const terminal = new RecordingTerminal(); + const tui = new TuiAltScreen(terminal, undefined, undefined, { mouse: false }); + + tui.start(); + assert.ok(terminal.writes.includes("\x1b[?1004h")); + tui.stop(); + assert.ok(terminal.writes.includes("\x1b[?1004l")); + }); +}); diff --git a/packages/tui/test/virtual-terminal.ts b/packages/tui/test/virtual-terminal.ts index 4e067f4e5..96a07d54a 100644 --- a/packages/tui/test/virtual-terminal.ts +++ b/packages/tui/test/virtual-terminal.ts @@ -9,6 +9,7 @@ const XtermTerminal = xterm.Terminal; * Virtual terminal for testing using xterm.js for accurate terminal emulation */ export class VirtualTerminal implements Terminal { + readonly focusReportingSupported: boolean = true; private xterm: XtermTerminalType; private inputHandler?: (data: string) => void; private resizeHandler?: () => void;