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
5 changes: 5 additions & 0 deletions .changeset/pi-tui-upstream-v1-3-0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": patch
---

Update the terminal UI library to upstream v1.3.0: in fullscreen mode, Ctrl+Home and Ctrl+End now scroll the transcript to the top and bottom, and Home and End move the cursor in the input box.
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ function fakeTerminal(rows: number, columns = 120): Terminal {
clearScreen: () => {},
setTitle: () => {},
setProgress: () => {},
setProgramStatus: () => {},
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ function fakeTerminal(rows: number, columns = 120): Terminal {
clearScreen: () => {},
setTitle: () => {},
setProgress: () => {},
setProgramStatus: () => {},
};
}

Expand Down
1 change: 1 addition & 0 deletions apps/pythinker-code/test/tui/controllers/notify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ function makeHarness(enabled = true, fullscreen = false) {
clearScreen: () => {},
setTitle: () => {},
setProgress: () => {},
setProgramStatus: () => {},
};
const ui = fullscreen ? new TuiAltScreen(terminal) : new TuiMainScreen(terminal);
const editor = new CustomEditor(ui);
Expand Down
1 change: 1 addition & 0 deletions apps/pythinker-code/test/tui/task-output-viewer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ function fakeTerminal(rows: number, columns = 120): Terminal {
clearScreen: () => {},
setTitle: () => {},
setProgress: () => {},
setProgramStatus: () => {},
};
}

Expand Down
1 change: 1 addition & 0 deletions apps/pythinker-code/test/tui/tasks-browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ function fakeTerminal(rows: number, columns = 120): Terminal {
clearScreen: () => {},
setTitle: () => {},
setProgress: () => {},
setProgramStatus: () => {},
};
}

Expand Down
1 change: 1 addition & 0 deletions apps/pythinker-code/test/tui/tui-frame.bench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ class StubTerminal implements Terminal {
clearScreen(): void {}
setTitle(): void {}
setProgress(): void {}
setProgramStatus(): void {}
}

/** Returns the same array reference every frame, mirroring the app's cached message components. */
Expand Down
1 change: 1 addition & 0 deletions apps/pythinker-code/test/tui/utils/screen-takeover.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ function stubTerminal(): Terminal {
clearScreen: () => {},
setTitle: () => {},
setProgress: () => {},
setProgramStatus: () => {},
};
}

Expand Down
2 changes: 2 additions & 0 deletions docs/reference/keyboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ Type `!` in an empty input box to enter shell mode and run terminal commands dir
| Shortcut | Function |
| --- | --- |
| `Ctrl-G` | Edit the current input in an external editor |
| `Home` / `End` | Move the cursor to the start or end of the current line |
| `Ctrl-Home` / `Ctrl-End` | Scroll the transcript to the top or bottom (fullscreen mode) |
| `Ctrl-V` | Paste an image or video from the clipboard (Unix / macOS) |
| `Alt-V` | Paste an image or video from the clipboard (Windows) |
| `Ctrl--` | Undo |
Expand Down
8 changes: 5 additions & 3 deletions packages/pi-tui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,15 +258,15 @@ The TUI appends a full SGR reset and OSC 8 reset at the end of each rendered lin
Components that display a text cursor and need IME (Input Method Editor) support should implement the `Focusable` interface:

```typescript
import { CURSOR_MARKER, type Component, type Focusable } from "@pymodel/pi-tui";
import { CURSOR_MARKER, type Component, type Focusable, renderFakeCursor } from "@pymodel/pi-tui";

class MyInput implements Component, Focusable {
focused: boolean = false; // Set by TUI when focus changes

render(width: number): string[] {
const marker = this.focused ? CURSOR_MARKER : "";
// Emit marker right before the fake cursor
return [`> ${beforeCursor}${marker}\x1b[7m${atCursor}\x1b[27m${afterCursor}`];
return [`> ${beforeCursor}${marker}${renderFakeCursor(atCursor)}${afterCursor}`];
}

invalidate(): void {}
Expand All @@ -279,7 +279,9 @@ When a `Focusable` component has focus, TUI:
3. Positions the hardware terminal cursor at that location
4. Shows the hardware cursor only when `showHardwareCursor` is enabled

The cursor remains hidden by default. This keeps the fake cursor rendering, while still positioning the hardware cursor for terminals that track IME candidate windows with hidden cursors. Some terminals require a visible hardware cursor for IME positioning; enable it with the `TUI` constructor option, `setShowHardwareCursor(true)`, or `PI_HARDWARE_CURSOR=1`. The `Editor` and `Input` built-in components already implement this interface.
`renderFakeCursor()` wraps the cursor cell in zero-width markers. TUI renders them as reverse video. When `showHardwareCursor` is enabled, TUI removes the fake cursor that directly follows `CURSOR_MARKER`, so the focused component shows only the terminal cursor. Fake cursors without a preceding `CURSOR_MARKER` stay visible.

The hardware cursor remains hidden by default. This keeps the fake cursor rendering, while still positioning the hardware cursor for terminals that track IME candidate windows with hidden cursors. Some terminals require a visible hardware cursor for IME positioning; enable it with the renderer constructor's `showHardwareCursor` argument or `setShowHardwareCursor(true)`. The `Editor` and `Input` built-in components already implement this interface.

**Container components with embedded inputs:** When a container component (dialog, selector, etc.) contains an `Input` or `Editor` child, the container must implement `Focusable` and propagate the focus state to the child:

Expand Down
4 changes: 2 additions & 2 deletions packages/pi-tui/UPSTREAM.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ Update this section after every sync. Do not reuse the previous range.

- **Repo:** `https://github.com/earendil-works/pi.git`
- **Subtree:** `packages/tui`
- **Commit:** `a7229ddc21810d6245105978033b7df645ecc2f7` (2026-10-03; upstream tag v1.0.1)
- **Darwin prebuilds:** rebuilt locally from `native/darwin/src/darwin-platform.m` with `native/darwin/build.sh`, not copied from upstream. Exports and linked libraries match the upstream v1.0.1 prebuilds.
- **Commit:** `71023aec1546d6d677a65669c5665c0927ce0dd0` (2026-10-15; upstream tag v1.3.0)
- **Darwin prebuilds:** rebuilt locally from `native/darwin/src/darwin-platform.m` with `native/darwin/build.sh`, not copied from upstream. Exports and linked libraries match the upstream v1.0.1 prebuilds. Upstream did not change the native sources or prebuilds between v1.0.1 and v1.3.0.
- **This commit is an upstream marker.** It may not exist in this repo's object database.

## Reconstruct the fork
Expand Down
5 changes: 5 additions & 0 deletions packages/pi-tui/src/components/box.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ export class Box implements Component {
// Don't invalidate here - we'll detect bgFn changes by sampling output
}

setPaddingX(paddingX: number): void {
this.paddingX = paddingX;
this.invalidateCache();
}

private invalidateCache(): void {
this.cache = undefined;
}
Expand Down
5 changes: 3 additions & 2 deletions packages/pi-tui/src/components/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type Component,
CURSOR_MARKER,
type Focusable,
renderFakeCursor,
type TUI,
type TuiMouseEvent,
type TuiMouseEventResult,
Expand Down Expand Up @@ -682,12 +683,12 @@ export class Editor implements Component, Focusable {
const afterGraphemes = [...this.segment(after, "grapheme")];
const firstGrapheme = afterGraphemes[0]?.segment || "";
const restAfter = after.slice(firstGrapheme.length);
const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
const cursor = renderFakeCursor(firstGrapheme);
displayText = before + marker + cursor + restAfter;
// lineVisibleWidth stays the same - we're replacing, not adding
} else {
// Cursor is at the end - add highlighted space
const cursor = "\x1b[7m \x1b[0m";
const cursor = renderFakeCursor(" ");
displayText = before + marker + cursor;
lineVisibleWidth = lineVisibleWidth + 1;
// If cursor overflows content width into the padding, flag it
Expand Down
15 changes: 11 additions & 4 deletions packages/pi-tui/src/components/input.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { getKeybindings } from "../keybindings.ts";
import { decodeKittyPrintable } from "../keys.ts";
import { KillRing } from "../kill-ring.ts";
import { type Component, CURSOR_MARKER, type Focusable, type TuiMouseEvent, type TuiMouseEventResult } from "../tui.ts";
import {
type Component,
CURSOR_MARKER,
type Focusable,
renderFakeCursor,
type TuiMouseEvent,
type TuiMouseEventResult,
} from "../tui.ts";
import { UndoStack } from "../undo-stack.ts";
import { getGraphemeSegmenter, isWhitespaceChar, sliceByColumn, truncateToWidth, visibleWidth } from "../utils.ts";
import { findWordBackward, findWordForward } from "../word-navigation.ts";
Expand Down Expand Up @@ -423,7 +430,7 @@ export class Input implements Component, Focusable {
const atCursor = graphemes[0]?.segment ?? " ";
const afterCursor = placeholder.slice(atCursor.length);
const marker = this.focused ? CURSOR_MARKER : "";
const cursorChar = `\x1b[7m${this.placeholderStyle(atCursor)}\x1b[27m`;
const cursorChar = renderFakeCursor(this.placeholderStyle(atCursor));
const textWithCursor = marker + cursorChar + this.placeholderStyle(afterCursor);
const padding = " ".repeat(Math.max(0, availableWidth - visibleWidth(textWithCursor)));
return [this.prompt + textWithCursor + padding];
Expand Down Expand Up @@ -487,8 +494,8 @@ export class Input implements Component, Focusable {
// Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning)
const marker = this.focused ? CURSOR_MARKER : "";

// Use inverse video to show cursor
const cursorChar = `\x1b[7m${atCursor}\x1b[27m`; // ESC[7m = reverse video, ESC[27m = normal
// TUI draws the fake cursor in reverse video, or omits it when the hardware cursor is shown
const cursorChar = renderFakeCursor(atCursor);
const textWithCursor = beforeCursor + marker + cursorChar + afterCursor;

// Calculate visual width
Expand Down
5 changes: 5 additions & 0 deletions packages/pi-tui/src/components/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ export class Text implements Component {
this.cachedLines = undefined;
}

setPaddingX(paddingX: number): void {
this.paddingX = paddingX;
this.invalidate();
}

invalidate(): void {
this.cachedText = undefined;
this.cachedWidth = undefined;
Expand Down
3 changes: 3 additions & 0 deletions packages/pi-tui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ export { type RenderLatexOptions, renderLatex } from "./latex.ts";
// Native platform integration
export { getNativeClipboard, type NativeClipboard } from "./native-platform.ts";
export { oklabToOkhslLightness } from "./oklab.ts";
// Program status reporting (OSC 7501)
export { formatProgramStatus, type ProgramStatus } from "./program-status.ts";
// Input buffering for batch splitting
export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer.ts";
// Terminal interface and implementations
Expand Down Expand Up @@ -167,6 +169,7 @@ export {
type OverlayMargin,
type OverlayOptions,
type OverlayUnfocusOptions,
renderFakeCursor,
type SizeValue,
type TUI,
type TuiInputListener,
Expand Down
8 changes: 4 additions & 4 deletions packages/pi-tui/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,11 @@ export const TUI_KEYBINDINGS = {
description: "Move cursor word right",
},
"tui.editor.cursorLineStart": {
defaultKeys: ["home", "ctrl+home", "ctrl+a"],
defaultKeys: ["home", "ctrl+a"],
description: "Move to line start",
},
"tui.editor.cursorLineEnd": {
defaultKeys: ["end", "ctrl+end", "ctrl+e"],
defaultKeys: ["end", "ctrl+e"],
description: "Move to line end",
},
"tui.editor.jumpForward": {
Expand Down Expand Up @@ -205,8 +205,8 @@ export const TUI_KEYBINDINGS = {
defaultKeys: "escape",
description: "Close transcript search",
},
"tui.altScreen.top": { defaultKeys: "home", description: "Scroll viewport to top" },
"tui.altScreen.bottom": { defaultKeys: "end", description: "Scroll viewport to bottom" },
"tui.altScreen.top": { defaultKeys: "ctrl+home", description: "Scroll viewport to top" },
"tui.altScreen.bottom": { defaultKeys: "ctrl+end", description: "Scroll viewport to bottom" },
} as const satisfies KeybindingDefinitions;

export interface KeybindingConflict {
Expand Down
92 changes: 73 additions & 19 deletions packages/pi-tui/src/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,10 @@ const SYMBOL_KEYS = new Set([
"?",
]);

const SHIFTED_SYMBOL_KEYS = new Set("~!@#$%^&*()_+|{}:<>?");

const MODIFIER_NAMES = ["shift", "alt", "ctrl", "super"] as const;

const MODIFIERS = {
shift: 1,
alt: 2,
Expand Down Expand Up @@ -365,6 +369,17 @@ function normalizeShiftedLetterIdentityCodepoint(codepoint: number, modifier: nu
return codepoint;
}

function getLogicalShiftedSymbolCandidate(
codepoint: number | undefined,
modifier: number,
allowedSymbols: ReadonlySet<string>,
): { codepoint: number; modifier: number } | undefined {
if (codepoint === undefined || (modifier & MODIFIERS.shift) === 0) return undefined;
const normalizedCodepoint = normalizeKittyFunctionalCodepoint(codepoint);
if (!allowedSymbols.has(String.fromCharCode(normalizedCodepoint))) return undefined;
return { codepoint: normalizedCodepoint, modifier: modifier & ~MODIFIERS.shift };
}

const LEGACY_KEY_SEQUENCES = {
up: ["\x1b[A", "\x1bOA"],
down: ["\x1b[B", "\x1bOB"],
Expand Down Expand Up @@ -656,9 +671,6 @@ function matchesKittySequence(data: string, expectedCodepoint: number, expectedM
const actualMod = parsed.modifier & ~LOCK_MASK;
const expectedMod = expectedModifier & ~LOCK_MASK;

// Check if modifiers match
if (actualMod !== expectedMod) return false;

const normalizedCodepoint = normalizeShiftedLetterIdentityCodepoint(
normalizeKittyFunctionalCodepoint(parsed.codepoint),
parsed.modifier,
Expand All @@ -668,8 +680,22 @@ function matchesKittySequence(data: string, expectedCodepoint: number, expectedM
expectedModifier,
);

// Primary match: codepoint matches directly after normalizing functional keys
if (normalizedCodepoint === normalizedExpectedCodepoint) return true;
// Physical match: primary codepoint and every reported modifier match.
if (actualMod === expectedMod && normalizedCodepoint === normalizedExpectedCodepoint) return true;

// Logical match: Kitty reports symbols such as "+" as Shift plus the physical "=" key.
// Bindings name the produced symbol, so consume that implicit Shift while preserving
// Ctrl, Alt, and Super. The physical "shift+=" spelling remains valid through the match above.
const logicalShiftedSymbol = getLogicalShiftedSymbolCandidate(parsed.shiftedKey, actualMod, SYMBOL_KEYS);
if (
logicalShiftedSymbol &&
(logicalShiftedSymbol.modifier & ~LOCK_MASK) === expectedMod &&
logicalShiftedSymbol.codepoint === normalizeKittyFunctionalCodepoint(expectedCodepoint)
) {
return true;
}

if (actualMod !== expectedMod) return false;

// Alternate match: use base layout key for non-Latin keyboard layouts.
// This allows Ctrl+С (Cyrillic) to match Ctrl+c (Latin) when terminal reports
Expand Down Expand Up @@ -764,13 +790,19 @@ function isDigitKey(key: string): boolean {
}

function matchesPrintableModifyOtherKeys(data: string, expectedKeycode: number, expectedModifier: number): boolean {
if (expectedModifier === 0) return false;
const parsed = parseModifyOtherKeysSequence(data);
if (!parsed || parsed.modifier !== expectedModifier) return false;
return (
if (!parsed) return false;
const physicalMatch =
parsed.modifier === expectedModifier &&
normalizeShiftedLetterIdentityCodepoint(parsed.codepoint, parsed.modifier) ===
normalizeShiftedLetterIdentityCodepoint(expectedKeycode, expectedModifier)
normalizeShiftedLetterIdentityCodepoint(expectedKeycode, expectedModifier);
if (physicalMatch) return true;
const logicalShiftedSymbol = getLogicalShiftedSymbolCandidate(
parsed.codepoint,
parsed.modifier,
SHIFTED_SYMBOL_KEYS,
);
return logicalShiftedSymbol?.modifier === expectedModifier && logicalShiftedSymbol.codepoint === expectedKeycode;
}

function formatKeyNameWithModifiers(keyName: string, modifier: number): string | undefined {
Expand All @@ -788,15 +820,22 @@ function formatKeyNameWithModifiers(keyName: string, modifier: number): string |
function parseKeyId(
keyId: string,
): { key: string; ctrl: boolean; shift: boolean; alt: boolean; super: boolean } | null {
const parts = keyId.toLowerCase().split("+");
const key = parts[parts.length - 1];
if (!key) return null;
let key = keyId.toLowerCase();
const modifiers = new Set<(typeof MODIFIER_NAMES)[number]>();
while (true) {
const modifier = MODIFIER_NAMES.find((name) => key.startsWith(`${name}+`));
if (!modifier) break;
if (modifiers.has(modifier)) return null;
modifiers.add(modifier);
key = key.slice(modifier.length + 1);
}
if (!key || (key.includes("+") && key !== "+")) return null;
return {
key,
ctrl: parts.includes("ctrl"),
shift: parts.includes("shift"),
alt: parts.includes("alt"),
super: parts.includes("super"),
ctrl: modifiers.has("ctrl"),
shift: modifiers.has("shift"),
alt: modifiers.has("alt"),
super: modifiers.has("super"),
};
}

Expand Down Expand Up @@ -1197,7 +1236,9 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
}

// Check both raw char and Kitty sequence (needed for release events)
return data === key || matchesKittySequence(data, codepoint, 0);
return (
data === key || matchesKittySequence(data, codepoint, 0) || matchesPrintableModifyOtherKeys(data, codepoint, 0)
);
}

return false;
Expand Down Expand Up @@ -1251,12 +1292,25 @@ function formatParsedKey(codepoint: number, modifier: number, baseLayoutKey?: nu
export function parseKey(data: string): string | undefined {
const kitty = parseKittySequence(data);
if (kitty) {
return formatParsedKey(kitty.codepoint, kitty.modifier, kitty.baseLayoutKey);
const logicalShiftedSymbol = getLogicalShiftedSymbolCandidate(kitty.shiftedKey, kitty.modifier, SYMBOL_KEYS);
return formatParsedKey(
logicalShiftedSymbol?.codepoint ?? kitty.codepoint,
logicalShiftedSymbol?.modifier ?? kitty.modifier,
kitty.baseLayoutKey,
);
}

const modifyOtherKeys = parseModifyOtherKeysSequence(data);
if (modifyOtherKeys) {
return formatParsedKey(modifyOtherKeys.codepoint, modifyOtherKeys.modifier);
const logicalShiftedSymbol = getLogicalShiftedSymbolCandidate(
modifyOtherKeys.codepoint,
modifyOtherKeys.modifier,
SHIFTED_SYMBOL_KEYS,
);
return formatParsedKey(
logicalShiftedSymbol?.codepoint ?? modifyOtherKeys.codepoint,
logicalShiftedSymbol?.modifier ?? modifyOtherKeys.modifier,
);
}

// Mode-aware legacy sequences
Expand Down
Loading
Loading