From f88e708454bb28578d977bfb418fbe98a09a4c19 Mon Sep 17 00:00:00 2001 From: elkaix Date: Sat, 3 Oct 2026 12:06:05 -0400 Subject: [PATCH 1/9] feat(tui): make fullscreen the default TUI mode --- .../components/dialogs/tui-mode-selector.ts | 10 +++--- apps/pythinker-code/src/tui/config.ts | 6 ++-- .../test/tui/commands/reload.test.ts | 2 +- apps/pythinker-code/test/tui/config.test.ts | 32 +++++++++++-------- docs/configuration/config-files.md | 4 +-- 5 files changed, 29 insertions(+), 25 deletions(-) diff --git a/apps/pythinker-code/src/tui/components/dialogs/tui-mode-selector.ts b/apps/pythinker-code/src/tui/components/dialogs/tui-mode-selector.ts index fe1e3e7ff..b2a560af4 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/tui-mode-selector.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/tui-mode-selector.ts @@ -2,16 +2,16 @@ import type { TuiMode } from '../../config'; import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; const TUI_MODE_OPTIONS: readonly ChoiceOption[] = [ + { + value: 'fullscreen', + label: 'Fullscreen', + description: 'Prompt fixed at the bottom; in-app scrolling, selection, and transcript search.', + }, { value: 'regular', label: 'Regular', description: 'Render into the terminal\'s native scrollback.', }, - { - value: 'fullscreen', - label: 'Fullscreen (experimental)', - description: 'Alternate screen with in-app scrolling, selection, and transcript search.', - }, ]; export interface TuiModeSelectorOptions { diff --git a/apps/pythinker-code/src/tui/config.ts b/apps/pythinker-code/src/tui/config.ts index cc26dc524..68bb1e915 100644 --- a/apps/pythinker-code/src/tui/config.ts +++ b/apps/pythinker-code/src/tui/config.ts @@ -137,7 +137,7 @@ export const DEFAULT_UPGRADE_PREFERENCES: UpgradePreferences = { export const DEFAULT_TUI_CONFIG: TuiConfig = TuiConfigSchema.parse({ theme: 'auto', - tuiMode: 'regular', + tuiMode: 'fullscreen', renderLatex: true, disablePasteBurst: false, cacheExpiryHint: true, @@ -257,7 +257,7 @@ export function normalizeTuiConfig( } } const tuiModeValue = config.tui_mode; - let tuiMode: TuiMode = 'regular'; + let tuiMode: TuiMode = DEFAULT_TUI_CONFIG.tuiMode ?? 'fullscreen'; if (tuiModeValue !== undefined) { if (tuiModeValue === 'regular' || tuiModeValue === 'fullscreen') { tuiMode = tuiModeValue; @@ -310,7 +310,7 @@ export function renderTuiConfig(config: TuiConfig): string { if (statusCommand) { statusLines.push(`command = "${escapeTomlBasicString(statusCommand)}"`); } - const tuiModeLine = `tui_mode = "${config.tuiMode ?? 'regular'}" # "regular" | "fullscreen" ("fullscreen" is experimental)`; + const tuiModeLine = `tui_mode = "${config.tuiMode ?? 'fullscreen'}" # "fullscreen" | "regular" ("regular" keeps the chat in native terminal scrollback)`; const markdownSection = config.markdown?.mermaid === 'off' ? `[markdown]\nmermaid = "off" # "final" | "off"\n` diff --git a/apps/pythinker-code/test/tui/commands/reload.test.ts b/apps/pythinker-code/test/tui/commands/reload.test.ts index cf73c1f9a..b8b4b59e1 100644 --- a/apps/pythinker-code/test/tui/commands/reload.test.ts +++ b/apps/pythinker-code/test/tui/commands/reload.test.ts @@ -213,7 +213,7 @@ auto_install = false }); it('does not notice when tui_mode matches the running UI mode', async () => { - await writeTuiConfig('theme = "dark"\n'); + await writeTuiConfig('theme = "dark"\ntui_mode = "regular"\n'); const host = makeHost(); await handleReloadTuiCommand(host); diff --git a/apps/pythinker-code/test/tui/config.test.ts b/apps/pythinker-code/test/tui/config.test.ts index 509e9df8a..b51cb879c 100644 --- a/apps/pythinker-code/test/tui/config.test.ts +++ b/apps/pythinker-code/test/tui/config.test.ts @@ -62,7 +62,7 @@ auto_install = false expect(config).toEqual({ theme: 'light', - tuiMode: 'regular', + tuiMode: 'fullscreen', renderLatex: true, disablePasteBurst: false, cacheExpiryHint: true, @@ -121,7 +121,7 @@ command = " " expect(config).toEqual({ theme: 'auto', - tuiMode: 'regular', + tuiMode: 'fullscreen', renderLatex: true, disablePasteBurst: false, cacheExpiryHint: true, @@ -172,7 +172,7 @@ command = " " expect(await loadTuiConfig(filePath)).toEqual({ theme: 'light', - tuiMode: 'regular', + tuiMode: 'fullscreen', renderLatex: true, disablePasteBurst: false, cacheExpiryHint: true, @@ -358,8 +358,12 @@ mermaid = "${value}" }); describe('TUI config tui_mode', () => { - it('defaults tui_mode to regular when omitted', () => { - expect(parseTuiConfig(`theme = "dark"`).tuiMode).toBe('regular'); + it('defaults tui_mode to fullscreen when omitted', () => { + expect(parseTuiConfig(`theme = "dark"`).tuiMode).toBe('fullscreen'); + }); + + it('keeps an explicit tui_mode = "regular"', () => { + expect(parseTuiConfig(`tui_mode = "regular"`).tuiMode).toBe('regular'); }); it('parses tui_mode = "fullscreen"', () => { @@ -370,7 +374,7 @@ tui_mode = "fullscreen" expect(config.tuiMode).toBe('fullscreen'); }); - it('warns and falls back to regular for unknown tui_mode values without failing the file', () => { + it('warns and falls back to the default for unknown tui_mode values without failing the file', () => { const warnings: string[] = []; const config = parseTuiConfig( ` @@ -380,7 +384,7 @@ tui_mode = "weird" (message) => warnings.push(message), ); - expect(config.tuiMode).toBe('regular'); + expect(config.tuiMode).toBe('fullscreen'); expect(config.theme).toBe('dark'); expect(warnings).toEqual(['[tui.toml] ignoring unknown tui_mode value: weird']); }); @@ -389,16 +393,16 @@ tui_mode = "weird" await saveTuiConfig(DEFAULT_TUI_CONFIG, filePath); const text = readFileSync(filePath, 'utf-8'); - expect(text).toContain('\ntui_mode = "regular"'); + expect(text).toContain('\ntui_mode = "fullscreen"'); expect(text).not.toContain('# tui_mode'); }); - it('writes a live tui_mode when fullscreen and round-trips it', async () => { - await saveTuiConfig({ ...DEFAULT_TUI_CONFIG, tuiMode: 'fullscreen' }, filePath); + it('writes a live tui_mode when regular and round-trips it', async () => { + await saveTuiConfig({ ...DEFAULT_TUI_CONFIG, tuiMode: 'regular' }, filePath); const text = readFileSync(filePath, 'utf-8'); - expect(text).toContain('\ntui_mode = "fullscreen"'); - expect((await loadTuiConfig(filePath)).tuiMode).toBe('fullscreen'); + expect(text).toContain('\ntui_mode = "regular"'); + expect((await loadTuiConfig(filePath)).tuiMode).toBe('regular'); }); }); @@ -432,7 +436,7 @@ describe('TUI config tui_mode env migration', () => { const config = await loadTuiConfig(filePath, (message) => warnings.push(message)); - expect(config.tuiMode).toBe('regular'); + expect(config.tuiMode).toBe('fullscreen'); expect(warnings).toEqual(['[tui.toml] ignoring unknown tui_mode value: weird']); expect(readFileSync(filePath, 'utf-8')).toBe('tui_mode = "weird"\n'); }); @@ -443,7 +447,7 @@ describe('TUI config tui_mode env migration', () => { const config = await loadTuiConfig(filePath); - expect(config.tuiMode).toBe('regular'); + expect(config.tuiMode).toBe('fullscreen'); expect(readFileSync(filePath, 'utf-8')).toBe('theme = "dark"\n'); }); diff --git a/docs/configuration/config-files.md b/docs/configuration/config-files.md index 98163ae81..b1620f093 100644 --- a/docs/configuration/config-files.md +++ b/docs/configuration/config-files.md @@ -559,7 +559,7 @@ Alongside `config.toml`, the CLI keeps terminal-UI and client preferences in a c | Field | Type | Default | Description | | --- | --- | --- | --- | | `theme` | `string` | `auto` | Color theme: `auto`, `dark`, `light`, or the name of a [custom theme](../customization/themes.md) | -| `tui_mode` | `string` | `regular` | UI layout: `regular` renders into the terminal scrollback; `fullscreen` makes parts of the UI mouse-interactive. A layout change takes effect only after restart; `/reload-tui` does not switch the layout | +| `tui_mode` | `string` | `fullscreen` | UI layout: `fullscreen` keeps the prompt fixed at the bottom with in-app scrolling and mouse interaction; `regular` renders into the terminal scrollback. A layout change takes effect only after restart; `/reload-tui` does not switch the layout | | `render_latex` | `boolean` | `true` | Render LaTeX math expressions in Markdown messages as Unicode text; `false` keeps the raw source | | `disable_paste_burst` | `boolean` | `false` | Disable the non-bracketed paste-burst fallback that keeps rapid multi-line pastes from submitting line by line | | `cache_expiry_hint` | `boolean` | `true` | On resume or when submitting after a long idle stretch, warn that the context cache may have expired and offer to compact or start a new session (v2 engine only) | @@ -581,7 +581,7 @@ Model, cwd, git branch, permission mode, plan mode, context usage, session id, v ```toml # ~/.pythinker-code/tui.toml theme = "auto" # "auto" | "dark" | "light" | custom theme name -tui_mode = "regular" # "regular" | "fullscreen" +tui_mode = "fullscreen" # "fullscreen" | "regular" render_latex = true # false keeps LaTeX math in messages as raw source disable_paste_burst = false # true disables non-bracketed paste-burst fallback cache_expiry_hint = true # false disables the "cache expired" dialog on resume / idle submit From 47f5a0f927f062148ee6de9c027dce9168277f0e Mon Sep 17 00:00:00 2001 From: elkaix Date: Sat, 3 Oct 2026 12:10:40 -0400 Subject: [PATCH 2/9] feat(tui): cycle thinking effort with Shift-Tab and tint the prompt frame --- .../pythinker-code/src/tui/commands/config.ts | 21 ++++++++ .../src/tui/components/dialogs/help-panel.ts | 2 +- apps/pythinker-code/src/tui/constant/tips.ts | 2 +- .../src/tui/controllers/editor-keyboard.ts | 19 ++----- apps/pythinker-code/src/tui/pythinker-tui.ts | 26 +++++++-- .../tui/controllers/editor-keyboard.test.ts | 54 +++---------------- .../tui/pythinker-tui-message-flow.test.ts | 46 +++++++++++----- docs/guides/interaction.md | 2 +- docs/reference/keyboard.md | 4 +- 9 files changed, 92 insertions(+), 84 deletions(-) diff --git a/apps/pythinker-code/src/tui/commands/config.ts b/apps/pythinker-code/src/tui/commands/config.ts index f858b531b..c8525064e 100644 --- a/apps/pythinker-code/src/tui/commands/config.ts +++ b/apps/pythinker-code/src/tui/commands/config.ts @@ -401,11 +401,31 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string = ); } +/** + * Shift+Tab: step to the next thinking effort of the current model and wrap. + * `off` is skipped when the model declares concrete efforts. The switch is + * session-only and quiet, so repeated presses do not fill the transcript. + */ +export async function cycleThinkingEffort(host: SlashCommandHost): Promise { + const alias = host.state.appState.model; + const model = host.state.appState.availableModels[alias]; + if (model === undefined) return; + if (host.state.appState.streamingPhase !== 'idle') return; + const segments = segmentsFor(effectiveModelForHost(host, model)); + const hasEfforts = segments.some((effort) => effort !== 'off' && effort !== 'on'); + const cycle = hasEfforts ? segments.filter((effort) => effort !== 'off') : segments; + if (cycle.length < 2) return; + const index = cycle.indexOf(host.state.appState.thinkingEffort); + const next = cycle[(index + 1) % cycle.length]!; + await performModelSwitch(host, alias, next, false, true); +} + async function performModelSwitch( host: SlashCommandHost, alias: string, effort: ThinkingEffort, persist: boolean, + quiet = false, ): Promise { let session = host.session; if (session === undefined) { @@ -488,6 +508,7 @@ async function performModelSwitch( } } + if (quiet) return; let status: string; if (effectiveModelChanged) { status = persist diff --git a/apps/pythinker-code/src/tui/components/dialogs/help-panel.ts b/apps/pythinker-code/src/tui/components/dialogs/help-panel.ts index 81434f36d..55f862310 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/help-panel.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/help-panel.ts @@ -31,7 +31,7 @@ export interface HelpPanelCommand { /** Static list — keep in sync with the global editor bindings. */ export const DEFAULT_KEYBOARD_SHORTCUTS: readonly KeyboardShortcut[] = [ - { keys: 'Shift-Tab', description: 'Toggle plan mode' }, + { keys: 'Shift-Tab', description: 'Cycle thinking effort' }, { keys: 'Ctrl-G', description: 'Edit in external editor ($VISUAL / $EDITOR)' }, { keys: 'Ctrl-O', description: 'Toggle tool output / compaction summary expansion' }, { keys: 'Ctrl-T', description: 'Expand / collapse the todo list (when truncated)' }, diff --git a/apps/pythinker-code/src/tui/constant/tips.ts b/apps/pythinker-code/src/tui/constant/tips.ts index 3c9754d0e..ce50ddc62 100644 --- a/apps/pythinker-code/src/tui/constant/tips.ts +++ b/apps/pythinker-code/src/tui/constant/tips.ts @@ -43,6 +43,6 @@ export const ALL_TIPS: readonly ToolbarTip[] = [ { text: '/help: show commands' }, { text: '/compact compresses context when it gets long', priority: 2 }, { text: 'ctrl-o to hide or reveal tool output switching between a clean chat view and full execution details', priority: 2 }, - { text: 'shift-tab to Plan mode to review the approach before Pythinker edits files.', priority: 2 }, + { text: '/plan to review the approach before Pythinker edits files.', priority: 2 }, { text: '/model: switch model', priority: 2 }, ]; diff --git a/apps/pythinker-code/src/tui/controllers/editor-keyboard.ts b/apps/pythinker-code/src/tui/controllers/editor-keyboard.ts index 5f914bf1f..e1895819a 100644 --- a/apps/pythinker-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/pythinker-code/src/tui/controllers/editor-keyboard.ts @@ -75,7 +75,7 @@ export interface EditorKeyboardHost { openUndoSelector(): void; stop(exitCode?: number): Promise; ensureSession(): Promise; - handlePlanToggle(next: boolean): void; + cycleThinkingEffort(): void; handleInputModeChange(mode: 'prompt' | 'bash'): void; clearQueuedMessages(): void; setExternalEditorRunning(running: boolean): void; @@ -264,21 +264,8 @@ export class EditorKeyboardController { }; editor.onShiftTab = () => { - const togglePlan = (): void => { - const next = !host.state.appState.planMode; - host.track('shortcut_plan_toggle', { enabled: next }); - host.track('shortcut_mode_switch', { to_mode: next ? 'plan' : 'agent' }); - host.handlePlanToggle(next); - }; - if (host.session === undefined) { - // v2 session-less: lazy-create the session, then toggle — the same - // path /plan takes. - void host.ensureSession().then((session) => { - if (session !== undefined) togglePlan(); - }); - return; - } - togglePlan(); + host.track('shortcut_effort_cycle'); + host.cycleThinkingEffort(); }; editor.onInputModeChange = (mode) => { diff --git a/apps/pythinker-code/src/tui/pythinker-tui.ts b/apps/pythinker-code/src/tui/pythinker-tui.ts index 15dd103a6..33f08e13a 100644 --- a/apps/pythinker-code/src/tui/pythinker-tui.ts +++ b/apps/pythinker-code/src/tui/pythinker-tui.ts @@ -62,6 +62,7 @@ import { type SkillListSession, } from './commands'; import * as slashCommands from './commands/dispatch'; +import { cycleThinkingEffort } from './commands/config'; import { CacheHintController } from './controllers/cache-hint-controller'; import { BannerComponent } from './components/chrome/banner'; import { DeviceCodeBoxComponent } from './components/chrome/device-code-box'; @@ -1204,8 +1205,8 @@ export class PythinkerTUI { // Input Dispatch // ========================================================================= - handlePlanToggle(next: boolean): void { - void slashCommands.handlePlanCommand(this, next ? 'on' : 'off'); + cycleThinkingEffort(): void { + void cycleThinkingEffort(this); } handleInputModeChange(mode: 'prompt' | 'bash'): void { @@ -2278,7 +2279,7 @@ export class PythinkerTUI { !sameStringArrays(this.state.appState.additionalDirs, patch.additionalDirs ?? []); const busyChanged = 'streamingPhase' in patch || 'isCompacting' in patch; Object.assign(this.state.appState, patch); - if ('planMode' in patch) this.updateEditorBorderHighlight(); + if ('planMode' in patch || 'thinkingEffort' in patch) this.updateEditorBorderHighlight(); this.state.footer.setState(this.state.appState); this.updateActivityPane(); if (busyChanged) { @@ -3782,7 +3783,11 @@ export class PythinkerTUI { const highlighted = this.state.appState.planMode || isBash || trimmed.startsWith('/'); this.state.editor.borderHighlighted = highlighted; // Shell mode gets its own hue; plan-mode and slash context stay primary. - const borderToken = isBash ? 'shellMode' : highlighted ? 'primary' : 'border'; + const borderToken = isBash + ? 'shellMode' + : highlighted + ? 'primary' + : effortBorderToken(this.state.appState.thinkingEffort); this.state.editor.borderColor = (s: string) => currentTheme.fg(borderToken, s); this.state.ui.requestRender(); } @@ -4362,3 +4367,16 @@ function toSteerInputItem(message: QueuedMessage): SteerInputItem { videoAttachmentIds: message.videoAttachmentIds, }; } + +const EFFORT_BORDER_TOKENS: Readonly> = { + on: 'effortHigh', + low: 'effortLow', + medium: 'effortMedium', + high: 'effortHigh', + xhigh: 'effortXHigh', + max: 'effortMax', +}; + +export function effortBorderToken(effort: string): ColorToken { + return EFFORT_BORDER_TOKENS[effort] ?? 'border'; +} diff --git a/apps/pythinker-code/test/tui/controllers/editor-keyboard.test.ts b/apps/pythinker-code/test/tui/controllers/editor-keyboard.test.ts index 806f62e65..57e7f4964 100644 --- a/apps/pythinker-code/test/tui/controllers/editor-keyboard.test.ts +++ b/apps/pythinker-code/test/tui/controllers/editor-keyboard.test.ts @@ -409,15 +409,13 @@ describe('EditorKeyboardController input changes', () => { }); }); -describe('EditorKeyboardController Shift-Tab plan toggle', () => { - function createShiftTabHarness(options: { sessionless?: boolean } = {}) { +describe('EditorKeyboardController Shift-Tab effort cycle', () => { + it('cycles thinking effort instead of toggling plan mode', () => { const editor: Record unknown) | undefined> = { setHistoryFilter: vi.fn() as unknown as (...args: never[]) => unknown, }; - const handlePlanToggle = vi.fn(); + const cycleThinkingEffort = vi.fn(); const track = vi.fn(); - const showError = vi.fn(); - const ensureSession = vi.fn(async (): Promise<{ id: string } | undefined> => ({ id: 'ses-lazy' })); const host = { state: { editor, @@ -426,53 +424,17 @@ describe('EditorKeyboardController Shift-Tab plan toggle', () => { footer: { setTransientHint: vi.fn() }, ui: { requestRender: vi.fn() }, }, - session: options.sessionless ? undefined : { cancel: vi.fn(async () => {}) }, - ensureSession, - handlePlanToggle, + session: { cancel: vi.fn(async () => {}) }, + cycleThinkingEffort, track, - showError, btwPanelController: { cancelRunning: vi.fn(), closeOrCancel: vi.fn() }, } as unknown as EditorKeyboardHost; - new EditorKeyboardController(host, undefined as unknown as ImageAttachmentStore).install(); - const onShiftTab = editor['onShiftTab'] as unknown as () => void; - return { onShiftTab, handlePlanToggle, track, showError, ensureSession }; - } - - it('toggles plan mode directly with an active session', () => { - const { onShiftTab, handlePlanToggle, ensureSession } = createShiftTabHarness(); - - onShiftTab(); - - expect(ensureSession).not.toHaveBeenCalled(); - expect(handlePlanToggle).toHaveBeenCalledWith(true); - }); - - it('lazy-creates the session before toggling on v2 when session-less', async () => { - const { onShiftTab, ensureSession, handlePlanToggle, track } = createShiftTabHarness({ - sessionless: true, - }); - - onShiftTab(); - expect(handlePlanToggle).not.toHaveBeenCalled(); - - await vi.waitFor(() => { - expect(handlePlanToggle).toHaveBeenCalledWith(true); - }); - expect(ensureSession).toHaveBeenCalledOnce(); - expect(track).toHaveBeenCalledWith('shortcut_plan_toggle', { enabled: true }); - }); - - it('does not toggle when the lazy creation fails on v2', async () => { - const { onShiftTab, ensureSession, handlePlanToggle } = createShiftTabHarness({ - sessionless: true, - }); - ensureSession.mockResolvedValue(undefined); - onShiftTab(); - await new Promise((resolve) => setImmediate(resolve)); + (editor['onShiftTab'] as unknown as () => void)(); - expect(handlePlanToggle).not.toHaveBeenCalled(); + expect(cycleThinkingEffort).toHaveBeenCalledOnce(); + expect(track).toHaveBeenCalledWith('shortcut_effort_cycle'); }); }); diff --git a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts index 96607936f..acf4eacff 100644 --- a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts +++ b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts @@ -24,6 +24,7 @@ import { ApprovalPanelComponent } from '#/tui/components/dialogs/approval-panel' import { EffortSelectorComponent } from '#/tui/components/dialogs/effort-selector'; import { pythinkerCodePluginMarketplaceUrl } from '#/constant/app'; import { BRAILLE_SPINNER_FRAMES } from '#/tui/constant/rendering'; +import { currentTheme } from '#/tui/theme'; import { AgentDynamicWorkflowProgressComponent, agentDynamicWorkflowGridHeightForTerminalRows, @@ -1523,6 +1524,38 @@ describe('PythinkerTUI message flow', () => { thinking: { enabled: true }, }); + it('cycles thinking effort with Shift-Tab, skipping off, and tints the prompt frame', async () => { + const session = makeSession(); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver, harness } = await makeDriver( + session, + { getConfig: vi.fn(async () => thinkingModelsConfig()) }, + startupInput, + ); + harness.track.mockClear(); + const press = async (expected: string) => { + driver.state.editor.onShiftTab?.(); + await vi.waitFor(() => { + expect(driver.state.appState.thinkingEffort).toBe(expected); + }); + }; + + expect(driver.state.appState.thinkingEffort).toBe('high'); + await press('max'); + await press('low'); + await press('high'); + await press('max'); + await press('low'); + + expect(harness.track.mock.calls.map(([event]) => event)).toContain('shortcut_effort_cycle'); + expect(session.setPlanMode).not.toHaveBeenCalled(); + expect(driver.state.editor.borderColor('x')).toBe(currentTheme.fg('effortLow', 'x')); + expect(stripSgr(renderTranscript(driver))).not.toContain('Thinking set to'); + }); + it('blocks an effort switch once the waited-out first prompt starts a turn (v2 engine)', async () => { const lazySession = makeSession({ id: 'ses-lazy' }); const startupInput: PythinkerTUIStartupInput = { @@ -2697,19 +2730,6 @@ command = "vim" expect(failedSession.onEvent).toHaveBeenCalledOnce(); }); - it('tracks Shift-Tab mode switches through the editor handler', async () => { - const { driver, session, harness } = await makeDriver(); - harness.track.mockClear(); - - driver.state.editor.onShiftTab?.(); - - await vi.waitFor(() => { - expect(session.setPlanMode).toHaveBeenCalledWith(true); - }); - expect(harness.track).toHaveBeenCalledWith('shortcut_plan_toggle', { enabled: true }); - expect(harness.track).toHaveBeenCalledWith('shortcut_mode_switch', { to_mode: 'plan' }); - }); - it('routes /yolo through session permission state without app-layer telemetry duplication', async () => { const { driver, session, harness } = await makeDriver(); harness.track.mockClear(); diff --git a/docs/guides/interaction.md b/docs/guides/interaction.md index 15f25f51f..419940398 100644 --- a/docs/guides/interaction.md +++ b/docs/guides/interaction.md @@ -75,7 +75,7 @@ Approvals are not triggered for regular tool calls in Ask When Needed mode, nor In Plan mode the agent first outputs an action plan and waits for your approval before modifying any files — useful for complex or high-risk tasks. -- Toggle: `Shift-Tab` or `/plan` +- Toggle: `/plan` - Clear the current plan: `/plan clear` (only while idle) After producing a plan the agent pauses for your review — you can approve it, reject it, or ask for revisions. Exiting Plan mode requires your confirmation even if Ask When Needed mode is also active. Never Ask mode is the exception: plan exits are approved automatically and marked as "Auto-approved" in the transcript. diff --git a/docs/reference/keyboard.md b/docs/reference/keyboard.md index f8a3365f1..7a087dc55 100644 --- a/docs/reference/keyboard.md +++ b/docs/reference/keyboard.md @@ -26,10 +26,10 @@ Pressing `Ctrl-C` **during streaming** cancels immediately — no second confirm | Shortcut | Function | | --- | --- | -| `Shift-Tab` | Toggle Plan mode | +| `Shift-Tab` | Cycle thinking effort | | `!` | Enter shell mode (in an empty input box) | -Press `Shift-Tab` to enable or disable Plan mode. When enabled, the Agent prioritizes read-only tools for research and planning and can write to the current plan file; `Bash` is subject to the current permission mode and regular rules, without any additional separate approval triggered by Plan mode. Simply toggling does not create an empty plan file. Press `Shift-Tab` again to exit Plan mode. +Press `Shift-Tab` to step through the current model's thinking efforts (for example low, medium, high, xhigh, max), wrapping at the end. The prompt frame takes the color of the selected effort. Run `/plan` to enable or disable Plan mode. When enabled, the Agent prioritizes read-only tools for research and planning and can write to the current plan file; `Bash` is subject to the current permission mode and regular rules, without any additional separate approval triggered by Plan mode. Simply toggling does not create an empty plan file. Run `/plan` again to exit Plan mode. Type `!` in an empty input box to enter shell mode and run terminal commands directly; while a command is running, press `Ctrl+B` to move it to a background task. See [Interaction and input](../guides/interaction.md#shell-mode). From 129164dca54073e171927f477928023d26657f01 Mon Sep 17 00:00:00 2001 From: elkaix Date: Sat, 3 Oct 2026 12:13:09 -0400 Subject: [PATCH 3/9] feat(tui): draw the prompt as plain rules like pi --- .../src/tui/components/editor/custom-editor.ts | 16 ++++++++++++---- .../tui/components/editor/custom-editor.test.ts | 4 ++-- .../tui/components/editor/side-borders.test.ts | 13 +++++++++++++ .../test/tui/fullscreen-layout.test.ts | 9 +++++---- .../test/tui/pythinker-tui-message-flow.test.ts | 4 ++-- 5 files changed, 34 insertions(+), 12 deletions(-) diff --git a/apps/pythinker-code/src/tui/components/editor/custom-editor.ts b/apps/pythinker-code/src/tui/components/editor/custom-editor.ts index d68cfa5ec..0d0d1b6b6 100644 --- a/apps/pythinker-code/src/tui/components/editor/custom-editor.ts +++ b/apps/pythinker-code/src/tui/components/editor/custom-editor.ts @@ -344,6 +344,7 @@ export class CustomEditor extends Editor { return wrapWithSideBorders(lines, (s) => this.borderColor(s), { connectedAbove: this.connectedAbove && !this.borderHighlighted, label: isBash ? ` ${currentTheme.boldFg('shellMode', '! shell mode')} ` : undefined, + plain: !this.connectedAbove, }); } @@ -854,6 +855,9 @@ export function injectPromptSymbol( * only if they're literal spaces — that protects the cursor-overflow * case where the rightmost column is an SGR-tagged inverse cursor. * + * With `options.plain`, rule rows keep plain `─` ends and content rows get + * no side bars: the prompt reads as two horizontal rules, like pi. + * * When `options.label` is set, it is overlaid on the left of the top border * (e.g. the `! shell mode` badge), replacing the leading dashes. It is only * applied to a plain dash run, never to a `↑/↓ N more` scroll indicator. @@ -861,15 +865,19 @@ export function injectPromptSymbol( export function wrapWithSideBorders( lines: string[], paint: (s: string) => string, - options: { readonly connectedAbove?: boolean; readonly label?: string } = {}, + options: { + readonly connectedAbove?: boolean; + readonly label?: string; + readonly plain?: boolean; + } = {}, ): string[] { let seenTop = false; return lines.map((line) => { const plain = stripSgr(line); if (plain.length > 0 && plain[0] === '─') { const isTop = !seenTop; - const leftCorner = seenTop ? '╰' : options.connectedAbove === true ? '├' : '╭'; - const rightCorner = seenTop ? '╯' : options.connectedAbove === true ? '┤' : '╮'; + const leftCorner = options.plain === true ? '─' : seenTop ? '╰' : options.connectedAbove === true ? '├' : '╭'; + const rightCorner = options.plain === true ? '─' : seenTop ? '╯' : options.connectedAbove === true ? '┤' : '╮'; seenTop = true; if (plain.length === 1) return paint(leftCorner); const middle = plain.slice(1, -1); @@ -886,7 +894,7 @@ export function wrapWithSideBorders( } return paint(leftCorner + middle + rightCorner); } - if (line.length === 0) return line; + if (line.length === 0 || options.plain === true) return line; const firstCh = line[0]; const lastCh = line.at(-1); const head = firstCh === ' ' ? paint('│') : (firstCh ?? ''); diff --git a/apps/pythinker-code/test/tui/components/editor/custom-editor.test.ts b/apps/pythinker-code/test/tui/components/editor/custom-editor.test.ts index 80d05d7a3..58b9f4ad3 100644 --- a/apps/pythinker-code/test/tui/components/editor/custom-editor.test.ts +++ b/apps/pythinker-code/test/tui/components/editor/custom-editor.test.ts @@ -737,9 +737,9 @@ describe('CustomEditor bash mode border label', () => { const editor = makeEditor(); editor.inputMode = 'bash'; const top = stripAnsi(editor.render(90)[0] ?? ''); - expect(top.startsWith('╭')).toBe(true); + expect(top.startsWith('─')).toBe(true); expect(top).toContain('! shell mode'); - expect(top.endsWith('╮')).toBe(true); + expect(top.endsWith('─')).toBe(true); }); it('does not show the shell mode label in prompt mode', () => { diff --git a/apps/pythinker-code/test/tui/components/editor/side-borders.test.ts b/apps/pythinker-code/test/tui/components/editor/side-borders.test.ts index 6d3145b14..a06d968b0 100644 --- a/apps/pythinker-code/test/tui/components/editor/side-borders.test.ts +++ b/apps/pythinker-code/test/tui/components/editor/side-borders.test.ts @@ -103,4 +103,17 @@ describe('wrapWithSideBorders', () => { expect(out[0]).toContain('↑ 5 more'); expect(out[0]).not.toContain('shell mode'); }); + + it('draws plain rules without corners or side bars when plain', () => { + const paint = (s: string): string => `<${s}>`; + const out = wrapWithSideBorders(['─────', ' x ', '─────'], paint, { plain: true }); + expect(out).toEqual(['<─────>', ' x ', '<─────>']); + }); + + it('keeps the label overlay on a plain top rule', () => { + const top = '─'.repeat(30); + const out = wrapWithSideBorders([top, ' x ', top], id, { label: ' ! shell mode ', plain: true }); + expect(out[0]).toBe(`─ ! shell mode ${'─'.repeat(15)}`); + expect(out[0]).toHaveLength(top.length); + }); }); diff --git a/apps/pythinker-code/test/tui/fullscreen-layout.test.ts b/apps/pythinker-code/test/tui/fullscreen-layout.test.ts index 51359ac71..29b14d445 100644 --- a/apps/pythinker-code/test/tui/fullscreen-layout.test.ts +++ b/apps/pythinker-code/test/tui/fullscreen-layout.test.ts @@ -451,9 +451,10 @@ describe('fullscreen layout', () => { await vt.waitForRender(); const rows = screenRows(); - const promptRow = rows.findIndex((line) => /│\s*>/.test(line)); + const promptRow = rows.findIndex((line) => /^\s*>/.test(line)); expect(promptRow).toBeGreaterThan(0); - expect(rows[promptRow + 1]).toContain('╰'); + expect(rows[promptRow - 1]).toContain('───'); + expect(rows[promptRow + 1]).toContain('───'); state.ui.stop(); }); @@ -776,9 +777,9 @@ describe('fullscreen layout', () => { expect(pillLines(state)).toEqual([]); const rows = vt.getViewport(); expect(rows[0]).toContain('\u957F\u6587 30'); - expect(rows[1]).toContain('╭'); + expect(rows[1]).toContain('───'); expect(rows[2]).toContain('draft input'); - expect(rows[3]).toContain('╰'); + expect(rows[3]).toContain('───'); if (height === 5) expect(rows[4]).toContain('test-model'); state.ui.stop(); diff --git a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts index acf4eacff..0b233e456 100644 --- a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts +++ b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts @@ -5752,8 +5752,8 @@ command = "vim" expect(driver.state.btwPanelContainer.children).toHaveLength(0); expect(requestRender.mock.calls.at(-1)).toEqual([true]); const editorTopBorder = stripSgr(driver.state.editor.render(80)[0] ?? ''); - expect(editorTopBorder.startsWith('╭')).toBe(true); - expect(editorTopBorder.endsWith('╮')).toBe(true); + expect(editorTopBorder.startsWith('─')).toBe(true); + expect(editorTopBorder.endsWith('─')).toBe(true); expect(driver.state.editor.focused).toBe(true); }); From 9bd4e2c2c493c28133ca2e38b9092e6afc0c3394 Mon Sep 17 00:00:00 2001 From: elkaix Date: Sat, 3 Oct 2026 12:18:32 -0400 Subject: [PATCH 4/9] feat(tui): show the activity spinner inside the prompt's top rule --- .../tui/components/chrome/activity-spinner.ts | 4 + .../tui/components/editor/custom-editor.ts | 21 ++++- .../src/tui/components/panes/activity-pane.ts | 10 --- apps/pythinker-code/src/tui/pythinker-tui.ts | 5 ++ .../components/editor/custom-editor.test.ts | 39 ++++++++ .../components/panes/activity-pane.test.ts | 90 ++++--------------- .../tui/pythinker-tui-message-flow.test.ts | 2 +- 7 files changed, 84 insertions(+), 87 deletions(-) diff --git a/apps/pythinker-code/src/tui/components/chrome/activity-spinner.ts b/apps/pythinker-code/src/tui/components/chrome/activity-spinner.ts index 3eb0dfd89..59d752c6e 100644 --- a/apps/pythinker-code/src/tui/components/chrome/activity-spinner.ts +++ b/apps/pythinker-code/src/tui/components/chrome/activity-spinner.ts @@ -99,6 +99,10 @@ export class ActivitySpinner extends Text { return this.inlineText; } + renderLine(): string { + return this.displayText; + } + private updateDisplay(): void { if (this.useVerbLabels) this.label = formatThinkingSpinnerLabel(); const frame = this.frames[this.currentFrame]!; diff --git a/apps/pythinker-code/src/tui/components/editor/custom-editor.ts b/apps/pythinker-code/src/tui/components/editor/custom-editor.ts index 0d0d1b6b6..5a0cc29b6 100644 --- a/apps/pythinker-code/src/tui/components/editor/custom-editor.ts +++ b/apps/pythinker-code/src/tui/components/editor/custom-editor.ts @@ -8,11 +8,13 @@ import { matchesKey, Key, SelectList, + truncateToWidth, visibleWidth, type SelectItem, type TUI, } from '@pymodel/pi-tui'; +import type { ActivitySpinner } from '#/tui/components/chrome/activity-spinner'; import { currentTheme } from '#/tui/theme'; import { createEditorTheme } from '#/tui/theme/pi-tui-theme'; import { printableChar } from '#/tui/utils/printable-key'; @@ -154,6 +156,8 @@ export class CustomEditor extends Editor { public inputMode: 'prompt' | 'bash' = 'prompt'; public onInputModeChange?: (mode: 'prompt' | 'bash') => void; public connectedAbove = false; + /** Activity spinner drawn inside the top rule while the agent works (pi style). */ + public ruleStatus: ActivitySpinner | undefined; public borderHighlighted = false; /** * Called when the user triggers "paste image" (Ctrl-V on Unix, @@ -341,11 +345,16 @@ export class CustomEditor extends Editor { // overwrite it (e.g. plan-mode / slash-context highlight via // `editor.borderColor = chalk.hex(primary)`), so we route corners and // side bars through the same hook to stay in sync. - return wrapWithSideBorders(lines, (s) => this.borderColor(s), { + const wrapped = wrapWithSideBorders(lines, (s) => this.borderColor(s), { connectedAbove: this.connectedAbove && !this.borderHighlighted, label: isBash ? ` ${currentTheme.boldFg('shellMode', '! shell mode')} ` : undefined, plain: !this.connectedAbove, }); + const top = wrapped[0]; + if (this.ruleStatus !== undefined && !isBash && !this.connectedAbove && top !== undefined && /^─+$/.test(stripSgr(top))) { + wrapped[0] = ruleWithStatus(this.ruleStatus, width, (s) => this.borderColor(s)); + } + return wrapped; } private computeArgumentHint(): string | undefined { @@ -843,6 +852,16 @@ export function injectPromptSymbol( return ' ' + rendered + ' ' + line.slice(4); } +function ruleWithStatus( + status: ActivitySpinner, + width: number, + paint: (s: string) => string, +): string { + status.setAvailableWidth(Math.max(1, width - 4)); + const text = truncateToWidth(status.renderLine(), Math.max(1, width - 4), ''); + return paint('── ') + text + ' ' + paint('─'.repeat(Math.max(0, width - 4 - visibleWidth(text)))); +} + /** * Post-process pi-tui's editor output to draw a full box around it. * diff --git a/apps/pythinker-code/src/tui/components/panes/activity-pane.ts b/apps/pythinker-code/src/tui/components/panes/activity-pane.ts index 06d1fa83b..f31f303fd 100644 --- a/apps/pythinker-code/src/tui/components/panes/activity-pane.ts +++ b/apps/pythinker-code/src/tui/components/panes/activity-pane.ts @@ -19,11 +19,8 @@ export function formatActivitySpinnerTip(tip: string | undefined): string { } export class ActivityPaneComponent extends Container { - private spinnerRef?: ActivitySpinner; - constructor(options: ActivityPaneOptions) { super(); - this.spinnerRef = options.spinner; if ( (options.mode === 'waiting' || @@ -34,17 +31,10 @@ export class ActivityPaneComponent extends Container { ) { this.addChild(new Spacer(1)); options.spinner.setTip(formatActivitySpinnerTip(options.tip)); - this.addChild(options.spinner); if (options.detail !== undefined && options.detail.length > 0) { this.addChild(new Text(currentTheme.fg('textDim', options.detail), ACTIVITY_DETAIL_INDENT, 0)); } } } - override render(width: number): string[] { - if (this.spinnerRef && 'setAvailableWidth' in this.spinnerRef) { - this.spinnerRef.setAvailableWidth(width); - } - return super.render(width); - } } diff --git a/apps/pythinker-code/src/tui/pythinker-tui.ts b/apps/pythinker-code/src/tui/pythinker-tui.ts index 33f08e13a..92ca3ca90 100644 --- a/apps/pythinker-code/src/tui/pythinker-tui.ts +++ b/apps/pythinker-code/src/tui/pythinker-tui.ts @@ -3473,6 +3473,7 @@ export class PythinkerTUI { this.lastActivityMode = activityModeKey; this.state.activityContainer.clear(); + this.state.editor.ruleStatus = undefined; switch (effectiveMode) { case 'hidden': @@ -3485,6 +3486,7 @@ export class PythinkerTUI { const spinner = this.ensureActivitySpinner(waitingSpinnerLabel(stepRetry)); this.syncAgentDynamicWorkflowActivitySpinner(placeSpinnerInAgentDynamicWorkflow ? spinner : undefined); if (placeSpinnerInAgentDynamicWorkflow) break; + this.state.editor.ruleStatus = spinner; this.state.activityContainer.addChild( new ActivityPaneComponent({ mode: 'waiting', @@ -3500,6 +3502,7 @@ export class PythinkerTUI { currentTheme.fg('primary', s), ); this.syncAgentDynamicWorkflowActivitySpinner(undefined); + this.state.editor.ruleStatus = spinner; this.state.activityContainer.addChild( new ActivityPaneComponent({ mode: 'thinking', @@ -3514,6 +3517,7 @@ export class PythinkerTUI { currentTheme.fg('primary', s), ); this.syncAgentDynamicWorkflowActivitySpinner(undefined); + this.state.editor.ruleStatus = spinner; this.state.activityContainer.addChild( new ActivityPaneComponent({ mode: 'composing', @@ -3527,6 +3531,7 @@ export class PythinkerTUI { const spinner = this.ensureActivitySpinner(); this.syncAgentDynamicWorkflowActivitySpinner(placeSpinnerInAgentDynamicWorkflow ? spinner : undefined); if (placeSpinnerInAgentDynamicWorkflow) break; + this.state.editor.ruleStatus = spinner; this.state.activityContainer.addChild( new ActivityPaneComponent({ mode: 'tool', diff --git a/apps/pythinker-code/test/tui/components/editor/custom-editor.test.ts b/apps/pythinker-code/test/tui/components/editor/custom-editor.test.ts index 58b9f4ad3..e17fa2e57 100644 --- a/apps/pythinker-code/test/tui/components/editor/custom-editor.test.ts +++ b/apps/pythinker-code/test/tui/components/editor/custom-editor.test.ts @@ -6,6 +6,7 @@ import type { } from '@pymodel/pi-tui'; import { describe, expect, it, vi } from 'vitest'; +import type { ActivitySpinner } from '#/tui/components/chrome/activity-spinner'; import { CustomEditor } from '#/tui/components/editor/custom-editor'; import { FileMentionProvider } from '#/tui/components/editor/file-mention-provider'; @@ -877,3 +878,41 @@ describe('CustomEditor bash mode file completion', () => { expect(calls.every((call) => call.force === true)).toBe(true); }); }); + +describe('CustomEditor activity status in the top rule', () => { + const strip = (s: string): string => s.replaceAll(/\u001B\[[0-9;]*m/g, ''); + const statusSpinner = (line: string) => { + let width = 0; + return { + setAvailableWidth(value: number) { + width = value; + }, + renderLine() { + return width > 0 && line.length > width ? line.slice(0, width) : line; + }, + } as unknown as ActivitySpinner; + }; + + it('draws a plain rule when idle', () => { + const editor = makeEditor(); + expect(strip(editor.render(40)[0] ?? '')).toBe('─'.repeat(40)); + }); + + it('draws the spinner line inside the top rule while working', () => { + const editor = makeEditor(); + editor.ruleStatus = statusSpinner('⠼ Working…'); + const top = strip(editor.render(40)[0] ?? ''); + expect(top.startsWith('── ⠼ Working… ─')).toBe(true); + expect(top).toHaveLength(40); + }); + + it('keeps the shell mode label instead of the status in bash mode', () => { + const editor = makeEditor(); + editor.ruleStatus = statusSpinner('⠼ Working…'); + editor.inputMode = 'bash'; + const top = strip(editor.render(60)[0] ?? ''); + expect(top).toContain('! shell mode'); + expect(top).not.toContain('Working'); + }); +}); + diff --git a/apps/pythinker-code/test/tui/components/panes/activity-pane.test.ts b/apps/pythinker-code/test/tui/components/panes/activity-pane.test.ts index 2665fc303..18cb8c76c 100644 --- a/apps/pythinker-code/test/tui/components/panes/activity-pane.test.ts +++ b/apps/pythinker-code/test/tui/components/panes/activity-pane.test.ts @@ -21,43 +21,27 @@ function createMockSpinner(initialText = 'working') { availableWidth = width; update(); }, + renderLine() { + return spinner.render(availableWidth > 0 ? availableWidth : 200)[0] ?? ''; + }, }) as unknown as import('#/tui/components/chrome/activity-spinner').ActivitySpinner, getTip: () => tip, }; } describe('ActivityPaneComponent', () => { - it('renders the waiting loader after a spacer', () => { - const { spinner } = createMockSpinner('loading'); - const component = new ActivityPaneComponent({ - mode: 'waiting', - spinner, - }); - - expect(component.render(80).map((line) => line.trimEnd())).toEqual(['', 'loading']); - }); - - it('renders the composing spinner after a spacer', () => { - const { spinner } = createMockSpinner('working'); - const component = new ActivityPaneComponent({ - mode: 'composing', - spinner, - }); - - expect(component.render(80).map((line) => line.trimEnd())).toEqual(['', 'working']); - }); - - it('renders the thinking spinner after a spacer', () => { - const { spinner } = createMockSpinner('thinking'); - const component = new ActivityPaneComponent({ - mode: 'thinking', - spinner, - }); + it.each(['waiting', 'tool', 'composing', 'thinking'] as const)( + 'keeps one spacer row for %s; the spinner is drawn in the editor rule', + (mode) => { + const { spinner, getTip } = createMockSpinner('working'); + const component = new ActivityPaneComponent({ mode, spinner, tip: 'ctrl+s: steer mid-turn' }); - expect(component.render(80).map((line) => line.trimEnd())).toEqual(['', 'thinking']); - }); + expect(component.render(80).map((line) => line.trimEnd())).toEqual(['']); + expect(getTip()).toBe(' · Tip: ctrl+s: steer mid-turn'); + }, + ); - it('renders the detail line under the waiting spinner', () => { + it('renders the detail line under the spacer', () => { const { spinner } = createMockSpinner('working'); const component = new ActivityPaneComponent({ mode: 'waiting', @@ -68,56 +52,12 @@ describe('ActivityPaneComponent', () => { const lines = component .render(80) .map((line) => line.replaceAll(/\u001B\[[0-9;]*m/g, '').trimEnd()); - expect(lines).toEqual(['', 'working', ' 429 · rate limited']); + expect(lines).toEqual(['', ' 429 · rate limited']); }); - it.each(['waiting', 'tool', 'composing', 'thinking'] as const)( - 'renders the %s spinner with tip after a spacer', - (mode) => { - const { spinner } = createMockSpinner('working'); - const component = new ActivityPaneComponent({ - mode, - spinner, - tip: 'ctrl+s: steer mid-turn', - }); - - expect(component.render(80).map((line) => line.trimEnd())).toEqual([ - '', - 'working · Tip: ctrl+s: steer mid-turn', - ]); - }, - ); - - it.each(['waiting', 'tool', 'composing'] as const)( - 'does not render a tip for %s when none is provided', - (mode) => { - const { spinner } = createMockSpinner('working'); - const component = new ActivityPaneComponent({ - mode, - spinner, - }); - - expect(component.render(80).map((line) => line.trimEnd())).toEqual(['', 'working']); - }, - ); - it('renders nothing for hidden, or for thinking without a spinner', () => { expect(new ActivityPaneComponent({ mode: 'hidden' }).render(80)).toEqual([]); expect(new ActivityPaneComponent({ mode: 'thinking' }).render(80)).toEqual([]); }); - - it.each(['waiting', 'tool', 'composing'] as const)( - 'hides the tip for %s when the terminal is too narrow', - (mode) => { - const { spinner } = createMockSpinner('working'); - const component = new ActivityPaneComponent({ - mode, - spinner, - tip: 'ctrl+s: steer mid-turn', - }); - - // Width 8 is exactly the width of "working" (no spinner frame in the mock). - expect(component.render(8).map((line) => line.trimEnd())).toEqual(['', 'working']); - }, - ); }); + diff --git a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts index 0b233e456..6e168da79 100644 --- a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts +++ b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts @@ -415,7 +415,7 @@ async function confirmUndoSelection(driver: MessageDriver): Promise { } function renderActivity(driver: MessageDriver): string { - return driver.state.activityContainer.render(120).join('\n'); + return [...driver.state.activityContainer.render(120), driver.state.editor.render(120)[0] ?? ''].join('\n'); } function renderBtwPanel(driver: MessageDriver): string { From 028a3f5d1549a07654f867e4c45c496f47aa7c28 Mon Sep 17 00:00:00 2001 From: elkaix Date: Sat, 3 Oct 2026 12:18:37 -0400 Subject: [PATCH 5/9] chore: add changesets for the pi-style dock and effort cycle --- .changeset/pi-style-dock.md | 5 +++++ .changeset/shift-tab-effort.md | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 .changeset/pi-style-dock.md create mode 100644 .changeset/shift-tab-effort.md diff --git a/.changeset/pi-style-dock.md b/.changeset/pi-style-dock.md new file mode 100644 index 000000000..cb1d6b1c2 --- /dev/null +++ b/.changeset/pi-style-dock.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": minor +--- + +The TUI now opens in fullscreen by default with the prompt fixed at the bottom, drawn as two plain rules with the working spinner inside the top rule; set `tui_mode = "regular"` in `tui.toml` to keep the chat in native terminal scrollback (existing `tui.toml` files keep their saved mode). diff --git a/.changeset/shift-tab-effort.md b/.changeset/shift-tab-effort.md new file mode 100644 index 000000000..6cc6f35d4 --- /dev/null +++ b/.changeset/shift-tab-effort.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": minor +--- + +Shift-Tab now cycles the current model's thinking effort (for example low, medium, high, xhigh, max) and the prompt frame takes that effort's color; Shift-Tab no longer toggles plan mode, so use `/plan` instead. From af23bce6993fbd986929cc5124bcbd83a8d7b6ed Mon Sep 17 00:00:00 2001 From: elkaix Date: Sat, 3 Oct 2026 12:20:37 -0400 Subject: [PATCH 6/9] feat(tui): show one blinking MCP loading line with pending server names --- .changeset/quiet-mcp-loading-line.md | 5 + .../components/messages/mcp-loading-line.ts | 43 +++++++ .../tui/controllers/session-event-handler.ts | 109 +++++++----------- .../tui/pythinker-tui-message-flow.test.ts | 52 +++++++-- 4 files changed, 131 insertions(+), 78 deletions(-) create mode 100644 .changeset/quiet-mcp-loading-line.md create mode 100644 apps/pythinker-code/src/tui/components/messages/mcp-loading-line.ts diff --git a/.changeset/quiet-mcp-loading-line.md b/.changeset/quiet-mcp-loading-line.md new file mode 100644 index 000000000..f214346ab --- /dev/null +++ b/.changeset/quiet-mcp-loading-line.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +The TUI now shows one "Loading MCP: …" line with a blinking dot and the names of the servers still connecting, and removes it once all have loaded, instead of one line per connected server; failures and servers that need OAuth still get their own line. diff --git a/apps/pythinker-code/src/tui/components/messages/mcp-loading-line.ts b/apps/pythinker-code/src/tui/components/messages/mcp-loading-line.ts new file mode 100644 index 000000000..7f4657b4a --- /dev/null +++ b/apps/pythinker-code/src/tui/components/messages/mcp-loading-line.ts @@ -0,0 +1,43 @@ +import { Text } from '@pymodel/pi-tui'; +import type { TUI } from '@pymodel/pi-tui'; + +import { STATUS_BULLET } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; + +const BLINK_INTERVAL_MS = 500; + +/** Transient "Loading MCP: a, b" row with the same blinking bullet as running tools. */ +export class McpLoadingLine extends Text { + private blinkOn = true; + private names: readonly string[]; + private readonly timer: ReturnType; + + constructor( + private readonly ui: TUI, + names: readonly string[], + ) { + super('', 1, 0); + this.names = names; + this.refresh(); + this.timer = setInterval(() => { + this.blinkOn = !this.blinkOn; + this.refresh(); + }, BLINK_INTERVAL_MS); + } + + setNames(names: readonly string[]): void { + this.names = names; + this.refresh(); + } + + stop(): void { + clearInterval(this.timer); + } + + private refresh(): void { + const bullet = this.blinkOn ? currentTheme.fg('text', STATUS_BULLET) : ' '; + const label = currentTheme.fg('textMuted', `Loading MCP: ${this.names.join(', ')}`); + this.setText(`${bullet}${label}`); + this.ui.requestRender(); + } +} diff --git a/apps/pythinker-code/src/tui/controllers/session-event-handler.ts b/apps/pythinker-code/src/tui/controllers/session-event-handler.ts index 3372ae120..1798479e2 100644 --- a/apps/pythinker-code/src/tui/controllers/session-event-handler.ts +++ b/apps/pythinker-code/src/tui/controllers/session-event-handler.ts @@ -35,7 +35,6 @@ import type { import { ActivitySpinner } from '../components/chrome/activity-spinner'; import { buildGoalMarker } from '../components/messages/goal-markers'; -import { StatusMessageComponent } from '../components/messages/status-message'; import { DynamicWorkflowModeMarkerComponent, type DynamicWorkflowModeMarkerState, @@ -69,7 +68,7 @@ import { selectMcpStartupStatusRows, } from '../utils/mcp-server-status'; import { openUrl } from '#/utils/open-url'; -import { currentTheme } from '#/tui/theme'; +import { McpLoadingLine } from '../components/messages/mcp-loading-line'; import type { ColorToken } from '#/tui/theme'; import { errorReportHintLine } from '../constant/feedback'; import { formatStepDebugTiming } from '#/utils/usage/debug-timing'; @@ -165,7 +164,7 @@ export class SessionEventHandler { renderedSkillActivationIds: Set = new Set(); renderedPluginCommandActivationIds: Set = new Set(); renderedMcpServerStatusKeys: Map = new Map(); - mcpServerStatusSpinners: Map = new Map(); + mcpLoadingLine: McpLoadingLine | undefined; mcpServers: Map = new Map(); private goalCompletionAwaitingClear = false; private goalCompletionTurnEnded = false; @@ -252,9 +251,10 @@ export class SessionEventHandler { this.renderMcpServerStatus(server); } + const live = new Map(this.mcpServers); this.mcpServers.clear(); for (const server of servers) { - this.mcpServers.set(server.name, server); + this.mcpServers.set(server.name, live.get(server.name) ?? server); } const hidden: McpServerStatusSnapshot[] = []; for (const server of servers) { @@ -263,8 +263,9 @@ export class SessionEventHandler { this.renderedMcpServerStatusKeys.set(server.name, mcpServerStatusKey(server)); hidden.push(server); } - const summary = formatMcpStartupStatusSummary(servers); + const summary = formatMcpStartupStatusSummary([...this.mcpServers.values()]); host.setAppState({ mcpServersSummary: summary || null }); + this.updateMcpLoadingRow(); } handleEvent(event: Event, sendQueued: (item: QueuedMessage) => void): void { @@ -321,10 +322,7 @@ export class SessionEventHandler { } stopAllMcpServerStatusSpinners(): void { - for (const spinner of this.mcpServerStatusSpinners.values()) { - spinner.stop(); - } - this.mcpServerStatusSpinners.clear(); + this.removeMcpLoadingRow(); } // --------------------------------------------------------------------------- @@ -1018,77 +1016,52 @@ export class SessionEventHandler { this.host.setAppState({ mcpServersSummary: summary || null }); switch (server.status) { - case 'connected': { - const toolStr = `${server.toolCount} tool${server.toolCount === 1 ? '' : 's'}`; - const message = `MCP server "${server.name}" connected · ${toolStr} (${server.transport})`; - this.finalizeMcpServerStatusRow(server.name, message, 'success'); - return; - } - case 'failed': { - const message = `MCP server "${server.name}" failed${server.error !== undefined ? `: ${server.error}` : ''}`; - this.finalizeMcpServerStatusRow(server.name, message, 'error'); - return; - } - case 'needs-auth': { - const message = `MCP server "${server.name}" needs OAuth — run /mcp-config login ${server.name}`; - this.finalizeMcpServerStatusRow(server.name, message, 'warning'); - return; - } - case 'disabled': - this.finalizeMcpServerStatusRow( - server.name, - `MCP server "${server.name}" disabled`, - 'textMuted', + case 'failed': + this.host.showStatus( + `MCP server "${server.name}" failed${server.error !== undefined ? `: ${server.error}` : ''}`, + 'error', ); - return; - case 'removed': - this.finalizeMcpServerStatusRow( - server.name, - `MCP server "${server.name}" removed`, - 'textMuted', + break; + case 'needs-auth': + this.host.showStatus( + `MCP server "${server.name}" needs OAuth — run /mcp-config login ${server.name}`, + 'warning', ); - return; - case 'pending': - this.showMcpServerStatusSpinner(server.name); - return; + break; + default: + break; } + this.updateMcpLoadingRow(); } - private showMcpServerStatusSpinner(name: string): void { + // One transient row while servers connect; it leaves the transcript once none + // is pending. Connected servers are summarised in the welcome card instead. + private updateMcpLoadingRow(): void { const { state } = this.host; - const label = `MCP server "${name}" connecting…`; - const existing = this.mcpServerStatusSpinners.get(name); - if (existing !== undefined) { - existing.setLabel(label); + const pending = [...this.mcpServers.values()] + .filter((server) => server.status === 'pending') + .map((server) => server.name); + if (pending.length === 0) { + this.removeMcpLoadingRow(); + return; + } + if (this.mcpLoadingLine !== undefined) { + this.mcpLoadingLine.setNames(pending); return; } - const tint = (s: string): string => currentTheme.fg('textMuted', s); - const spinner = new ActivitySpinner(state.ui, tint, label); - state.transcriptContainer.addChild(spinner); - this.mcpServerStatusSpinners.set(name, spinner); + this.mcpLoadingLine = new McpLoadingLine(state.ui, pending); + state.transcriptContainer.addChild(this.mcpLoadingLine); state.ui.requestRender(); } - private finalizeMcpServerStatusRow(name: string, message: string, color: ColorToken): void { - const { state } = this.host; - const spinner = this.mcpServerStatusSpinners.get(name); - if (spinner === undefined) { - this.host.showStatus(message, color); - return; - } + private removeMcpLoadingRow(): void { + const spinner = this.mcpLoadingLine; + if (spinner === undefined) return; spinner.stop(); - const status = new StatusMessageComponent(message, color); - const children = state.transcriptContainer.children; - const idx = children.indexOf(spinner); - if (idx >= 0) { - // In-place replacement is picked up by the container's ref-checked - // render cache; a tree-wide invalidate is unnecessary (and costly). - children[idx] = status; - } else { - state.transcriptContainer.addChild(status); - } - this.mcpServerStatusSpinners.delete(name); - state.ui.requestRender(); + // oxlint-disable-next-line unicorn/prefer-dom-node-remove + this.host.state.transcriptContainer.removeChild(spinner); + this.mcpLoadingLine = undefined; + this.host.state.ui.requestRender(); } private handleSkillActivated(event: SkillActivatedEvent): void { diff --git a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts index 6e168da79..b0f4b125a 100644 --- a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts +++ b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts @@ -2786,12 +2786,12 @@ command = "vim" } expect(subscribeOrder).toBeLessThan(snapshotOrder); const transcript = renderTranscript(driver); - expect(transcript).toContain('MCP server "local-tools" connected'); - expect(transcript).toContain('2 tools (stdio)'); + expect(transcript).not.toContain('MCP server "local-tools"'); + expect(transcript).not.toContain('Loading MCP'); expect(transcript).toContain('MCP server "remote-tools" failed: connection refused'); }); - it('deduplicates identical MCP status updates while allowing reconnect transitions', async () => { + it('shows one transient MCP loading line that disappears once servers connect', async () => { const eventListeners: Array<(event: Event) => void> = []; const connectedServer = { name: 'local-tools', @@ -2817,9 +2817,7 @@ command = "vim" server: connectedServer, } as Event); - expect(countOccurrences(renderTranscript(driver), 'MCP server "local-tools" connected')).toBe( - 1, - ); + expect(renderTranscript(driver)).not.toContain('MCP server "local-tools"'); eventListeners[0]?.({ type: 'mcp.server.status', @@ -2831,6 +2829,8 @@ command = "vim" toolCount: 0, }, } as Event); + expect(countOccurrences(renderTranscript(driver), 'Loading MCP: local-tools')).toBe(1); + eventListeners[0]?.({ type: 'mcp.server.status', agentId: 'main', @@ -2838,9 +2838,41 @@ command = "vim" server: connectedServer, } as Event); - expect(countOccurrences(renderTranscript(driver), 'MCP server "local-tools" connected')).toBe( - 2, - ); + const transcript = renderTranscript(driver); + expect(transcript).not.toContain('Loading MCP'); + expect(transcript).not.toContain('MCP server "local-tools"'); + }); + + it('lists loading MCP server names and drops each one as it connects', async () => { + const eventListeners: Array<(event: Event) => void> = []; + const server = (name: string, status: 'pending' | 'connected') => ({ + type: 'mcp.server.status', + agentId: 'main', + sessionId: 'ses-1', + server: { name, transport: 'http', status, toolCount: status === 'connected' ? 1 : 0 }, + }) as Event; + const session = makeSession({ + onEvent: vi.fn((listener: (event: Event) => void) => { + eventListeners.push(listener); + return vi.fn(); + }), + listMcpServers: vi.fn(async () => []), + }); + const { driver } = await makeDriver(session); + driver.sessionEventHandler.startSubscription(); + await Promise.resolve(); + + eventListeners[0]?.(server('alpha', 'pending')); + eventListeners[0]?.(server('beta', 'pending')); + expect(renderTranscript(driver)).toContain('Loading MCP: alpha, beta'); + + eventListeners[0]?.(server('alpha', 'connected')); + const partial = renderTranscript(driver); + expect(partial).toContain('Loading MCP: beta'); + expect(partial).not.toContain('alpha'); + + eventListeners[0]?.(server('beta', 'connected')); + expect(renderTranscript(driver)).not.toContain('Loading MCP'); }); it('does not let a late MCP snapshot overwrite a live status event', async () => { @@ -2890,8 +2922,8 @@ command = "vim" await Promise.resolve(); const transcript = renderTranscript(driver); - expect(transcript).toContain('MCP server "local-tools" connected'); expect(transcript).not.toContain('stale failure'); + expect(driver.sessionEventHandler.mcpServers.get('local-tools')?.status).toBe('connected'); }); it('sends normal editor input to the active session and marks the turn as waiting', async () => { From 94f4e626e683e09fdbcaa979716dd90de9799a37 Mon Sep 17 00:00:00 2001 From: elkaix Date: Sat, 3 Oct 2026 12:20:37 -0400 Subject: [PATCH 7/9] chore(pi-tui): sync vendored pi-tui to upstream v1.0.1 --- .changeset/pi-tui-upstream-v1-0-1.md | 5 + packages/pi-tui/README.md | 103 ++++- packages/pi-tui/UPSTREAM.md | 3 +- packages/pi-tui/native/clipboard.h | 42 +- packages/pi-tui/native/darwin/README.md | 2 +- .../darwin-arm64/darwin-platform.node | Bin 51480 -> 52024 bytes .../prebuilds/darwin-x64/darwin-platform.node | Bin 18144 -> 22792 bytes .../native/darwin/src/darwin-platform.m | 39 ++ packages/pi-tui/native/napi.h | 2 + packages/pi-tui/src/autocomplete.ts | 77 +++- packages/pi-tui/src/colors.ts | 367 ++++++++++++++++++ packages/pi-tui/src/components/box.ts | 18 +- packages/pi-tui/src/components/editor.ts | 21 +- packages/pi-tui/src/components/image.ts | 44 ++- packages/pi-tui/src/components/markdown.ts | 16 +- packages/pi-tui/src/components/text.ts | 3 +- packages/pi-tui/src/fuzzy.ts | 47 +-- packages/pi-tui/src/index.ts | 42 +- packages/pi-tui/src/latex.ts | 146 ++++++- packages/pi-tui/src/native-platform.ts | 2 + packages/pi-tui/src/oklab.ts | 233 +++++++++++ packages/pi-tui/src/terminal-colors.ts | 34 +- packages/pi-tui/src/terminal-image.ts | 87 ++++- packages/pi-tui/src/terminal.ts | 25 +- packages/pi-tui/src/tui-alt-screen.ts | 117 ++++-- packages/pi-tui/src/tui.ts | 187 +++++---- packages/pi-tui/src/utils.ts | 162 +++++--- packages/pi-tui/src/wheel-scroll.ts | 82 ++++ .../test/autocomplete-skill-slash.test.ts | 98 +++++ packages/pi-tui/test/autocomplete.test.ts | 302 ++++++++++++-- packages/pi-tui/test/colors.test.ts | 47 +++ packages/pi-tui/test/editor.test.ts | 215 ++++++++++ packages/pi-tui/test/image-test.ts | 2 +- packages/pi-tui/test/latex.test.ts | 32 +- packages/pi-tui/test/markdown.test.ts | 22 +- packages/pi-tui/test/mouse-components.test.ts | 62 +++ packages/pi-tui/test/overlay-options.test.ts | 41 ++ ...ression-slice-by-column-ansi-order.test.ts | 17 + packages/pi-tui/test/terminal-colors.test.ts | 191 ++++----- packages/pi-tui/test/terminal-image.test.ts | 220 ++++++++++- packages/pi-tui/test/terminal.test.ts | 15 + packages/pi-tui/test/tui-alt-screen.test.ts | 143 ++++++- .../pi-tui/test/viewport-overwrite-repro.ts | 4 +- packages/pi-tui/test/visible-width.test.ts | 26 ++ packages/pi-tui/test/wheel-scroll.test.ts | 47 +++ 45 files changed, 2942 insertions(+), 448 deletions(-) create mode 100644 .changeset/pi-tui-upstream-v1-0-1.md create mode 100644 packages/pi-tui/src/colors.ts create mode 100644 packages/pi-tui/src/oklab.ts create mode 100644 packages/pi-tui/src/wheel-scroll.ts create mode 100644 packages/pi-tui/test/autocomplete-skill-slash.test.ts create mode 100644 packages/pi-tui/test/colors.test.ts create mode 100644 packages/pi-tui/test/regression-slice-by-column-ansi-order.test.ts create mode 100644 packages/pi-tui/test/visible-width.test.ts create mode 100644 packages/pi-tui/test/wheel-scroll.test.ts diff --git a/.changeset/pi-tui-upstream-v1-0-1.md b/.changeset/pi-tui-upstream-v1-0-1.md new file mode 100644 index 000000000..06c04bbf3 --- /dev/null +++ b/.changeset/pi-tui-upstream-v1-0-1.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Update the terminal UI library to upstream pi v1.0.1, which keeps WezTerm images while scrolling, converts non-PNG images for Kitty, and fixes ANSI color order when a styled line is cut. diff --git a/packages/pi-tui/README.md b/packages/pi-tui/README.md index 0ded7666f..9c9678362 100644 --- a/packages/pi-tui/README.md +++ b/packages/pi-tui/README.md @@ -68,6 +68,91 @@ tui.requestRender(); // Request a re-render tui.onDebug = () => console.log("Debug triggered"); ``` +### Colors and terminal styles + +Colors are values that can be converted or mixed before terminal rendering: + +```typescript +import { + colorToRgb, + foregroundAnsi, + getTerminalColorMode, + mixColors, + parseColor, + rgbColor, + styleText, +} from "@earendil-works/pi-tui"; + +const accent = parseColor("oklch(70% 0.12 220)"); +const background = parseColor("#20242a"); +const foreground = mixColors(accent, background, 0.2); + +const text = styleText( + "Ready", + { fg: foreground, bg: background, bold: true }, + getTerminalColorMode(), +); +``` + +`Color` is an indexed ANSI color, an sRGB color, or an OKLCH color. Every color converts to sRGB, so color math such as `mixColors()` always works. Indices 0-15 follow the user's terminal palette, so their sRGB values are approximations. `styleText()` converts colors to truecolor or 256-color output based on the requested terminal mode. + +`parseColor()` also accepts OKHSL, as in `okhsl(250 60% 55%)`; `okhslColor()` builds it in code and `colorToOkhsl()` reads any color's OKHSL channels. OKHSL saturation is relative to the most the sRGB gamut allows at the hue and lightness, so every value is in gamut and equal saturation looks equally colorful across hues. OKHSL colors are converted to sRGB when created. + +Conversions are not cached. OKLCH colors, especially ones outside the sRGB gamut, are more expensive to convert than sRGB or indexed colors. For colors used on every render, convert once and reuse the result: + +```typescript +const { r, g, b } = colorToRgb(mixColors(accent, background, 0.2)); +const foreground = rgbColor(r, g, b); // cheap to render repeatedly +const foregroundCode = foregroundAnsi(foreground, getTerminalColorMode()); +``` + +### Alternate-screen viewport layouts + +`TuiAltScreen` can render an explicit terminal-height layout. `VStack` and `HStack` allocate constrained regions, while `ScrollView` owns scrolling for one region. These semantics are intentionally unavailable on `TuiMainScreen`, where the terminal owns scrollback. + +```typescript +import { + Container, + isViewportTUI, + ScrollView, + Text, + VStack, +} from "@earendil-works/pi-tui"; + +const transcript = new Container(); +transcript.addChild(new Text("History")); + +const editorAndFooter = new VStack([ + editor, + new Text("status"), +]); + +if (isViewportTUI(tui)) { + tui.setLayoutRoot(new VStack([ + { + component: new ScrollView(transcript, { + follow: "end", + primary: true, + overscroll: "chain", + }), + basis: 0, + grow: 1, + minSize: 1, + }, + { + component: editorAndFooter, + basis: "auto", + shrink: 1, + minSize: 1, + }, + ])); +} +``` + +Stack entries support `basis`, `grow`, `shrink`, `minSize`, `maxSize`, and responsive `visible` callbacks. Mouse-wheel input targets the scroll view under the pointer and unused delta chains to outer scroll views by default. The primary scroll view receives the alternate-screen keyboard navigation actions and wheel input over non-scrollable regions. It can also jump between OSC 133 semantic prompt markers, matching common terminal prompt-navigation shortcuts. Press `Ctrl+Shift+F` to open or close its bordered search panel. The panel shows the configured previous/next shortcuts and provides clickable arrow controls; by default, `Enter`/`Ctrl+G` and `Shift+Enter`/`Ctrl+Shift+G` move between matches, and `Escape` also closes search. `TuiAltScreenOptions.searchMatchStyle` and `searchCurrentMatchStyle` customize match highlighting, while `searchNavigationButtonStyle` styles each arrow button and receives its hover state. `TuiAltScreenOptions.scrollToEndIndicator` renders a clickable label centered on the last row of a `follow: "end"` primary scroll view while it is scrolled away from the end; clicking it resumes end-following. + +Layout geometry is rebuilt for each requested frame. Stateful components are retained, and their existing rendered-line caches remain effective. Calling `render(width)` directly on these layout components produces an unbounded document, which is also used when alt mode restores the main screen. + ### Overlays Overlays render components on top of existing content without replacing it. Useful for dialogs, menus, and modal UI. @@ -154,7 +239,8 @@ All components implement: interface Component { render(width: number): string[]; handleInput?(data: string): void; - invalidate?(): void; + handleMouse?(event: TuiMouseEvent): TuiMouseEventResult | undefined; + invalidate(): void; } ``` @@ -162,7 +248,8 @@ interface Component { |--------|-------------| | `render(width)` | Returns an array of strings, one per line. Each line **must not exceed `width`** or the TUI will error. Use `truncateToWidth()` or manual wrapping to ensure this. | | `handleInput?(data)` | Called when the component has focus and receives keyboard input. The `data` string contains raw terminal input (may include ANSI escape sequences). | -| `invalidate?()` | Called to clear any cached render state. Components should re-render from scratch on the next `render()` call. | +| `handleMouse?(event)` | Called by `TuiAltScreen` for normalized pointer input targeted at the component. | +| `invalidate()` | Required. Clear any cached render state so the next `render()` starts from scratch. Components without cached render state can use an empty implementation. | The TUI appends a full SGR reset and OSC 8 reset at the end of each rendered line. Styles do not carry across lines. If you emit multi-line text with styling, reapply styles per line or use `wrapTextWithAnsi()` so styles are preserved for each wrapped line. @@ -181,6 +268,8 @@ class MyInput implements Component, Focusable { // Emit marker right before the fake cursor return [`> ${beforeCursor}${marker}\x1b[7m${atCursor}\x1b[27m${afterCursor}`]; } + + invalidate(): void {} } ``` @@ -678,6 +767,8 @@ class MyInteractiveComponent implements Component { return truncateToWidth(prefix + item, width); }); } + + invalidate(): void {} } ``` @@ -709,6 +800,8 @@ class MyComponent implements Component { // Pad to exact width (optional, for backgrounds) return [line + " ".repeat(width - visible)]; } + + invalidate(): void {} } ``` @@ -766,7 +859,7 @@ See `test/chat-simple.ts` for a complete chat interface example with: Run it: ```bash -npx tsx test/chat-simple.ts +node test/chat-simple.ts ``` ## Development @@ -779,7 +872,7 @@ npm install npm run check # Run the demo -npx tsx test/chat-simple.ts +node test/chat-simple.ts ``` ### Debug logging @@ -787,5 +880,5 @@ npx tsx test/chat-simple.ts Set `PI_TUI_WRITE_LOG` to capture the raw ANSI stream written to stdout. ```bash -PI_TUI_WRITE_LOG=/tmp/tui-ansi.log npx tsx test/chat-simple.ts +PI_TUI_WRITE_LOG=/tmp/tui-ansi.log node test/chat-simple.ts ``` diff --git a/packages/pi-tui/UPSTREAM.md b/packages/pi-tui/UPSTREAM.md index 84ab43c37..f5f65772b 100644 --- a/packages/pi-tui/UPSTREAM.md +++ b/packages/pi-tui/UPSTREAM.md @@ -8,7 +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:** `53816d7dcc5ebe3a0eedec3cd07196c3a66d83fd` (2026-09-14; v0.85.1 plus upstream main through this commit) +- **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. - **This commit is an upstream marker.** It may not exist in this repo's object database. ## Reconstruct the fork diff --git a/packages/pi-tui/native/clipboard.h b/packages/pi-tui/native/clipboard.h index 0987b4ed6..4e7d0a70f 100644 --- a/packages/pi-tui/native/clipboard.h +++ b/packages/pi-tui/native/clipboard.h @@ -7,8 +7,17 @@ #include #endif -typedef enum { CLIPBOARD_TEXT, CLIPBOARD_IMAGE, CLIPBOARD_WRITE } clipboard_operation; -typedef enum { CLIPBOARD_UNAVAILABLE, CLIPBOARD_EMPTY, CLIPBOARD_UTF8, CLIPBOARD_LATIN1, CLIPBOARD_UTF16, CLIPBOARD_BUFFER } clipboard_format; +typedef enum { CLIPBOARD_TEXT, CLIPBOARD_IMAGE, CLIPBOARD_WRITE, CLIPBOARD_FILES } clipboard_operation; +// CLIPBOARD_PATHS stores concatenated, NUL-terminated UTF-8 paths in clipboard_job.data. +typedef enum { + CLIPBOARD_UNAVAILABLE, + CLIPBOARD_EMPTY, + CLIPBOARD_UTF8, + CLIPBOARD_LATIN1, + CLIPBOARD_UTF16, + CLIPBOARD_BUFFER, + CLIPBOARD_PATHS +} clipboard_format; typedef struct { napi_async_work work; @@ -63,6 +72,33 @@ static void PI_NAPI_CALL execute_clipboard_work(napi_env env, void* data) { clipboard_execute(data); } +static int create_clipboard_paths_value( + napi_env env, + const clipboard_job* job, + napi_create_string_utf8_fn create_string, + napi_value* result +) { + napi_create_array_fn create_array = (napi_create_array_fn)node_symbol("napi_create_array"); + napi_set_element_fn set_element = (napi_set_element_fn)node_symbol("napi_set_element"); + if (!create_array || !set_element || create_array(env, result) != 0) return 1; + + const char* cursor = job->data; + const char* end = cursor + job->length; + for (uint32_t index = 0; cursor < end; index++) { + const char* terminator = cursor; + while (terminator < end && *terminator != '\0') terminator++; + if (terminator == end) return 1; + + napi_value item = 0; + if (create_string(env, cursor, (size_t)(terminator - cursor), &item) != 0 || + set_element(env, *result, index, item) != 0) { + return 1; + } + cursor = terminator + 1; + } + return 0; +} + static void PI_NAPI_CALL complete_clipboard_work(napi_env env, int status, void* data) { clipboard_job* job = data; napi_create_string_utf8_fn create_string = (napi_create_string_utf8_fn)node_symbol("napi_create_string_utf8"); @@ -71,6 +107,8 @@ static void PI_NAPI_CALL complete_clipboard_work(napi_env env, int status, void* if (!job->error && job->operation != CLIPBOARD_WRITE) { if (job->format == CLIPBOARD_EMPTY) { result = null_value(env); + } else if (job->format == CLIPBOARD_PATHS) { + status = create_clipboard_paths_value(env, job, create_string, &result); } else if (job->format == CLIPBOARD_BUFFER) { napi_create_buffer_copy_fn create_buffer = (napi_create_buffer_copy_fn)node_symbol("napi_create_buffer_copy"); status = create_buffer(env, job->length, job->data, 0, &result); diff --git a/packages/pi-tui/native/darwin/README.md b/packages/pi-tui/native/darwin/README.md index 2eb5c722e..74c5c5911 100644 --- a/packages/pi-tui/native/darwin/README.md +++ b/packages/pi-tui/native/darwin/README.md @@ -1,6 +1,6 @@ # Darwin native prebuilds -Provides modifier-key state and asynchronous text/image clipboard access using AppKit. +Provides modifier-key state and asynchronous text, image, and file-path clipboard access using AppKit. ## Building diff --git a/packages/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node b/packages/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node index f453d0c06d5f17cad59ed232f6280564569e5708..db94be97d687b08100adf4afee85b644fc84c17e 100755 GIT binary patch delta 5832 zcmb7I3sh9s)js#$0fvV>2Zr|yMiJi-L6In4K#4&s1WB4_#}P)M!5N$x5{R}lQL85Y zv2!`*ohXU@|HaH|tyZuxNgFh3&0|?n8`HJYWB2*W(xr_G=z1r7azucRA<><&R2sZ|9C-6t4mzF9V zR$8XX;L_=%nyCp}-HJe`Dz}xsq*;)d_ml#QRc_u>^rEITd4`o$N8b_~$XHZAP6I?aD6j2nRu%Cy#k>0JHo7;x)n3Kt9@n|eu z_GFiaZH9u>+IbTkcnR`7cd^Vg6ZR61=lxh;=DsYSL^@2|wTlH>8dWjw9fdOhPN6`zN9FPv0zD8lDb0uhM278TGuHW|nFBEpF3_Q< z>>1(mWZbngF;ouS>7e65r-H5o#nS5f;onHy9h^Xy#b)rI%%YFQP9Be))dk-peb=fv z@5nmdJF<~TUxy;iER@a%>E@&v^kj5FVPm26VKDdtn~yx{BZbl*fQNwh@VCsbtw=fq zK3kl9v?#_fYdwN?hm-DW;AF&;gRW?@)*M0VI-zv~yjMW;5M}TV&Wozu`|7#bbW4nd zyPtj;Qy7~!o9&|xf$q)enoVbp%ZXbT4C;d}&MWhJ=TOtQ?5O;C5;AY^rkVu0W!$|B zAAwE3pz}QhDvMp4BQGE_Rfj4$wJVkcmSarzw}g%9eATi}lCNLL^xcCQ(9GDGuG+ag z(cQGY<-NX|^A4MMB!YlM&|tN~ydxEy6s(BoyCHBsRGdev?tPb#J}kl-+jE=?9`c+R zI1fA`WcikYvXxjNknYAL@t`)D6dJ~nwZL_TNV4{jp47=YFNuN4yo>Bu`SUXGCM*v# zz!uwWAl)q7E@0Vdq!~x`edhJdBb5h4On` z-|ll<-vk(VNi~oF$}%Pt+&Mw-JEe|AfwC;|gZC{*b{s(?=ZVcPXny4v1hiD2>js!? z)&m}Mgh(^bsM&%mW!T0uyiA1l%ioxwR`fV4k3 zd=dR8;TY#w_m}85^5jET(l|p- z-vKNg3vQi|x~(UiNbmCLm+Iw-D9-&tn(r};Gv{thgyEgQpPv=Ig9sw;FP!%{wlU9@ zOb-57NuVoQBpQz>*IW>8l7;vS)VIJCn-T7WQ(OyTQArF{RACy=S?V}D@1?YXJ zRlWn1ZB-*E+oDyV#W;vU$@sY{*96J}?A4MU*@ssQECp{s(7Al^h?dRp|5V{0fik1L zFeD5AN(`{&9#Y5e2la=MZnoDrxUSS2#Q>Xiwz^`=Fs`gdny(!FiOQ@!T1$yso*M9C z!Q(;mh|67znmx#lEiF4>J};JV*U_=#lJQwz=3kgAE3++_4n;P@sWMIWWr21H4-J2d zV>m}c>TqC5BkLC75L<}DtOAExIS#in?@ik=`Hjb_ij`gl^!i;;I*TM@%f}4;42`V@ z!>7>yZXZ*8yAf0mvX`+7q z!cyr1rUI`AwPEC5Lo!)=Jet%YZ9S8p%9nh&woGkmEUt=--Zm&C`$W$0u^ic}rBcTT zJ(W1#wFE)Stl_()&1y(1H}5a%{xSJa~--MHdzrzS~_cXelsuxYRm+`HVyHjuPAM+bKVBropf8_Jc-K$H!L)$WUSb!; zeAIe8F9xVvx1(GJvvF4Q24$e+mVW6i{c@#W0Of)WVbYbm-}S~bL)kPpxuf(FHmU}f z_&;IR9Ii2}e+vC%wf{%-^VI%F=vS)!_t9_RaLLc6{^YR8gR{3|k{JTf~y_~F%bdK6%sO>hi{x;;k4Q;v&ZM_Zs&#frO z6GF=J7>V&rsF7@adQ2|8pr0DE+TyHiYPQsvosEwDajBJj>MB5E3&TdU-C}gwY{q7@ zeYJ&FrC%R!HMcaG>g^V@(_%8)?dEnOa~u|@$K`^a8<#twR$^QEV@ti$Q6kHfkOs5UTxqk{wYOLRn(CcRHmlj*&g83A$yGM{ z5=;B21`BX`yTfT&Q)_9lTO4o-7d9(dZLz{zbB&q4li|oPH>mosUeRoJI4q75TMH9( z(5abg67dzQxq`x7iUeWpPkQRh%q)o_g#?EoKG?S6d+}3zDQE@4y($ns9&d`waKbl4 z6HR<93EPBMOazR?p+Q5lKOt){YyUj)$O3B2Dt5iez8mozQRzvQeyY+zm0nXRQ9sXP zRGO;N2`bH3={%J#R%x9|SE#gEp)S&YqfNIZ&BW@WB1iAt+kjNkX$T-fgIJ==5}NX@rd*%M-9CF z6Z%ANId_u2)my~n(JQ?bu3p|FnySmIDohni%a$)UO@OzBqe%zFv4b#6+=D^&;UHkT zO(oxCayad+^-i)@lm{QHs4TbHnk{B)oxRn97b&rmMA1}n_oBxwR$71PgZnI@s<2sq z(5j}chu9y`i6+!Wy$MNbs%HrcW&4T{*N*x!JD5Rx<99r#xyi~{Ep+bTETK&&(zlLH zhy}yVC5tSwW;k7cct++{u9NE)w0L3`FTJLTIUe=PFlP)99(wHXgD#G38hdu40U+6x zhg}DNN5@Yqcy#>om~mXbZj;BIfnm9(n8n!&9@VdsvFkgB^7kIKQ{)$7Qv}R63`OSlF---IvFhmja_EgslyHk_P-Ur;9c^6 zMcDxCbTFd9ro3q^D&{F24X;3wgEkHgryrZ;|JTvK`GE=eqyISa_{9IJy6LK;l~Lc- zEPdr@)%-CP&5HIv)+q*VuTgsQsAuW-6{S*+YaFYWTdWOaY;h_$RF)yc3X;BlY<`+r ze59ehxdHvwm8O+V)&`56(96e)!{3iL@+1bgi(FUidA0)VX)xQLXtGXkX*N6Y%3CwT zYHP3%|F+LQ=+B=d&YIVt`C`}h%0F+uKkmfIC2Kajo_pvc{qNg)hL$e&%$VNn)}M04 z9Uwiw8A>`m<-p(8e|6D+bK);NkJs-yweHy8<95H<{LSyksy^d}2QMt3&vsQEuRi@~ zcJS?2>ApW0jazyyKWZLovA%OX^5K8(`r!V8DT%eyW49QhR{!nrq;%6GG~xIPnJxxF>Aqp`EAuruV#1$ED6?LX(C&4*c=R&ZO*T7A|`_|6^a* ZzJc@u8NXj2{@97_(uPCTRmx1szW^_UrYrye delta 3900 zcmb7Hc~BHr8h_nAGYl|{90LP#%z%QRprV+d>uPg&gm{3EHTL|vUIL0g0D?{)X6@Lz^{{rkS} zeeZq8``*FVn}jWOg3HCP4VLQ0hA+1e<+n`*vfLuw8WnDdRM{p?YKf#HxKZI=O-y)I z?v14Dnb@kbdQIDFr3Z}(^buzdzo~VV2x{L=EA7vZ3eS&K*+HC&`LXmZepF;toXT!d zfiRxOT(Pu`FQBW1ggU_(n5}gZdQgZ640*<72z|n`MU^#b(roGwZ1jS#gsY%Ax>(La zXXplmRaC3p8juS5k#2N&o5rT9ousXr-qMYV46RXl|CEdu6rZN)2lLT^biKZRW}y}b zQ=N8#kW}#1CxPHuuATO&Mn?_dwo+j2>UD`9Zj_nOo+a>LaQl#prcC+_JE3!tAYs0p!W=Mw9zn3$LYTapmz*qMxFlC z2tv|1>NCXBWdUz;6X@-LVF9Xm6ZGwLoN;){9(c!$q@jhPF>l!~9*yz7Gl+(#iAe?2 zXN;LzZj&Pv^=U zcl+d%d#EKSEjdh-jhraAS&4iQ{SO7=*$4a;2uB*o&O|ANqoqObge**wym>@ANvfx8 z=jr{RypT1jq!p6&Jk1SG;g|AsesI5-wnT{;`ayNp;tgg7M0q+-J;4dO>6o7`56rNL zcIh}&9{Fw6+odD)a9~L^j~P+lmRukrwJG^=`}(r9 zSzxpwaY-`09g^5f1mET4+=^^%3XL@<1i3~?I;gA>4K&YOl;4Coy@JK_B~Vd2nxZ5E z#e$t*{qK*>MBa-&#a;yKvv@vN#zc6o32`1X{H4B-INENGu-9P_;T zGWyrN7m?S%I-7GmI|r0Fp^b5JypuMnw^)QECCWUa_h1CV8Lrly+$WOLeeq)E|z z7Y+&0o=sZ6GXaE%HIku(g7wXg7G)OoAx$9)sAeO^69O8i3rn}lc(9)osAQme3etW! z-~~Fyuk%H!vLq~%IMpmxRInGuS@xJrCRqQ?FwUX{Q(puF%zMVyf_|R%D9>kbYP&Mf z>bb3;#q+a*D1BgCFHy!RSnuFSap%ghDBLk9;CvKvp8N6KTsI0^Y8FZ^Db8o4Nq_C? zvVKFRllm@!yVx<6!vG$YF1R~S5$S=zdpc0v>Ka)b1!tjyo&6Cz~J}Wgh-Fb*)AyqucS*?pVHltNx8WD zaN;16tp3u)r>eiDwK?t^0#QyK0c**8Hu?1)^;Zr&WlVj4>GV@-j2|xc0yLubrG?QmR!?{)$jn@w$>q36Ql!ds?zlU3$%4=aqj6N_+locH_-5+BO%e^1a*J7LBzhyzP>m*uV2P5+4;~qA76$8fV6h;pv%# zd-RH})KuoEa*SPES-fIN`3QQzzKXt@IM0cy)cH`R6A&J2{?{X;U;IIjnD94}Fp6+h zcr&%&k>KTtt{B(oEIr{+KBP%c^gE1%Umirb(?NvK#_IfaO>Si0amhNcI<-GKI^Dr)&#+=CG9?`8Q z;`M`Fn1!A{aoJp1zOdY}tQ?ed)wg8F5uj!Vb=8G`8zZ<4t$; zSA+>wCiY{wpqoy;<}@&h{WhX;{--MFeLZ;gs(i8ks>&->0+O_={ImIRK`?#)e-;D> zUN?S-!aJWUjOd|&yRqAVaXt9SJ$T&LO#fNs&F;bDMboVx*B-&0|9rsC9twDPbsKQ5 z2Y*-PpG63kU>D%gLI@sT1lvG)fQJFY(Qxf4=V%m=9bD``hKbl(%1&c$vzlcqgFBPp zo?ym*--Oh0#z_Ot=F>T6httNhvDABZ1FdZ_)156AdbK5(UT8@UEnBg$tYlf$8>JZsaV}$S z_ew{@O5QZ`QZf=`CY*<6IwzQDw>zwC45w+W@+zrw9M4Y{CBf)6eT-+FX@K+zg(Hoj z;U=rRFG+D6t3|<5unDq!Q(js>lILz!{2hu4BCEXGiv;IxQvks~te-a&R(*jMlin)t z{AmL59626G3Koz>HX7aTU}Kxt-L8aN1Do}*jL)!Rm+#viD*g1M}bLcfysP2Nj8y~cKX^q zEeff2CV6JKFp9Sy(#!2`31)=dDE;zHeQMD+9R!<0!eNRJ8?T8A+4(d0Sm>33s5I*Z zW@&*_c8)jEZnxj#4sB|04Eo(2VfBpiHk$Md@-p?p6yg*N>bQv9mYjA>!jZzK5W z*dYo=aHc%W)s*I(!j$LrdqN?SXq6|~g&>IG2f>;0&}fm@jv0(~mJk3s>N_9h$xMP3j5Ppm zs6!qEo?@*C=XSv0WouW#r)WF}b+aqf#F6T2da7ZJvaepqwW26JP(Q!?XKApfZo#RC zOD_6oXUl=luSJ`LlVmg7L|mrvI`>u1qrR)qQb0KKI2UCDPLf5sw@f?;ra5e0-KJ1j zX`kovHL4zU)4XaGS!F{|y(2Ww-qEqt7oOXkYMHl6QA569AT+Pa#6b?pZN-_A3+ZrQ zZNL4 z8gI&9m?59B!+>(wyhuo$=l3`rYTz%>Lm0F zYwy{U@5s@;MYq|1Vqasw-R@qq$EuBNdP&dQhvAWRDDD`97DsT;W4r~AIklGo9wTrM z2m1kjPvG+aYhs_-w(PEnB}un8p_?t%#jSU2cg2-%XaD$_pn}j3*a-Rm1?Up_{Uf*Mz zo$HD_wmPE{Yb&@FlI+QYvA*z>ll6KZ=qlrt4 z+zV_zD@_{n2|k3;f){t7i|rVW9T>+RCZp2=+Y84?eJGq91Uwni_F_T+Hhv0KhsJZQ z^hwvxg2MXQ^7S=?5gP_j&KN-B(0L%`QC0fZn#7A{{r3nMwkGjR3M8+uNj#PUDHX3t zbmJ;mWlW4eo=%Db2*@;NjZ-@zYo{>A4baRO1O=%+QP%#w^n^=0o*YDtB0E7H4s#){ z3k2J>#P$%eMHPdaJ=qnH7~pbgCz69seZo*~X&;O#5c9(tkPTXWFXqNpK6Mv;4Tl;_ zAr12YIwc9bfrAv46R58_)R(pg;CTdI%CQur7Z7Lz=+cz!eERHmY3)0B$Rb!}ZB4fv zd%x1QWgm~<-IdaDX+Mlb^mXmGb=lxTmv#yryR^t)TZ!Aa5GE78i&i&GR^8ES*QyJm zCv$DFDm0;#nq{8nBypMw~!yf|_{XH}^yvIIMm0fZ??rltyl-4=VE2%>lQFk9CXRK<*UM)yfB&>}CCzE%24jdoN_ ztJrBdL zEEMEgfqT3&1@xwl{xj0PWPil=Zw@r$e?# zwgypBmem(sel4|@g-aPr7G6puTE9`k(ffGdJqyuxeaXy%Sf5MV*@M8cbzO$r_vp!S zF75Fh9DW2eJS2Yw1cV-dG@~DDl2czfv&gBtW=iOz2(4}7?G5N5S@=7Ny4u6LxDrnn zz%$@odf7w3xbzpPCkz$V80g+k)OtRM{!&6d$LzyGbD%~$-G#8cmiXg&os$?#ByjtI zdO7M&ZOP2y#4mw#X-{v5IB2$nG;=^R8X;NBMN>JNAoxiVtk0T;o@%tO+H!8Cg$%x! zNyJc8&pUY;5n|vYy-sZcIL{KC8^PI@v)ZKp5_i~qCf4@}`3ayQe>GS9SX++0!KB>J zDfa7G3NN|)Mmh~8;F)1$zpDXJx61DLaW&qkxg5_rYh7XNn1Z$Ywg~%m1uyHDyEf1zhzlT%oiD$-)gJ zS;S84CzNb;t~<@ALN9QXe$xwKZPzj6v@8Kw^(heQc2T6$jZ#Z1mLii_CD4 zjF`wZp*gmVqaW{qXV4!yGbs#o$xWK`DmcNz=P=D=T8z7HMs5aoD{&JW!s-!0XFMY~ z$@19UoNUn(ATv}-Z+h7x)VYT~lJ!ek!IONOVnHvvMj(>7*Y7ucT!={HKN3RxgxAX^ zp`bgf!fVX_ZX~(n=3^AcJl&NOis#%m{h`j1JcI~}DMH<4gJ5rbpW*t+iLx+RUN zB2B_~|8W)f0K(x2etp46i@1A>HzPA2Y|Fb9bA}W{q2EH}9n-JbkZ&yUx55G<`B`e-3SfJTiv;k@zk8H!g$3k#_{q zPZ#1z(5`(cZgv~NW!(NHzj=fUjP0n(cpP)Y4u>bX;&Xb?lS{j!7&l+hyNa2u>*w5= zi)O%?#uc0oVHYaqPlxSv&x1!p@`+xL1Vhh-xILm3jkuxZWE#BqtglF{ZOMlSd*j0| zD4&Ed+vY#}KBwmf2TNSdB`n%U zewxn}sw;xF5*`M*ajzr?hpvFU10WbW3zg@I@?2pPDC;EC4fObB>-vzwDL!X6h!VfX z2!UY+Y4ZMY8UlL2m_^{L0BbZuJnCXRcF^3U$6fH=177~r+r;_JTU3%BTft-Q?p`Q} zu9r%3&ar;SUUZ4L!%c4a>!FZS``TIlM(7-S=ijh!9`4JBjsgzf#@*o3zLjSmhr?wp zvK@L4B_wqYgfIVp59+?gqNe3QSP7iIvLl!;C^GJyuz)eA7 z8Z<&{`Rld^`fwNCoA{bkq{?rAZth+W@O_;7aK3@_ahwdfcsOWR)@groYM5g@V58q+ zg*zxm4>IFB9(>jOIbu4`g&#wGV=0V{o*Hj^aF_G#BQ@GjCA)U}6nb>XdlpgS)T)aq zYWVLG$ypPwK<1p4t^;~{Ek6#P;^?ZJv!mk(dYZ1R*#IG0g>!tiE@#_p+X^by9 z`$}Yoy5y`xA!FUG)R_1fmf`n8+aUBsgX+6QeFfED5cS)s{-mh;slH9r z@1*(zqJAIM?-BLiQvFU*{}a^%qCQ0RMp36QC&5?kYet;B>;B(VU znk#O_EG;L!r=vO=eRuzo+o1{Q6 zEcpVV4#gXm+D(n6P#E2_P^+&w%z_)u5V|ho4E6W?ujnX6o( zw9-@vMEw2{qCBeVF%_mcg%E640$~=2v^Og1ExvH;V$6^Vw$fQ$y+Y}LU|_|_VvrWr ziJ0L+h(p2Ng+Mf{rSfFW?Ikw3xK6@GF6qaBBsRI^>fyIxVAu2_myt-po1cLm+oh zuyQtmT;UB7ZNtu?U-}pxLil^X zVw7}HiS>)U*^o!*oarn#rxLf?E&z0Xg?mOisGfP?{txet^4T?9D$AwY*~7UkXZ|>r z`!ta11eT*sz&pFiY~18?#*^&qk@Y~<16dDbJ&^T4)&p4&WId4eK-L3U4`e-%^}t`X z2P*%nt;-sh^+47GSr24Ako7>;16dDbJ&^T4)&p4&WId4eK-L3m@qeIn_|0;oS$3Ia zw^=@7mOISyd9&;@%U8|vkXar!%cExbomuANpKIxOivAZ2$Dmmjo25_U&Ft^Y@)P_| zIvo=wUa;dPoZ8j(QEda^+47G zSr24Ako7>;16dDbJ&^T4)&p4&WId4e!2fR#OrF8k&V&h*E8WZL(z^oaAM|UN-NgQO zU4HjyjJcCy3=D0(V3zj!6|NQ7KY)z{v{?#JXnDZD2|FH$aQWh!tK3yCdtIG-CNx;= z3%7eZ`0grfYhdc3+|e{*?^Wkf?A78&&S;XfA*^#CO?Sbl{+98rBdoJ%v<%b8&JWZ4 zQ!34`@P%6H6nx5NT`}0`>rieLdr>^t@RWvWYYyu!taQ_t3O(LC+}_qZ+$|C8b^;MT zCDp_~VCZhdH~8?u!R`x3sm8aTFtv9K4=fqpM#4I2d@N3ci&>`Hq{FhhG*gM&&DPa+ z^T1Wv`IYXfn;h%0uca;+QN2pF-_sJ}TUl5)Q}ZnrG-HbvwHKh>>+$=8UV@4MHTm&5 zI|3vC&8h-tFtr$U?BepacMw!!LfCZ#Xkp5o!(u9jTOwh6ER4If7F&91gPdJH6FL%V zC4sdmypr0N6AaWu%pE|$H>B{`xZ>ttwj-{!sa92?F|((;CT!~&u}v$Ka6}EPqWyYA z*#YCQqXoNZ0AZ&Jw)|m3psKX^uw6%WV~bwIuejHjGKm~Nj`bB#*;jCX&iy$9rdJ0|h&=(RCyCwG`o_NVNBbzK%nCUg)5o&j3<0KYolWVEl>0X@NZ!c!LGr zCSdG@5eI$gCR5(q0v^VHZo$*e8T`?98-7d^Fs|a5Z^73Hcvzmt0*3_*4~XL)3;d7; z{*?vZVS%5qz%N+fmjsM$*W%c3f&XHGzqi0sF%oDR=AUJO%Peq}1+KNgtrqw`0T27* zK?{t&`S&R&qr}I|*abMJ;Kbfrb|KEGIO+Ruv`va)hko{?pExC)5XA?Ml&22pCtBKn zh0k5!16+(v$9W#k|4knfJL5b!W7-*WB^uRe=I2rEd&P5({8S#0!n9P0jJi~6vS85OdvB=pP_w3l1`=PQL8Uv{J#OH Cfu70$ delta 3936 zcma)9eNa@_6~AvkSYW{gWaAuoOb3J0nzkyFvGJ{#T2rGAPJI2H`!1UBSMPA| zdB1bcJ?EZt&%Jxu@s`pbS2_Zje)jF=?|iJnHX*HZ=j^PeY)hm{ji&6*%N1GsC3lzP z?$74al!~mmUJFO;7M<(mFMz$*XTgUQi|+ODzbh5qvpE*rVG%~y#+Bq;uG(@Y`6V|) z(qWi9P`9CH@N>5D4>GAy365xfOG6@-gpaf#z~i>@-b!dtOST9jY%QSJp2Hh$_wx<* z2PPC-(j`)f0n6|N$AvMQzi0n$))J|?O=>PF=7aW%tbWNZlI;FsKG#vXGkh>e-gj!$e#{G(~?2!WKIFuk^o~q;HPIWM&2kll=^Pq(V+daFz_m8 zzLGzgYOd5=;px~|KXJ;PpCneSjo8|L^nLLC=)#F7l`-dN@ogpGzYgB( z^t)D0Z-eAPK0j#;ADL9~Ov@lCen&uTIM4eMwI#A^wP~%w@4(hQ6P_ z>H48}H8s z>;XJY@S20~O?h)fjWFDHaCK6>@lh&YoLZTeKE!g>8_vYu{r41`FjrpnkaB6Ys zD{irJ<6Y!&z@h3@2UR`&V;mk;Pf;J9*Sp!HwjWm)1)dBv1QrJ(4abaYDg5K)@%Q}= z_Ka%G3Th{@l5+$DcJ54BR9Ny!Ln04Js!_Fn{RwfhhJ*r7j zYR6&ZbApsIE7&gxwgIZzF-X56xK2nZ_CbOXKwh1uDvSB6Y2$pqOaX5IK7tylyiV|+ zfV?lQz{XSgjkFC)2Vaq1Ht#)%uww&mHC}ca->Tl$i%9G4MVheFzUWomYWor&Wvm(J zON~1}B5kdzD?~H&wr*pdizm{@+1^g!=_3kKE+Dp0+!=H5DI?S;-oa5X(F22K^XM4`c503Cg zbB8o)0}BH+fycvY`<0>~%29WAWx|nFOhz8mD6x8Z{W#*5^%XE?dSj%b^%!m;KB>mK zJ=-;`FQCuKpzk=o+i%%Cu^f?)@t-L8b_WMc)srD?f)L!0_L*D)0oH?Q_N}%(5ze12b1LwHXMw{0kVv(q9r}tP3)jL{hN$9S*rkWluvY?v$gJWE~_p2qC@j zJWdmI1Lm2Kb~Ru|~c(#AegweTjnqG6z^d7J@jmg3)r6i=U?efHK3f=N`&^1h) zVJv1ezF{<^eTn!tV;*f%S(@Nd6g}1|65_<45J}^fm1r?3>1Bz0TJUAlJ5_o;LY8CQ zt?TLh>dpYM#fJAl6UoHEFuiW6+$jtn7d&$WE~Dw#_WMOmV%uYI73i6hjWn}?twUcF z%)pi?6f2~n;5=DXauJz99Djr%ilgnI**7nCk)*9pQ7z`%h zOJJT}?WDcN#ZG&zqWGu+yb)+q9eWqzBMM)}QL8TJ1JikXxT<$^E>3vSz54`Ln6nGz zm6-OmX&Nh`FHZG0&a6+>`U2YZAkKfhd^xtv_~^E2;wpnHBaYBfPrX&Ki>>J})k&4A z%`6&ld)4->Ma;8ZY)8D@j#L=@HlH%8B>E+kLh-`C67kIHr^x5Qq^h@|3~PER1p1NL zgKQY3LNM9l>05oqe&O+gv4?2;ao;yWfz6-7X8YC*TkAf3X0~~%{g%V?{3}!t@$_F2 zS(g2*x=i^nq}?(v>0uw% z=?}%LXq_b=n7soNp>PBW11@~LMw;6O`2wH!vDPePgr+8bkBhUUn-XPZB}RKy_v+xx${!EOoiE)=0cZ=~2G42uLuf_NqG42!NevF~`*qSjY?yy<7+07e9??_4@ zeTk*qll#3xb91X~{H?K9`I)?>X-$o-jkDKpdiX6Uz3`xgGS|n+yq?MBT<*QGjCWh%kdwj zqmsX0JJP;!Y6t&%!CC%5IMc~G*rqyO5H6z4;|s%+a$8Q?UsBRMo}^+Fa?YsG@p2dcKHw~|AmxudP(|N|o`z1vhG(nTi~rjoZ&>i5TKoElp%StRig(xL^PajwU(+*dnj$T&o0mqGu3FdBya~QU`yYQ= B2WtQT diff --git a/packages/pi-tui/native/darwin/src/darwin-platform.m b/packages/pi-tui/native/darwin/src/darwin-platform.m index 38823357f..5cdd8f912 100644 --- a/packages/pi-tui/native/darwin/src/darwin-platform.m +++ b/packages/pi-tui/native/darwin/src/darwin-platform.m @@ -66,6 +66,40 @@ static void clipboard_execute(clipboard_job* job) { } else { clipboard_copy(job, text.UTF8String, [text lengthOfBytesUsingEncoding:NSUTF8StringEncoding], CLIPBOARD_UTF8); } + } else if (job->operation == CLIPBOARD_FILES) { + NSArray* urls = [pasteboard readObjectsForClasses:@[[NSURL class]] + options:@{NSPasteboardURLReadingFileURLsOnlyKey: @YES}]; + size_t total = 0; + for (NSURL* url in urls) { + const char* path = url.fileSystemRepresentation; + if (!path) continue; + size_t length = strlen(path); + if (length == SIZE_MAX || total > SIZE_MAX - length - 1) { + job->error = "Clipboard file paths are too large"; + return; + } + total += length + 1; + } + if (total == 0) { + job->format = CLIPBOARD_EMPTY; + return; + } + char* paths = clipboard_alloc(total); + if (!paths) { + job->error = "Out of memory"; + return; + } + char* cursor = paths; + for (NSURL* url in urls) { + const char* path = url.fileSystemRepresentation; + if (!path) continue; + size_t length = strlen(path); + memcpy(cursor, path, length + 1); + cursor += length + 1; + } + job->data = paths; + job->length = total; + job->format = CLIPBOARD_PATHS; } else { if (![pasteboard availableTypeFromArray:@[ NSPasteboardTypePNG, NSPasteboardTypeTIFF ]]) { job->format = CLIPBOARD_EMPTY; @@ -84,10 +118,15 @@ static void clipboard_execute(clipboard_job* job) { } } +static napi_value PI_NAPI_CALL get_clipboard_file_paths(napi_env env, napi_callback_info info) { + return queue_clipboard(env, info, CLIPBOARD_FILES); +} + PI_NAPI_EXPORT napi_value napi_register_module_v1(napi_env env, napi_value exports) { set_function_export(env, exports, "isModifierPressed", is_modifier_pressed); set_function_export(env, exports, "getText", get_clipboard_text); set_function_export(env, exports, "setText", set_clipboard_text); set_function_export(env, exports, "getImage", get_clipboard_image); + set_function_export(env, exports, "getFilePaths", get_clipboard_file_paths); return exports; } diff --git a/packages/pi-tui/native/napi.h b/packages/pi-tui/native/napi.h index 93c985c67..816a8202e 100644 --- a/packages/pi-tui/native/napi.h +++ b/packages/pi-tui/native/napi.h @@ -26,6 +26,7 @@ typedef void* napi_deferred; typedef void (PI_NAPI_CALL *napi_async_execute_callback)(napi_env, void*); typedef void (PI_NAPI_CALL *napi_async_complete_callback)(napi_env, int, void*); typedef int (PI_NAPI_CALL *napi_create_async_work_fn)(napi_env, napi_value, napi_value, napi_async_execute_callback, napi_async_complete_callback, void*, napi_async_work*); +typedef int (PI_NAPI_CALL *napi_create_array_fn)(napi_env, napi_value*); typedef int (PI_NAPI_CALL *napi_async_work_fn)(napi_env, napi_async_work); typedef int (PI_NAPI_CALL *napi_create_promise_fn)(napi_env, napi_deferred*, napi_value*); typedef int (PI_NAPI_CALL *napi_settle_deferred_fn)(napi_env, napi_deferred, napi_value); @@ -41,6 +42,7 @@ typedef int (PI_NAPI_CALL *napi_get_value_fn)(napi_env, napi_value*); typedef int (PI_NAPI_CALL *napi_get_value_string_utf8_fn)(napi_env, napi_value, char*, size_t, size_t*); typedef int (PI_NAPI_CALL *napi_get_value_string_utf16_fn)(napi_env, napi_value, uint16_t*, size_t, size_t*); typedef int (PI_NAPI_CALL *napi_set_named_property_fn)(napi_env, napi_value, const char*, napi_value); +typedef int (PI_NAPI_CALL *napi_set_element_fn)(napi_env, napi_value, uint32_t, napi_value); typedef int (PI_NAPI_CALL *napi_throw_error_fn)(napi_env, const char*, const char*); static void* node_symbol(const char* name) { diff --git a/packages/pi-tui/src/autocomplete.ts b/packages/pi-tui/src/autocomplete.ts index d1c76fe4b..4cb60c10e 100644 --- a/packages/pi-tui/src/autocomplete.ts +++ b/packages/pi-tui/src/autocomplete.ts @@ -3,8 +3,12 @@ import { readdirSync, statSync } from "fs"; import { homedir } from "os"; import { basename, dirname, join } from "path"; import { fuzzyFilter } from "./fuzzy.ts"; +import { autocompleteBoundaryRegex, autocompleteSeparatorRegex } from "./utils.ts"; const PATH_DELIMITERS = new Set([" ", "\t", '"', "'", "="]); +const tokenStartRegex = new RegExp(`${autocompleteBoundaryRegex.source}$`, "u"); +// Opening wrappers that may precede a path in prose, mapped to their closing counterpart. +const PATH_WRAPPERS: Record = { "(": ")", "[": "]", "{": "}", "<": ">", "`": "`" }; function toDisplayPath(value: string): string { return value.replace(/\\/g, "/"); @@ -43,12 +47,29 @@ function buildFdPathQuery(query: string): string { } function findLastDelimiter(text: string): number { - for (let i = text.length - 1; i >= 0; i -= 1) { - if (PATH_DELIMITERS.has(text[i] ?? "")) { - return i; + let lastDelimiter = -1; + let index = 0; + for (const character of text) { + index += character.length; + if (PATH_DELIMITERS.has(character) || autocompleteSeparatorRegex.test(character)) { + lastDelimiter = index - 1; } } - return -1; + return lastDelimiter; +} + +// Strip opening wrappers before a path, e.g. "(~/Dev" -> "~/Dev" or "`src/ma" -> "src/ma". +// Keep a wrapper if the token also contains its closer, e.g. "app/[slug]/pa" or "(group)/pa". +function stripLeadingWrappers(token: string): string { + let result = token; + while (result.length > 0) { + const closer = PATH_WRAPPERS[result[0]!]; + if (!closer || result.includes(closer, 1)) { + break; + } + result = result.slice(1); + } + return result; } function findUnclosedQuoteStart(text: string): number | null { @@ -68,7 +89,11 @@ function findUnclosedQuoteStart(text: string): number | null { } function isTokenStart(text: string, index: number): boolean { - return index === 0 || PATH_DELIMITERS.has(text[index - 1] ?? ""); + let start = index; + while (start > 0 && PATH_WRAPPERS[text[start - 1]!]) { + start -= 1; + } + return PATH_DELIMITERS.has(text[start - 1] ?? "") || tokenStartRegex.test(text.slice(0, start)); } function extractQuotedPrefix(text: string): string | null { @@ -108,7 +133,7 @@ function buildCompletionValue( path: string, options: { isDirectory: boolean; isAtPrefix: boolean; isQuotedPrefix: boolean }, ): string { - const needsQuotes = options.isQuotedPrefix || path.includes(" "); + const needsQuotes = options.isQuotedPrefix || autocompleteSeparatorRegex.test(path); const prefix = options.isAtPrefix ? "@" : ""; if (!needsQuotes) { @@ -319,11 +344,12 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { }; } - if (!options.force && textBeforeCursor.startsWith("/")) { - const spaceIndex = textBeforeCursor.indexOf(" "); + const commandText = textBeforeCursor.trimStart(); + if (!options.force && commandText.startsWith("/")) { + const spaceIndex = commandText.indexOf(" "); if (spaceIndex === -1) { - const prefix = textBeforeCursor.slice(1); + const prefix = commandText.slice(1); const commandItems = this.commands.map((cmd) => { const name = "name" in cmd ? cmd.name : cmd.value; const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined; @@ -336,7 +362,16 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { }; }); - const filtered = fuzzyFilter(commandItems, prefix, (item) => item.name).map((item) => ({ + const bareNameMatches = fuzzyFilter(commandItems, prefix, (item) => + item.name.startsWith("skill:") ? item.name.slice("skill:".length) : item.name, + ); + const bareNameMatchSet = new Set(bareNameMatches); + const fullNameOnlyMatches = fuzzyFilter( + commandItems.filter((item) => item.name.startsWith("skill:") && !bareNameMatchSet.has(item)), + prefix, + (item) => item.name, + ); + const filtered = [...bareNameMatches, ...fullNameOnlyMatches].map((item) => ({ value: item.name, label: item.label, ...(item.description && { description: item.description }), @@ -346,12 +381,12 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { return { items: filtered, - prefix: textBeforeCursor, + prefix: commandText, }; } - const commandName = textBeforeCursor.slice(1, spaceIndex); - const argumentText = textBeforeCursor.slice(spaceIndex + 1); + const commandName = commandText.slice(1, spaceIndex); + const argumentText = commandText.slice(spaceIndex + 1); const command = this.commands.find((cmd) => { const name = "name" in cmd ? cmd.name : cmd.value; @@ -481,10 +516,10 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { } const lastDelimiterIndex = findLastDelimiter(text); - const tokenStart = lastDelimiterIndex === -1 ? 0 : lastDelimiterIndex + 1; + const token = stripLeadingWrappers(lastDelimiterIndex === -1 ? text : text.slice(lastDelimiterIndex + 1)); - if (text[tokenStart] === "@") { - return text.slice(tokenStart); + if (token.startsWith("@")) { + return token; } return null; @@ -498,7 +533,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { } const lastDelimiterIndex = findLastDelimiter(text); - const pathPrefix = lastDelimiterIndex === -1 ? text : text.slice(lastDelimiterIndex + 1); + const pathPrefix = stripLeadingWrappers(lastDelimiterIndex === -1 ? text : text.slice(lastDelimiterIndex + 1)); // For forced extraction (Tab key), always return something if (forceExtract) { @@ -511,9 +546,9 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { return pathPrefix; } - // Return empty string only after a space (not for completely empty text) + // Return an empty prefix after whitespace or CJK punctuation, but not for empty text. // Empty text should not trigger file suggestions - that's for forced Tab completion - if (pathPrefix === "" && text.endsWith(" ")) { + if (pathPrefix === "" && text !== "" && tokenStartRegex.test(text)) { return pathPrefix; } @@ -707,8 +742,8 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { // Sort directories first, then alphabetically suggestions.sort((a, b) => { - const aIsDir = a.value.endsWith("/"); - const bIsDir = b.value.endsWith("/"); + const aIsDir = a.label.endsWith("/"); + const bIsDir = b.label.endsWith("/"); if (aIsDir && !bIsDir) return -1; if (!aIsDir && bIsDir) return 1; return a.label.localeCompare(b.label); diff --git a/packages/pi-tui/src/colors.ts b/packages/pi-tui/src/colors.ts new file mode 100644 index 000000000..4e1b7845e --- /dev/null +++ b/packages/pi-tui/src/colors.ts @@ -0,0 +1,367 @@ +import { linearSrgbToRgb, okhslToRgb, oklabToLinearSrgb, rgbToOkhsl, rgbToOklab } from "./oklab.ts"; +import type { RgbColor } from "./terminal-colors.ts"; + +export interface IndexedColor { + readonly kind: "indexed"; + readonly index: number; +} + +export interface RgbColorValue { + readonly kind: "rgb"; + readonly r: number; + readonly g: number; + readonly b: number; +} + +export interface OklchColorValue { + readonly kind: "oklch"; + readonly l: number; + readonly c: number; + readonly h: number; +} + +/** A concrete color. Every color can be converted to sRGB, so color math never fails. */ +export type Color = IndexedColor | RgbColorValue | OklchColorValue; +export type TerminalColorMode = "256color" | "truecolor"; +export type ColorMixSpace = "oklch" | "srgb"; + +export interface OklchChannels { + l: number; + c: number; + h: number; +} + +/** + * OKHSL channels: hue in degrees, saturation and lightness 0-1. Saturation is relative to the most the + * sRGB gamut allows at that hue and lightness, so every value is in gamut. + */ +export interface OkhslChannels { + h: number; + s: number; + l: number; +} + +export interface TextAttributes { + bold?: boolean; + dim?: boolean; + italic?: boolean; + underline?: boolean; + inverse?: boolean; + strikethrough?: boolean; +} + +export interface TextStyle extends TextAttributes { + fg?: Color; + bg?: Color; +} + +function requireFinite(value: number, name: string): void { + if (!Number.isFinite(value)) throw new Error(`${name} must be finite`); +} + +export function indexedColor(index: number): IndexedColor { + if (!Number.isInteger(index) || index < 0 || index > 255) { + throw new Error(`ANSI color index must be an integer from 0 to 255: ${index}`); + } + return Object.freeze({ kind: "indexed", index }); +} + +export function rgbColor(r: number, g: number, b: number): RgbColorValue { + for (const [name, value] of [ + ["r", r], + ["g", g], + ["b", b], + ] as const) { + requireFinite(value, name); + if (value < 0 || value > 255) throw new Error(`${name} must be between 0 and 255: ${value}`); + } + return Object.freeze({ kind: "rgb", r, g, b }); +} + +export function oklchColor(l: number, c: number, h: number): OklchColorValue { + requireFinite(l, "l"); + requireFinite(c, "c"); + requireFinite(h, "h"); + if (l < 0 || l > 1) throw new Error(`l must be between 0 and 1: ${l}`); + if (c < 0) throw new Error(`c must not be negative: ${c}`); + return Object.freeze({ kind: "oklch", l, c, h: ((h % 360) + 360) % 360 }); +} + +const NUMBER_PATTERN = String.raw`[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?`; +const OKLCH_PATTERN = new RegExp( + `^oklch\\(\\s*(${NUMBER_PATTERN})(%)?\\s+(${NUMBER_PATTERN})\\s+(${NUMBER_PATTERN})(?:deg)?\\s*\\)$`, + "i", +); +const OKHSL_PATTERN = new RegExp( + `^okhsl\\(\\s*(${NUMBER_PATTERN})(?:deg)?\\s+(${NUMBER_PATTERN})(%)?\\s+(${NUMBER_PATTERN})(%)?\\s*\\)$`, + "i", +); + +/** + * An OKHSL color, converted to sRGB. Saturation is relative to the sRGB gamut at the hue and lightness, + * so equal saturation looks equally colorful across hues and lightness. + * @param h Hue in degrees. + * @param s Saturation, 0-1. + * @param l Lightness, 0-1. + */ +export function okhslColor(h: number, s: number, l: number): RgbColorValue { + requireFinite(h, "h"); + requireFinite(s, "s"); + requireFinite(l, "l"); + if (s < 0 || s > 1) throw new Error(`s must be between 0 and 1: ${s}`); + if (l < 0 || l > 1) throw new Error(`l must be between 0 and 1: ${l}`); + const { r, g, b } = okhslToRgb(h, s, l); + return rgbColor(r, g, b); +} + +export function colorToOkhsl(color: Color): OkhslChannels { + return rgbToOkhsl(colorToRgb(color)); +} + +export function parseColor(value: string | number): Color { + if (typeof value === "number") return indexedColor(value); + + const hex = /^#([\da-f]{3}|[\da-f]{6})$/i.exec(value); + if (hex) { + const digits = hex[1]!.length === 3 ? [...hex[1]!].map((digit) => digit + digit).join("") : hex[1]!; + return rgbColor( + Number.parseInt(digits.slice(0, 2), 16), + Number.parseInt(digits.slice(2, 4), 16), + Number.parseInt(digits.slice(4, 6), 16), + ); + } + + const oklch = OKLCH_PATTERN.exec(value); + if (oklch) { + const lightness = Number.parseFloat(oklch[1]!) / (oklch[2] ? 100 : 1); + return oklchColor(lightness, Number.parseFloat(oklch[3]!), Number.parseFloat(oklch[4]!)); + } + + const okhsl = OKHSL_PATTERN.exec(value); + if (okhsl) { + const saturation = Number.parseFloat(okhsl[2]!) / (okhsl[3] ? 100 : 1); + const lightness = Number.parseFloat(okhsl[4]!) / (okhsl[5] ? 100 : 1); + return okhslColor(Number.parseFloat(okhsl[1]!), saturation, lightness); + } + + throw new Error(`Invalid color value: ${value}`); +} + +const BASIC_COLORS: readonly RgbColor[] = [ + { r: 0, g: 0, b: 0 }, + { r: 128, g: 0, b: 0 }, + { r: 0, g: 128, b: 0 }, + { r: 128, g: 128, b: 0 }, + { r: 0, g: 0, b: 128 }, + { r: 128, g: 0, b: 128 }, + { r: 0, g: 128, b: 128 }, + { r: 192, g: 192, b: 192 }, + { r: 128, g: 128, b: 128 }, + { r: 255, g: 0, b: 0 }, + { r: 0, g: 255, b: 0 }, + { r: 255, g: 255, b: 0 }, + { r: 0, g: 0, b: 255 }, + { r: 255, g: 0, b: 255 }, + { r: 0, g: 255, b: 255 }, + { r: 255, g: 255, b: 255 }, +]; +const CUBE_VALUES = [0, 95, 135, 175, 215, 255] as const; +const GRAY_VALUES = Array.from({ length: 24 }, (_, index) => 8 + index * 10); + +function indexedToRgb(index: number): RgbColor { + if (index < 16) return { ...BASIC_COLORS[index]! }; + if (index < 232) { + const cubeIndex = index - 16; + return { + r: CUBE_VALUES[Math.floor(cubeIndex / 36)]!, + g: CUBE_VALUES[Math.floor((cubeIndex % 36) / 6)]!, + b: CUBE_VALUES[cubeIndex % 6]!, + }; + } + const gray = 8 + (index - 232) * 10; + return { r: gray, g: gray, b: gray }; +} + +function isInSrgbGamut(linear: number[]): boolean { + const epsilon = 1e-7; + return linear.every((channel) => channel >= -epsilon && channel <= 1 + epsilon); +} + +function oklchToRgb({ l, c, h }: OklchChannels): RgbColor { + // Gamut mapping keeps the hue fixed, so its direction is computed once and scaled by chroma. + const radians = (h * Math.PI) / 180; + const cos = Math.cos(radians); + const sin = Math.sin(radians); + const atChroma = (chroma: number) => oklabToLinearSrgb([l, chroma * cos, chroma * sin]); + + const direct = atChroma(c); + if (isInSrgbGamut(direct)) return linearSrgbToRgb(direct); + + // Reduce chroma until the color fits. The achromatic color is always in gamut, so it is the + // fallback when no bisection step fits, e.g. `oklch(100% 0.3 150)` must map to white. + let linear = atChroma(0); + let low = 0; + let high = c; + for (let index = 0; index < 20; index++) { + const chroma = (low + high) / 2; + const candidate = atChroma(chroma); + if (isInSrgbGamut(candidate)) { + low = chroma; + linear = candidate; + } else { + high = chroma; + } + } + return linearSrgbToRgb(linear); +} + +export function colorToRgb(color: Color): RgbColor { + switch (color.kind) { + case "indexed": + return indexedToRgb(color.index); + case "rgb": + return { r: color.r, g: color.g, b: color.b }; + case "oklch": + return oklchToRgb(color); + } +} + +export function colorToOklch(color: Color): OklchChannels { + if (color.kind === "oklch") return { l: color.l, c: color.c, h: color.h }; + const [l, a, b] = rgbToOklab(colorToRgb(color)); + return { l, c: Math.hypot(a, b), h: ((Math.atan2(b, a) * 180) / Math.PI + 360) % 360 }; +} + +export function colorToHex(color: Color): string { + const { r, g, b } = colorToRgb(color); + const channel = (value: number) => Math.round(value).toString(16).padStart(2, "0"); + return `#${channel(r)}${channel(g)}${channel(b)}`; +} + +export function mixColors(first: Color, second: Color, amount: number, space: ColorMixSpace = "oklch"): Color { + requireFinite(amount, "amount"); + if (amount < 0 || amount > 1) throw new Error(`amount must be between 0 and 1: ${amount}`); + + if (space === "srgb") { + const a = colorToRgb(first); + const b = colorToRgb(second); + return rgbColor(a.r + (b.r - a.r) * amount, a.g + (b.g - a.g) * amount, a.b + (b.b - a.b) * amount); + } + + const a = colorToOklch(first); + const b = colorToOklch(second); + const firstHue = a.c < 1e-7 ? b.h : a.h; + const secondHue = b.c < 1e-7 ? firstHue : b.h; + const hueDelta = ((secondHue - firstHue + 540) % 360) - 180; + return oklchColor(a.l + (b.l - a.l) * amount, a.c + (b.c - a.c) * amount, firstHue + hueDelta * amount); +} + +function findClosest(values: readonly number[], target: number): number { + let closestIndex = 0; + let closestDistance = Infinity; + for (let index = 0; index < values.length; index++) { + const distance = Math.abs(target - values[index]!); + if (distance < closestDistance) { + closestIndex = index; + closestDistance = distance; + } + } + return closestIndex; +} + +function colorDistance(first: RgbColor, second: RgbColor): number { + const dr = first.r - second.r; + const dg = first.g - second.g; + const db = first.b - second.b; + return dr * dr * 0.299 + dg * dg * 0.587 + db * db * 0.114; +} + +function rgbToAnsi256(color: RgbColor): number { + const rIndex = findClosest(CUBE_VALUES, color.r); + const gIndex = findClosest(CUBE_VALUES, color.g); + const bIndex = findClosest(CUBE_VALUES, color.b); + const cubeColor = { r: CUBE_VALUES[rIndex]!, g: CUBE_VALUES[gIndex]!, b: CUBE_VALUES[bIndex]! }; + const cubeIndex = 16 + 36 * rIndex + 6 * gIndex + bIndex; + + const gray = Math.round(0.299 * color.r + 0.587 * color.g + 0.114 * color.b); + const grayOffset = findClosest(GRAY_VALUES, gray); + const grayValue = GRAY_VALUES[grayOffset]!; + const spread = Math.max(color.r, color.g, color.b) - Math.min(color.r, color.g, color.b); + if ( + spread < 10 && + colorDistance(color, { r: grayValue, g: grayValue, b: grayValue }) < colorDistance(color, cubeColor) + ) { + return 232 + grayOffset; + } + return cubeIndex; +} + +function colorAnsi(color: Color, mode: TerminalColorMode, background: boolean): string { + if (color.kind === "indexed") return `\x1b[${background ? 48 : 38};5;${color.index}m`; + + const rgb = colorToRgb(color); + if (mode === "truecolor") { + return `\x1b[${background ? 48 : 38};2;${Math.round(rgb.r)};${Math.round(rgb.g)};${Math.round(rgb.b)}m`; + } + return `\x1b[${background ? 48 : 38};5;${rgbToAnsi256(rgb)}m`; +} + +export function foregroundAnsi(color: Color, mode: TerminalColorMode): string { + return colorAnsi(color, mode, false); +} + +export function backgroundAnsi(color: Color, mode: TerminalColorMode): string { + return colorAnsi(color, mode, true); +} + +export function styleText(text: string, options: TextStyle, mode: TerminalColorMode): string { + return styleTextWithAnsi( + text, + options.fg && foregroundAnsi(options.fg, mode), + options.bg && backgroundAnsi(options.bg, mode), + options, + ); +} + +/** + * Like `styleText()`, but with precomputed color escape sequences, e.g. cached theme colors. + * Colors in `options` are ignored. + */ +export function styleTextWithAnsi( + text: string, + fgAnsi: string | undefined, + bgAnsi: string | undefined, + options: TextAttributes, +): string { + // Resets are prepended so they close in reverse order of the opening sequences. + let prefix = ""; + let suffix = ""; + if (fgAnsi) { + prefix += fgAnsi; + suffix = "\x1b[39m"; + } + if (bgAnsi) { + prefix += bgAnsi; + suffix = `\x1b[49m${suffix}`; + } + if (options.bold) prefix += "\x1b[1m"; + if (options.dim) prefix += "\x1b[2m"; + if (options.bold || options.dim) suffix = `\x1b[22m${suffix}`; + if (options.italic) { + prefix += "\x1b[3m"; + suffix = `\x1b[23m${suffix}`; + } + if (options.underline) { + prefix += "\x1b[4m"; + suffix = `\x1b[24m${suffix}`; + } + if (options.inverse) { + prefix += "\x1b[7m"; + suffix = `\x1b[27m${suffix}`; + } + if (options.strikethrough) { + prefix += "\x1b[9m"; + suffix = `\x1b[29m${suffix}`; + } + return `${prefix}${text}${suffix}`; +} diff --git a/packages/pi-tui/src/components/box.ts b/packages/pi-tui/src/components/box.ts index c66f70f67..c70bd6003 100644 --- a/packages/pi-tui/src/components/box.ts +++ b/packages/pi-tui/src/components/box.ts @@ -1,5 +1,5 @@ import { type Component, dispatchMouseEvent, type TuiMouseDispatchResult, type TuiMouseEvent } from "../tui.ts"; -import { applyBackgroundToLine, visibleWidth } from "../utils.ts"; +import { flattenLines, visibleWidth } from "../utils.ts"; type RenderCache = { childLines: string[]; @@ -106,14 +106,16 @@ export class Box implements Component { const contentWidth = Math.max(1, width - this.paddingX * 2); const leftPad = " ".repeat(this.paddingX); - // Render all children + // Render all children. Keep the child lines unpadded: children usually return the same string + // objects every frame, so the cache check below is a cheap identity comparison per line. + // Padding here would create new strings that must be compared character by character. const childLines: string[] = []; const mouseChildren: Array<{ component: Component; height: number }> = []; for (const child of this.children) { const lines = child.render(contentWidth); mouseChildren.push({ component: child, height: lines.length }); for (const line of lines) { - childLines.push(leftPad + line); + childLines.push(line); } } this.mouseLayout = { width: contentWidth, children: mouseChildren }; @@ -140,7 +142,7 @@ export class Box implements Component { // Content for (const line of childLines) { - result.push(this.applyBg(line, width)); + result.push(this.applyBg(leftPad + line, width)); } // Bottom padding @@ -149,6 +151,7 @@ export class Box implements Component { } // Update cache + flattenLines(result); this.cache = { childLines, width, bgSample, lines: result }; return result; @@ -158,10 +161,7 @@ export class Box implements Component { const visLen = visibleWidth(line); const padNeeded = Math.max(0, width - visLen); const padded = line + " ".repeat(padNeeded); - - if (this.bgFn) { - return applyBackgroundToLine(padded, width, this.bgFn); - } - return padded; + // Already padded to width, so apply the background directly instead of measuring the line again. + return this.bgFn ? this.bgFn(padded) : padded; } } diff --git a/packages/pi-tui/src/components/editor.ts b/packages/pi-tui/src/components/editor.ts index 39b25afbc..4af2f2b12 100644 --- a/packages/pi-tui/src/components/editor.ts +++ b/packages/pi-tui/src/components/editor.ts @@ -13,6 +13,8 @@ import { } from "../tui.ts"; import { UndoStack } from "../undo-stack.ts"; import { + autocompleteBoundaryRegex, + autocompleteSeparatorRegex, cjkBreakRegex, getGraphemeSegmenter, getWordSegmenter, @@ -269,18 +271,28 @@ const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { const ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS = 20; const DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS = ["@", "#"]; +// Unquoted completions end at whitespace or CJK punctuation; quoted paths may contain either. +const unquotedAutocompleteSuffixRegex = new RegExp(`(?:(?!${autocompleteSeparatorRegex.source}).)*`, "u"); +// Trigger tokens may be wrapped in prose, e.g. "(@src/foo" or "`@src/foo". +const autocompleteTokenStartSource = `${autocompleteBoundaryRegex.source}[([{<\`]*`; function escapeCharacterClass(value: string): string { return value.replace(/[\\^$.*+?()[\]{}|-]/g, "\\$&"); } function buildTriggerPattern(triggerCharacters: string[]): RegExp { - return new RegExp(`(?:^|[\\s])[${triggerCharacters.map(escapeCharacterClass).join("")}][^\\s]*$`); + return new RegExp( + `${autocompleteTokenStartSource}(?:@"[^"]*|[${triggerCharacters.map(escapeCharacterClass).join("")}]${unquotedAutocompleteSuffixRegex.source})$`, + "u", + ); } function buildDebouncePattern(triggerCharacters: string[]): RegExp { const escapedWithoutAt = triggerCharacters.filter((character) => character !== "@").map(escapeCharacterClass); - return new RegExp(`(?:^|[ \\t])(?:@(?:"[^"]*|[^\\s]*)|[${escapedWithoutAt.join("")}][^\\s]*)$`); + return new RegExp( + `${autocompleteTokenStartSource}(?:@(?:"[^"]*|${unquotedAutocompleteSuffixRegex.source})|[${escapedWithoutAt.join("")}]${unquotedAutocompleteSuffixRegex.source})$`, + "u", + ); } function createScrollBorder(direction: "↑" | "↓", hiddenLineCount: number, width: number): string { @@ -1348,13 +1360,12 @@ export class Editor implements Component, Focusable { else if (this.autocompleteTriggerCharacters.includes(char)) { const currentLine = this.state.lines[this.state.cursorLine] || ""; const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); - const charBeforeSymbol = textBeforeCursor[textBeforeCursor.length - 2]; - if (textBeforeCursor.length === 1 || charBeforeSymbol === " " || charBeforeSymbol === "\t") { + if (this.autocompleteTriggerPattern.test(textBeforeCursor)) { this.tryTriggerAutocomplete(); } } // Also auto-trigger when typing letters in a slash command or symbol completion context - else if (/[a-zA-Z0-9.:\-_]/.test(char)) { + else if (/[a-zA-Z0-9.:\-_]/.test(char) || cjkBreakRegex.test(char)) { const currentLine = this.state.lines[this.state.cursorLine] || ""; const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); // Check if we're in a slash command (with or without space for arguments) diff --git a/packages/pi-tui/src/components/image.ts b/packages/pi-tui/src/components/image.ts index 91d68016a..e96244226 100644 --- a/packages/pi-tui/src/components/image.ts +++ b/packages/pi-tui/src/components/image.ts @@ -3,6 +3,7 @@ import { getCapabilities, getCellDimensions, getImageDimensions, + getPngDimensions, type ImageDimensions, imageFallback, renderImage, @@ -10,6 +11,35 @@ import { import type { Component } from "../tui.ts"; import { truncateToWidth } from "../utils.ts"; +/** + * Converts base64 image data to base64 PNG data, or returns null if it cannot. + * Called synchronously during rendering. + */ +export type ImageTranscoder = (base64Data: string, mimeType: string) => string | null; + +let imageTranscoder: ImageTranscoder | undefined; +// Backstop for callers that recreate Image instances. Keyed by source data, least recently used first. +const pngCache = new Map(); + +/** + * Register the converter used for non-PNG images on Kitty-protocol terminals, which only accept PNG. + * Without one, such images render as text fallbacks. + */ +export function setImageTranscoder(transcoder: ImageTranscoder | undefined): void { + imageTranscoder = transcoder; + pngCache.clear(); +} + +function toPng(base64Data: string, mimeType: string): string | null { + if (!imageTranscoder) return null; + const cached = pngCache.get(base64Data); + const png = cached === undefined ? imageTranscoder(base64Data, mimeType) : cached; + pngCache.delete(base64Data); + pngCache.set(base64Data, png); + if (pngCache.size > 32) pngCache.delete(pngCache.keys().next().value!); + return png; +} + export interface ImageTheme { fallbackColor: (str: string) => string; } @@ -29,6 +59,8 @@ export class Image implements Component { private theme: ImageTheme; private options: ImageOptions; private imageId?: number; + /** Converted PNG data for Kitty. Failures are not stored so a later transcoder can retry. */ + private pngData?: string; private cachedLines?: string[]; private cachedWidth?: number; @@ -69,13 +101,21 @@ export class Image implements Component { const maxHeight = this.options.maxHeightCells ?? defaultMaxHeight; const caps = getCapabilities(); + let data: string | null = this.base64Data; + let dimensions = this.dimensions; + if (caps.images === "kitty" && this.mimeType !== "image/png") { + this.pngData ??= toPng(this.base64Data, this.mimeType) ?? undefined; + data = this.pngData ?? null; + // Conversion may apply EXIF rotation, so prefer the PNG's own dimensions. + if (data) dimensions = getPngDimensions(data) ?? dimensions; + } let lines: string[]; - if (caps.images) { + if (caps.images && data) { if (caps.images === "kitty" && this.imageId === undefined) { this.imageId = allocateImageId(); } - const result = renderImage(this.base64Data, this.dimensions, { + const result = renderImage(data, dimensions, { maxWidthCells: maxWidth, maxHeightCells: maxHeight, imageId: this.imageId, diff --git a/packages/pi-tui/src/components/markdown.ts b/packages/pi-tui/src/components/markdown.ts index e0efd832b..8b8ebaec6 100644 --- a/packages/pi-tui/src/components/markdown.ts +++ b/packages/pi-tui/src/components/markdown.ts @@ -2,7 +2,7 @@ import { Marked, type Token, Tokenizer, type TokenizerExtension, type Tokens } f import { renderLatex } from "../latex.ts"; import { getCapabilities, hyperlink, isImageLine } from "../terminal-image.ts"; import type { Component } from "../tui.ts"; -import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.ts"; +import { applyBackgroundToLine, flattenLines, visibleWidth, wrapTextWithAnsi } from "../utils.ts"; const STRICT_STRIKETHROUGH_REGEX = /^(~~)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/; @@ -338,6 +338,10 @@ export class Markdown implements Component { private cachedText?: string; private cachedWidth?: number; private cachedLines?: string[]; + // Parsed tokens depend only on the source, so they survive theme and width invalidation. Held weakly: a token tree is + // about ten times the size of its source, and every message of a long transcript keeps a Markdown component. The + // tokens survive a burst of re-renders, such as a theme preview, and are collected afterwards. + private cachedTokens?: WeakRef<{ source: string; tokens: Token[] }>; constructor( text: string, @@ -390,8 +394,13 @@ export class Markdown implements Component { const normalizedText = text.replace(/\t/g, " "); // Parse markdown to HTML-like tokens - const tokens = markdownParser.lexer(normalizedText); - trimPartialClosingFences(tokens); + const cached = this.cachedTokens?.deref(); + let tokens = cached?.source === normalizedText ? cached.tokens : undefined; + if (!tokens) { + tokens = markdownParser.lexer(normalizedText); + trimPartialClosingFences(tokens); + this.cachedTokens = new WeakRef({ source: normalizedText, tokens }); + } // Convert tokens to styled terminal output const renderedLines: string[] = []; @@ -451,6 +460,7 @@ export class Markdown implements Component { // Combine top padding, content, and bottom padding const result = emptyLines.concat(contentLines, emptyLines); + flattenLines(result); // Update cache this.cachedText = this.text; diff --git a/packages/pi-tui/src/components/text.ts b/packages/pi-tui/src/components/text.ts index 8e2ffdc10..6aaa36bb6 100644 --- a/packages/pi-tui/src/components/text.ts +++ b/packages/pi-tui/src/components/text.ts @@ -1,5 +1,5 @@ import type { Component } from "../tui.ts"; -import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.ts"; +import { applyBackgroundToLine, flattenLines, visibleWidth, wrapTextWithAnsi } from "../utils.ts"; /** * Text component - displays multi-line text with word wrapping @@ -96,6 +96,7 @@ export class Text implements Component { } const result = [...emptyLines, ...contentLines, ...emptyLines]; + flattenLines(result); // Update cache this.cachedText = this.text; diff --git a/packages/pi-tui/src/fuzzy.ts b/packages/pi-tui/src/fuzzy.ts index 26d59b087..624b57de4 100644 --- a/packages/pi-tui/src/fuzzy.ts +++ b/packages/pi-tui/src/fuzzy.ts @@ -27,33 +27,34 @@ export function fuzzyMatch(query: string, text: string): FuzzyMatch { let lastMatchIndex = -1; let consecutiveMatches = 0; - for (let i = 0; i < textLower.length && queryIndex < normalizedQuery.length; i++) { - if (textLower[i] === normalizedQuery[queryIndex]) { - const isWordBoundary = i === 0 || /[\s\-_./:]/.test(textLower[i - 1]!); - - // Reward consecutive matches - if (lastMatchIndex === i - 1) { - consecutiveMatches++; - score -= consecutiveMatches * 5; - } else { - consecutiveMatches = 0; - // Penalize gaps - if (lastMatchIndex >= 0) { - score += (i - lastMatchIndex - 1) * 2; - } - } + while (queryIndex < normalizedQuery.length) { + const i = textLower.indexOf(normalizedQuery[queryIndex]!, lastMatchIndex + 1); + if (i === -1) break; - // Reward word boundary matches - if (isWordBoundary) { - score -= 10; - } + const isWordBoundary = i === 0 || /[\s\-_./:]/.test(textLower[i - 1]!); - // Slight penalty for later matches - score += i * 0.1; + // Reward consecutive matches + if (lastMatchIndex === i - 1) { + consecutiveMatches++; + score -= consecutiveMatches * 5; + } else { + consecutiveMatches = 0; + // Penalize gaps + if (lastMatchIndex >= 0) { + score += (i - lastMatchIndex - 1) * 2; + } + } - lastMatchIndex = i; - queryIndex++; + // Reward word boundary matches + if (isWordBoundary) { + score -= 10; } + + // Slight penalty for later matches + score += i * 0.1; + + lastMatchIndex = i; + queryIndex++; } if (queryIndex < normalizedQuery.length) { diff --git a/packages/pi-tui/src/index.ts b/packages/pi-tui/src/index.ts index a487b016f..9372382d2 100644 --- a/packages/pi-tui/src/index.ts +++ b/packages/pi-tui/src/index.ts @@ -9,12 +9,45 @@ export { CombinedAutocompleteProvider, type SlashCommand, } from "./autocomplete.ts"; +// Colors and styling +export { + backgroundAnsi, + type Color, + type ColorMixSpace, + colorToHex, + colorToOkhsl, + colorToOklch, + colorToRgb, + foregroundAnsi, + type IndexedColor, + indexedColor, + mixColors, + type OkhslChannels, + type OklchChannels, + type OklchColorValue, + okhslColor, + oklchColor, + parseColor, + type RgbColorValue, + rgbColor, + styleText, + styleTextWithAnsi, + type TerminalColorMode, + type TextAttributes, + type TextStyle, +} from "./colors.ts"; // Components export { Box } from "./components/box.ts"; export { CancellableLoader } from "./components/cancellable-loader.ts"; export { Editor, type EditorOptions, type EditorTheme } from "./components/editor.ts"; export { HStack } from "./components/h-stack.ts"; -export { Image, type ImageOptions, type ImageTheme } from "./components/image.ts"; +export { + Image, + type ImageOptions, + type ImageTheme, + type ImageTranscoder, + setImageTranscoder, +} from "./components/image.ts"; export { Input } from "./components/input.ts"; export { Loader, type LoaderIndicatorOptions } from "./components/loader.ts"; export { type DefaultTextStyle, Markdown, type MarkdownOptions, type MarkdownTheme } from "./components/markdown.ts"; @@ -77,16 +110,17 @@ export { export { type RenderLatexOptions, renderLatex } from "./latex.ts"; // Native platform integration export { getNativeClipboard, type NativeClipboard } from "./native-platform.ts"; +export { oklabToOkhslLightness } from "./oklab.ts"; // Input buffering for batch splitting export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer.ts"; // Terminal interface and implementations -export { ProcessTerminal, type Terminal } from "./terminal.ts"; +export { isAppleTerminalSession, ProcessTerminal, type Terminal } from "./terminal.ts"; // Terminal colors export { - parseOsc11BackgroundColor, parseTerminalColorSchemeReport, type RgbColor, type TerminalColorScheme, + type TerminalColors, } from "./terminal-colors.ts"; // Terminal image support export { @@ -104,6 +138,7 @@ export { getImageDimensions, getJpegDimensions, getPngDimensions, + getTerminalColorMode, getWebpDimensions, hyperlink, type ImageDimensions, @@ -156,3 +191,4 @@ export { visibleWidth, wrapTextWithAnsi, } from "./utils.ts"; +export type { WheelScrollLines } from "./wheel-scroll.ts"; diff --git a/packages/pi-tui/src/latex.ts b/packages/pi-tui/src/latex.ts index cb8479bad..41bbcc445 100644 --- a/packages/pi-tui/src/latex.ts +++ b/packages/pi-tui/src/latex.ts @@ -523,6 +523,7 @@ const SPACING_COMMANDS = new Set([ ]); const NEGATIVE_SPACING_COMMANDS = new Set(["!", "negmedspace", "negthickspace", "negthinspace"]); const NEGATIVE_SPACE = "\u0000"; +const FONT_SWITCH_COMMANDS = new Set(["bf", "cal", "it", "rm", "sf", "sl", "tt"]); const IGNORED_COMMANDS = new Set([ "displaystyle", "limits", @@ -610,10 +611,17 @@ function replaceCharacters(value: string, replacements: Readonly padLayoutLine(line, width)) ?? []), + " ".repeat(width), + ...(lower?.lines.map((line) => padLayoutLine(line, width)) ?? []), + ], + width, + baseline: upper?.lines.length ?? 0, + }); } else { const width = Math.max(0, ...node.lines.map((line) => visibleWidth(line))); layouts.push({ @@ -815,6 +842,7 @@ class LatexParser { private position = 0; private supported = true; private stackFractions = true; + private scriptDepth = 0; constructor(source: string, layoutNodes: LayoutNode[], display: boolean) { this.source = source; @@ -866,7 +894,7 @@ class LatexParser { if (character === "^" || character === "_") { this.position++; result = result.trimEnd(); - const script = formatScript(this.parseRequiredArgument(false), character === "_" ? "sub" : "sup"); + const script = this.parseScripts(character); if (result.endsWith(NAMED_OPERATOR_END)) { result = `${result.slice(0, -NAMED_OPERATOR_END.length)}${script}${NAMED_OPERATOR_END}`; } else { @@ -918,6 +946,64 @@ class LatexParser { return result; } + private parseScripts(initialMarker: "^" | "_"): string { + const scripts: { sub?: string; sup?: string } = {}; + const order: Array<"sub" | "sup"> = []; + const parse = (marker: "^" | "_"): void => { + const kind = marker === "_" ? "sub" : "sup"; + this.scriptDepth++; + try { + scripts[kind] = this.parseRequiredArgument(false); + } finally { + this.scriptDepth--; + } + order.push(kind); + }; + + parse(initialMarker); + let nextPosition = this.position; + while (nextPosition < this.source.length && /\s/.test(this.source[nextPosition] ?? "")) { + nextPosition++; + } + const nextMarker = this.source[nextPosition]; + if ((nextMarker === "^" || nextMarker === "_") && nextMarker !== initialMarker) { + this.position = nextPosition + 1; + parse(nextMarker); + } + + const subUnicode = scripts.sub === undefined ? undefined : formatUnicodeScript(scripts.sub, "sub"); + const supUnicode = scripts.sup === undefined ? undefined : formatUnicodeScript(scripts.sup, "sup"); + const canUseLayout = ![scripts.sub, scripts.sup].some( + (value) => + value !== undefined && + (value.includes("/") || + (!value.includes(LAYOUT_MARKER_START) && Array.from(value).length > 1 && !/[A-Z*∗]/.test(value))), + ); + const needsLayout = + this.display && + canUseLayout && + (this.scriptDepth > 0 || + (scripts.sub !== undefined && subUnicode === undefined) || + (scripts.sup !== undefined && supUnicode === undefined)); + if (!needsLayout) { + return order + .map((kind) => + kind === "sub" + ? (subUnicode ?? formatScript(scripts.sub ?? "", kind)) + : (supUnicode ?? formatScript(scripts.sup ?? "", kind)), + ) + .join(""); + } + + const index = + this.layoutNodes.push({ + type: "script", + lower: scripts.sub === undefined ? undefined : normalizeOutput(scripts.sub), + upper: scripts.sup === undefined ? undefined : normalizeOutput(scripts.sup), + }) - 1; + return `${LAYOUT_MARKER_START}${index}${LAYOUT_MARKER_END}`; + } + private parseWhitespace(): string { while (this.position < this.source.length && /\s/.test(this.source[this.position] ?? "")) { this.position++; @@ -961,6 +1047,12 @@ class LatexParser { if (NEGATIVE_SPACING_COMMANDS.has(command)) { return NEGATIVE_SPACE; } + if (FONT_SWITCH_COMMANDS.has(command)) { + while (this.position < this.source.length && /\s/.test(this.source[this.position] ?? "")) { + this.position++; + } + return ""; + } if (IGNORED_COMMANDS.has(command)) { return ""; } @@ -1284,18 +1376,7 @@ class LatexParser { } if (environment === "cases" || environment === "cases*") { - const rows = this.splitEnvironmentRows(body) - .map((row) => row.split("&").map((cell) => this.renderNested(cell, false).trim())) - .filter((row) => row.some(Boolean)); - return rows - .map((row, index) => { - const value = (row[0] ?? "").replace(/,\s*$/, ""); - const condition = row[1] ?? ""; - const delimiter = index === 0 ? "⎧" : index === rows.length - 1 ? "⎩" : "⎨"; - const conditionPrefix = /^(?:if|when|for|otherwise)\b/i.test(condition) ? " " : " if "; - return `${delimiter} ${value}${condition ? `${conditionPrefix}${condition}` : ""}`; - }) - .join("\n"); + return this.renderCases(body); } if ( @@ -1309,6 +1390,37 @@ class LatexParser { return body; } + private renderCases(body: string): string { + const rows = this.splitEnvironmentRows(body) + .map((row) => row.split("&").map((cell) => this.renderNested(cell, false).trim())) + .filter((row) => row.some(Boolean)); + const valueWidth = Math.max(0, ...rows.map((row) => visibleWidth((row[0] ?? "").replace(/,\s*$/, "")))); + const contents = rows.map((row) => { + const value = (row[0] ?? "").replace(/,\s*$/, ""); + const condition = row[1] ?? ""; + if (!condition) { + return value; + } + const conditionPrefix = /^(?:if|when|for|otherwise)\b/i.test(condition) ? " " : " if "; + return `${value}${PROTECTED_SPACE.repeat(valueWidth - visibleWidth(value))}${conditionPrefix}${condition}`; + }); + if (contents.length <= 1) { + return contents.length === 0 ? "" : `⎧ ${contents[0]}`; + } + + const middle = Math.floor(contents.length / 2); + const visualRows: Array = [...contents]; + if (contents.length % 2 === 0) { + visualRows.splice(middle, 0, undefined); + } + const lines = visualRows.map((content, index) => { + const delimiter = index === 0 ? "⎧" : index === visualRows.length - 1 ? "⎩" : "⎨"; + return content === undefined ? delimiter : `${delimiter} ${content}`; + }); + const index = this.layoutNodes.push({ type: "matrix", lines, baseline: middle }) - 1; + return `${LAYOUT_MARKER_START}${index}${LAYOUT_MARKER_END}`; + } + private renderMatrix(environment: string, body: string): string { const matrix = this.splitEnvironmentRows(body) .map((row) => row.split("&").map((cell) => this.renderNested(cell, false).trim())) diff --git a/packages/pi-tui/src/native-platform.ts b/packages/pi-tui/src/native-platform.ts index 46da9ad76..5e9256450 100644 --- a/packages/pi-tui/src/native-platform.ts +++ b/packages/pi-tui/src/native-platform.ts @@ -11,6 +11,8 @@ export interface NativeClipboard { getText(): Promise; /** Undefined means unavailable, null means no image; transfer failures reject. */ getImage(): Promise; + /** POSIX paths of file URLs on the clipboard. Undefined means unsupported, null means no files. */ + getFilePaths?(): Promise; /** Linux uses command-line tools to retain clipboard ownership instead. */ setText?(text: string): Promise; } diff --git a/packages/pi-tui/src/oklab.ts b/packages/pi-tui/src/oklab.ts new file mode 100644 index 000000000..87c6f9d92 --- /dev/null +++ b/packages/pi-tui/src/oklab.ts @@ -0,0 +1,233 @@ +/** + * Oklab and OKHSL <-> sRGB conversion. `colors.ts` builds its OKLCH, OKHSL, and color mixing on it. + * + * Oklab and OKHSL are Björn Ottosson's color spaces; OKHSL's saturation is relative to the sRGB gamut at + * each hue and lightness. This is a port of his reference implementation (https://bottosson.github.io/posts/colorpicker/), + * Copyright (c) 2021 Björn Ottosson, used under the MIT license: + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the + * following conditions: The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY + * KIND, EXPRESS OR IMPLIED. + */ + +import type { RgbColor } from "./terminal-colors.ts"; + +type Vector = [number, number, number]; +type Matrix = [Vector, Vector, Vector]; + +const multiply = (m: Matrix, [x, y, z]: Vector): Vector => + m.map((row) => row[0] * x + row[1] * y + row[2] * z) as Vector; + +// ============================================================================ +// OKHSL <-> sRGB +// ============================================================================ + +const LINEAR_SRGB_TO_LMS: Matrix = [ + [0.4122214694707629, 0.5363325372617349, 0.0514459932675022], + [0.2119034958178251, 0.6806995506452344, 0.1073969535369405], + [0.0883024591900564, 0.2817188391361215, 0.6299787016738222], +]; +const LMS_TO_LAB: Matrix = [ + [0.210454268309314, 0.793617774702305, -0.0040720430116193], + [1.9779985324311684, -2.42859224204858, 0.450593709617411], + [0.0259040424655478, 0.7827717124575296, -0.8086757549230774], +]; +const LAB_TO_LMS: Matrix = [ + [1, 0.3963377773761749, 0.2158037573099136], + [1, -0.1055613458156586, -0.0638541728258133], + [1, -0.0894841775298119, -1.2914855480194092], +]; +const LMS_TO_LINEAR_SRGB: Matrix = [ + [4.0767416360759583, -3.3077115392580629, 0.2309699031821043], + [-1.2684379732850315, 2.6097573492876882, -0.341319376002657], + [-0.0041960761386756, -0.7034186179359362, 1.7076146940746117], +]; +/** + * Per sRGB channel (red, green, blue): the (a, b) half-plane where that channel clips + * first, and the polynomial approximating the maximum saturation there. + */ +const SATURATION_FIT: [[number, number], [number, number, number, number, number]][] = [ + [ + [-1.8817031, -0.80936501], + [1.19086277, 1.76576728, 0.59662641, 0.75515197, 0.56771245], + ], + [ + [1.8144408, -1.19445267], + [0.73956515, -0.45954404, 0.08285427, 0.12541073, -0.14503204], + ], + [ + [0.13110758, 1.81333971], + [1.35733652, -0.00915799, -1.1513021, -0.50559606, 0.00692167], + ], +]; +const K1 = 0.206; +const K2 = 0.03; +const K3 = (1 + K1) / (1 + K2); + +/** Oklab lightness to OKHSL lightness. */ +export const oklabToOkhslLightness = (x: number): number => + 0.5 * (K3 * x - K1 + Math.sqrt((K3 * x - K1) ** 2 + 4 * K2 * K3 * x)); +/** OKHSL lightness to Oklab lightness. */ +const okhslToOklabLightness = (x: number): number => (x * x + K1 * x) / (K3 * (x + K2)); + +/** sRGB transfer function: linear to encoded channel, both 0-1. */ +const linearToSrgb = (value: number): number => + value > 0.0031308 ? 1.055 * value ** (1 / 2.4) - 0.055 : 12.92 * value; +/** Inverse sRGB transfer function: encoded to linear channel, both 0-1. */ +const srgbToLinear = (value: number): number => (value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4); + +/** Oklab [L, a, b] to linear sRGB [r, g, b] (0-1, may leave the gamut). */ +export function oklabToLinearSrgb(lab: Vector): Vector { + return multiply(LMS_TO_LINEAR_SRGB, multiply(LAB_TO_LMS, lab).map((value) => value ** 3) as Vector); +} + +/** Linear sRGB [r, g, b] (0-1) to Oklab [L, a, b]. */ +function linearSrgbToOklab(rgb: Vector): Vector { + return multiply(LMS_TO_LAB, multiply(LINEAR_SRGB_TO_LMS, rgb).map(Math.cbrt) as Vector); +} + +/** sRGB channels (0-255) to Oklab [L, a, b]. */ +export function rgbToOklab({ r, g, b }: RgbColor): Vector { + return linearSrgbToOklab([r / 255, g / 255, b / 255].map(srgbToLinear) as Vector); +} + +/** Linear sRGB [r, g, b] to sRGB channels (0-255, rounded), clipping out-of-gamut channels. */ +export function linearSrgbToRgb(linear: Vector): RgbColor { + const [r, g, b] = linear.map((value) => Math.round(Math.min(1, Math.max(0, linearToSrgb(value))) * 255)) as Vector; + return { r, g, b }; +} + +/** Rate of change of each cube-root LMS component along a chroma direction (a, b). */ +function lmsSlopes(a: number, b: number): Vector { + return [LAB_TO_LMS[0], LAB_TO_LMS[1], LAB_TO_LMS[2]].map((row) => row[1] * a + row[2] * b) as Vector; +} + +/** Largest saturation (C/L) inside sRGB for hue (a, b): polynomial fit plus one Halley step. */ +function maxSaturation(a: number, b: number): number { + const channel = SATURATION_FIT.findIndex(([[x, y]], index) => index === 2 || x * a + y * b > 1); + const [k0, k1, k2, k3, k4] = SATURATION_FIT[channel]![1]; + const weights = LMS_TO_LINEAR_SRGB[channel]; + const saturation = k0 + k1 * a + k2 * b + k3 * a * a + k4 * a * b; + + const slopes = lmsSlopes(a, b); + const base = slopes.map((k) => 1 + saturation * k); + const dot = (values: number[]) => values.reduce((sum, value, index) => sum + weights![index]! * value, 0); + const f = dot(base.map((value) => value ** 3)); + const f1 = dot(base.map((value, index) => 3 * slopes[index]! * value ** 2)); + const f2 = dot(base.map((value, index) => 6 * slopes[index]! ** 2 * value)); + return saturation - (f * f1) / (f1 * f1 - 0.5 * f * f2); +} + +/** Oklab lightness and chroma of the most saturated sRGB color of hue (a, b). */ +function cusp(a: number, b: number): [number, number] { + const saturation = maxSaturation(a, b); + const lightness = Math.cbrt(1 / Math.max(...oklabToLinearSrgb([1, saturation * a, saturation * b]))); + return [lightness, lightness * saturation]; +} + +/** Chroma where the constant-lightness line at `lightness` leaves the sRGB gamut. */ +function maxChroma(a: number, b: number, lightness: number, [cuspL, cuspC]: [number, number]): number { + if (lightness <= cuspL) return (cuspC * lightness) / cuspL; + // Upper half: triangle edge, then one Halley step against each channel reaching 1. + const t = (cuspC * (lightness - 1)) / (cuspL - 1); + const slopes = lmsSlopes(a, b); + const lms = slopes.map((k) => lightness + t * k); + const cubes = lms.map((value) => value ** 3); + const first = lms.map((value, index) => 3 * slopes[index]! * value ** 2); + const second = lms.map((value, index) => 6 * slopes[index]! ** 2 * value); + const dot = (row: Vector, values: number[]) => row[0] * values[0]! + row[1] * values[1]! + row[2] * values[2]!; + const steps = LMS_TO_LINEAR_SRGB.map((row) => { + const f = dot(row, cubes) - 1; + const f1 = dot(row, first); + const f2 = dot(row, second); + const u = f1 / (f1 * f1 - 0.5 * f * f2); + return u >= 0 ? -f * u : Number.MAX_VALUE; + }); + return t + Math.min(...steps); +} + +/** OKHSL's chroma reference points at lightness L and hue (a, b): [c0, cMid, cMax]. */ +function chromaStops(L: number, a: number, b: number): [number, number, number] { + const peak = cusp(a, b); + const cMax = maxChroma(a, b, L, peak); + const k = cMax / Math.min(L * (peak[1] / peak[0]), (1 - L) * (peak[1] / (1 - peak[0]))); + const midS = + 0.11516993 + + 1 / + (7.4477897 + + 4.1590124 * b + + a * + (-2.19557347 + + 1.75198401 * b + + a * (-2.13704948 - 10.02301043 * b + a * (-4.24894561 + 5.38770819 * b + 4.69891013 * a)))); + const midT = + 0.11239642 + + 1 / + (1.6132032 - + 0.68124379 * b + + a * + (0.40370612 + + 0.90148123 * b + + a * (-0.27087943 + 0.6122399 * b + a * (0.00299215 - 0.45399568 * b - 0.14661872 * a)))); + const cMid = 0.9 * k * Math.sqrt(Math.sqrt(1 / (1 / (L * midS) ** 4 + 1 / ((1 - L) * midT) ** 4))); + const c0 = Math.sqrt(1 / (1 / (L * 0.4) ** 2 + 1 / ((1 - L) * 0.8) ** 2)); + return [c0, cMid, cMax]; +} + +/** + * Convert OKHSL to sRGB channels (0-255, rounded), clipping out-of-gamut channels. + * @param hue Hue in degrees. + * @param saturation Saturation, 0-1. + * @param lightness Lightness, 0-1. + */ +export function okhslToRgb(hue: number, saturation: number, lightness: number): RgbColor { + const L = okhslToOklabLightness(lightness); + let lab: Vector = [L, 0, 0]; + if (L > 0 && L < 1 && saturation > 0) { + const angle = (2 * Math.PI * (((hue % 360) + 360) % 360)) / 360; + const a = Math.cos(angle); + const b = Math.sin(angle); + const [c0, cMid, cMax] = chromaStops(L, a, b); + // Chroma rises from 0 through cMid at s = 0.8 to cMax at s = 1. + let chroma: number; + if (saturation < 0.8) { + const t = 1.25 * saturation; + const k1 = 0.8 * c0; + chroma = (t * k1) / (1 - (1 - k1 / cMid) * t); + } else { + const t = 5 * (saturation - 0.8); + const k1 = (0.2 * cMid ** 2 * 1.25 ** 2) / c0; + chroma = cMid + (t * k1) / (1 - (1 - k1 / (cMax - cMid)) * t); + } + lab = [L, chroma * a, chroma * b]; + } + return linearSrgbToRgb(oklabToLinearSrgb(lab)); +} + +/** + * Convert sRGB channels (0-255) to OKHSL. + * @returns Hue `h` in degrees (0 for grays), saturation `s` and lightness `l` 0-1. + */ +export function rgbToOkhsl(rgb: RgbColor): { h: number; s: number; l: number } { + const [L, labA, labB] = rgbToOklab(rgb); + const chroma = Math.hypot(labA, labB); + const lightness = oklabToOkhslLightness(L); + if (chroma < 1e-9 || lightness <= 0 || lightness >= 1) return { h: 0, s: 0, l: lightness }; + + const hue = ((Math.atan2(labB, labA) * 180) / Math.PI + 360) % 360; + const [c0, cMid, cMax] = chromaStops(L, labA / chroma, labB / chroma); + let saturation: number; + if (chroma < cMid) { + const k1 = 0.8 * c0; + saturation = 0.8 * (chroma / (k1 + (1 - k1 / cMid) * chroma)); + } else { + const k1 = (0.2 * cMid ** 2 * 1.25 ** 2) / c0; + const offset = chroma - cMid; + saturation = 0.8 + 0.2 * (offset / (k1 + (1 - k1 / (cMax - cMid)) * offset)); + } + return { h: hue, s: Math.min(1, Math.max(0, saturation)), l: lightness }; +} diff --git a/packages/pi-tui/src/terminal-colors.ts b/packages/pi-tui/src/terminal-colors.ts index e700ee476..713c8d56c 100644 --- a/packages/pi-tui/src/terminal-colors.ts +++ b/packages/pi-tui/src/terminal-colors.ts @@ -6,6 +6,16 @@ export interface RgbColor { export type TerminalColorScheme = "dark" | "light"; +/** Colors the terminal reports for its current theme. */ +export interface TerminalColors { + /** Default foreground (OSC 10). */ + foreground?: RgbColor; + /** Default background (OSC 11). */ + background?: RgbColor; + /** ANSI colors 0-15 (OSC 4). Only set when the terminal reported all 16. */ + palette?: RgbColor[]; +} + function hexToRgb(hex: string): RgbColor { const normalized = hex.startsWith("#") ? hex.slice(1) : hex; const r = parseInt(normalized.slice(0, 2), 16); @@ -25,20 +35,28 @@ function parseOscHexChannel(channel: string): number | undefined { return Math.round((parseInt(channel, 16) / max) * 255); } -const OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN = /^\x1b\]11;([^\x07\x1b]*)(?:\x07|\x1b\\)$/i; -const COLOR_SCHEME_REPORT_PATTERN = /^(?:\x1b\[\?997;(1|2)n)+$/; +/** What an OSC color reply reports: the default foreground (OSC 10), background (OSC 11), or a palette index (OSC 4). */ +export type OscColorTarget = "foreground" | "background" | number; -export function isOsc11BackgroundColorResponse(data: string): boolean { - return OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN.test(data); -} +const OSC_COLOR_RESPONSE_PATTERN = /^\x1b\](?:(1[01])|4;(\d{1,3}));([^\x07\x1b]*)(?:\x07|\x1b\\)$/i; +const COLOR_SCHEME_REPORT_PATTERN = /^(?:\x1b\[\?997;(1|2)n)+$/; -export function parseOsc11BackgroundColor(data: string): RgbColor | undefined { - const match = data.match(OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN); +/** + * Parse an OSC 10, 11, or 4 color reply. Returns undefined when `data` is not such a reply; + * `rgb` is undefined when it is a reply with an unparseable color. + */ +export function parseOscColorResponse(data: string): { target: OscColorTarget; rgb: RgbColor | undefined } | undefined { + const match = data.match(OSC_COLOR_RESPONSE_PATTERN); if (!match) { return undefined; } + const target: OscColorTarget = + match[1] === "10" ? "foreground" : match[1] === "11" ? "background" : Number.parseInt(match[2]!, 10); + return { target, rgb: parseOscColorValue(match[3]!) }; +} - const value = match[1]!.trim(); +function parseOscColorValue(rawValue: string): RgbColor | undefined { + const value = rawValue.trim(); if (value.startsWith("#")) { const hex = value.slice(1); if (/^[0-9a-f]{6}$/i.test(hex)) { diff --git a/packages/pi-tui/src/terminal-image.ts b/packages/pi-tui/src/terminal-image.ts index b2a2ddd0d..78da5f381 100644 --- a/packages/pi-tui/src/terminal-image.ts +++ b/packages/pi-tui/src/terminal-image.ts @@ -2,6 +2,7 @@ import { execSync } from "node:child_process"; import { homedir } from "node:os"; import { isAbsolute } from "node:path"; import { pathToFileURL } from "node:url"; +import type { TerminalColorMode } from "./colors.ts"; export type ImageProtocol = "kitty" | "iterm2" | null; @@ -71,7 +72,7 @@ function detectCapabilitiesFromEnvironment(tmuxForwardsHyperlink: () => boolean) const terminalEmulator = process.env['TERMINAL_EMULATOR']?.toLowerCase() || ""; const term = process.env['TERM']?.toLowerCase() || ""; const colorTerm = process.env['COLORTERM']?.toLowerCase() || ""; - const hasTrueColorHint = colorTerm === "truecolor" || colorTerm === "24bit"; + const hasTrueColorHint = colorTerm === "truecolor" || colorTerm === "24bit" || term.endsWith("-direct"); const isWindowsConsole = process.platform === "win32"; // Emit OSC 8 hyperlinks only when tmux confirms it forwards. @@ -168,6 +169,10 @@ export function getCapabilities(): TerminalCapabilities { return cachedCapabilities; } +export function getTerminalColorMode(capabilities: TerminalCapabilities = getCapabilities()): TerminalColorMode { + return capabilities.trueColor ? "truecolor" : "256color"; +} + export function resetCapabilitiesCache(): void { cachedCapabilities = null; } @@ -327,6 +332,7 @@ export interface KittyImagePlacement { transmissionGeneration: number; transmissionBytes: number; estimatedDecodedBytes: number; + rows: number; sequence: string; replacementLine: string; } @@ -344,13 +350,27 @@ export function registerKittyImageMetadata(metadata: KittyImageMetadata): void { } } -function getRegisteredKittyImageMetadata(line: string): RegisteredKittyImageMetadata | undefined { - const controls = /\x1b_G([^;]*);/.exec(line)?.[1]; - if (!controls) return undefined; +function getRegisteredKittyImageMetadataFromControls(controls: string): RegisteredKittyImageMetadata | undefined { const imageId = /(?:^|,)i=(\d+)(?:,|$)/.exec(controls)?.[1]; return imageId === undefined ? undefined : kittyImageMetadata.get(Number.parseInt(imageId, 10)); } +function getRegisteredKittyImageMetadata(line: string): RegisteredKittyImageMetadata | undefined { + const controls = /\x1b_G([^;]*);/.exec(line)?.[1]; + return controls === undefined ? undefined : getRegisteredKittyImageMetadataFromControls(controls); +} + +function getExplicitKittyImageRows(controls: string): number | undefined { + const value = /(?:^|,)r=(\d+)(?:,|$)/.exec(controls)?.[1]; + if (value === undefined) return undefined; + const rows = Number.parseInt(value, 10); + return rows > 0 ? rows : undefined; +} + +function getKittyImageRowsFromControls(controls: string, fallbackRows: number): number { + return getExplicitKittyImageRows(controls) ?? fallbackRows; +} + export function getKittyImageMetadata(line: string): KittyImageMetadata | undefined { const metadata = getRegisteredKittyImageMetadata(line); if (!metadata) return undefined; @@ -383,11 +403,21 @@ const KITTY_PLACEMENT_CONTROL_KEYS = new Set([ "V", ]); +/** Read the number of rows covered by an image placement without scanning its payload. */ +export function getKittyImagePlacementRows(line: string): number | undefined { + const controls = /\x1b_G([^;]*);/.exec(line)?.[1]; + if (controls === undefined) return undefined; + const explicitRows = getExplicitKittyImageRows(controls); + if (explicitRows !== undefined) return explicitRows; + return getRegisteredKittyImageMetadataFromControls(controls)?.rows; +} + /** Build a placement-only command for an image line emitted by {@link renderImage}. */ export function getKittyImagePlacement(line: string): KittyImagePlacement | undefined { const match = /\x1b_G([^;]*);/.exec(line); - const metadata = getRegisteredKittyImageMetadata(line); - if (!match || !metadata) return undefined; + if (!match) return undefined; + const metadata = getRegisteredKittyImageMetadataFromControls(match[1]!); + if (!metadata) return undefined; let commandStart = match.index; let commandControls = match[1]!; @@ -413,6 +443,7 @@ export function getKittyImagePlacement(line: string): KittyImagePlacement | unde transmissionGeneration: metadata.transmissionGeneration, transmissionBytes: transmissionEnd - match.index, estimatedDecodedBytes: metadata.widthPx * metadata.heightPx * 4, + rows: getKittyImageRowsFromControls(match[1]!, metadata.rows), sequence, replacementLine: `${line.slice(0, match.index)}${sequence}${line.slice(transmissionEnd)}`, }; @@ -432,11 +463,21 @@ export function cropKittyImageLine(line: string, hiddenRows: number, visibleRows return `${line.slice(0, match.index)}\x1b_G${controls.join(",")};${line.slice(match.index + match[0].length)}`; } +function chooseLessDistortedCellCount(upperCount: number, idealCount: number): number { + if (upperCount <= 1) return upperCount; + + const lowerCount = upperCount - 1; + const upperDistortion = Math.max(upperCount / idealCount, idealCount / upperCount); + const lowerDistortion = Math.max(lowerCount / idealCount, idealCount / lowerCount); + return lowerDistortion < upperDistortion ? lowerCount : upperCount; +} + export function calculateImageCellSize( imageDimensions: ImageDimensions, maxWidthCells: number, maxHeightCells?: number, cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 }, + optimizeAspectRatio = false, ): ImageCellSize { const maxWidth = Math.max(1, Math.floor(maxWidthCells)); const maxHeight = maxHeightCells === undefined ? undefined : Math.max(1, Math.floor(maxHeightCells)); @@ -449,13 +490,26 @@ export function calculateImageCellSize( const scaledWidthPx = imageWidth * scale; const scaledHeightPx = imageHeight * scale; - const columns = Math.ceil(scaledWidthPx / cellDimensions.widthPx); - const rows = Math.ceil(scaledHeightPx / cellDimensions.heightPx); + let columns = Math.max(1, Math.min(maxWidth, Math.ceil(scaledWidthPx / cellDimensions.widthPx))); + const heightRows = scaledHeightPx / cellDimensions.heightPx; + let rows = Math.max(1, Math.ceil(heightRows)); + if (maxHeight !== undefined) { + rows = Math.min(maxHeight, rows); + } - return { - columns: Math.max(1, Math.min(maxWidth, columns)), - rows: Math.max(1, maxHeight === undefined ? rows : Math.min(maxHeight, rows)), - }; + if (!optimizeAspectRatio) { + return { columns, rows }; + } + + if (widthScale <= heightScale) { + const idealRows = (columns * cellDimensions.widthPx * imageHeight) / (imageWidth * cellDimensions.heightPx); + rows = chooseLessDistortedCellCount(rows, idealRows); + } else { + const idealColumns = (rows * cellDimensions.heightPx * imageWidth) / (imageHeight * cellDimensions.widthPx); + columns = chooseLessDistortedCellCount(columns, idealColumns); + } + + return { columns, rows }; } export function calculateImageRows( @@ -619,7 +673,14 @@ export function renderImage( } const maxWidth = options.maxWidthCells ?? 80; - const size = calculateImageCellSize(imageDimensions, maxWidth, options.maxHeightCells, getCellDimensions()); + // Reduce Kitty's cell-aligned distortion without shrinking iTerm2 reservations. + const size = calculateImageCellSize( + imageDimensions, + maxWidth, + options.maxHeightCells, + getCellDimensions(), + caps.images === "kitty", + ); if (caps.images === "kitty") { if (options.imageId !== undefined) { diff --git a/packages/pi-tui/src/terminal.ts b/packages/pi-tui/src/terminal.ts index 09c468c1c..5f83e4763 100644 --- a/packages/pi-tui/src/terminal.ts +++ b/packages/pi-tui/src/terminal.ts @@ -141,6 +141,8 @@ export class ProcessTerminal implements Terminal { private _kittyProtocolActive = false; private _modifyOtherKeysActive = false; private keyboardProtocolPushed = false; + /** DA1 replies owed to keyboard protocol queries. Later DA1 replies answer other queries and are forwarded. */ + private pendingKeyboardProtocolDeviceAttributes = 0; private keyboardProtocolNegotiationBuffer = ""; private keyboardProtocolBufferFlushTimer?: ReturnType; private stdinBuffer?: StdinBuffer; @@ -215,16 +217,16 @@ export class ProcessTerminal implements Terminal { // Forward individual sequences to the input handler this.stdinBuffer.on("data", (sequence) => { - const negotiationSequence = this.readKeyboardProtocolNegotiationSequence(sequence); - if (negotiationSequence === "pending") { + const negotiation = this.readKeyboardProtocolNegotiationSequence(sequence); + if (negotiation === "pending") { this.scheduleKeyboardProtocolNegotiationBufferFlush(); return; // Wait briefly for the rest of a split Kitty response. } - if (this.handleKeyboardProtocolNegotiationSequence(negotiationSequence)) { + if (negotiation && this.handleKeyboardProtocolNegotiationSequence(negotiation.parsed)) { return; } - this.forwardInputSequence(sequence); + this.forwardInputSequence(negotiation?.sequence ?? sequence); }); // Re-wrap paste content with bracketed paste markers for existing editor handling @@ -257,15 +259,19 @@ export class ProcessTerminal implements Terminal { this.setupStdinBuffer(); process.stdin.on("data", this.stdinDataHandler!); this.keyboardProtocolPushed = true; + this.pendingKeyboardProtocolDeviceAttributes += 1; this.clearKeyboardProtocolNegotiationBuffer(); process.stdout.write(KITTY_KEYBOARD_PROTOCOL_QUERY); } private handleKeyboardProtocolNegotiationSequence( - negotiationSequence: KeyboardProtocolNegotiationSequence | undefined, + negotiationSequence: KeyboardProtocolNegotiationSequence, ): boolean { - if (!negotiationSequence) return false; this.clearKeyboardProtocolNegotiationBuffer(); + if (negotiationSequence.type === "device-attributes") { + if (this.pendingKeyboardProtocolDeviceAttributes === 0) return false; + this.pendingKeyboardProtocolDeviceAttributes -= 1; + } if (negotiationSequence.type === "kitty-flags") { if (negotiationSequence.flags !== 0) { this.disableModifyOtherKeys(); @@ -285,15 +291,16 @@ export class ProcessTerminal implements Terminal { return true; } + /** Returns the parsed negotiation reply with its full (possibly reassembled) sequence. */ private readKeyboardProtocolNegotiationSequence( sequence: string, - ): KeyboardProtocolNegotiationSequence | "pending" | undefined { + ): { parsed: KeyboardProtocolNegotiationSequence; sequence: string } | "pending" | undefined { if (this.keyboardProtocolNegotiationBuffer) { const bufferedSequence = this.keyboardProtocolNegotiationBuffer + sequence; const negotiationSequence = parseKeyboardProtocolNegotiationSequence(bufferedSequence); if (negotiationSequence) { this.clearKeyboardProtocolNegotiationBuffer(); - return negotiationSequence; + return { parsed: negotiationSequence, sequence: bufferedSequence }; } if (isKeyboardProtocolNegotiationSequencePrefix(bufferedSequence)) { this.setKeyboardProtocolNegotiationBuffer(bufferedSequence); @@ -303,7 +310,7 @@ export class ProcessTerminal implements Terminal { } const negotiationSequence = parseKeyboardProtocolNegotiationSequence(sequence); - if (negotiationSequence) return negotiationSequence; + if (negotiationSequence) return { parsed: negotiationSequence, sequence }; if (isKeyboardProtocolNegotiationSequencePrefix(sequence)) { this.setKeyboardProtocolNegotiationBuffer(sequence); return "pending"; diff --git a/packages/pi-tui/src/tui-alt-screen.ts b/packages/pi-tui/src/tui-alt-screen.ts index 3daddf197..47eac04dc 100644 --- a/packages/pi-tui/src/tui-alt-screen.ts +++ b/packages/pi-tui/src/tui-alt-screen.ts @@ -25,6 +25,7 @@ import { deleteKittyImage, getCapabilities, getKittyImagePlacement, + getKittyImagePlacementRows, type ImageProtocol, isImageLine, setCapabilities, @@ -58,6 +59,7 @@ import { truncateToWidth, visibleWidth, } from "./utils.ts"; +import { WheelScrollAccelerator, type WheelScrollLines } from "./wheel-scroll.ts"; const ENTER_ALT_SCREEN = "\x1b[?1049h"; const EXIT_ALT_SCREEN = "\x1b[?1049l"; @@ -79,6 +81,7 @@ const MAX_CACHED_OFFSCREEN_KITTY_TRANSMISSION_BYTES = 32 * 1024 * 1024; const MAX_CACHED_OFFSCREEN_KITTY_DECODED_BYTES = 64 * 1024 * 1024; const DOUBLE_CLICK_INTERVAL_MS = 500; const MAX_LAYOUT_EFFECT_PASSES = 10; +const COPY_ERROR_FLASH_DURATION_MS = 5000; // Regular mode delegates double-click selection to the terminal emulator. Fullscreen owns mouse selection, // so mirror common terminal word-selection behavior by keeping paths and kebab-case tokens whole. const TERMINAL_WORD_SELECTION_JOINERS = new Set(["/", "-"]); @@ -165,8 +168,11 @@ interface SearchHighlightRange { } export interface TuiAltScreenOptions { - /** Number of logical lines moved for each mouse-wheel event. */ - wheelScrollLines?: number; + /** + * Logical lines moved for each mouse-wheel event (default: 1). `"auto"` accelerates fast wheel + * spins on terminals that send one event per notch. Alt+wheel moves five times as far. + */ + wheelScrollLines?: WheelScrollLines; /** Capture mouse events for viewport scrolling and application-owned text selection. */ mouse?: boolean; /** Style a non-current transcript search match. */ @@ -187,10 +193,11 @@ export interface TuiAltScreenOptions { /** Automatically copy selected text to the clipboard on mouse release (default: true). */ copyOnSelect?: boolean; /** - * Copy selected text to the system clipboard. Return `true` on success; the caller flashes - * an error otherwise. When omitted, the selection is copied via an OSC 52 write. + * Copy selected text to the system clipboard. Return `true` on success, an error message to + * display on failure, or `false` for a generic error. When omitted, the selection is copied + * via an OSC 52 write. */ - copySelection?: (text: string) => Promise; + copySelection?: (text: string) => Promise; } /** Alternate-screen TUI with a scrollable, application-owned viewport. */ @@ -240,7 +247,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { x: number; y: number; }; - private readonly wheelScrollLines: number; + private readonly wheelScroll: WheelScrollAccelerator; private readonly mouseEnabled: boolean; private readonly searchMatchStyle: (text: string) => string; private readonly searchCurrentMatchStyle: (text: string) => string; @@ -249,7 +256,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { private readonly openUrl?: (url: string) => void; private readonly onRightClickPaste?: () => void; private copyOnSelect: boolean; - private readonly copySelection?: (text: string) => Promise; + private readonly copySelection?: (text: string) => Promise; constructor( terminal: Terminal, @@ -267,7 +274,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { }; this.implicitScrollView = new ScrollView(this.implicitDocument, { follow: "end", primary: true }); this.flashes = new AltScreenFlashContainer(() => this.requestRender()); - this.wheelScrollLines = Math.max(1, Math.floor(options.wheelScrollLines ?? 1)); + this.wheelScroll = new WheelScrollAccelerator(options.wheelScrollLines ?? 1); this.mouseEnabled = options.mouse ?? true; this.searchMatchStyle = options.searchMatchStyle ?? ((text) => `\x1b[4m${text}\x1b[24m`); this.searchCurrentMatchStyle = options.searchCurrentMatchStyle ?? ((text) => `\x1b[1;7m${text}\x1b[22;27m`); @@ -288,6 +295,10 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { return this.getPrimaryScrollView().isFollowingEnd; } + setWheelScrollLines(lines: WheelScrollLines): void { + this.wheelScroll.setLines(lines); + } + getCopyOnSelect(): boolean { return this.copyOnSelect; } @@ -308,6 +319,11 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { return this.copyTextToClipboard(text); } + /** The lines of the last rendered frame, one per terminal row, as written to the terminal. */ + getScreenLines(): string[] { + return [...this.previousScreen]; + } + setLayoutRoot(component: Component | undefined): void { if (this.layoutRoot === component) return; this.layoutRoot = component; @@ -714,9 +730,11 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { const wheelEvent = this.parseWheelEvent(data); if (wheelEvent) { - const event = this.createMouseEvent("wheel", wheelEvent.button, wheelEvent.x, wheelEvent.y, { - wheelDelta: wheelEvent.direction * this.getWheelScrollLines(wheelEvent.button), - }); + const lines = this.wheelScroll.next(wheelEvent.direction, performance.now()); + // SGR mouse button codes use bit 3 (value 8) for the Alt modifier. + const wheelDelta = + wheelEvent.direction * ((wheelEvent.button & 8) !== 0 ? lines * ALT_WHEEL_SCROLL_MULTIPLIER : lines); + const event = this.createMouseEvent("wheel", wheelEvent.button, wheelEvent.x, wheelEvent.y, { wheelDelta }); const overlay = this.dispatchMouseToOverlay(event); const result = overlay.result ?? (overlay.hit ? undefined : this.dispatchMouseToLayout(event)); if (result) { @@ -724,7 +742,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { return { consume: true }; } if (this.shouldDeferViewportInputToOverlay()) return undefined; - this.routeWheel(wheelEvent); + this.routeWheel(wheelEvent, wheelDelta); return { consume: true }; } const mouseEvent = this.parseSgrMouseEvent(data); @@ -1005,13 +1023,8 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { return undefined; } - private getWheelScrollLines(button: number): number { - // SGR mouse button codes use bit 3 (value 8) for the Alt modifier. - return (button & 8) !== 0 ? this.wheelScrollLines * ALT_WHEEL_SCROLL_MULTIPLIER : this.wheelScrollLines; - } - - private routeWheel(event: WheelEvent): void { - let remaining = event.direction * this.getWheelScrollLines(event.button); + private routeWheel(event: WheelEvent, delta: number): void { + let remaining = delta; const seen = new Set(); for (const scrollView of this.currentLayout ? getScrollViewsAt(this.currentLayout, event.x, event.y) : []) { seen.add(scrollView); @@ -1517,8 +1530,12 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { // "Copied!" while leaving the system clipboard untouched (e.g. macOS Terminal.app, tmux // without OSC 52 clipboard passthrough), so only report success when it actually copies. if (this.copySelection) { - const ok = await this.copySelection(text); - this.flash(ok ? "Copied!" : "Copy failed"); + const result = await this.copySelection(text); + const ok = result === true; + this.flash( + ok ? "Copied!" : typeof result === "string" ? result : "Copy failed", + ok ? undefined : COPY_ERROR_FLASH_DURATION_MS, + ); return ok; } this.terminal.write(`\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`); @@ -1689,11 +1706,14 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { const row = clip.y + clip.height - 1; if (row >= screen.length || isImageLine(screen[row] ?? "")) return screen; const scrollbarColumn = box ? getScrollbarGeometry(box)?.column : undefined; - const availableWidth = Math.max(0, (scrollbarColumn ?? clip.x + clip.width) - clip.x); - const text = truncateToWidth(this.scrollToEndIndicator(), availableWidth, ""); + const label = truncateToWidth(this.scrollToEndIndicator(), clip.width, ""); + const labelWidth = visibleWidth(label); + const column = clip.x + Math.floor((clip.width - labelWidth) / 2); + const rightEdge = scrollbarColumn ?? clip.x + clip.width; + const availableWidth = Math.max(0, rightEdge - column); + const text = truncateToWidth(label, availableWidth, ""); const textWidth = visibleWidth(text); if (textWidth === 0) return screen; - const column = clip.x + Math.floor((availableWidth - textWidth) / 2); const result = [...screen]; result[row] = compositeTuiLine(result[row] ?? "", text, column, textWidth, width); this.scrollToEndIndicatorRect = { row, column, width: textWidth }; @@ -1763,10 +1783,25 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { const fullRedraw = this.previousScreen.length === 0 || this.previousScreenWidth !== width || this.previousScreenHeight !== height; - const imagesNeedRedraw = screen.some( - (line, row) => - line !== this.previousScreen[row] && (isImageLine(line) || isImageLine(this.previousScreen[row] ?? "")), + const changedRows = screen.map((line, row) => line !== this.previousScreen[row]); + const imageAnchorsNeedRedraw = screen.some( + (line, row) => changedRows[row] && (isImageLine(line) || isImageLine(this.previousScreen[row] ?? "")), ); + const isWezTerm = Boolean(process.env["WEZTERM_PANE"]) || process.env["TERM_PROGRAM"]?.toLowerCase() === "wezterm"; + const imageCellsNeedRedraw = + !imageAnchorsNeedRedraw && + isWezTerm && + this.imageProtocol === "kitty" && + changedRows.some(Boolean) && + screen.some((line, row) => { + const placementRows = getKittyImagePlacementRows(line); + if (placementRows === undefined) return false; + for (let coveredRow = row; coveredRow < row + placementRows; coveredRow++) { + if (changedRows[coveredRow]) return true; + } + return false; + }); + const imagesNeedRedraw = imageAnchorsNeedRedraw || imageCellsNeedRedraw; const redrawImages = fullRedraw || imagesNeedRedraw; const hadUploadedKittyImages = this.uploadedKittyImages.size > 0; const preparedKittyScreen = @@ -1788,9 +1823,31 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { } buffer += preparedKittyScreen.evictedImageDeletion; - for (let row = 0; row < height; row++) { - if (!fullRedraw && !imagesNeedRedraw && screen[row] === this.previousScreen[row]) continue; - buffer += `\x1b[${row + 1};1H\x1b[2K${preparedKittyScreen.lines[row] ?? ""}`; + // WezTerm erases intersecting Kitty image cells when a later row write touches a covered row. + // Draw image placements after every clear and text write so nothing later intersects them; preserve + // the existing interleaved output for text-only frames and every other terminal. + const drawKittyImagesLast = + redrawImages && this.imageProtocol === "kitty" && screen.some(isImageLine) && isWezTerm; + if (drawKittyImagesLast) { + for (let row = 0; row < height; row++) { + if (!fullRedraw && !imagesNeedRedraw && screen[row] === this.previousScreen[row]) continue; + buffer += `\x1b[${row + 1};1H\x1b[2K`; + } + for (let row = 0; row < height; row++) { + if (!fullRedraw && !imagesNeedRedraw && screen[row] === this.previousScreen[row]) continue; + if (isImageLine(preparedKittyScreen.lines[row] ?? "")) continue; + buffer += `\x1b[${row + 1};1H${preparedKittyScreen.lines[row] ?? ""}`; + } + for (let row = 0; row < height; row++) { + if (!fullRedraw && !imagesNeedRedraw && screen[row] === this.previousScreen[row]) continue; + if (!isImageLine(preparedKittyScreen.lines[row] ?? "")) continue; + buffer += `\x1b[${row + 1};1H${preparedKittyScreen.lines[row] ?? ""}`; + } + } else { + for (let row = 0; row < height; row++) { + if (!fullRedraw && !imagesNeedRedraw && screen[row] === this.previousScreen[row]) continue; + buffer += `\x1b[${row + 1};1H\x1b[2K${preparedKittyScreen.lines[row] ?? ""}`; + } } if (cursorPos) { diff --git a/packages/pi-tui/src/tui.ts b/packages/pi-tui/src/tui.ts index 7445cb03b..7cd7f722e 100644 --- a/packages/pi-tui/src/tui.ts +++ b/packages/pi-tui/src/tui.ts @@ -6,11 +6,11 @@ import { performance } from "node:perf_hooks"; import { isKeyRelease, matchesKey } from "./keys.ts"; import type { Terminal } from "./terminal.ts"; import { - isOsc11BackgroundColorResponse, - parseOsc11BackgroundColor, + parseOscColorResponse, parseTerminalColorSchemeReport, type RgbColor, type TerminalColorScheme, + type TerminalColors, } from "./terminal-colors.ts"; import { getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.ts"; import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.ts"; @@ -81,7 +81,13 @@ export interface TuiMouseDispatchResult extends TuiMouseEventResult { export function dispatchMouseEvent(component: Component, event: TuiMouseEvent): TuiMouseDispatchResult | undefined { const result = component.handleMouse?.(event); if (!result) return undefined; - if ("target" in result) return result as TuiMouseDispatchResult; + if ("target" in result) { + // The component forwarded the event to a child it hosts. Like a delegating container, it routes + // keys to that child itself, so it keeps keyboard focus. Focusing the child directly would leave + // focus on a detached component once the host removes it, e.g. a closed settings submenu. + const forwarded = result as TuiMouseDispatchResult; + return forwarded.focus && component.handleInput ? { ...forwarded, focusTarget: component } : forwarded; + } if (!result.handled && !result.capture && !result.focus) return undefined; return { ...result, @@ -139,12 +145,34 @@ export type TuiInputListenerResult = { consume?: boolean; data?: string } | unde export type TuiInputListener = (data: string) => TuiInputListenerResult; /** Synchronous work after viewport layout and search reveal, before terminal output. */ export type LayoutEffect = () => void; -type PendingOsc11BackgroundQuery = { - settled: boolean; - resolve: ((rgb: RgbColor | undefined) => void) | undefined; +type PendingTerminalColorQuery = { + foreground?: RgbColor; + background?: RgbColor; + palette: Array; + /** Targets that already replied, so duplicates do not count twice. */ + replied: Set; + /** + * Receives the result: the promise's resolve until the timeout, then `onLateReply`. Unset once the + * query completed (on the DA1 reply or once every color replied); later replies are ignored. + */ + deliver: ((colors: TerminalColors) => void) | undefined; timer: NodeJS.Timeout | undefined; }; +const TERMINAL_PALETTE_SIZE = 16; +/** OSC 10 and 11 plus OSC 4 for every palette color. */ +const TERMINAL_COLOR_REPLY_COUNT = 2 + TERMINAL_PALETTE_SIZE; +/** + * Default colors, palette colors 0-15, and a trailing primary device attributes (DA1) request. + * Every terminal answers DA1 and terminals answer in order, so the DA1 reply marks the end of + * the color replies, including for terminals that ignore the color queries. + */ +const TERMINAL_COLOR_QUERY = `\x1b]10;?\x07\x1b]11;?\x07${Array.from( + { length: TERMINAL_PALETTE_SIZE }, + (_, index) => `\x1b]4;${index};?\x07`, +).join("")}\x1b[c`; +const DEVICE_ATTRIBUTES_RESPONSE_PATTERN = /^\x1b\[\?[\d;]*c$/; + /** * Interface for components that can receive focus and display a hardware cursor. * When focused, the component should emit CURSOR_MARKER at the cursor position @@ -452,8 +480,10 @@ export interface TUI extends Component { removeInputListener(listener: TuiInputListener): void; onTerminalColorSchemeChange(listener: (scheme: TerminalColorScheme) => void): () => void; setTerminalColorSchemeNotifications(enabled: boolean): void; - queryTerminalBackgroundColor(options: { timeoutMs: number }): Promise; - queryTerminalColorScheme(options: { timeoutMs: number }): Promise; + queryTerminalColors(options: { + timeoutMs: number; + onLateReply?: (colors: TerminalColors) => void; + }): Promise; } export const VIEWPORT_TUI = Symbol.for("@earendil-works/pi-tui/viewport"); @@ -489,8 +519,11 @@ export abstract class TuiBase extends Container implements TUI { private clearOnShrink = false; protected fullRedrawCount = 0; protected stopped = false; - private pendingOsc11BackgroundReplies = 0; - private pendingOsc11BackgroundQueries: PendingOsc11BackgroundQuery[] = []; + /** + * Color queries waiting for their DA1 reply, oldest first. Terminals answer in order, so color + * replies belong to the oldest one. Queries stay here after a timeout to collect late replies. + */ + private pendingTerminalColorQueries: PendingTerminalColorQuery[] = []; private terminalColorSchemeListeners = new Set<(scheme: TerminalColorScheme) => void>(); private terminalColorSchemeNotificationsEnabled = false; /** Directory for debug/crash logs. When undefined, debug logging is disabled and crash dumps fall back to the OS temp directory. */ @@ -539,7 +572,7 @@ export abstract class TuiBase extends Container implements TUI { if (this.showHardwareCursor === enabled) return; this.showHardwareCursor = enabled; if (!enabled) { - this.terminal.hideCursor(); + this.hideTerminalCursor(); } this.requestRender(); } @@ -705,7 +738,7 @@ export abstract class TuiBase extends Container implements TUI { if (!options?.nonCapturing && this.isOverlayVisible(entry)) { this.setFocus(component); } - this.terminal.hideCursor(); + this.hideTerminalCursor(); this.requestRender(); // Return handle for controlling this overlay @@ -721,7 +754,7 @@ export abstract class TuiBase extends Container implements TUI { const topVisible = this.getTopmostVisibleOverlay(); this.setFocus(topVisible?.component ?? entry.preFocus); } - if (this.overlayStack.length === 0) this.terminal.hideCursor(); + if (this.overlayStack.length === 0) this.hideTerminalCursor(); this.requestRender(); } }, @@ -803,10 +836,15 @@ export abstract class TuiBase extends Container implements TUI { const topVisible = this.getTopmostVisibleOverlay(); this.setFocus(topVisible?.component ?? overlay.preFocus); } - if (this.overlayStack.length === 0) this.terminal.hideCursor(); + if (this.overlayStack.length === 0) this.hideTerminalCursor(); this.requestRender(); } + /** Hide the cursor while running. After stop(), the shell owns the cursor and it must stay visible. */ + private hideTerminalCursor(): void { + if (!this.stopped) this.terminal.hideCursor(); + } + /** Check if there are any visible overlays */ hasOverlay(): boolean { return this.overlayStack.some((o) => this.isOverlayVisible(o)); @@ -1014,7 +1052,7 @@ export abstract class TuiBase extends Container implements TUI { } private handleTerminalInput(data: string): void { - if (this.consumeOsc11BackgroundResponse(data)) { + if (this.consumeTerminalColorResponse(data)) { return; } if (this.consumeTerminalColorSchemeReport(data)) { @@ -1091,30 +1129,52 @@ export abstract class TuiBase extends Container implements TUI { } } - private consumeOsc11BackgroundResponse(data: string): boolean { - if (this.pendingOsc11BackgroundReplies <= 0) { + private consumeTerminalColorResponse(data: string): boolean { + const query = this.pendingTerminalColorQueries[0]; + if (!query) { return false; } + if (DEVICE_ATTRIBUTES_RESPONSE_PATTERN.test(data)) { + this.pendingTerminalColorQueries.shift(); + this.completeTerminalColorQuery(query); + return true; + } - if (!isOsc11BackgroundColorResponse(data)) { + const response = parseOscColorResponse(data); + if (!response) { return false; } - - const rgb = parseOsc11BackgroundColor(data); - this.pendingOsc11BackgroundReplies -= 1; - const query = this.pendingOsc11BackgroundQueries.shift(); - if (query && !query.settled) { - query.settled = true; - if (query.timer) { - clearTimeout(query.timer); - query.timer = undefined; - } - query.resolve?.(rgb); - query.resolve = undefined; + const { target, rgb } = response; + const key = String(target); + if (!query.deliver || query.replied.has(key)) { + return true; + } + query.replied.add(key); + if (target === "foreground") { + query.foreground = rgb; + } else if (target === "background") { + query.background = rgb; + } else if (target < TERMINAL_PALETTE_SIZE) { + query.palette[target] = rgb; + } + if (query.replied.size === TERMINAL_COLOR_REPLY_COUNT) { + this.completeTerminalColorQuery(query); } return true; } + private terminalColorQueryResult(query: PendingTerminalColorQuery): TerminalColors { + const palette = query.palette.every((color) => color !== undefined) ? (query.palette as RgbColor[]) : undefined; + return { foreground: query.foreground, background: query.background, palette }; + } + + private completeTerminalColorQuery(query: PendingTerminalColorQuery): void { + const deliver = query.deliver; + query.deliver = undefined; + clearTimeout(query.timer); + deliver?.(this.terminalColorQueryResult(query)); + } + private consumeTerminalColorSchemeReport(data: string): boolean { const scheme = parseTerminalColorSchemeReport(data); if (!scheme) { @@ -1410,57 +1470,34 @@ export abstract class TuiBase extends Container implements TUI { } /** - * Query the terminal's default background color with OSC 11 (`ESC ] 11 ; ? BEL`). - * @param timeoutMs Query timeout in milliseconds. - * @returns Promise containing the parsed RGB color, or undefined if it times out or fails to parse. + * Query the terminal's theme colors: the default foreground (OSC 10), the default background + * (OSC 11), and ANSI colors 0-15 (OSC 4), followed by a DA1 request that marks the end of the + * replies. Resolves when the DA1 reply or all color replies arrive, or when the timeout expires. + * Colors the terminal did not report are undefined; the palette is only set when all 16 arrived. + * @param timeoutMs Query timeout in milliseconds, for terminals that do not answer DA1 either. + * @param onLateReply Receives the replies if the query completes after the timeout, e.g. over slow links. */ - queryTerminalBackgroundColor({ timeoutMs }: { timeoutMs: number }): Promise { + queryTerminalColors({ + timeoutMs, + onLateReply, + }: { + timeoutMs: number; + onLateReply?: (colors: TerminalColors) => void; + }): Promise { return new Promise((resolve) => { - const query: PendingOsc11BackgroundQuery = { - settled: false, - resolve, + const query: PendingTerminalColorQuery = { + palette: Array.from({ length: TERMINAL_PALETTE_SIZE }, () => undefined), + replied: new Set(), + deliver: resolve, timer: undefined, }; - + // Resolve with the replies so far, and keep collecting late replies for `onLateReply`. query.timer = setTimeout(() => { - if (query.settled) { - return; - } - query.settled = true; - query.timer = undefined; - query.resolve?.(undefined); - query.resolve = undefined; + query.deliver = onLateReply; + resolve(this.terminalColorQueryResult(query)); }, timeoutMs); - this.pendingOsc11BackgroundQueries.push(query); - this.pendingOsc11BackgroundReplies += 1; - this.terminal.write("\x1b]11;?\x07"); - }); - } - - /** - * Query the terminal's color-scheme preference with DSR (`CSI ? 996 n`). - * Terminals that support the color palette notification protocol reply with - * `CSI ? 997 ; 1 n` for dark or `CSI ? 997 ; 2 n` for light. - */ - queryTerminalColorScheme({ timeoutMs }: { timeoutMs: number }): Promise { - return new Promise((resolve) => { - let settled = false; - let timer: NodeJS.Timeout | undefined; - let unsubscribe: () => void = () => {}; - const settle = (scheme: TerminalColorScheme | undefined) => { - if (settled) return; - settled = true; - if (timer) { - clearTimeout(timer); - timer = undefined; - } - unsubscribe(); - resolve(scheme); - }; - - unsubscribe = this.onTerminalColorSchemeChange(settle); - timer = setTimeout(() => settle(undefined), timeoutMs); - this.terminal.write("\x1b[?996n"); + this.pendingTerminalColorQueries.push(query); + this.terminal.write(TERMINAL_COLOR_QUERY); }); } } diff --git a/packages/pi-tui/src/utils.ts b/packages/pi-tui/src/utils.ts index d51eb9096..245023bc7 100644 --- a/packages/pi-tui/src/utils.ts +++ b/packages/pi-tui/src/utils.ts @@ -54,6 +54,14 @@ const widthCache = new Map(); export const cjkBreakRegex = /[\p{Script_Extensions=Han}\p{Script_Extensions=Hiragana}\p{Script_Extensions=Katakana}\p{Script_Extensions=Hangul}\p{Script_Extensions=Bopomofo}]/u; +// CJK letters remain part of words and paths; only punctuation can separate prose from completions. +export const cjkPunctuationRegex = new RegExp( + `(?:(?=\\p{Punctuation})${cjkBreakRegex.source}|[,.:;!?()[]{}“”‘’…—])`, + "u", +); +export const autocompleteSeparatorRegex = new RegExp(`(?:\\s|${cjkPunctuationRegex.source})`, "u"); +export const autocompleteBoundaryRegex = new RegExp(`(?:^|${autocompleteSeparatorRegex.source})`, "u"); + function isPrintableAscii(str: string): boolean { for (let i = 0; i < str.length; i++) { const code = str.charCodeAt(i); @@ -172,8 +180,10 @@ function finalizeTruncatedResult( * check to avoid running the RGI_Emoji regex unnecessarily. */ function graphemeWidth(segment: string): number { - if (segment === "\t") { - return 3; + if (segment.length === 1) { + const code = segment.charCodeAt(0); + if (code >= 0x20 && code <= 0x7e) return 1; + if (code === 0x09) return 3; } // Some marks occupy cells even without a base character. @@ -242,9 +252,11 @@ export function visibleWidth(str: string): number { return 0; } - // Fast path: pure ASCII printable - if (isPrintableAscii(str)) { - return str.length; + // Fast path: printable ASCII, tabs, and ANSI escape sequences. Styled lines take this path, so + // re-rendering after a theme change does not run grapheme segmentation on every line. + const asciiWidth = asciiTabVisibleWidth(str); + if (asciiWidth !== -1) { + return asciiWidth; } // Check cache @@ -258,22 +270,25 @@ export function visibleWidth(str: string): number { if (str.includes("\t")) { clean = clean.replace(/\t/g, " "); } - if (clean.includes("\x1b")) { + let escapeIndex = clean.indexOf("\x1b"); + if (escapeIndex !== -1) { // Strip supported ANSI/OSC/APC escape sequences in one pass. // This covers CSI styling/cursor codes, OSC hyperlinks and prompt markers, // and APC sequences like CURSOR_MARKER. let stripped = ""; - let i = 0; - while (i < clean.length) { - const ansi = extractAnsiCode(clean, i); - if (ansi) { - i += ansi.length; - continue; + let copyFrom = 0; + while (escapeIndex !== -1) { + const length = ansiCodeLength(clean, escapeIndex); + if (length > 0) { + stripped += clean.slice(copyFrom, escapeIndex); + escapeIndex += length; + copyFrom = escapeIndex; + } else { + escapeIndex++; } - stripped += clean[i]; - i++; + escapeIndex = clean.indexOf("\x1b", escapeIndex); } - clean = stripped; + clean = stripped + clean.slice(copyFrom); } // Calculate width @@ -430,43 +445,66 @@ export function normalizeTerminalOutput(str: string): string { * Extract ANSI escape sequences from a string at the given position. */ export function extractAnsiCode(str: string, pos: number): { code: string; length: number } | null { - if (pos >= str.length || str[pos] !== "\x1b") return null; + const length = ansiCodeLength(str, pos); + return length > 0 ? { code: str.substring(pos, pos + length), length } : null; +} + +/** + * Width of a string made of printable ASCII, tabs, and ANSI escape sequences, or -1 if it contains + * anything else. Matches `visibleWidth` for those strings without allocating. + */ +function asciiTabVisibleWidth(str: string): number { + let width = 0; + let i = 0; + while (i < str.length) { + const code = str.charCodeAt(i); + if (code >= 0x20 && code <= 0x7e) { + width++; + i++; + } else if (code === 0x09) { + width += 3; + i++; + } else if (code === 0x1b) { + const length = ansiCodeLength(str, i); + if (length === 0) return -1; + i += length; + } else { + return -1; + } + } + return width; +} + +/** Length of the ANSI/OSC/APC escape sequence starting at `pos`, or 0 if there is none. */ +function ansiCodeLength(str: string, pos: number): number { + if (pos >= str.length || str.charCodeAt(pos) !== 0x1b) return 0; const next = str[pos + 1]; // CSI sequence: ESC [ ... m/G/K/H/J if (next === "[") { - let j = pos + 2; - while (j < str.length && !/[mGKHJ]/.test(str[j]!)) j++; - if (j < str.length) return { code: str.substring(pos, j + 1), length: j + 1 - pos }; - return null; + for (let j = pos + 2; j < str.length; j++) { + const c = str.charCodeAt(j); + // m, G, K, H, J + if (c === 0x6d || c === 0x47 || c === 0x4b || c === 0x48 || c === 0x4a) return j + 1 - pos; + } + return 0; } // OSC sequence: ESC ] ... BEL or ESC ] ... ST (ESC \) // Used for hyperlinks (OSC 8), window titles, etc. - if (next === "]") { - let j = pos + 2; - while (j < str.length) { - if (str[j] === "\x07") return { code: str.substring(pos, j + 1), length: j + 1 - pos }; - if (str[j] === "\x1b" && str[j + 1] === "\\") return { code: str.substring(pos, j + 2), length: j + 2 - pos }; - j++; - } - return null; - } - // APC sequence: ESC _ ... BEL or ESC _ ... ST (ESC \) // Used for cursor marker and application-specific commands - if (next === "_") { - let j = pos + 2; - while (j < str.length) { - if (str[j] === "\x07") return { code: str.substring(pos, j + 1), length: j + 1 - pos }; - if (str[j] === "\x1b" && str[j + 1] === "\\") return { code: str.substring(pos, j + 2), length: j + 2 - pos }; - j++; + if (next === "]" || next === "_") { + for (let j = pos + 2; j < str.length; j++) { + const c = str.charCodeAt(j); + if (c === 0x07) return j + 1 - pos; + if (c === 0x1b && str[j + 1] === "\\") return j + 2 - pos; } - return null; + return 0; } - return null; + return 0; } type Osc8Terminator = "\x07" | "\x1b\\"; @@ -757,15 +795,16 @@ class AnsiCodeTracker { } function updateTrackerFromText(text: string, tracker: AnsiCodeTracker): void { - let i = 0; - while (i < text.length) { - const ansiResult = extractAnsiCode(text, i); - if (ansiResult) { - tracker.process(ansiResult.code); - i += ansiResult.length; + let i = text.indexOf("\x1b"); + while (i !== -1) { + const length = ansiCodeLength(text, i); + if (length > 0) { + tracker.process(text.substring(i, i + length)); + i += length; } else { i++; } + i = text.indexOf("\x1b", i); } } @@ -776,6 +815,10 @@ export function getActiveBackgroundAnsi(text: string): string { return tracker.getActiveBackgroundCode(); } +function* graphemeSegments(text: string): Generator { + for (const { segment } of graphemeSegmenter.segment(text)) yield segment; +} + /** * Split text into words while keeping ANSI codes attached. */ @@ -804,14 +847,19 @@ function splitIntoTokensWithAnsi(text: string): string[] { continue; } - let end = i; - while (end < text.length && !extractAnsiCode(text, end)) { - end++; + // Visible text runs up to the next escape sequence. + let end = text.indexOf("\x1b", i + 1); + while (end !== -1 && ansiCodeLength(text, end) === 0) { + end = text.indexOf("\x1b", end + 1); } + if (end === -1) end = text.length; - for (const { segment } of graphemeSegmenter.segment(text.slice(i, end))) { + const chunk = text.slice(i, end); + // Printable ASCII characters are single graphemes, so skip the segmenter for them. + const ascii = isPrintableAscii(chunk); + for (const segment of ascii ? chunk : graphemeSegments(chunk)) { const segmentIsSpace = segment === " "; - if (!segmentIsSpace && cjkBreakRegex.test(segment)) { + if (!ascii && !segmentIsSpace && cjkBreakRegex.test(segment)) { flushCurrent(); const token = pendingAnsi + segment; pendingAnsi = ""; @@ -866,6 +914,15 @@ function splitIntoTokensWithAnsi(text: string): string[] { * @param width - Maximum visible width per line * @returns Array of wrapped lines (NOT padded to width) */ +/** + * Flatten cached lines. V8 keeps a string built by concatenation as a tree of its parts until something reads it + * whole, and a cached line kept as such a tree retains several times its own size. Converting a string to a number + * reads it whole, so V8 flattens it in place; the strings' values do not change. + */ +export function flattenLines(lines: readonly string[]): void { + for (const line of lines) Number(line); +} + export function wrapTextWithAnsi(text: string, width: number): string[] { if (!text) { return [""]; @@ -1251,8 +1308,11 @@ export function sliceWithWidth( while (i < line.length) { const ansi = extractAnsiCode(line, i); if (ansi) { - if (currentCol >= startCol && currentCol < endCol) result += ansi.code; - else if (currentCol < startCol) pendingAnsi += ansi.code; + if (currentCol >= startCol && currentCol < endCol) { + // Keep original order: codes from before the range must precede codes at the boundary + result += pendingAnsi + ansi.code; + pendingAnsi = ""; + } else if (currentCol < startCol) pendingAnsi += ansi.code; i += ansi.length; continue; } diff --git a/packages/pi-tui/src/wheel-scroll.ts b/packages/pi-tui/src/wheel-scroll.ts new file mode 100644 index 000000000..1b8f00918 --- /dev/null +++ b/packages/pi-tui/src/wheel-scroll.ts @@ -0,0 +1,82 @@ +/** Lines moved per mouse-wheel event, or `"auto"` to accelerate fast wheel spins. */ +export type WheelScrollLines = number | "auto"; + +// Several events closer than this belong to one physical notch (Ghostty emits them ~4 ms apart) +// or come from a high-resolution source. They move one line each and do not accelerate. +const BURST_GAP_MS = 5; +// A pause longer than this ends a scroll gesture. +const GESTURE_GAP_MS = 200; +// Average event gap that maps to one line per event. Faster events scale up proportionally. +const REFERENCE_GAP_MS = 100; +const MAX_AUTO_LINES = 6; + +/** + * Local macOS terminals receive wheel and trackpad deltas that the OS has already accelerated, + * and they emit one event per line. Other platforms, and SSH sessions where the client platform + * is unknown, usually send one event per wheel notch. + */ +function terminalAcceleratesWheel(): boolean { + const env = process.env; + return ( + process.platform === "darwin" && + env["SSH_CONNECTION"] === undefined && + env["SSH_CLIENT"] === undefined && + env["SSH_TTY"] === undefined + ); +} + +/** + * Converts wheel events into line counts. + * + * In `"auto"` mode on terminals that do not accelerate wheel input, the count follows event + * velocity: an isolated notch moves one line, while a fast spin moves up to six lines per event. + * For example, notches 100 ms apart move 1 line each, 50 ms apart move 2, and 20 ms apart move 5. + */ +export class WheelScrollAccelerator { + private lines: WheelScrollLines; + private readonly accelerate: boolean; + private lastTime = Number.NEGATIVE_INFINITY; + private lastDirection = 0; + private averageGap: number | undefined; + private carry = 0; + + constructor(lines: WheelScrollLines = "auto", accelerate = !terminalAcceleratesWheel()) { + this.lines = lines; + this.accelerate = accelerate; + } + + setLines(lines: WheelScrollLines): void { + this.lines = lines; + this.reset(); + } + + /** Return the positive line count for a wheel event in `direction` at time `now` (milliseconds). */ + next(direction: -1 | 1, now: number): number { + if (this.lines !== "auto") return Number.isFinite(this.lines) ? Math.max(1, Math.floor(this.lines)) : 1; + if (!this.accelerate) return 1; + + const gap = now - this.lastTime; + const sameGesture = direction === this.lastDirection && gap <= GESTURE_GAP_MS; + this.lastTime = now; + this.lastDirection = direction; + if (!sameGesture) { + this.averageGap = undefined; + this.carry = 0; + return 1; + } + if (gap < BURST_GAP_MS) return 1; + + this.averageGap = this.averageGap === undefined ? gap : (this.averageGap + gap) / 2; + const lines = Math.min(MAX_AUTO_LINES, Math.max(1, REFERENCE_GAP_MS / this.averageGap)) + this.carry; + const whole = Math.floor(lines); + this.carry = lines - whole; + return whole; + } + + private reset(): void { + this.lastTime = Number.NEGATIVE_INFINITY; + this.lastDirection = 0; + this.averageGap = undefined; + this.carry = 0; + } +} diff --git a/packages/pi-tui/test/autocomplete-skill-slash.test.ts b/packages/pi-tui/test/autocomplete-skill-slash.test.ts new file mode 100644 index 000000000..737fa666f --- /dev/null +++ b/packages/pi-tui/test/autocomplete-skill-slash.test.ts @@ -0,0 +1,98 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { CombinedAutocompleteProvider } from "../src/autocomplete.ts"; + +describe("CombinedAutocompleteProvider slash-command filter", () => { + const commands = [ + { name: "skill:deep-research", description: "Multi-agent deep research" }, + { name: "skill:research-idea", description: "Refine a raw idea into a falsifiable seed" }, + { name: "skill:to-sidecar", description: "Route work to a sidecar" }, + { name: "skill:brainstorm", description: "Generate ideas" }, + { name: "model", description: "Select the active model" }, + ]; + + async function suggestionsFor(prefix: string): Promise { + const provider = new CombinedAutocompleteProvider(commands, process.cwd()); + const line = `/${prefix}`; + const result = await provider.getSuggestions([line], 0, line.length, { + signal: new AbortController().signal, + }); + assert.ok(result, `expected suggestions for "/${prefix}"`); + return result.items.map((item) => item.value); + } + + it("ranks skill:research-idea first for query 'idea'", async () => { + const items = await suggestionsFor("idea"); + assert.equal(items[0], "skill:research-idea"); + assert.ok(items.indexOf("skill:deep-research") > items.indexOf("skill:research-idea")); + }); + + it("keeps ordinary slash commands matching", async () => { + const items = await suggestionsFor("mod"); + assert.ok(items.includes("model")); + }); + + it("completes commands after leading whitespace and preserves it", async () => { + const provider = new CombinedAutocompleteProvider([{ name: "model" }], process.cwd()); + for (const [line, expected] of [ + [" /", " /model "], + [" /mod", " /model "], + ["\t/mod", "\t/model "], + ] as const) { + const result = await provider.getSuggestions([line], 0, line.length, { + signal: new AbortController().signal, + }); + assert.ok(result); + assert.equal(result.prefix, line.trimStart()); + assert.deepStrictEqual( + result.items.map((item) => item.value), + ["model"], + ); + const applied = provider.applyCompletion([line], 0, line.length, result.items[0]!, result.prefix); + assert.equal(applied.lines[0], expected); + assert.equal(applied.cursorCol, expected.length); + } + }); + + it("completes command arguments after leading whitespace", async () => { + const provider = new CombinedAutocompleteProvider( + [ + { + name: "model", + getArgumentCompletions: (prefix: string) => { + assert.equal(prefix, "son"); + return [{ value: "sonnet", label: "sonnet" }]; + }, + }, + ], + process.cwd(), + ); + const line = " /model son"; + const result = await provider.getSuggestions([line], 0, line.length, { + signal: new AbortController().signal, + }); + assert.ok(result); + assert.equal(result.prefix, "son"); + const applied = provider.applyCompletion([line], 0, line.length, result.items[0]!, result.prefix); + assert.equal(applied.lines[0], " /model sonnet"); + }); + + it("keeps explicit skill: queries working", async () => { + const items = await suggestionsFor("skill:side"); + assert.ok(items.includes("skill:to-sidecar")); + }); + + // Regression test for #9944. + it("lists skills while typing the skill prefix", async () => { + const items = await suggestionsFor("skill"); + assert.deepStrictEqual( + items.filter((item) => item.startsWith("skill:")), + commands.filter((command) => command.name.startsWith("skill:")).map((command) => command.name), + ); + }); + + it("keeps fuzzy skill-prefix shorthand working", async () => { + const items = await suggestionsFor("skbra"); + assert.ok(items.includes("skill:brainstorm")); + }); +}); diff --git a/packages/pi-tui/test/autocomplete.test.ts b/packages/pi-tui/test/autocomplete.test.ts index 02f982b5e..748396cc3 100644 --- a/packages/pi-tui/test/autocomplete.test.ts +++ b/packages/pi-tui/test/autocomplete.test.ts @@ -147,6 +147,94 @@ describe("CombinedAutocompleteProvider", () => { assert.deepStrictEqual(values, ["@README.md", "@src/"].sort()); }); + test("recognizes @ after CJK punctuation without consuming the preceding text", async () => { + setupFolder(baseDir, { files: { "README.md": "readme" } }); + const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); + for (const before of ["查看,", "\u3000", ...",.:;!?()[]{}“”‘’…—。、「」『』《》【】"]) { + for (const force of [false, true]) { + const line = `${before}@REA`; + const result = await getSuggestions(provider, [line], 0, line.length, force); + assert.ok(result, line); + assert.strictEqual(result.prefix, "@REA"); + assert.deepStrictEqual( + result.items.map((item) => item.value), + ["@README.md"], + ); + const applied = provider.applyCompletion([line], 0, line.length, result.items[0]!, result.prefix); + assert.strictEqual(applied.lines[0], `${before}@README.md `); + assert.strictEqual(applied.cursorCol, applied.lines[0]!.length); + } + } + }); + + test("recognizes @ after opening wrappers like ( and backticks", async () => { + setupFolder(baseDir, { files: { "README.md": "readme" } }); + const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); + for (const before of ["(", "see (", "[", "`", "<", "{"]) { + const line = `${before}@REA`; + const result = await getSuggestions(provider, [line], 0, line.length); + assert.ok(result, line); + assert.strictEqual(result.prefix, "@REA"); + const applied = provider.applyCompletion([line], 0, line.length, result.items[0]!, result.prefix); + assert.strictEqual(applied.lines[0], `${before}@README.md `); + } + const embedded = "foo(@REA"; + assert.strictEqual(await getSuggestions(provider, [embedded], 0, embedded.length), null); + }); + + test("preserves CJK characters and embedded @ in attachment paths", async () => { + setupFolder(baseDir, { + files: { "文档/说明.md": "text", "文档@备份/说明.md": "backup" }, + }); + const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); + for (const before of ["", "查看,"]) { + for (const directory of ["文档", "文档@备份"]) { + const prefix = `@${directory}/说`; + const line = before + prefix; + const result = await getSuggestions(provider, [line], 0, line.length); + assert.ok(result, line); + assert.strictEqual(result.prefix, prefix); + assert.deepStrictEqual( + result.items.map((item) => item.value), + [`@${directory}/说明.md`], + ); + } + } + }); + + test("completes quoted CJK attachments after prose without losing path segments or quotes", async () => { + const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); + for (const separator of [" ", "\u3000", ",", "。"]) { + const directory = `我的${separator}文档`; + setupFolder(baseDir, { + files: { [`${directory}/说明.md`]: "text", "文档/说明.md": "not the quoted path" }, + }); + const line = `查看:@"${directory}/说"后文`; + const cursorCol = line.indexOf('"后文'); + const result = await getSuggestions(provider, [line], 0, cursorCol); + assert.ok(result); + assert.strictEqual(result.prefix, `@"${directory}/说`); + assert.deepStrictEqual( + result.items.map((item) => item.value), + [`@"${directory}/说明.md"`], + ); + const applied = provider.applyCompletion([line], 0, cursorCol, result.items[0]!, result.prefix); + assert.strictEqual(applied.lines[0], `查看:@"${directory}/说明.md" 后文`); + assert.strictEqual(applied.cursorCol, `查看:@"${directory}/说明.md" `.length); + } + }); + + test("does not interpret email addresses or @ after ASCII or CJK letters as attachment prefixes", async () => { + setupFolder(baseDir, { files: { "README.md": "readme", "example.com": "text" } }); + const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); + for (const before of ["user", "查看", "あ", "カ", "한", "ㄅ", "𠮷", "か\u3099", "禰\u{e0100}", "々", "A"]) { + for (const name of ["REA", "example.com"]) { + const line = `${before}@${name}`; + assert.strictEqual(await getSuggestions(provider, [line], 0, line.length), null, line); + } + } + }); + test("matches file with extension in query", async () => { setupFolder(baseDir, { files: { @@ -381,20 +469,21 @@ describe("CombinedAutocompleteProvider", () => { ); }); - test("quotes paths with spaces for @ suggestions", async () => { - setupFolder(baseDir, { - dirs: ["my folder"], - files: { - "my folder/test.txt": "content", - }, - }); - + test("quotes paths containing whitespace or CJK punctuation for @ suggestions", async () => { const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); - const line = "@my"; - const result = await getSuggestions(provider, [line], 0, line.length); - - const values = result?.items.map((item) => item.value); - assert.ok(values?.includes('@"my folder/"')); + for (const separator of [" ", "\u3000", ",", "。"]) { + const directory = `my${separator}folder`; + setupFolder(baseDir, { files: { [`${directory}/test.txt`]: "content" } }); + const line = "@my"; + const result = await getSuggestions(provider, [line], 0, line.length); + assert.ok(result); + const item = result.items.find((entry) => entry.value === `@"${directory}/"`); + assert.ok(item, directory); + const applied = provider.applyCompletion([line], 0, line.length, item, result.prefix); + const continued = await getSuggestions(provider, applied.lines, 0, applied.cursorCol); + assert.strictEqual(continued?.prefix, `@"${directory}/`); + assert.ok(continued?.items.some((entry) => entry.value === `@"${directory}/test.txt"`)); + } }); test("includes hidden paths but excludes .git", async () => { @@ -555,6 +644,153 @@ describe("CombinedAutocompleteProvider", () => { rmSync(baseDir, { recursive: true, force: true }); }); + test("completes Chinese path prefixes after whitespace or CJK punctuation on Tab", async () => { + setupFolder(baseDir, { files: { "说明.md": "file", "文档/说明.md": "nested file" } }); + const provider = new CombinedAutocompleteProvider([], baseDir); + const completions = [ + { prefix: "说", value: "说明.md" }, + { prefix: "文", value: "文档/" }, + { prefix: "文档/说", value: "文档/说明.md" }, + { prefix: "./文档/说", value: "./文档/说明.md" }, + ]; + if (process.platform !== "win32") { + completions.push({ prefix: `${baseDir}/文档/说`, value: `${baseDir}/文档/说明.md` }); + } + for (const separator of " \t\u3000\u00a0,:;。!?(「《") { + for (const { prefix, value } of completions) { + const before = `查看𠮷${separator}`; + const line = `${before}${prefix} 后文`; + const cursorCol = before.length + prefix.length; + const result = await getSuggestions(provider, [line], 0, cursorCol, true); + assert.ok(result, line); + assert.strictEqual(result.prefix, prefix); + assert.deepStrictEqual( + result.items.map((item) => item.value), + [value], + ); + const applied = provider.applyCompletion([line], 0, cursorCol, result.items[0]!, result.prefix); + assert.strictEqual(applied.lines[0], `${before}${value} 后文`); + assert.strictEqual(applied.cursorCol, before.length + value.length); + } + } + }); + + test("treats unquoted separators as boundaries even when a matching literal path exists", async () => { + setupFolder(baseDir, { files: { "归档/说明.md": "other" } }); + const provider = new CombinedAutocompleteProvider([], baseDir); + for (const separator of [" ", "\u3000", ",", "。"]) { + const directory = `资料${separator}归档`; + setupFolder(baseDir, { files: { [`${directory}/说明.md`]: "archive" } }); + for (const marker of ["", "@"]) { + const line = `${marker}${directory}/说`; + const result = await getSuggestions(provider, [line], 0, line.length, true); + assert.ok(result, line); + assert.strictEqual(result.prefix, "归档/说"); + assert.deepStrictEqual( + result.items.map((item) => item.value), + ["归档/说明.md"], + ); + } + const quoted = `查看,"${directory}/说"后文`; + const cursorCol = quoted.indexOf('"后文'); + const result = await getSuggestions(provider, [quoted], 0, cursorCol, true); + assert.ok(result); + assert.strictEqual(result.prefix, `"${directory}/说`); + assert.deepStrictEqual( + result.items.map((item) => item.value), + [`"${directory}/说明.md"`], + ); + const applied = provider.applyCompletion([quoted], 0, cursorCol, result.items[0]!, result.prefix); + assert.strictEqual(applied.lines[0], `查看,"${directory}/说明.md"后文`); + const missing = `查看,"不存在${separator}归档/说`; + assert.strictEqual(await getSuggestions(provider, [missing], 0, missing.length, true), null); + } + }); + + test("handles an empty prefix after whitespace or CJK punctuation consistently", async () => { + setupFolder(baseDir, { files: { "说明.md": "text" } }); + const provider = new CombinedAutocompleteProvider([], baseDir); + for (const separator of [" ", "\t", "\u3000", ",", "。"]) { + for (const force of [false, true]) { + const line = `查看${separator}`; + const result = await getSuggestions(provider, [line], 0, line.length, force); + assert.ok(result, line); + assert.strictEqual(result.prefix, ""); + assert.deepStrictEqual( + result.items.map((item) => item.value), + ["说明.md"], + ); + } + } + assert.strictEqual(await getSuggestions(provider, [""], 0, 0), null); + }); + + test("completes paths after opening wrappers like ( [ { < and backticks", async () => { + setupFolder(baseDir, { files: { "src/main.ts": "x" } }); + const provider = new CombinedAutocompleteProvider([], baseDir); + for (const wrapper of ["(", "[", "{", "<", "`", "((", "(`"]) { + for (const prefix of ["src/ma", "./src/ma"]) { + const before = `see ${wrapper}`; + const line = `${before}${prefix}`; + const result = await getSuggestions(provider, [line], 0, line.length, true); + assert.ok(result, line); + assert.strictEqual(result.prefix, prefix); + const value = prefix.replace("src/ma", "src/main.ts"); + assert.deepStrictEqual( + result.items.map((item) => item.value), + [value], + ); + const applied = provider.applyCompletion([line], 0, line.length, result.items[0]!, result.prefix); + assert.strictEqual(applied.lines[0], `${before}${value}`); + } + } + }); + + test("completes quoted paths after opening wrappers", async () => { + setupFolder(baseDir, { files: { "my dir/main.ts": "x" } }); + const provider = new CombinedAutocompleteProvider([], baseDir); + const line = 'see ("my dir/ma'; + const result = await getSuggestions(provider, [line], 0, line.length, true); + assert.ok(result); + assert.strictEqual(result.prefix, '"my dir/ma'); + assert.deepStrictEqual( + result.items.map((item) => item.value), + ['"my dir/main.ts"'], + ); + }); + + test("keeps wrappers that are closed inside the path", async () => { + setupFolder(baseDir, { files: { "[slug]/page.tsx": "x", "(group)/layout.tsx": "x" } }); + const provider = new CombinedAutocompleteProvider([], baseDir); + for (const [prefix, value] of [ + ["[slug]/pa", "[slug]/page.tsx"], + ["(group)/la", "(group)/layout.tsx"], + ["./[slug]/pa", "./[slug]/page.tsx"], + ]) { + const line = `see ${prefix}`; + const result = await getSuggestions(provider, [line], 0, line.length, true); + assert.ok(result, line); + assert.strictEqual(result.prefix, prefix); + assert.deepStrictEqual( + result.items.map((item) => item.value), + [value], + ); + } + }); + + test("preserves CJK characters in unprefixed Tab completions", async () => { + setupFolder(baseDir, { files: { "文档/说明.md": "text" } }); + const provider = new CombinedAutocompleteProvider([], baseDir); + const line = "文档/说"; + const result = await getSuggestions(provider, [line], 0, line.length, true); + assert.ok(result); + assert.strictEqual(result.prefix, line); + assert.deepStrictEqual( + result.items.map((item) => item.value), + ["文档/说明.md"], + ); + }); + test("preserves ./ prefix when completing paths", async () => { setupFolder(baseDir, { files: { @@ -601,21 +837,35 @@ describe("CombinedAutocompleteProvider", () => { rmSync(baseDir, { recursive: true, force: true }); }); - test("quotes paths with spaces for direct completion", async () => { - setupFolder(baseDir, { - dirs: ["my folder"], - files: { - "my folder/test.txt": "content", - }, - }); - + test("quotes paths containing whitespace or CJK punctuation for direct completion", async () => { const provider = new CombinedAutocompleteProvider([], baseDir); - const line = "my"; - const result = await getSuggestions(provider, [line], 0, line.length, true); + for (const separator of [" ", "\u3000", ",", "。"]) { + const directory = `my${separator}folder`; + setupFolder(baseDir, { files: { [`${directory}/test.txt`]: "content" } }); + const line = "my"; + const result = await getSuggestions(provider, [line], 0, line.length, true); + assert.ok(result); + const item = result.items.find((entry) => entry.value === `"${directory}/"`); + assert.ok(item, directory); + const applied = provider.applyCompletion([line], 0, line.length, item, result.prefix); + const continued = await getSuggestions(provider, applied.lines, 0, applied.cursorCol, true); + assert.strictEqual(continued?.prefix, `"${directory}/`); + assert.deepStrictEqual( + continued?.items.map((entry) => entry.value), + [`"${directory}/test.txt"`], + ); + } + }); - assert.notEqual(result, null, "Should return suggestions for path completion"); - const values = result?.items.map((item) => item.value); - assert.ok(values?.includes('"my folder/"')); + test("keeps quoted directories before files", async () => { + setupFolder(baseDir, { dirs: ["z folder", "z,folder"], files: { "a.txt": "text" } }); + const provider = new CombinedAutocompleteProvider([], baseDir); + const result = await getSuggestions(provider, [""], 0, 0, true); + assert.ok(result); + assert.deepStrictEqual( + result.items.map((item) => item.label.endsWith("/")), + [true, true, false], + ); }); test("continues completion inside quoted paths", async () => { diff --git a/packages/pi-tui/test/colors.test.ts b/packages/pi-tui/test/colors.test.ts new file mode 100644 index 000000000..a2f7c32f6 --- /dev/null +++ b/packages/pi-tui/test/colors.test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { + colorToHex, + colorToOkhsl, + colorToRgb, + indexedColor, + okhslColor, + oklchColor, + parseColor, + rgbColor, + styleText, +} from "../src/index.ts"; + +describe("colors", () => { + it("parses hex and OKLCH colors and rejects everything else", () => { + assert.deepStrictEqual(parseColor("#abc"), { kind: "rgb", r: 170, g: 187, b: 204 }); + assert.deepStrictEqual(parseColor("oklch(62% 0.1 200)"), { kind: "oklch", l: 0.62, c: 0.1, h: 200 }); + assert.throws(() => parseColor(""), /Invalid color value/); + assert.throws(() => parseColor("red"), /Invalid color value/); + }); + + it("gamut-maps OKLCH to sRGB, including the lightness limits", () => { + assert.deepStrictEqual(colorToRgb(oklchColor(0.627955, 0.257683, 29.2339)), { r: 255, g: 0, b: 0 }); + assert.deepStrictEqual(colorToRgb(oklchColor(1, 0.3, 150)), { r: 255, g: 255, b: 255 }); + assert.deepStrictEqual(colorToRgb(oklchColor(0, 0.3, 150)), { r: 0, g: 0, b: 0 }); + }); + + it("parses OKHSL colors and round-trips them", () => { + // Full saturation at the red cusp is pure sRGB red. + assert.deepStrictEqual(parseColor("okhsl(29.23 100% 56.8%)"), rgbColor(255, 0, 0)); + assert.deepStrictEqual(parseColor("OKHSL(250deg 60% 55%)"), okhslColor(250, 0.6, 0.55)); + assert.throws(() => parseColor("okhsl(250 160% 55%)"), /s must be between 0 and 1/); + for (const hex of ["#4f8eb3", "#20242a", "#f8f9fa"]) { + const { h, s, l } = colorToOkhsl(parseColor(hex)); + assert.strictEqual(colorToHex(okhslColor(h, s, l)), hex); + } + }); + + it("styles text and closes sequences in reverse order", () => { + assert.strictEqual( + styleText("Ready", { fg: rgbColor(18, 52, 86), bg: indexedColor(9), bold: true, italic: true }, "truecolor"), + "\x1b[38;2;18;52;86m\x1b[48;5;9m\x1b[1m\x1b[3mReady\x1b[23m\x1b[22m\x1b[49m\x1b[39m", + ); + assert.match(styleText("Ready", { fg: rgbColor(18, 52, 86) }, "256color"), /^\x1b\[38;5;\d+mReady\x1b\[39m$/); + }); +}); diff --git a/packages/pi-tui/test/editor.test.ts b/packages/pi-tui/test/editor.test.ts index ef8867199..2a37614b5 100644 --- a/packages/pi-tui/test/editor.test.ts +++ b/packages/pi-tui/test/editor.test.ts @@ -1,4 +1,7 @@ import assert from "node:assert"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, it, mock } from "node:test"; import { stripVTControlCharacters } from "node:util"; import { type AutocompleteProvider, CombinedAutocompleteProvider } from "../src/autocomplete.ts"; @@ -2409,6 +2412,218 @@ describe("Editor component", () => { }); describe("Autocomplete", () => { + it("triggers and debounces symbol completion after CJK punctuation", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + for (const before of [ + "查看,", + "\u3000", + ...",.:;!?()[]{}“”‘’…—。、「」『』《》【】", + "(", + "see (", + "`", + "[", + ]) { + for (const trigger of ["@", "#", "$", "-"]) { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const requests: string[] = []; + editor.setAutocompleteProvider({ + triggerCharacters: ["$", "-"], + getSuggestions: async (lines, cursorLine, cursorCol) => { + requests.push(lines[cursorLine]!.slice(0, cursorCol)); + return null; + }, + applyCompletion, + }); + editor.setText(before); + editor.handleInput(trigger); + t.mock.timers.tick(19); + await flushAutocomplete(); + assert.deepStrictEqual(requests, []); + t.mock.timers.tick(1); + await flushAutocomplete(); + assert.deepStrictEqual(requests, [before + trigger]); + + editor.handleInput("r"); + editor.handleInput("e"); + t.mock.timers.tick(19); + await flushAutocomplete(); + assert.strictEqual(requests.length, 1); + t.mock.timers.tick(1); + await flushAutocomplete(); + assert.deepStrictEqual(requests, [before + trigger, `${before}${trigger}re`]); + } + } + }); + + it("does not auto-trigger after CJK letters or for unprefixed paths", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const editor = new Editor(createTestTUI(), defaultEditorTheme); + let requests = 0; + editor.setAutocompleteProvider({ + getSuggestions: async () => { + requests++; + return null; + }, + applyCompletion, + }); + for (const text of [ + "user@example.com", + "张三@example.com", + "查看@src", + "あ@src", + "カ@src", + "한@src", + "ㄅ@src", + "𠮷@src", + "か\u3099@src", + "禰\u{e0100}@src", + "々@src", + "A@src", + "文档@备份", + "prefix#123", + "foo(@src", + "问题#123", + "查看,/path/", + "查看,./文档/", + "src/index.ts", + "./文档/说明.md", + "文档/说明.md", + "查看src/index.ts", + ]) { + editor.setText(""); + for (const char of text) editor.handleInput(char); + t.mock.timers.tick(20); + await flushAutocomplete(); + assert.strictEqual(requests, 0, text); + } + }); + + it("requests path completion after CJK punctuation only on Tab", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const requests: Array<{ text: string; force: boolean | undefined }> = []; + editor.setAutocompleteProvider({ + getSuggestions: async (lines, cursorLine, cursorCol, options) => { + requests.push({ text: lines[cursorLine]!.slice(0, cursorCol), force: options.force }); + return null; + }, + applyCompletion, + }); + const text = "查看,/path/"; + for (const char of text) editor.handleInput(char); + t.mock.timers.tick(20); + await flushAutocomplete(); + assert.deepStrictEqual(requests, []); + editor.handleInput("\t"); + await flushAutocomplete(); + assert.deepStrictEqual(requests, [{ text, force: true }]); + }); + + it("completes Chinese path prefixes after whitespace or CJK punctuation with Tab", async (t) => { + const baseDir = mkdtempSync(join(tmpdir(), "pi-editor-autocomplete-")); + t.after(() => rmSync(baseDir, { recursive: true, force: true })); + mkdirSync(join(baseDir, "文档")); + writeFileSync(join(baseDir, "文档", "说明.md"), "text"); + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.setAutocompleteProvider(new CombinedAutocompleteProvider([], baseDir)); + for (const separator of [" ", "\t", "\u3000", "\u00a0", ",", "。"]) { + editor.setText(`查看${separator}`); + const before = editor.getText(); + editor.handleInput("文"); + editor.handleInput("\t"); + await flushAutocomplete(); + assert.strictEqual(editor.getText(), `${before}文档/`); + editor.handleInput("说"); + editor.handleInput("\t"); + await flushAutocomplete(); + assert.strictEqual(editor.getText(), `${before}文档/说明.md`); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: editor.getText().length }); + } + }); + + it("ends unquoted trigger and debounce contexts at whitespace or CJK punctuation", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + for (const separator of [" ", "\u3000", ",", "。"]) { + for (const trigger of ["@", "#", "$"]) { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const requests: string[] = []; + const prefix = `${trigger}src`; + editor.setAutocompleteProvider({ + triggerCharacters: ["$"], + getSuggestions: async (lines, cursorLine, cursorCol) => { + const text = lines[cursorLine]!.slice(0, cursorCol); + requests.push(text); + return text === prefix ? { prefix, items: [{ value: `${prefix}/`, label: "src/" }] } : null; + }, + applyCompletion, + }); + editor.setText(`${trigger}sr`); + editor.handleInput("c"); + t.mock.timers.tick(20); + await flushAutocomplete(); + assert.strictEqual(editor.isShowingAutocomplete(), true); + editor.handleInput(separator); + await flushAutocomplete(); + assert.deepStrictEqual(requests, [prefix, prefix + separator]); + assert.strictEqual(editor.isShowingAutocomplete(), false); + editor.handleInput("文"); + t.mock.timers.tick(20); + await flushAutocomplete(); + assert.deepStrictEqual(requests, [prefix, prefix + separator]); + } + } + }); + + it("re-triggers CJK path completion after accepting directories and deleting", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const provider = new CombinedAutocompleteProvider([], process.cwd()); + for (const directory of ["文档", "我的 文档", "资料,归档"]) { + const quoted = directory !== "文档"; + const initial = quoted ? `@"${directory.slice(0, 2)}` : "@文"; + const directoryValue = quoted ? `@"${directory}/"` : `@${directory}/`; + const fileValue = quoted ? `@"${directory}/说明.md"` : `@${directory}/说明.md`; + const filePrefix = quoted ? `@"${directory}/说` : `@${directory}/说`; + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.setAutocompleteProvider({ + getSuggestions: async (lines, cursorLine, cursorCol) => { + const before = lines[cursorLine]!.slice(0, cursorCol); + const prefix = before.slice(before.indexOf("@")); + if (prefix === initial) { + return { prefix, items: [{ value: directoryValue, label: `${directory}/` }] }; + } + return prefix === filePrefix ? { prefix, items: [{ value: fileValue, label: "说明.md" }] } : null; + }, + applyCompletion: (...args) => provider.applyCompletion(...args), + }); + editor.setText(`查看:${initial}`); + editor.handleInput("\t"); + await flushAutocomplete(); + assert.strictEqual(editor.getText(), `查看:${directoryValue}`); + assert.strictEqual(editor.isShowingAutocomplete(), false); + + editor.handleInput("说"); + t.mock.timers.tick(20); + await flushAutocomplete(); + assert.strictEqual(editor.isShowingAutocomplete(), true); + + for (const deletion of ["\x7f", "\x1b[3~"]) { + editor.handleInput("错"); + t.mock.timers.tick(20); + await flushAutocomplete(); + assert.strictEqual(editor.isShowingAutocomplete(), false); + if (deletion === "\x1b[3~") editor.handleInput("\x1b[D"); + editor.handleInput(deletion); + t.mock.timers.tick(20); + await flushAutocomplete(); + assert.strictEqual(editor.isShowingAutocomplete(), true); + } + + editor.handleInput("\t"); + assert.strictEqual(editor.getText(), `查看:${fileValue} `); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: editor.getText().length }); + } + }); + it("auto-applies single force-file suggestion without showing menu", async () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); diff --git a/packages/pi-tui/test/image-test.ts b/packages/pi-tui/test/image-test.ts index 73a26862d..05fb32ac5 100644 --- a/packages/pi-tui/test/image-test.ts +++ b/packages/pi-tui/test/image-test.ts @@ -17,7 +17,7 @@ try { imageBuffer = readFileSync(testImagePath); } catch (_e) { console.error(`Failed to load image: ${testImagePath}`); - console.error("Usage: npx tsx test/image-test.ts [path-to-image.png]"); + console.error("Usage: node test/image-test.ts [path-to-image.png]"); process.exit(1); } diff --git a/packages/pi-tui/test/latex.test.ts b/packages/pi-tui/test/latex.test.ts index 6df4260cf..95a90a5cc 100644 --- a/packages/pi-tui/test/latex.test.ts +++ b/packages/pi-tui/test/latex.test.ts @@ -53,7 +53,7 @@ describe("renderLatex", () => { [String.raw`\ge 2`, "≥ 2"], [String.raw`\ge 3`, "≥ 3"], ["1", "1"], - [String.raw`\mathrm{diag}(-1/2,1,1)`, "diag(-1/2,1,1)"], + [String.raw`\mathrm{diag}(-1/2,1,1),\quad F_{\rm intrinsic}(\lambda)`, "diag(-1/2,1,1), F_intrinsic(λ)"], ["4+3xy", "4+3xy"], ]); }); @@ -230,7 +230,7 @@ c_n \Psi^\ast\Psi, & 0 { @@ -362,8 +367,8 @@ c_n it("uses natural case conditions and aligns matrix columns", () => { assert.strictEqual( - renderLatex(String.raw`\begin{cases}a & x<0 \\ b & \text{if }x=0 \\ c & \text{otherwise}\end{cases}`), - "⎧ a if x < 0\n⎨ b if x = 0\n⎩ c otherwise", + renderLatex(String.raw`f(x)=\begin{cases}a & x<0 \\ b & \text{if }x=0 \\ c & \text{otherwise}\end{cases}`), + " ⎧ a if x < 0\nf(x) = ⎨ b if x = 0\n ⎩ c otherwise", ); assert.strictEqual( renderLatex(String.raw`\begin{pmatrix}1&200\\3000&4\end{pmatrix}`), @@ -449,10 +454,10 @@ R\left(\frac{\pi}{4}\right) assert.strictEqual(renderLatex(String.raw`\int\limits_0^1 f(x)\,dx`, { display: true }), "1\n∫ f(x) dx\n0"); }); - it("uses the middle brace for intermediate case rows", () => { + it("centers even case rows around a middle brace", () => { assert.strictEqual( - renderLatex(String.raw`\begin{cases}a & x<0 \\ b & x=0 \\ c & x>0\end{cases}`), - "⎧ a if x < 0\n⎨ b if x = 0\n⎩ c if x > 0", + renderLatex(String.raw`f(x) = \begin{cases} x^{2} & x \geq 0 \\ -x & x < 0 \end{cases}`), + " ⎧ x² if x ≥ 0\nf(x) = ⎨\n ⎩ -x if x < 0", ); }); @@ -487,9 +492,14 @@ R\left(\frac{\pi}{4}\right) } }); - it("keeps fractions linear in scripts and text-style fractions", () => { - assert.strictEqual(renderLatex(String.raw`e^{\frac{1}{2}}`, { display: true }), "e^(1/2)"); - assert.strictEqual(renderLatex(String.raw`\tfrac{1}{2}`, { display: true }), "1/2"); + it("lays out unsupported and nested scripts while keeping script fractions linear", () => { + assert.strictEqual( + renderLatex(String.raw`\partial_tU_2(t,0)=Aj_*(1-t)^{-A-1}.\qquad x^{n^2}+x_{i_j}`, { + display: true, + }), + " 2\n -A-1 n\n∂ₜU₂(t,0) = Aj (1-t) . x +x\n * i\n j", + ); + assert.strictEqual(renderLatex(String.raw`e^{\frac{1}{2}}+\tfrac{1}{2}`, { display: true }), "e^(1/2)+1/2"); }); it("returns undefined for unsupported commands", () => { diff --git a/packages/pi-tui/test/markdown.test.ts b/packages/pi-tui/test/markdown.test.ts index 9d743fe81..6d182151c 100644 --- a/packages/pi-tui/test/markdown.test.ts +++ b/packages/pi-tui/test/markdown.test.ts @@ -805,7 +805,7 @@ describe("Markdown component", () => { describe("LaTeX math", () => { it("renders inline dollar and parenthesis delimiters", () => { const markdown = new Markdown( - String.raw`A map $\mathbb{C}^3 \to \mathbb{C}^3$, $xy$, $x-y$, $-x$, $\frac{1}{2}$, and \(s \to \infty\).`, + String.raw`A map $\mathbb{C}^3 \to \mathbb{C}^3$, $xy$, $x-y$, $-x$, $\frac{1}{2}$, $\rightarrow$, and \(s \to \infty\).`, 0, 0, defaultMarkdownTheme, @@ -813,7 +813,7 @@ describe("Markdown component", () => { const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); - assert.deepStrictEqual(lines, ["A map ℂ³ → ℂ³, xy, x-y, -x, 1/2, and s → ∞."]); + assert.deepStrictEqual(lines, ["A map ℂ³ → ℂ³, xy, x-y, -x, 1/2, →, and s → ∞."]); }); it("renders display dollar delimiters without Markdown escape corruption", () => { @@ -956,7 +956,13 @@ A= it("allows LaTeX rendering to be disabled", () => { const markdown = new Markdown( - String.raw`Map $\mathbb{C}^3 \to \mathbb{C}^3$`, + String.raw`$$ +\widetilde Y_{sf} += +(1-w_{sf})\mu_{sf}^{\mathrm{MAR}} +$$ + +Inline \(A_{sf}\)`, 0, 0, defaultMarkdownTheme, @@ -968,7 +974,15 @@ A= assert.deepStrictEqual( markdown.render(80).map((line) => stripAnsi(line).trimEnd()), - [String.raw`Map $\mathbb{C}^3 \to \mathbb{C}^3$`], + [ + "$$", + String.raw`\widetilde Y_{sf}`, + "=", + String.raw`(1-w_{sf})\mu_{sf}^{\mathrm{MAR}}`, + "$$", + "", + String.raw`Inline \(A_{sf}\)`, + ], ); }); diff --git a/packages/pi-tui/test/mouse-components.test.ts b/packages/pi-tui/test/mouse-components.test.ts index ee0a25c0b..1a53a54bf 100644 --- a/packages/pi-tui/test/mouse-components.test.ts +++ b/packages/pi-tui/test/mouse-components.test.ts @@ -60,6 +60,29 @@ class InputOverlay extends Container { } } +/** A settings submenu that routes keys to its nested list, like the coding agent's theme submenu. */ +class SubmenuHost extends Container { + readonly list = new SelectList( + [ + { value: "first", label: "First" }, + { value: "second", label: "Second" }, + ], + 5, + selectTheme, + ); + + constructor(done: (value?: string) => void) { + super(); + this.list.onSelect = (item) => done(item.value); + this.list.onCancel = () => done(); + this.addChild(this.list); + } + + handleInput(data: string): void { + this.list.handleInput(data); + } +} + describe("mouse-aware components", () => { it("positions a single-line input cursor on press", () => { const input = new Input(); @@ -226,6 +249,45 @@ describe("mouse-aware components", () => { tui.stop(); }); + it("keeps a settings list focused when a click in its submenu closes the submenu", async () => { + const terminal = new VirtualTerminal(30, 6); + const tui = new TuiAltScreen(terminal); + const changes: Array<{ id: string; value: string }> = []; + const list = new SettingsList( + [ + { id: "theme", label: "Theme", currentValue: "first", submenu: (_value, done) => new SubmenuHost(done) }, + { id: "other", label: "Other", currentValue: "off", values: ["off", "on"] }, + ], + 5, + settingsTheme, + (id, value) => changes.push({ id, value }), + () => {}, + ); + tui.addChild(list); + tui.setFocus(list); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\r"); + await terminal.waitForRender(); + // Press and release on the submenu's second row selects it and closes the submenu. + terminal.sendInput("\x1b[<0;3;2M"); + terminal.sendInput("\x1b[<0;3;2m"); + await terminal.waitForRender(); + assert.deepStrictEqual(changes, [{ id: "theme", value: "second" }]); + assert.strictEqual(tui.getFocusedComponent(), list); + + // Keys reach the visible list again instead of the closed submenu. + terminal.sendInput("\x1b[B"); + terminal.sendInput("\r"); + await terminal.waitForRender(); + assert.deepStrictEqual(changes, [ + { id: "theme", value: "second" }, + { id: "other", value: "on" }, + ]); + tui.stop(); + }); + it("positions and focuses the multiline editor through alternate-screen dispatch", async () => { const terminal = new VirtualTerminal(20, 6); const tui = new TuiAltScreen(terminal); diff --git a/packages/pi-tui/test/overlay-options.test.ts b/packages/pi-tui/test/overlay-options.test.ts index cea8777c0..b4463289c 100644 --- a/packages/pi-tui/test/overlay-options.test.ts +++ b/packages/pi-tui/test/overlay-options.test.ts @@ -22,6 +22,20 @@ class StaticOverlay implements Component { invalidate(): void {} } +class CursorTrackingTerminal extends VirtualTerminal { + cursorVisible = true; + + override hideCursor(): void { + this.cursorVisible = false; + super.hideCursor(); + } + + override showCursor(): void { + this.cursorVisible = true; + super.showCursor(); + } +} + class EmptyContent implements Component { render(): string[] { return []; @@ -538,4 +552,31 @@ describe("TUI overlay options", () => { tui.stop(); }); }); + + // https://github.com/earendil-works/pi/issues/10026 + describe("hiding after stop", () => { + it("hideOverlay() leaves the cursor visible", () => { + const terminal = new CursorTrackingTerminal(80, 24); + const tui: TUI = new TuiMainScreen(terminal); + tui.start(); + tui.showOverlay(new StaticOverlay(["OVERLAY"]), { nonCapturing: true }); + + tui.stop(); + tui.hideOverlay(); + + assert.strictEqual(terminal.cursorVisible, true); + }); + + it("overlay handle hide() leaves the cursor visible", () => { + const terminal = new CursorTrackingTerminal(80, 24); + const tui: TUI = new TuiMainScreen(terminal); + tui.start(); + const handle = tui.showOverlay(new StaticOverlay(["OVERLAY"]), { nonCapturing: true }); + + tui.stop(); + handle.hide(); + + assert.strictEqual(terminal.cursorVisible, true); + }); + }); }); diff --git a/packages/pi-tui/test/regression-slice-by-column-ansi-order.test.ts b/packages/pi-tui/test/regression-slice-by-column-ansi-order.test.ts new file mode 100644 index 000000000..b742817f9 --- /dev/null +++ b/packages/pi-tui/test/regression-slice-by-column-ansi-order.test.ts @@ -0,0 +1,17 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { sliceByColumn } from "../src/utils.ts"; + +// https://github.com/earendil-works/pi/issues/10169 +describe("sliceByColumn ANSI order regression", () => { + it("keeps a reset at the slice start after earlier style codes", () => { + const line = "\x1b[32mfoo\x1b[39m bar"; + assert.strictEqual(sliceByColumn(line, 3, 4, true), "\x1b[32m\x1b[39m bar"); + }); + + it("does not leak color into text after a highlighted token", () => { + const line = "Another \x1b[35malpha\x1b[39m line with \x1b[35mbeta\x1b[39m later."; + const after = sliceByColumn(line, 13, 100, true); + assert.strictEqual(after, "\x1b[35m\x1b[39m line with \x1b[35mbeta\x1b[39m later."); + }); +}); diff --git a/packages/pi-tui/test/terminal-colors.test.ts b/packages/pi-tui/test/terminal-colors.test.ts index 229549732..1f2b7013f 100644 --- a/packages/pi-tui/test/terminal-colors.test.ts +++ b/packages/pi-tui/test/terminal-colors.test.ts @@ -2,12 +2,13 @@ import assert from "node:assert"; import { describe, it } from "node:test"; import { type Component, - parseOsc11BackgroundColor, parseTerminalColorSchemeReport, type Terminal, + type TerminalColors, type TUI, TuiMainScreen, } from "../src/index.ts"; +import { parseOscColorResponse } from "../src/terminal-colors.ts"; class TestTerminal implements Terminal { private inputHandler?: (data: string) => void; @@ -90,27 +91,6 @@ class InputRecorder implements Component { const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); -describe("parseOsc11BackgroundColor", () => { - it("parses 16-bit OSC 11 rgb responses", () => { - assert.deepStrictEqual(parseOsc11BackgroundColor("\x1b]11;rgb:0000/8000/ffff\x07"), { - r: 0, - g: 128, - b: 255, - }); - }); - - it("parses OSC 11 hex responses", () => { - assert.deepStrictEqual(parseOsc11BackgroundColor("\x1b]11;#ffffff\x1b\\"), { r: 255, g: 255, b: 255 }); - assert.deepStrictEqual(parseOsc11BackgroundColor("\x1b]11;#000000\x07"), { r: 0, g: 0, b: 0 }); - }); - - it("rejects non-strict OSC 11 responses", () => { - assert.strictEqual(parseOsc11BackgroundColor(`x\x1b]11;#ffffff\x07`), undefined); - assert.strictEqual(parseOsc11BackgroundColor("\x1b]10;#ffffff\x07"), undefined); - assert.strictEqual(parseOsc11BackgroundColor("\x1b]11;#ffffff\x07x"), undefined); - }); -}); - describe("parseTerminalColorSchemeReport", () => { it("parses color scheme reports", () => { assert.strictEqual(parseTerminalColorSchemeReport("\x1b[?997;1n"), "dark"); @@ -123,128 +103,95 @@ describe("parseTerminalColorSchemeReport", () => { }); }); -describe("TUI.queryTerminalBackgroundColor", () => { - it("writes OSC 11 query and resolves with the parsed RGB reply", async () => { - const terminal = new TestTerminal(); - const tui: TUI = new TuiMainScreen(terminal); - tui.start(); - try { - const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1000 }); - assert.ok(terminal.writes.includes("\x1b]11;?\x07")); - - terminal.sendInput("\x1b]11;#ffffff\x07"); - - assert.deepStrictEqual(await query, { r: 255, g: 255, b: 255 }); - } finally { - tui.stop(); - } - }); - - it("consumes OSC 11 replies before input listeners and focused component dispatch", async () => { - const terminal = new TestTerminal(); - const tui: TUI = new TuiMainScreen(terminal); - const component = new InputRecorder(); - const listenerInputs: string[] = []; - tui.addChild(component); - tui.setFocus(component); - tui.addInputListener((data) => { - listenerInputs.push(data); - return undefined; +describe("parseOscColorResponse", () => { + it("parses OSC 10, 11, and 4 replies", () => { + assert.deepStrictEqual(parseOscColorResponse("\x1b]10;rgb:ffff/ffff/ffff\x07"), { + target: "foreground", + rgb: { r: 255, g: 255, b: 255 }, }); - tui.start(); - try { - const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1000 }); - - terminal.sendInput("\x1b]11;#000000\x07"); - - assert.deepStrictEqual(await query, { r: 0, g: 0, b: 0 }); - assert.deepStrictEqual(listenerInputs, []); - assert.deepStrictEqual(component.inputs, []); - } finally { - tui.stop(); - } + assert.deepStrictEqual(parseOscColorResponse("\x1b]4;13;#ff0080\x1b\\"), { + target: 13, + rgb: { r: 255, g: 0, b: 128 }, + }); + assert.deepStrictEqual(parseOscColorResponse("\x1b]4;1;bogus\x07"), { target: 1, rgb: undefined }); + assert.strictEqual(parseOscColorResponse("\x1b]12;#ffffff\x07"), undefined); }); +}); - it("consumes unparseable strict OSC 11 replies and resolves undefined", async () => { - const terminal = new TestTerminal(); - const tui: TUI = new TuiMainScreen(terminal); - const component = new InputRecorder(); - const listenerInputs: string[] = []; - tui.addChild(component); - tui.setFocus(component); - tui.addInputListener((data) => { - listenerInputs.push(data); - return undefined; - }); - tui.start(); - try { - const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1000 }); +const PALETTE_REPLIES = Array.from({ length: 16 }, (_, index) => `\x1b]4;${index};#000000\x07`); +const DA1 = "\x1b[?62;22c"; +const BLACK = { r: 0, g: 0, b: 0 }; +const WHITE = { r: 255, g: 255, b: 255 }; + +function setup(): { terminal: TestTerminal; tui: TUI; component: InputRecorder } { + const terminal = new TestTerminal(); + const tui: TUI = new TuiMainScreen(terminal); + const component = new InputRecorder(); + tui.addChild(component); + tui.setFocus(component); + tui.start(); + return { terminal, tui, component }; +} - terminal.sendInput("\x1b]11;not-a-color\x07"); +describe("TUI.queryTerminalColors", () => { + it("queries all colors in one write and consumes the replies", async () => { + const { terminal, tui, component } = setup(); + try { + const query = tui.queryTerminalColors({ timeoutMs: 1000 }); + const written = terminal.writes.at(-1) ?? ""; + assert.ok(written.startsWith("\x1b]10;?\x07\x1b]11;?\x07\x1b]4;0;?\x07") && written.endsWith("\x1b[c")); - assert.strictEqual(await query, undefined); - assert.deepStrictEqual(listenerInputs, []); - assert.deepStrictEqual(component.inputs, []); + terminal.sendInput("x"); + terminal.sendInput("\x1b]10;#ffffff\x07"); + terminal.sendInput("\x1b]11;rgb:0000/0000/0000\x1b\\"); + for (const reply of PALETTE_REPLIES) terminal.sendInput(reply); + // Resolves once every reply arrived, without waiting for DA1. + assert.deepStrictEqual(await query, { + foreground: WHITE, + background: BLACK, + palette: Array.from({ length: 16 }, () => BLACK), + }); + terminal.sendInput(DA1); + assert.deepStrictEqual(component.inputs, ["x"]); } finally { tui.stop(); } }); - it("dispatches non-matching input normally while waiting for an OSC 11 reply", async () => { - const terminal = new TestTerminal(); - const tui: TUI = new TuiMainScreen(terminal); - const component = new InputRecorder(); - const listenerInputs: string[] = []; - tui.addChild(component); - tui.setFocus(component); - tui.addInputListener((data) => { - listenerInputs.push(data); - return undefined; - }); - tui.start(); + it("resolves on DA1 with the replies that arrived, in query order", async () => { + const { terminal, tui } = setup(); try { - let settled = false; - const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1000 }).then((rgb) => { - settled = true; - return rgb; - }); - - terminal.sendInput("x"); - await Promise.resolve(); - - assert.strictEqual(settled, false); - assert.deepStrictEqual(listenerInputs, ["x"]); - assert.deepStrictEqual(component.inputs, ["x"]); + const first = tui.queryTerminalColors({ timeoutMs: 1000 }); + const second = tui.queryTerminalColors({ timeoutMs: 1000 }); + terminal.sendInput("\x1b]11;#000000\x07"); + // An incomplete palette is dropped. + for (const reply of PALETTE_REPLIES.slice(0, 8)) terminal.sendInput(reply); + terminal.sendInput(DA1); + terminal.sendInput(DA1); - terminal.sendInput("\x1b]11;#ffffff\x07"); - assert.deepStrictEqual(await query, { r: 255, g: 255, b: 255 }); + assert.deepStrictEqual(await first, { foreground: undefined, background: BLACK, palette: undefined }); + assert.deepStrictEqual(await second, { foreground: undefined, background: undefined, palette: undefined }); } finally { tui.stop(); } }); - it("keeps consuming a late OSC 11 reply after timeout", async () => { - const terminal = new TestTerminal(); - const tui: TUI = new TuiMainScreen(terminal); - const component = new InputRecorder(); - const listenerInputs: string[] = []; - tui.addChild(component); - tui.setFocus(component); - tui.addInputListener((data) => { - listenerInputs.push(data); - return undefined; - }); - tui.start(); + it("reports late replies after a timeout and consumes them until DA1", async () => { + const { terminal, tui, component } = setup(); try { - const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1 }); + const late: TerminalColors[] = []; + const query = tui.queryTerminalColors({ timeoutMs: 1, onLateReply: (colors) => late.push(colors) }); await wait(5); - - assert.strictEqual(await query, undefined); + assert.strictEqual((await query).background, undefined); terminal.sendInput("\x1b]11;#ffffff\x07"); - - assert.deepStrictEqual(listenerInputs, []); + terminal.sendInput(DA1); + assert.deepStrictEqual(late, [{ foreground: undefined, background: WHITE, palette: undefined }]); assert.deepStrictEqual(component.inputs, []); + + // With no query pending, color replies are ordinary input again. + terminal.sendInput("\x1b]11;#ffffff\x07"); + assert.deepStrictEqual(component.inputs, ["\x1b]11;#ffffff\x07"]); } finally { tui.stop(); } diff --git a/packages/pi-tui/test/terminal-image.test.ts b/packages/pi-tui/test/terminal-image.test.ts index 069136d00..8ca552cbc 100644 --- a/packages/pi-tui/test/terminal-image.test.ts +++ b/packages/pi-tui/test/terminal-image.test.ts @@ -5,8 +5,8 @@ import assert from "node:assert"; import { homedir } from "node:os"; import { join } from "node:path"; -import { describe, it } from "node:test"; -import { Image } from "../src/components/image.ts"; +import { afterEach, beforeEach, describe, it } from "node:test"; +import { Image, setImageTranscoder } from "../src/components/image.ts"; import { cropKittyImageLine, deleteAllKittyImages, @@ -18,6 +18,7 @@ import { getCapabilities, getKittyImageMetadata, getKittyImagePlacement, + getKittyImagePlacementRows, hyperlink, imageFallback, isImageLine, @@ -442,6 +443,12 @@ describe("detectCapabilities", () => { assert.strictEqual(caps.images, null); }); }); + + it("detects truecolor from direct-color TERM values", () => { + withEnv({ TERM: "xterm-direct" }, () => { + assert.strictEqual(detectCapabilities(() => false).trueColor, true); + }); + }); }); describe("iTerm2 image encoding", () => { @@ -457,6 +464,11 @@ describe("Kitty image cursor movement", () => { assert.ok(sequence.startsWith("\x1b_Ga=T,f=100,q=2,C=1,c=2,r=2;")); }); + it("reads explicit placement rows without registered metadata", () => { + const sequence = encodeKitty("AAAA", { columns: 2, rows: 3, moveCursor: false }); + assert.strictEqual(getKittyImagePlacementRows(sequence), 3); + }); + it("suppresses Kitty replies for delete commands", () => { assert.strictEqual(deleteKittyImage(42), "\x1b_Ga=d,d=I,i=42,q=2\x1b\\"); assert.strictEqual(deleteAllKittyImages(), "\x1b_Ga=d,d=A,q=2\x1b\\"); @@ -526,8 +538,10 @@ describe("Kitty image cursor movement", () => { const line = `left ${cropKittyImageLine(transmission, 2, 1)} right`; const placement = getKittyImagePlacement(line); assert.ok(placement); + assert.strictEqual(getKittyImagePlacementRows(line), 1); assert.strictEqual(placement.transmissionBytes, line.length - "left ".length - " right".length); assert.strictEqual(placement.estimatedDecodedBytes, 100 * 100 * 4); + assert.strictEqual(placement.rows, 1); assert.strictEqual(placement.sequence, "\x1b_Ga=p,q=2,C=1,c=3,i=42,y=66,h=34,r=1\x1b\\"); assert.strictEqual(placement.replacementLine, `left ${placement.sequence} right`); assert.ok(!placement.replacementLine.includes("AAAA")); @@ -622,6 +636,144 @@ describe("Kitty image cursor movement", () => { }); }); +// #8938: reduce Kitty placement distortion without shrinking iTerm2 reservations. +describe("image cell sizing", () => { + afterEach(() => { + resetCapabilitiesCache(); + setCellDimensions({ widthPx: 9, heightPx: 18 }); + }); + + describe("Kitty", () => { + beforeEach(() => { + setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); + }); + + it("reserves at least one Kitty row for thin images", () => { + setCellDimensions({ widthPx: 9, heightPx: 18 }); + const result = renderImage("AAAA", { widthPx: 1200, heightPx: 12 }, { maxWidthCells: 60 }); + assert.ok(result); + assert.strictEqual(result.rows, 1); + assert.ok(result.sequence.includes(",c=60,r=1;")); + }); + + it("keeps Kitty placement, reserved lines, and cropping metadata consistent across width changes", () => { + setCellDimensions({ widthPx: 9, heightPx: 18 }); + const image = new Image( + "AAAA", + "image/png", + { fallbackColor: (value) => value }, + { maxWidthCells: 60, imageId: 8938 }, + { widthPx: 615, heightPx: 86 }, + ); + const lines = image.render(62); + assert.strictEqual(lines.length, 4); + assert.deepStrictEqual(lines.slice(1), ["", "", ""]); + assert.ok(lines[0].includes(",c=60,r=4,i=8938;")); + assert.deepStrictEqual(getKittyImageMetadata(lines[0]), { + imageId: 8938, + columns: 60, + rows: 4, + widthPx: 615, + heightPx: 86, + }); + const cropped = cropKittyImageLine(lines[0], 1, 2); + assert.strictEqual( + getKittyImagePlacement(cropped)?.sequence, + "\x1b_Ga=p,q=2,C=1,c=60,i=8938,y=21,h=44,r=2\x1b\\", + ); + + const narrowerLines = image.render(32); + assert.strictEqual(narrowerLines.length, 2); + assert.ok(narrowerLines[0].includes(",c=30,r=2,i=8938;")); + assert.strictEqual(getKittyImageMetadata(narrowerLines[0])?.rows, 2); + }); + + it("keeps the ceiling placement when rounding down would increase distortion", () => { + setCellDimensions({ widthPx: 15, heightPx: 28 }); + const result = renderImage("AAAA", { widthPx: 615, heightPx: 86 }, { maxWidthCells: 60 }); + assert.ok(result); + assert.strictEqual(result.rows, 5); + assert.ok(result.sequence.includes(",c=60,r=5;")); + }); + + it("keeps height-limited Kitty columns, reservations, and crop metadata consistent", () => { + setCellDimensions({ widthPx: 14, heightPx: 28 }); + const image = new Image( + "AAAA", + "image/png", + { fallbackColor: (value) => value }, + { maxWidthCells: 30, imageId: 8938 }, + { widthPx: 400, heightPx: 900 }, + ); + const lines = image.render(32); + assert.strictEqual(lines.length, 15); + assert.ok(lines[0].includes(",c=13,r=15,i=8938;")); + assert.deepStrictEqual(getKittyImageMetadata(lines[0]), { + imageId: 8938, + columns: 13, + rows: 15, + widthPx: 400, + heightPx: 900, + }); + assert.strictEqual( + getKittyImagePlacement(cropKittyImageLine(lines[0], 1, 2))?.sequence, + "\x1b_Ga=p,q=2,C=1,c=13,i=8938,y=60,h=120,r=2\x1b\\", + ); + const narrowerLines = image.render(22); + assert.strictEqual(narrowerLines.length, 10); + assert.ok(narrowerLines[0].includes(",c=9,r=10,i=8938;")); + }); + + it("chooses thin Kitty widths by proportions while keeping at least one column", () => { + setCellDimensions({ widthPx: 1, heightPx: 1 }); + for (const [widthPx, columns] of [ + [1, 1], + [140, 1], + [149, 2], + ]) { + const result = renderImage("AAAA", { widthPx, heightPx: 1000 }, { maxWidthCells: 30, maxHeightCells: 10 }); + assert.ok(result); + assert.strictEqual(result.columns, columns); + assert.strictEqual(result.rows, 10); + assert.ok(result.sequence.includes(`,c=${columns},r=10;`)); + } + }); + }); + + describe("iTerm2", () => { + beforeEach(() => { + setCapabilities({ images: "iterm2", trueColor: true, hyperlinks: true }); + }); + + it("keeps iTerm2's ceiling width when height-limited", () => { + setCellDimensions({ widthPx: 14, heightPx: 28 }); + const result = renderImage("AAAA", { widthPx: 400, heightPx: 900 }, { maxWidthCells: 30, maxHeightCells: 15 }); + assert.ok(result); + assert.strictEqual(result.columns, 14); + assert.strictEqual(result.rows, 15); + assert.strictEqual(result.sequence, "\x1b]1337;File=inline=1;size=3;width=14;height=auto:AAAA\x07"); + }); + + it("keeps iTerm2's ceiling-based reserved lines and cursor offset", () => { + setCellDimensions({ widthPx: 9, heightPx: 18 }); + const image = new Image( + "AAAA", + "image/png", + { fallbackColor: (value) => value }, + { maxWidthCells: 60 }, + { widthPx: 615, heightPx: 86 }, + ); + assert.deepStrictEqual(image.render(62), [ + "", + "", + "", + "", + "\x1b[4A\x1b]1337;File=inline=1;size=3;width=60;height=auto:AAAA\x07", + ]); + }); + }); +}); + describe("imageFallback", () => { it("shortens home-prefixed absolute paths without hyperlinks", () => { setCapabilities({ images: null, trueColor: false, hyperlinks: false }); @@ -673,6 +825,70 @@ describe("imageFallback", () => { }); }); +// Kitty only accepts PNG (f=100); non-PNG images must be transcoded (#10292) +describe("Image transcoding", () => { + const jpeg = Buffer.from("jpeg").toString("base64"); + // Minimal PNG header (signature + IHDR) for a 40x10 image. Enough for getPngDimensions. + const png = Buffer.from("89504e470d0a1a0a0000000d49484452000000280000000a", "hex").toString("base64"); + let calls: string[]; + const render = (data: string, mimeType: string) => + new Image(data, mimeType, { fallbackColor: (value) => value }, {}, { widthPx: 20, heightPx: 20 }).render(20); + const transcode = (data: string) => { + calls.push(data); + return data === jpeg ? png : null; + }; + + beforeEach(() => { + calls = []; + setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); + setCellDimensions({ widthPx: 10, heightPx: 10 }); + }); + + afterEach(() => { + setImageTranscoder(undefined); + resetCapabilitiesCache(); + setCellDimensions({ widthPx: 9, heightPx: 18 }); + }); + + it("sends converted PNG data sized from the PNG", () => { + setImageTranscoder(transcode); + const lines = render(jpeg, "image/jpeg"); + assert.ok(lines[0].includes("f=100") && lines[0].includes(`;${png}\x1b\\`)); + // 40x10 PNG at 18 columns: 5 rows, not the 18 rows of the 20x20 source dimensions. + assert.strictEqual(lines.length, 5); + }); + + it("renders a text fallback until a working transcoder is registered", () => { + const image = new Image(jpeg, "image/jpeg", { fallbackColor: (value) => value }); + assert.match(image.render(80)[0], /^\[Image: \[image\/jpeg\]/); + setImageTranscoder(() => null); + image.invalidate(); + assert.match(image.render(80)[0], /^\[Image: \[image\/jpeg\]/); + setImageTranscoder(transcode); + image.invalidate(); + assert.ok(image.render(80)[0].includes("\x1b_G")); + }); + + it("converts each image once", () => { + setImageTranscoder(transcode); + const image = new Image(jpeg, "image/jpeg", { fallbackColor: (value) => value }); + image.render(80); + render(jpeg, "image/jpeg"); // New instance hits the shared cache. + for (let i = 0; i < 40; i++) render(`other-${i}`, "image/jpeg"); // Evicts the shared entry. + image.invalidate(); + image.render(40); // Instance keeps its own PNG. + assert.strictEqual(calls.filter((data) => data === jpeg).length, 1); + }); + + it("does not convert PNG data or iTerm2 output", () => { + setImageTranscoder(transcode); + assert.ok(render(png, "image/png")[0].includes(`;${png}\x1b\\`)); + setCapabilities({ images: "iterm2", trueColor: true, hyperlinks: true }); + assert.ok(render(jpeg, "image/jpeg").at(-1)?.endsWith(`:${jpeg}\x07`)); + assert.deepStrictEqual(calls, []); + }); +}); + describe("hyperlink", () => { it("wraps text in OSC 8 open and close sequences", () => { const result = hyperlink("click me", "https://example.com"); diff --git a/packages/pi-tui/test/terminal.test.ts b/packages/pi-tui/test/terminal.test.ts index 80eadd626..79f4410f3 100644 --- a/packages/pi-tui/test/terminal.test.ts +++ b/packages/pi-tui/test/terminal.test.ts @@ -187,6 +187,21 @@ describe("ProcessTerminal Kitty keyboard protocol negotiation", () => { } }); + it("forwards device attributes replies that answer other queries", () => { + const harness = setupNegotiation(); + try { + harness.send("\x1b[?7u"); + harness.send("\x1b[?62;4;52c"); + assert.equal(harness.getInput(), undefined); + + // The TUI's color query uses DA1 as its own sentinel. + harness.send("\x1b[?62;4;52c"); + assert.equal(harness.getInput(), "\x1b[?62;4;52c"); + } finally { + harness.cleanup(); + } + }); + it("forwards normal input while waiting for Kitty response", () => { const harness = setupNegotiation(); try { diff --git a/packages/pi-tui/test/tui-alt-screen.test.ts b/packages/pi-tui/test/tui-alt-screen.test.ts index e45537e30..eab198581 100644 --- a/packages/pi-tui/test/tui-alt-screen.test.ts +++ b/packages/pi-tui/test/tui-alt-screen.test.ts @@ -304,7 +304,51 @@ describe("TuiAltScreen", () => { tui.stop(); }); - it("leaves the scrollbar clickable when the jump-to-end indicator spans the transcript", async () => { + it("keeps the jump-to-end indicator centered as the auto scrollbar hides and reappears", async () => { + // Regression test for #9136: auto scrollbar visibility must not move the indicator. + const terminal = new VirtualTerminal(80, 6); + const label = " ↓ Jump to latest message · End "; + const tui = new TuiAltScreen(terminal, undefined, undefined, { + scrollToEndIndicator: () => label, + }); + const transcript = new ScrollView( + new Text(Array.from({ length: 20 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0), + { follow: "end", primary: true, scrollbar: "auto", scrollbarHideDelayMs: 0 }, + ); + tui.setLayoutRoot(transcript); + tui.start(); + try { + await terminal.waitForRender(); + + // Scrolling over the track keeps the scrollbar visible until the pointer leaves. + terminal.sendInput("\x1b[<64;80;1M"); + await terminal.waitForRender(); + assert.strictEqual(transcript.isScrollbarVisible, true); + assert.strictEqual(transcript.isFollowingEnd, false); + const scrollTop = transcript.scrollTop; + const visibleColumn = terminal.getViewport()[5].indexOf(label); + + // Leaving the track lets the auto-hide timer expire without changing the content. + terminal.sendInput("\x1b[<35;79;1M"); + await terminal.waitForRender(); + assert.strictEqual(transcript.isScrollbarVisible, false); + assert.strictEqual(transcript.scrollTop, scrollTop); + const hiddenColumn = terminal.getViewport()[5].indexOf(label); + + terminal.sendInput("\x1b[<35;80;1M"); + await terminal.waitForRender(); + assert.strictEqual(transcript.isScrollbarVisible, true); + assert.strictEqual(transcript.scrollTop, scrollTop); + const revealedColumn = terminal.getViewport()[5].indexOf(label); + + assert.deepStrictEqual([visibleColumn, hiddenColumn, revealedColumn], [24, 24, 24]); + } finally { + tui.stop(); + } + }); + + it("leaves the scrollbar visible and clickable when the jump-to-end indicator spans the transcript", async () => { + // Regression coverage for #9136: centering must not paint or capture clicks over the scrollbar. const terminal = new VirtualTerminal(30, 6); const tui = new TuiAltScreen(terminal, undefined, undefined, { scrollToEndIndicator: () => "↓".repeat(30), @@ -327,6 +371,7 @@ describe("TuiAltScreen", () => { assert.strictEqual(transcript.isFollowingEnd, false); // The indicator must not intercept a press on the scrollbar's last column. + assert.strictEqual(terminal.getViewport()[3], `${"↓".repeat(29)}┃`); terminal.sendInput("\x1b[<0;30;4M"); terminal.sendInput("\x1b[<0;30;4m"); await terminal.waitForRender(); @@ -625,6 +670,31 @@ describe("TuiAltScreen", () => { tui.stop(); }); + // #9758: wheel line counts can change at runtime; Alt keeps its multiplier. + it("applies runtime wheel line count updates", async () => { + const terminal = new VirtualTerminal(20, 4); + const tui = new TuiAltScreen(terminal, undefined, undefined, { wheelScrollLines: 3 }); + const deltas: Array = []; + tui.addChild( + new MouseRegion(new Text("wheel target", 0, 0), (event) => { + if (event.type !== "wheel") return undefined; + deltas.push(event.wheelDelta); + return { handled: true }; + }), + ); + tui.start(); + try { + await terminal.waitForRender(); + terminal.sendInput("\x1b[<64;1;1M"); + tui.setWheelScrollLines(2); + terminal.sendInput("\x1b[<65;1;1M"); + terminal.sendInput("\x1b[<72;1;1M"); + assert.deepStrictEqual(deltas, [-3, 2, -10]); + } finally { + tui.stop(); + } + }); + it("chains unused wheel delta to an outer scroll view", async () => { const terminal = new VirtualTerminal(20, 4); const tui = new TuiAltScreen(terminal, undefined, undefined, { wheelScrollLines: 3 }); @@ -1214,6 +1284,47 @@ describe("TuiAltScreen", () => { tui.stop(); }); + it("redraws WezTerm Kitty images after writes to covered rows", async () => { + // Regression test for #10319: a scrollbar update below an unchanged image anchor erased its cells. + const weztermPane = process.env.WEZTERM_PANE; + let tui: TuiAltScreen | undefined; + process.env.WEZTERM_PANE = "1"; + setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); + try { + const terminal = new RecordingTerminal(20, 4); + const imageId = 10319; + const imageLine = encodeKitty("AAAA", { columns: 2, rows: 3, imageId, moveCursor: false }); + registerKittyImageMetadata({ imageId, columns: 2, rows: 3, widthPx: 100, heightPx: 100 }); + let coveredLine = ""; + tui = new TuiAltScreen(terminal); + tui.setLayoutRoot({ + render: () => [imageLine, coveredLine, "", "after"], + invalidate: () => {}, + }); + tui.start(); + await terminal.waitForRender(); + const eventCount = terminal.events.length; + + coveredLine = "changed"; + tui.requestRender(); + await terminal.waitForRender(); + const redrawWrites = terminal.events + .slice(eventCount) + .filter((event): event is { type: "write"; data: string } => event.type === "write") + .map((event) => event.data) + .join(""); + const placementIndex = redrawWrites.indexOf("\x1b_Ga=p,q=2"); + assert.ok(redrawWrites.includes("\x1b_Ga=d,d=a,q=2\x1b\\")); + assert.ok(placementIndex > redrawWrites.indexOf("changed")); + assert.ok(!redrawWrites.includes("\x1b_Ga=T")); + } finally { + tui?.stop(); + resetCapabilitiesCache(); + if (weztermPane === undefined) delete process.env.WEZTERM_PANE; + else process.env.WEZTERM_PANE = weztermPane; + } + }); + it("reuses moved Kitty images without dropping HStack siblings", async () => { setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); try { @@ -1573,6 +1684,36 @@ describe("TuiAltScreen", () => { tui.stop(); }); + it("flashes a specific error returned by the injected copySelection handler", async () => { + // Regression test for #9618. + const terminal = new RecordingTerminal(80, 4); + const tui = new TuiAltScreen(terminal, undefined, undefined, { + copyOnSelect: false, + copySelection: async () => "Clipboard unavailable: install wl-clipboard", + }); + let flashDuration: number | undefined; + const flash = tui.flash.bind(tui); + tui.flash = (message, durationMs) => { + flashDuration = durationMs; + flash(message, durationMs); + }; + tui.addChild(new Text("alpha\nbeta\ngamma\ndelta", 0, 0)); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[<0;1;1M"); + terminal.sendInput("\x1b[<32;4;2M"); + terminal.sendInput("\x1b[<0;4;2m"); + await terminal.waitForRender(); + assert.strictEqual(await tui.copyActiveSelectionToClipboard(), false); + await terminal.waitForRender(); + + assert.ok(terminal.getViewport().some((line) => line.includes("Clipboard unavailable: install wl-clipboard"))); + assert.ok(terminal.getViewport().every((line) => !line.includes("Copy failed"))); + assert.strictEqual(flashDuration, 5000); + tui.stop(); + }); + it("does not append whitespace to double-click word highlighting", async () => { const terminal = new RecordingTerminal(20, 1); const tui = new TuiAltScreen(terminal); diff --git a/packages/pi-tui/test/viewport-overwrite-repro.ts b/packages/pi-tui/test/viewport-overwrite-repro.ts index 1826af068..f4541d5bf 100644 --- a/packages/pi-tui/test/viewport-overwrite-repro.ts +++ b/packages/pi-tui/test/viewport-overwrite-repro.ts @@ -2,11 +2,11 @@ * TUI viewport overwrite repro * * Place this file at: packages/tui/test/viewport-overwrite-repro.ts - * Run from repo root: npx tsx packages/tui/test/viewport-overwrite-repro.ts + * Run from repo root: node packages/tui/test/viewport-overwrite-repro.ts * * For reliable repro, run in a small terminal (8-12 rows) or a tmux session: * tmux new-session -d -s tui-bug -x 80 -y 12 - * tmux send-keys -t tui-bug "npx tsx packages/tui/test/viewport-overwrite-repro.ts" Enter + * tmux send-keys -t tui-bug "node packages/tui/test/viewport-overwrite-repro.ts" Enter * tmux attach -t tui-bug * * Expected behavior: diff --git a/packages/pi-tui/test/visible-width.test.ts b/packages/pi-tui/test/visible-width.test.ts new file mode 100644 index 000000000..09d305e79 --- /dev/null +++ b/packages/pi-tui/test/visible-width.test.ts @@ -0,0 +1,26 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { visibleWidth } from "../src/utils.ts"; + +describe("visibleWidth", () => { + it("measures styled ASCII without counting escape sequences", () => { + assert.strictEqual(visibleWidth("\x1b[38;5;4mhello\x1b[39m world"), 11); + assert.strictEqual(visibleWidth("\x1b]8;;https://example.com\x07link\x1b]8;;\x07"), 4); + assert.strictEqual(visibleWidth("\x1b]133;A\x1b\\prompt"), 6); + assert.strictEqual(visibleWidth("\x1b_pi:c\x07cursor"), 6); + }); + + it("counts tabs as three columns in styled text", () => { + assert.strictEqual(visibleWidth("\x1b[1ma\tb\x1b[22m"), 5); + }); + + it("measures styled non-ASCII text", () => { + assert.strictEqual(visibleWidth("\x1b[31m日本\x1b[39m ok"), 7); + assert.strictEqual(visibleWidth("\x1b[31m─→\x1b[39m"), 2); + }); + + it("treats unterminated escape sequences as zero-width control characters", () => { + assert.strictEqual(visibleWidth("\x1b[31"), 3); + assert.strictEqual(visibleWidth("a\x1b"), 1); + }); +}); diff --git a/packages/pi-tui/test/wheel-scroll.test.ts b/packages/pi-tui/test/wheel-scroll.test.ts new file mode 100644 index 000000000..281893245 --- /dev/null +++ b/packages/pi-tui/test/wheel-scroll.test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { WheelScrollAccelerator } from "../src/wheel-scroll.ts"; + +function scroll(accelerator: WheelScrollAccelerator, times: number[], direction: -1 | 1 = 1): number[] { + return times.map((time) => accelerator.next(direction, time)); +} + +// #9758: fullscreen wheel scrolling was one line per notch on terminals that do not accelerate wheels. +describe("WheelScrollAccelerator", () => { + it("uses fixed line counts regardless of timing", () => { + const accelerator = new WheelScrollAccelerator(3, true); + assert.deepStrictEqual(scroll(accelerator, [0, 10, 20, 1000]), [3, 3, 3, 3]); + accelerator.setLines(0.5); + assert.strictEqual(accelerator.next(1, 2000), 1); + }); + + it("keeps one line per event in auto mode when the terminal already accelerates", () => { + const accelerator = new WheelScrollAccelerator("auto", false); + assert.deepStrictEqual(scroll(accelerator, [0, 10, 20, 30]), [1, 1, 1, 1]); + }); + + it("scales auto mode with wheel velocity", () => { + const accelerator = new WheelScrollAccelerator("auto", true); + assert.deepStrictEqual(scroll(accelerator, [0, 150, 300, 450]), [1, 1, 1, 1]); + assert.deepStrictEqual(scroll(accelerator, [1000, 1050, 1100, 1150]), [1, 2, 2, 2]); + assert.deepStrictEqual(scroll(accelerator, [2000, 2020, 2040, 2060]), [1, 5, 5, 5]); + assert.deepStrictEqual(scroll(accelerator, [3000, 3010, 3020, 3030]), [1, 6, 6, 6]); + }); + + it("does not accelerate bursts of events for a single notch", () => { + const accelerator = new WheelScrollAccelerator("auto", true); + assert.deepStrictEqual(scroll(accelerator, [0, 3, 6, 9]), [1, 1, 1, 1]); + }); + + it("resets acceleration on direction changes and pauses", () => { + const accelerator = new WheelScrollAccelerator("auto", true); + assert.deepStrictEqual(scroll(accelerator, [0, 20, 40]), [1, 5, 5]); + assert.strictEqual(accelerator.next(-1, 60), 1); + assert.deepStrictEqual(scroll(accelerator, [500, 520]), [1, 5]); + }); + + it("carries fractional lines between events", () => { + const accelerator = new WheelScrollAccelerator("auto", true); + assert.deepStrictEqual(scroll(accelerator, [0, 40, 80, 120, 160]), [1, 2, 3, 2, 3]); + }); +}); From 4a008f80cbff4666eb5aab8e2ef36da9dd53c3e8 Mon Sep 17 00:00:00 2001 From: elkaix Date: Sat, 3 Oct 2026 13:00:55 -0400 Subject: [PATCH 8/9] fix: address review findings on the pi dock PR Catch rejections from the Shift-Tab effort cycle, make latex script normalization linear, and use the fork package name in pi-tui README examples. --- apps/pythinker-code/src/tui/pythinker-tui.ts | 4 +++- .../tui/pythinker-tui-message-flow.test.ts | 24 +++++++++++++++++++ packages/pi-tui/README.md | 4 ++-- packages/pi-tui/src/latex.ts | 5 +++- 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/apps/pythinker-code/src/tui/pythinker-tui.ts b/apps/pythinker-code/src/tui/pythinker-tui.ts index 92ca3ca90..cc99b0107 100644 --- a/apps/pythinker-code/src/tui/pythinker-tui.ts +++ b/apps/pythinker-code/src/tui/pythinker-tui.ts @@ -1206,7 +1206,9 @@ export class PythinkerTUI { // ========================================================================= cycleThinkingEffort(): void { - void cycleThinkingEffort(this); + void cycleThinkingEffort(this).catch((error: unknown) => { + this.showError(`Failed to cycle thinking effort: ${formatErrorMessage(error)}`); + }); } handleInputModeChange(mode: 'prompt' | 'bash'): void { diff --git a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts index b0f4b125a..1455fa953 100644 --- a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts +++ b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts @@ -1556,6 +1556,30 @@ describe('PythinkerTUI message flow', () => { expect(stripSgr(renderTranscript(driver))).not.toContain('Thinking set to'); }); + it('shows an error instead of an unhandled rejection when the Shift-Tab effort cycle fails', async () => { + const session = makeSession(); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver } = await makeDriver( + session, + { getConfig: vi.fn(async () => thinkingModelsConfig()) }, + startupInput, + ); + expect(driver.session).toBeUndefined(); + vi.spyOn(driver, 'waitForLazyCreation').mockRejectedValueOnce(new Error('create failed')); + + driver.state.editor.onShiftTab?.(); + + await vi.waitFor(() => { + expect(stripSgr(renderTranscript(driver))).toContain( + 'Failed to cycle thinking effort: create failed', + ); + }); + expect(driver.state.appState.thinkingEffort).toBe('high'); + }); + it('blocks an effort switch once the waited-out first prompt starts a turn (v2 engine)', async () => { const lazySession = makeSession({ id: 'ses-lazy' }); const startupInput: PythinkerTUIStartupInput = { diff --git a/packages/pi-tui/README.md b/packages/pi-tui/README.md index 9c9678362..e3b4c41bb 100644 --- a/packages/pi-tui/README.md +++ b/packages/pi-tui/README.md @@ -81,7 +81,7 @@ import { parseColor, rgbColor, styleText, -} from "@earendil-works/pi-tui"; +} from "@pymodel/pi-tui"; const accent = parseColor("oklch(70% 0.12 220)"); const background = parseColor("#20242a"); @@ -117,7 +117,7 @@ import { ScrollView, Text, VStack, -} from "@earendil-works/pi-tui"; +} from "@pymodel/pi-tui"; const transcript = new Container(); transcript.addChild(new Text("History")); diff --git a/packages/pi-tui/src/latex.ts b/packages/pi-tui/src/latex.ts index 41bbcc445..227aff41b 100644 --- a/packages/pi-tui/src/latex.ts +++ b/packages/pi-tui/src/latex.ts @@ -612,7 +612,10 @@ function replaceCharacters(value: string, replacements: Readonly part.trim()) + .join(""); } function formatUnicodeScript(value: string, kind: "sub" | "sup"): string | undefined { From 541106f2f49203127263da61acd371b1c1c2c05f Mon Sep 17 00:00:00 2001 From: elkaix Date: Sat, 3 Oct 2026 13:10:41 -0400 Subject: [PATCH 9/9] feat(tui): make the primary color periwinkle instead of cyan --- .changeset/periwinkle-primary.md | 5 ++++ apps/pythinker-code/src/tui/theme/colors.ts | 28 +++++++++---------- .../dialogs/provider-manager.test.ts | 2 +- .../test/tui/theme/palette.test.ts | 6 ++-- docs/customization/themes.md | 12 ++++---- 5 files changed, 29 insertions(+), 24 deletions(-) create mode 100644 .changeset/periwinkle-primary.md diff --git a/.changeset/periwinkle-primary.md b/.changeset/periwinkle-primary.md new file mode 100644 index 000000000..665a9da0f --- /dev/null +++ b/.changeset/periwinkle-primary.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +The TUI's main color (links, inline code, selections, focus, plan and shell mode) is now periwinkle instead of cyan in both the dark and light themes. diff --git a/apps/pythinker-code/src/tui/theme/colors.ts b/apps/pythinker-code/src/tui/theme/colors.ts index 9d029c84a..02b9dc128 100644 --- a/apps/pythinker-code/src/tui/theme/colors.ts +++ b/apps/pythinker-code/src/tui/theme/colors.ts @@ -182,11 +182,11 @@ export interface ColorPalette { } export const darkColors: ColorPalette = { - /* Clean cyan used for actions, links, menus, and focus on dark terminals. */ - primary: '#5FC3E8', + /* Periwinkle used for actions, links, menus, and focus on dark terminals. */ + primary: '#B4B8F8', accent: '#EE9983', - primaryShimmer: '#A5E3F7', + primaryShimmer: '#D6D9FF', accentShimmer: '#FFC4B8', warningShimmer: '#FFD474', borderShimmer: '#848CA8', @@ -224,7 +224,7 @@ export const darkColors: ColorPalette = { diffRemovedDimmed: '#B55E68', roleUser: '#FFCB6B', - shellMode: '#5FC3E8', + shellMode: '#B4B8F8', workflowTitle: '#EE9983', @@ -246,7 +246,7 @@ export const darkColors: ColorPalette = { rainbowViolet: '#C763E9', modeAutoAccept: '#66D49A', - modePlan: '#5FC3E8', + modePlan: '#B4B8F8', modePermission: '#D99AF0', modeFast: '#FFB45E', @@ -255,17 +255,17 @@ export const darkColors: ColorPalette = { selectionBg: '#344274', surfaceHighlight: '#1C2238', - progressFill: '#5FC3E8', - progressHead: '#A5E3F7', + progressFill: '#B4B8F8', + progressHead: '#D6D9FF', progressEmpty: '#D9DEE8', }; export const lightColors: ColorPalette = { - /* Dark cyan for WCAG AA contrast on light terminal backgrounds. */ - primary: '#006A88', + /* Deep periwinkle for WCAG AA contrast on light terminal backgrounds. */ + primary: '#4B4FC4', accent: '#9C261C', - primaryShimmer: '#004B63', + primaryShimmer: '#35388F', accentShimmer: '#7C1C12', warningShimmer: '#6F4700', borderShimmer: '#4F567A', @@ -303,7 +303,7 @@ export const lightColors: ColorPalette = { diffRemovedDimmed: '#8D4852', roleUser: '#9A4A00', - shellMode: '#006A88', + shellMode: '#4B4FC4', workflowTitle: '#9C261C', @@ -325,7 +325,7 @@ export const lightColors: ColorPalette = { rainbowViolet: '#7C1C9C', modeAutoAccept: '#26704C', - modePlan: '#006A88', + modePlan: '#4B4FC4', modePermission: '#7A3C96', modeFast: '#9A570F', @@ -334,8 +334,8 @@ export const lightColors: ColorPalette = { selectionBg: '#C9D1FA', surfaceHighlight: '#E8EBFC', - progressFill: '#006A88', - progressHead: '#004B63', + progressFill: '#4B4FC4', + progressHead: '#35388F', progressEmpty: '#6B7280', }; diff --git a/apps/pythinker-code/test/tui/components/dialogs/provider-manager.test.ts b/apps/pythinker-code/test/tui/components/dialogs/provider-manager.test.ts index 1292f3b53..d0e6b950b 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/provider-manager.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/provider-manager.test.ts @@ -10,7 +10,7 @@ import { darkColors } from '#/tui/theme/colors'; // Truecolor SGR fragments for the darkColors tokens we assert on // (see theme/colors.ts). Forcing chalk.level below guarantees they appear. -const PRIMARY = '38;2;95;195;232'; // colors.primary #5FC3E8 +const PRIMARY = '38;2;180;184;248'; // colors.primary #B4B8F8 const MUTED = '38;2;133;133;133'; // colors.textMuted #858585 const BOLD = '[1m'; const ESC = String.fromCodePoint(27); diff --git a/apps/pythinker-code/test/tui/theme/palette.test.ts b/apps/pythinker-code/test/tui/theme/palette.test.ts index 4b931ec18..cce7d05c0 100644 --- a/apps/pythinker-code/test/tui/theme/palette.test.ts +++ b/apps/pythinker-code/test/tui/theme/palette.test.ts @@ -91,9 +91,9 @@ describe('theme palettes', () => { } }); - it('uses cyan actions with readable neutral text in both built-in palettes', () => { - expect(darkColors.primary).toBe('#5FC3E8'); - expect(lightColors.primary).toBe('#006A88'); + it('uses periwinkle actions with readable neutral text in both built-in palettes', () => { + expect(darkColors.primary).toBe('#B4B8F8'); + expect(lightColors.primary).toBe('#4B4FC4'); for (const palette of [darkColors, lightColors]) { expect(palette.shellMode).toBe(palette.primary); diff --git a/docs/customization/themes.md b/docs/customization/themes.md index 8b8ed907e..9814cdaf3 100644 --- a/docs/customization/themes.md +++ b/docs/customization/themes.md @@ -10,9 +10,9 @@ Active `/model` provider and `AskUserQuestion` tabs use `selectionBg` for the ba | Token | `dark` | `light` | What it controls | | --- | --- | --- | --- | -| `primary` | `#5FC3E8` | `#006A88` | Dominant interactive/brand colour: links & inline code, the selected item in nearly every dialog, the focused editor border, plan/"running" badges, spinners. The most widely used token. | +| `primary` | `#B4B8F8` | `#4B4FC4` | Dominant interactive/brand colour: links & inline code, the selected item in nearly every dialog, the focused editor border, plan/"running" badges, spinners. The most widely used token. | | `accent` | `#EE9983` | `#9C261C` | Coral secondary highlight: approval "▶" prefix, device-code box, image placeholder, BTW / queue panes, custom-registry import. | -| `primaryShimmer` | `#A5E3F7` | `#004B63` | Bright primary pulse used by running-state animations. | +| `primaryShimmer` | `#D6D9FF` | `#35388F` | Bright primary pulse used by running-state animations. | | `accentShimmer` | `#FFC4B8` | `#7C1C12` | Accent pulse used by attention animations. | | `warningShimmer` | `#FFD474` | `#6F4700` | Bright warning pulse used by attention animations. | | `borderShimmer` | `#848CA8` | `#4F567A` | Bright border pulse used by focused-panel animations. | @@ -41,7 +41,7 @@ Active `/model` provider and `AskUserQuestion` tabs use `selectionBg` for the ba | `diffAddedDimmed` | `#57966F` | `#316A48` | De-emphasized added diff context. | | `diffRemovedDimmed` | `#B55E68` | `#8D4852` | De-emphasized removed diff context. | | `roleUser` | `#FFCB6B` | `#9A4A00` | User message bullet, skill-activation name, and user-specific accents. | -| `shellMode` | `#5FC3E8` | `#006A88` | Shell mode (`!`) prompt, editor border, and echoed command line. | +| `shellMode` | `#B4B8F8` | `#4B4FC4` | Shell mode (`!`) prompt, editor border, and echoed command line. | | `workflowTitle` | `#EE9983` | `#9C261C` | Coral title used by the Dynamic Workflow mission-control frame. | | `agentRed` | `#E2697D` | `#9D2539` | Red identity used by the first agent in Dynamic Workflow progress and grouped output. | | `agentOrange` | `#E2B069` | `#9D6B25` | Orange identity used by the second agent in Dynamic Workflow progress and grouped output. | @@ -59,7 +59,7 @@ Active `/model` provider and `AskUserQuestion` tabs use `selectionBg` for the ba | `rainbowIndigo` | `#6E63E9` | `#261C9C` | Indigo spectrum stop for future keyword and gradient highlighting. | | `rainbowViolet` | `#C763E9` | `#7C1C9C` | Violet spectrum stop for future keyword and gradient highlighting. | | `modeAutoAccept` | `#66D49A` | `#26704C` | Auto-accept badge colour for the mode-specific status treatment. | -| `modePlan` | `#5FC3E8` | `#006A88` | Plan badge colour for the mode-specific status treatment. | +| `modePlan` | `#B4B8F8` | `#4B4FC4` | Plan badge colour for the mode-specific status treatment. | | `modePermission` | `#D99AF0` | `#7A3C96` | Permission badge colour for the mode-specific status treatment. | | `modeFast` | `#FFB45E` | `#9A570F` | Fast badge colour for the mode-specific status treatment. | | `background` | `#000000` | `#FFFFFF` | Assumed terminal background against which themed surfaces are tuned. | @@ -69,8 +69,8 @@ Active `/model` provider and `AskUserQuestion` tabs use `selectionBg` for the ba | `toolPendingBg` | `#1D2129` | `#E8EEF7` | Background tint for a tool card while the call is running. | | `toolSuccessBg` | `#14171B` | `#F1F3F5` | Legacy custom-theme token; completed tool cards use the terminal background. | | `toolErrorBg` | `#291D1D` | `#F9E9E9` | Background tint for a tool card after an error result. | -| `progressFill` | `#5FC3E8` | `#006A88` | Active Dynamic Workflow progress bars and status labels. | -| `progressHead` | `#A5E3F7` | `#004B63` | Leading highlight for active Dynamic Workflow progress. | +| `progressFill` | `#B4B8F8` | `#4B4FC4` | Active Dynamic Workflow progress bars and status labels. | +| `progressHead` | `#D6D9FF` | `#35388F` | Leading highlight for active Dynamic Workflow progress. | | `progressEmpty` | `#D9DEE8` | `#6B7280` | Empty segment of the Dynamic Workflow aggregate progress line. | ## Use the custom-theme skill