Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/ai/test/stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions packages/coding-agent/docs/terminal-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 12 additions & 2 deletions packages/tui/src/components/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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++) {
Expand Down
28 changes: 28 additions & 0 deletions packages/tui/src/terminal.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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<typeof setTimeout>;
Expand Down
48 changes: 22 additions & 26 deletions packages/tui/src/tui-alt-screen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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\\))+/;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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";
}
Expand Down
2 changes: 1 addition & 1 deletion packages/tui/src/tui-main-screen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
30 changes: 30 additions & 0 deletions packages/tui/src/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -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 {}
Expand All @@ -399,6 +408,10 @@ export abstract class TuiBase extends Container implements TUI {
this.requestRender();
}

isTerminalFocused(): boolean {
return this.terminalFocused;
}

getClearOnShrink(): boolean {
return this.clearOnShrink;
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
49 changes: 48 additions & 1 deletion packages/tui/test/editor-placeholder-cursor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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");
Expand Down
Loading
Loading