diff --git a/.changeset/clickable-options-and-tabs.md b/.changeset/clickable-options-and-tabs.md new file mode 100644 index 00000000000..259f98f97d9 --- /dev/null +++ b/.changeset/clickable-options-and-tabs.md @@ -0,0 +1,7 @@ +--- +'@shopify/app': minor +'@shopify/cli': minor +'@shopify/cli-kit': minor +--- + +Enable mouse support for prompts and app dev tabs; hold Option in iTerm2 or Shift elsewhere to select text, or run `shopify config mouse off` to disable it. diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 2520c347e81..b909281605f 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -3167,6 +3167,26 @@ "value": "export interface configautoupgradestatus {\n\n}" } }, + "configmouseoff": { + "docs-shopify.dev/commands/interfaces/config-mouse-off.interface.ts": { + "filePath": "docs-shopify.dev/commands/interfaces/config-mouse-off.interface.ts", + "name": "configmouseoff", + "description": "The following flags are available for the `config mouse off` command:", + "isPublicDocs": true, + "members": [], + "value": "export interface configmouseoff {\n\n}" + } + }, + "configmouseon": { + "docs-shopify.dev/commands/interfaces/config-mouse-on.interface.ts": { + "filePath": "docs-shopify.dev/commands/interfaces/config-mouse-on.interface.ts", + "name": "configmouseon", + "description": "The following flags are available for the `config mouse on` command:", + "isPublicDocs": true, + "members": [], + "value": "export interface configmouseon {\n\n}" + } + }, "docfetch": { "docs-shopify.dev/commands/interfaces/doc-fetch.interface.ts": { "filePath": "docs-shopify.dev/commands/interfaces/doc-fetch.interface.ts", diff --git a/packages/app/src/cli/services/dev/ui/components/DevSessionUI.test.tsx b/packages/app/src/cli/services/dev/ui/components/DevSessionUI.test.tsx index 0d8c893d1d2..7138dbc8280 100644 --- a/packages/app/src/cli/services/dev/ui/components/DevSessionUI.test.tsx +++ b/packages/app/src/cli/services/dev/ui/components/DevSessionUI.test.tsx @@ -61,6 +61,10 @@ const initialStatus: DevSessionStatus = { const onAbort = vi.fn() +function mouseWheelUp(column: number, row: number): string { + return `\u001B[<64;${column};${row}M` +} + describe('DevSessionUI', () => { beforeEach(() => { mocks.terminalSupportsHyperlinks.mockReturnValue(false) @@ -405,6 +409,7 @@ describe('DevSessionUI', () => { onAbort={onAbort} />, ) + await waitForContent(renderInstance, 'third backend message') const promise = renderInstance.waitUntilExit() @@ -583,6 +588,29 @@ describe('DevSessionUI', () => { renderInstance.unmount() }) + test('temporarily releases mouse reporting when scrolling', async () => { + const renderInstance = render( + , + {stdoutIsTTY: true}, + ) + const stdoutWrite = vi.spyOn(renderInstance.stdout, 'write') + + await waitForInputsToBeReady() + // The row is intentionally outside the rendered UI to cover trackpad gestures + // over blank areas of the terminal viewport. + await sendInputAndWait(renderInstance, 10, mouseWheelUp(2, 200)) + + expect(stdoutWrite).toHaveBeenCalledWith('\u001B[?1003l\u001B[?1002l\u001B[?1000l') + + renderInstance.unmount() + }) + test('hides Local URL in app info when an app URL is available', async () => { // Given const renderInstance = render( diff --git a/packages/app/src/cli/services/dev/ui/components/DevSessionUI.tsx b/packages/app/src/cli/services/dev/ui/components/DevSessionUI.tsx index 4737d595bb8..a6de1aee8e5 100644 --- a/packages/app/src/cli/services/dev/ui/components/DevSessionUI.tsx +++ b/packages/app/src/cli/services/dev/ui/components/DevSessionUI.tsx @@ -8,7 +8,7 @@ import {Alert, ConcurrentOutput, Link, LoadingIndicator, TabularData} from '@sho import {useAbortSignal} from '@shopify/cli-kit/node/ui/hooks' import React, {FunctionComponent, useEffect, useMemo, useState} from 'react' import {AbortController, AbortSignal} from '@shopify/cli-kit/node/abort' -import {Box, Text, useInput, useStdin} from '@shopify/cli-kit/node/ink' +import {Box, MouseProvider, Text, useInput, useStdin} from '@shopify/cli-kit/node/ink' import {handleCtrlC} from '@shopify/cli-kit/node/ui' import {openURL, terminalSupportsHyperlinks} from '@shopify/cli-kit/node/system' import figures from '@shopify/cli-kit/node/figures' @@ -242,7 +242,7 @@ const DevSessionUI: FunctionComponent = ({ }, } - return ( + const content = ( <> = ({ ) : null} ) + + return canUseShortcuts && !isAborted ? ( + + {content} + + ) : ( + content + ) } export {DevSessionUI} diff --git a/packages/app/src/cli/services/dev/ui/components/TabPanel.test.tsx b/packages/app/src/cli/services/dev/ui/components/TabPanel.test.tsx index 9ed7aff0e7f..91b604b2694 100644 --- a/packages/app/src/cli/services/dev/ui/components/TabPanel.test.tsx +++ b/packages/app/src/cli/services/dev/ui/components/TabPanel.test.tsx @@ -1,14 +1,21 @@ import {TabPanel, Tab} from './TabPanel.js' import { - render, + render as renderUI, sendInputAndWait, sendInputAndWaitForChange, + waitForContent, waitForInputsToBeReady, } from '@shopify/cli-kit/node/testing/ui' import React from 'react' import {describe, expect, test, vi} from 'vitest' import {unstyled} from '@shopify/cli-kit/node/output' -import {Text} from '@shopify/cli-kit/node/ink' +import {MouseProvider, Text} from '@shopify/cli-kit/node/ink' + +const render = (element: React.ReactElement) => renderUI({element}, {stdoutIsTTY: true}) + +function mouseClick(column: number, row: number): [string, string] { + return [`\u001B[<0;${column};${row}M`, `\u001B[<0;${column};${row}m`] +} const mocks = vi.hoisted(() => { return { @@ -109,6 +116,34 @@ describe('TabPanel', () => { renderInstance.unmount() }) + test('switches to a different tab when its header is clicked', async () => { + const renderInstance = render() + + await waitForInputsToBeReady() + await waitForContent(renderInstance, 'Second tab content', () => + mouseClick(20, 2).forEach((input) => renderInstance.stdin.write(input)), + ) + + expect(renderInstance.lastFrame()).toContain('Second tab content') + expect(renderInstance.lastFrame()).not.toContain('First tab content') + + renderInstance.unmount() + }) + + test('accounts for output rendered before the tab panel', async () => { + const renderInstance = render() + + await waitForInputsToBeReady() + await sendInputAndWait(renderInstance, 60, '\u001B[40;1R') + await waitForContent(renderInstance, 'Second tab content', () => + mouseClick(20, 37).forEach((input) => renderInstance.stdin.write(input)), + ) + + expect(renderInstance.lastFrame()).toContain('Second tab content') + + renderInstance.unmount() + }) + test('executes tab action when action tab is pressed', async () => { const renderInstance = render() diff --git a/packages/app/src/cli/services/dev/ui/components/TabPanel.tsx b/packages/app/src/cli/services/dev/ui/components/TabPanel.tsx index 3415f7b6027..a03f694152b 100644 --- a/packages/app/src/cli/services/dev/ui/components/TabPanel.tsx +++ b/packages/app/src/cli/services/dev/ui/components/TabPanel.tsx @@ -1,5 +1,14 @@ import React, {useState, useRef, useLayoutEffect} from 'react' -import {Box, Text, useInput, useStdin, useStdout, measureElement} from '@shopify/cli-kit/node/ink' +import { + Box, + Text, + useInput, + useStdin, + useStdout, + measureElement, + useOnClick, + type DOMElement, +} from '@shopify/cli-kit/node/ink' export interface Tab { label: string @@ -27,6 +36,27 @@ interface TabPanelProps { // Using a width less than 100% reduces (but doesn't eliminate) screen artifacts when resizing the terminal const TAB_WIDTH_PERCENTAGE = 0.9 +interface ClickableTabProps { + active?: boolean + header: string + onClick: () => void +} + +const ClickableTab: React.FunctionComponent = ({active = false, header, onClick}) => { + const tabRef = useRef(null) + useOnClick(tabRef, (event) => { + if (event.button === 'left') onClick() + }) + + return ( + + + {header} + + + ) +} + export const TabPanel: React.FunctionComponent = ({tabs, initialActiveTab}) => { const {stdout} = useStdout() const {isRawModeSupported: canUseShortcuts} = useStdin() @@ -112,6 +142,19 @@ export const TabPanel: React.FunctionComponent = ({tabs, initialA const contentTabs = tabsArray.filter((tab) => !tab.action) const actionTabs = tabsArray.filter((tab) => tab.action) + const activateTab = async (tab: TabDisplay) => { + if (tab.action) { + await tab.action() + } else { + setActiveTab(tab.inputKey) + } + } + + const activateTabFromClick = (tab: TabDisplay) => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + activateTab(tab) + } + return ( <> = ({tabs, initialA borderTop > - - {'│'} - {contentTabs.map((tab) => { - return ( - - - {tab.header} - - {'│'} - - ) - })} - + + {contentTabs.map((tab) => ( + + activateTabFromClick(tab)} + /> + + + ))} {displayActions && ( {actionTabs.map((tab, index) => ( - - ({tab.inputKey}) {tab.label} - {index < actionTabs.length - 1 && ' │ '} - + + activateTabFromClick(tab)} /> + {index < actionTabs.length - 1 && } + ))} )} diff --git a/packages/cli-kit/package.json b/packages/cli-kit/package.json index 56c8c30dda3..5795c22e4bb 100644 --- a/packages/cli-kit/package.json +++ b/packages/cli-kit/package.json @@ -106,6 +106,7 @@ "@bugsnag/js": "8.9.0", "@graphql-typed-document-node/core": "3.2.0", "@iarna/toml": "2.2.5", + "@ink-tools/ink-mouse": "2.1.0", "@oclif/core": "4.8.3", "@shopify/polaris": "12.27.0", "@shopify/polaris-icons": "8.11.1", diff --git a/packages/cli-kit/src/private/node/conf-store.test.ts b/packages/cli-kit/src/private/node/conf-store.test.ts index af8d48b9585..3cd21a263b1 100644 --- a/packages/cli-kit/src/private/node/conf-store.test.ts +++ b/packages/cli-kit/src/private/node/conf-store.test.ts @@ -11,7 +11,9 @@ import { runAtMinimumInterval, getConfigStoreForPartnerStatus, getCachedPartnerAccountStatus, + getMouseEnabled, setCachedPartnerAccountStatus, + setMouseEnabled, runWithRateLimit, } from './conf-store.js' import {isLocalEnvironment} from './context/service.js' @@ -74,6 +76,22 @@ describe('removeSession', () => { }) }) +describe('mouse preference', () => { + test('is enabled by default and persists an explicit preference', async () => { + await inTemporaryDirectory(async (cwd) => { + const config = new LocalStorage({cwd}) + + expect(getMouseEnabled(config)).toBe(true) + + setMouseEnabled(false, config) + expect(getMouseEnabled(config)).toBe(false) + + setMouseEnabled(true, config) + expect(getMouseEnabled(config)).toBe(true) + }) + }) +}) + describe('getCurrentSessionId', () => { test('returns the content of the currentSessionId key in production', async () => { await inTemporaryDirectory(async (cwd) => { diff --git a/packages/cli-kit/src/private/node/conf-store.ts b/packages/cli-kit/src/private/node/conf-store.ts index 72b583f1f5f..df2d3948888 100644 --- a/packages/cli-kit/src/private/node/conf-store.ts +++ b/packages/cli-kit/src/private/node/conf-store.ts @@ -33,6 +33,7 @@ export interface ConfSchema { currentDevSessionId?: string cache?: Cache autoUpgradeEnabled?: boolean + mouseEnabled?: boolean } let _instance: LocalStorage | undefined @@ -285,6 +286,25 @@ export function setAutoUpgradeEnabled(enabled: boolean, config: LocalStorage = cliKitStore()): boolean { + return config.get('mouseEnabled') ?? true +} + +/** + * Set mouse interaction preference. + * + * @param enabled - Whether mouse interactions should be enabled. + */ +export function setMouseEnabled(enabled: boolean, config: LocalStorage = cliKitStore()): void { + config.set('mouseEnabled', enabled) +} + export function getConfigStoreForPartnerStatus() { return new LocalStorage>({ projectName: 'shopify-cli-kit-partner-status', diff --git a/packages/cli-kit/src/private/node/testing/ui.ts b/packages/cli-kit/src/private/node/testing/ui.ts index 76f31f36b69..be79963a934 100644 --- a/packages/cli-kit/src/private/node/testing/ui.ts +++ b/packages/cli-kit/src/private/node/testing/ui.ts @@ -18,6 +18,7 @@ class Stderr extends EventEmitter { export class Stdin extends EventEmitter { isTTY: boolean + isRaw = false data: string | null = null constructor(options: {isTTY?: boolean} = {}) { @@ -28,10 +29,16 @@ export class Stdin extends EventEmitter { write = (data: string) => { this.data = data this.emit('readable') + this.emit('data', data) } setEncoding() {} - setRawMode() {} + setRawMode(isRaw: boolean) { + this.isRaw = isRaw + } + + pause() {} + resume() {} ref() {} unref() {} read: () => string | null = () => { @@ -59,10 +66,11 @@ interface RenderOptions { stdout?: EventEmitter stderr?: EventEmitter stdin?: EventEmitter + stdoutIsTTY?: boolean } export const render = (tree: ReactElement, options: RenderOptions = {}): Instance => { - const stdout = new Stdout({columns: 100}) + const stdout = Object.assign(new Stdout({columns: 100}), {isTTY: options.stdoutIsTTY ?? false}) const stderr = new Stderr() const stdin = new Stdin() diff --git a/packages/cli-kit/src/private/node/ui.tsx b/packages/cli-kit/src/private/node/ui.tsx index 7e53ea3e04d..eb42bcf099d 100644 --- a/packages/cli-kit/src/private/node/ui.tsx +++ b/packages/cli-kit/src/private/node/ui.tsx @@ -77,10 +77,20 @@ interface Instance { unmount: () => void } +const mouseTrackingControlSequences = new Set([ + '\u001B[6n', + '\u001B[?1003l\u001B[?1002l\u001B[?1000h', + '\u001B[?1003l\u001B[?1002l\u001B[?1000l', + '\u001B[?1000h\u001B[?1002h\u001B[?1003h\u001B[?1006h', + '\u001B[?1006l\u001B[?1003l\u001B[?1002l\u001B[?1000l', +]) +const cursorControlSequences = new Set(['\u001B[?25h', '\u001B[?25l']) + export class Stdout extends EventEmitter { columns: number rows: number readonly frames: string[] = [] + readonly controlSequences: string[] = [] private _lastFrame?: string constructor(options: {columns?: number; rows?: number}) { @@ -90,7 +100,14 @@ export class Stdout extends EventEmitter { } write = (frame: string) => { + if (mouseTrackingControlSequences.has(frame)) { + this.controlSequences.push(frame) + return + } + this.frames.push(frame) + if (cursorControlSequences.has(frame)) return + // Ink writes `this.lastOutput + '\n'` to stdout during unmount when // running in a CI environment (detected via `is-in-ci`). In debug // mode (which tests use), `lastOutput` is never updated, so the write diff --git a/packages/cli-kit/src/private/node/ui/components/AutocompletePrompt.test.tsx b/packages/cli-kit/src/private/node/ui/components/AutocompletePrompt.test.tsx index b909d64b7e3..9261f05c141 100644 --- a/packages/cli-kit/src/private/node/ui/components/AutocompletePrompt.test.tsx +++ b/packages/cli-kit/src/private/node/ui/components/AutocompletePrompt.test.tsx @@ -4,6 +4,7 @@ import { sendInputAndWait, sendInputAndWaitForChange, sendInputAndWaitForContent, + waitFor, waitForInputsToBeReady, render, } from '../../testing/ui.js' @@ -26,6 +27,10 @@ const ARROW_DOWN = '\u001B[B' const ENTER = '\r' const DELETE = '\u007F' +function mouseClick(column: number, row: number): [string, string] { + return [`\u001B[<0;${column};${row}M`, `\u001B[<0;${column};${row}m`] +} + const DATABASE = [ {label: 'first', value: 'first'}, {label: 'second', value: 'second'}, @@ -81,10 +86,13 @@ const DATABASE = [ beforeEach(() => { vi.mocked(useStdout).mockReturnValue({ - stdout: new Stdout({ - columns: 80, - rows: 80, - }) as any, + stdout: Object.assign( + new Stdout({ + columns: 80, + rows: 80, + }), + {isTTY: true}, + ) as any, write: () => {}, }) }) @@ -128,6 +136,41 @@ describe('AutocompletePrompt', async () => { expect(onEnter).toHaveBeenCalledWith(items[1]!.value) }) + test('selects an organization from its absolute terminal row', async () => { + const onEnter = vi.fn() + const organizations = [ + {label: 'First organization', value: 'first'}, + {label: 'Second organization', value: 'second'}, + {label: 'Third organization', value: 'third'}, + {label: 'Fourth organization', value: 'fourth'}, + {label: 'Fifth organization', value: 'fifth'}, + {label: 'Sixth organization', value: 'sixth'}, + ] + const renderInstance = render( + + Promise.resolve({ + data: organizations.filter((organization) => organization.label.includes(term)), + }) + } + searchDebounceMs={0} + />, + {stdoutIsTTY: true}, + ) + + await waitForInputsToBeReady() + await sendInputAndWait(renderInstance, 60, '\u001B[40;1R') + await waitFor( + () => mouseClick(4, 32).forEach((input) => renderInstance.stdin.write(input)), + () => onEnter.mock.calls.length > 0, + ) + + expect(onEnter).toHaveBeenCalledWith('second') + }) + test('renders groups', async () => { const items = [ {label: 'first', value: 'first', group: 'Automations'}, @@ -174,7 +217,7 @@ describe('AutocompletePrompt', async () => { ninth tenth - Press ↑↓ arrows to select, enter to confirm. + Press ↑↓ arrows to select, enter to confirm, or click an option. " `) }) @@ -205,10 +248,10 @@ describe('AutocompletePrompt', async () => { expect(renderInstance.lastFrame()).toMatchInlineSnapshot(` "? Associate your project with the org Castile Ventures? - ┃ \u001b[1mAdd\u001b[22m + ┃ Add ┃ • new-ext ┃ - ┃ \u001b[1mRemove\u001b[22m + ┃ Remove ┃ • integrated-demand-ext ┃ • order-discount @@ -217,7 +260,7 @@ describe('AutocompletePrompt', async () => { third fourth - Press ↑↓ arrows to select, enter to confirm. + Press ↑↓ arrows to select, enter to confirm, or click an option. " `) }) @@ -260,7 +303,7 @@ describe('AutocompletePrompt', async () => { third fourth - Press ↑↓ arrows to select, enter to confirm. + Press ↑↓ arrows to select, enter to confirm, or click an option. " `) }) @@ -408,7 +451,7 @@ describe('AutocompletePrompt', async () => { twenty-fourth   twenty-fifth   - Press ↑↓ arrows to select, enter to confirm. + Press ↑↓ arrows to select, enter to confirm, or click an option. " `) @@ -444,7 +487,7 @@ describe('AutocompletePrompt', async () => { thirty-fifth   thirty-sixth   - Press ↑↓ arrows to select, enter to confirm. + Press ↑↓ arrows to select, enter to confirm, or click an option. " `) @@ -479,7 +522,7 @@ describe('AutocompletePrompt', async () => { twenty-fourth   twenty-fifth   - Press ↑↓ arrows to select, enter to confirm. + Press ↑↓ arrows to select, enter to confirm, or click an option. " `) @@ -516,7 +559,7 @@ describe('AutocompletePrompt', async () => { thirty-fifth   thirty-sixth   - Press ↑↓ arrows to select, enter to confirm. + Press ↑↓ arrows to select, enter to confirm, or click an option. " `) @@ -579,7 +622,7 @@ describe('AutocompletePrompt', async () => { twenty-fourth   twenty-fifth   - Press ↑↓ arrows to select, enter to confirm. + Press ↑↓ arrows to select, enter to confirm, or click an option. " `) @@ -615,7 +658,7 @@ describe('AutocompletePrompt', async () => { - Press ↑↓ arrows to select, enter to confirm. + Press ↑↓ arrows to select, enter to confirm, or click an option. " `) }) @@ -721,7 +764,7 @@ describe('AutocompletePrompt', async () => { thirty-fifth   thirty-sixth   - Press ↑↓ arrows to select, enter to confirm. + Press ↑↓ arrows to select, enter to confirm, or click an option. " `) @@ -756,7 +799,7 @@ describe('AutocompletePrompt', async () => { twenty-fourth   twenty-fifth   - Press ↑↓ arrows to select, enter to confirm. + Press ↑↓ arrows to select, enter to confirm, or click an option. " `) }) @@ -808,7 +851,7 @@ describe('AutocompletePrompt', async () => { twenty-fourth   twenty-fifth   - Press ↑↓ arrows to select, enter to confirm. + Press ↑↓ arrows to select, enter to confirm, or click an option. 1-50 of many Find what you're looking for by typing its name. " `) @@ -850,13 +893,13 @@ describe('AutocompletePrompt', async () => { expect(renderInstance.lastFrame()).toMatchInlineSnapshot(` "? Associate your project with the org Castile Ventures? Type to search... - Automations \u001b[46m \u001b[49m - > first \u001b[100m \u001b[49m - second \u001b[100m \u001b[49m - \u001b[100m \u001b[49m - Merchant Admin \u001b[100m \u001b[49m + Automations   + > first   + second   +   + Merchant Admin   - Press ↑↓ arrows to select, enter to confirm. + Press ↑↓ arrows to select, enter to confirm, or click an option. 1-10 of many Find what you're looking for by typing its name. " `) diff --git a/packages/cli-kit/src/private/node/ui/components/Mouse.test.tsx b/packages/cli-kit/src/private/node/ui/components/Mouse.test.tsx new file mode 100644 index 00000000000..13e92321b57 --- /dev/null +++ b/packages/cli-kit/src/private/node/ui/components/Mouse.test.tsx @@ -0,0 +1,67 @@ +import {MouseProvider, useOnClick} from './Mouse.js' +import {render, sendInputAndWait, waitForInputsToBeReady} from '../../testing/ui.js' +import React, {useRef} from 'react' +import {Box, DOMElement, Text} from 'ink' +import {beforeEach, describe, expect, test, vi} from 'vitest' + +const mocks = vi.hoisted(() => ({ + getMouseEnabled: vi.fn(() => true), +})) + +vi.mock('../../conf-store.js', async (importOriginal) => { + const actual = await importOriginal() + return {...actual, getMouseEnabled: mocks.getMouseEnabled} +}) + +function Clickable({onClick}: {onClick: () => void}) { + const ref = useRef(null) + useOnClick(ref, onClick) + return ( + + Click me + + ) +} + +function mouseClick(column: number, row: number): [string, string] { + return [`\u001B[<0;${column};${row}M`, `\u001B[<0;${column};${row}m`] +} + +describe('MouseProvider', () => { + beforeEach(() => { + mocks.getMouseEnabled.mockReturnValue(true) + }) + + test('handles clicks when mouse interactions are enabled', async () => { + const onClick = vi.fn() + const renderInstance = render( + + + , + {stdoutIsTTY: true}, + ) + + await waitForInputsToBeReady() + await sendInputAndWait(renderInstance, 10, ...mouseClick(2, 1)) + + expect(onClick).toHaveBeenCalledOnce() + renderInstance.unmount() + }) + + test('ignores clicks when mouse interactions are disabled', async () => { + mocks.getMouseEnabled.mockReturnValue(false) + const onClick = vi.fn() + const renderInstance = render( + + + , + {stdoutIsTTY: true}, + ) + + await waitForInputsToBeReady() + await sendInputAndWait(renderInstance, 10, ...mouseClick(2, 1)) + + expect(onClick).not.toHaveBeenCalled() + renderInstance.unmount() + }) +}) diff --git a/packages/cli-kit/src/private/node/ui/components/Mouse.tsx b/packages/cli-kit/src/private/node/ui/components/Mouse.tsx new file mode 100644 index 00000000000..b564a192714 --- /dev/null +++ b/packages/cli-kit/src/private/node/ui/components/Mouse.tsx @@ -0,0 +1,244 @@ +import {getMouseEnabled} from '../../conf-store.js' +import React, {createContext, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react' +import {Box, DOMElement, useStdin, useStdout} from 'ink' +import { + getBoundingClientRect, + getElementDimensions, + MouseProvider as InkMouseProvider, + useOnClick as useInkOnClick, + useOnMouseEnter as useInkOnMouseEnter, + type ClickHandler, + type ElementRef, + type MouseEnterHandler, +} from '@ink-tools/ink-mouse' + +const CURSOR_POSITION_REQUEST = '\u001B[6n' +// Ink removes the leading escape character before passing terminal input to useInput. +const CURSOR_POSITION_RESPONSE_PREFIX = '[' +const MOUSE_ORIGIN_QUERY_TIMEOUT_MS = 100 +const MOUSE_LAYOUT_POLL_INTERVAL_MS = 50 +const MOUSE_SCROLL_RELEASE_MS = 1500 +// Mouse tracking modes are mutually exclusive in terminal emulators. After disabling +// movement modes, explicitly restore basic press/release reporting for clickable tabs. +const ENABLE_CLICK_ONLY_MOUSE = '\u001B[?1003l\u001B[?1002l\u001B[?1000h' +const DISABLE_MOUSE_REPORTING = '\u001B[?1003l\u001B[?1002l\u001B[?1000l' +const SGR_MOUSE_EVENT_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[<(\\d+);\\d+;\\d+[Mm]`, 'gu') +const MouseOriginContext = createContext(0) + +interface MouseProviderProps extends React.PropsWithChildren { + allowTerminalScrolling?: boolean + trackMouseMovement?: boolean +} + +function getMouseTrackingMode(trackMouseMovement: boolean): string | undefined { + if (trackMouseMovement) return undefined + return ENABLE_CLICK_ONLY_MOUSE +} + +function containsMouseWheelEvent(data: Buffer | string): boolean { + return [...data.toString().matchAll(SGR_MOUSE_EVENT_PATTERN)].some((match) => { + const buttonCode = Number(match[1]) + return buttonCode >= 64 && buttonCode < 128 + }) +} + +function parseCursorPosition(data: Buffer | string): {row: number; column: number} | undefined { + const response = data.toString() + const responseStart = response.indexOf(CURSOR_POSITION_RESPONSE_PREFIX) + const responseEnd = response.indexOf('R', responseStart) + if (responseStart === -1 || responseEnd === -1) return undefined + + const [row, column] = response.slice(responseStart + CURSOR_POSITION_RESPONSE_PREFIX.length, responseEnd).split(';') + const parsedRow = Number(row) + const parsedColumn = Number(column) + if (!Number.isInteger(parsedRow) || !Number.isInteger(parsedColumn)) return undefined + + return {row: parsedRow, column: parsedColumn} +} + +function removeSgrMouseResponses(input: string): string { + let sanitizedInput = input + let responseStart = sanitizedInput.indexOf('[<') + + while (responseStart !== -1) { + const pressEnd = sanitizedInput.indexOf('M', responseStart) + const releaseEnd = sanitizedInput.indexOf('m', responseStart) + const responseEnd = [pressEnd, releaseEnd].filter((index) => index !== -1).sort((left, right) => left - right)[0] + if (responseEnd === undefined) break + + const fields = sanitizedInput.slice(responseStart + 2, responseEnd).split(';') + const isMouseResponse = + fields.length === 3 && fields.every((field) => field.length > 0 && Number.isInteger(Number(field))) + if (!isMouseResponse) { + responseStart = sanitizedInput.indexOf('[<', responseStart + 1) + continue + } + + sanitizedInput = sanitizedInput.slice(0, responseStart) + sanitizedInput.slice(responseEnd + 1) + responseStart = sanitizedInput.indexOf('[<', responseStart) + } + + return sanitizedInput +} + +export function removeTerminalInputResponses(input: string): string { + let sanitizedInput = input + let responseStart = sanitizedInput.indexOf(CURSOR_POSITION_RESPONSE_PREFIX) + + while (responseStart !== -1) { + const responseEnd = sanitizedInput.indexOf('R', responseStart) + if (responseEnd === -1) break + + const response = sanitizedInput.slice(responseStart, responseEnd + 1) + if (!parseCursorPosition(response)) { + responseStart = sanitizedInput.indexOf(CURSOR_POSITION_RESPONSE_PREFIX, responseStart + 1) + continue + } + + sanitizedInput = sanitizedInput.slice(0, responseStart) + sanitizedInput.slice(responseEnd + 1) + responseStart = sanitizedInput.indexOf(CURSOR_POSITION_RESPONSE_PREFIX, responseStart) + } + + return removeSgrMouseResponses(sanitizedInput) +} + +export function MouseProvider({children, ...mouseProviderProps}: MouseProviderProps): React.ReactElement { + if (getMouseEnabled()) { + return {children} + } + + return ( + + {children} + + ) +} + +function EnabledMouseProvider({ + allowTerminalScrolling = false, + children, + trackMouseMovement = true, +}: MouseProviderProps): React.ReactElement { + const rootRef = useRef(null) + const {stdin} = useStdin() + const {stdout} = useStdout() + const [verticalOffset, setVerticalOffset] = useState(0) + const [rootHeight, setRootHeight] = useState() + const scrollReleaseTimeoutRef = useRef>() + const mouseTrackingMode = getMouseTrackingMode(trackMouseMovement) + + useEffect(() => { + const measureRoot = () => { + const measuredHeight = getElementDimensions(rootRef.current)?.height + setRootHeight((currentHeight) => (currentHeight === measuredHeight ? currentHeight : measuredHeight)) + } + + measureRoot() + const interval = setInterval(measureRoot, MOUSE_LAYOUT_POLL_INTERVAL_MS) + return () => clearInterval(interval) + }, []) + + useEffect(() => { + if (!stdin.isTTY || !stdout.isTTY || rootHeight === undefined) return + + const stopListening = () => { + clearTimeout(timeout) + stdin.off('data', handleCursorPosition) + } + const handleCursorPosition = (data: Buffer | string) => { + const cursorPosition = parseCursorPosition(data) + const rootDimensions = getElementDimensions(rootRef.current) + if (!cursorPosition || !rootDimensions) return + + const trailingLineOffset = cursorPosition.column === 1 ? 1 : 0 + setVerticalOffset(Math.max(0, cursorPosition.row - rootDimensions.height - trailingLineOffset)) + stopListening() + } + + stdin.on('data', handleCursorPosition) + const timeout = setTimeout(stopListening, MOUSE_ORIGIN_QUERY_TIMEOUT_MS) + stdout.write(CURSOR_POSITION_REQUEST) + + return stopListening + }, [rootHeight, stdin, stdout]) + + useEffect(() => { + if (mouseTrackingMode && stdout.isTTY) stdout.write(mouseTrackingMode) + }, [mouseTrackingMode, stdout]) + + const releaseMouseForTerminalScrolling = useCallback(() => { + if (!mouseTrackingMode || !stdout.isTTY) return + + stdout.write(DISABLE_MOUSE_REPORTING) + clearTimeout(scrollReleaseTimeoutRef.current) + scrollReleaseTimeoutRef.current = setTimeout(() => { + stdout.write(mouseTrackingMode) + }, MOUSE_SCROLL_RELEASE_MS) + }, [mouseTrackingMode, stdout]) + + useEffect(() => { + return () => clearTimeout(scrollReleaseTimeoutRef.current) + }, []) + + useEffect(() => { + if (!allowTerminalScrolling || !mouseTrackingMode || !stdin.isTTY) return + + const handleTerminalInput = (data: Buffer | string) => { + if (containsMouseWheelEvent(data)) releaseMouseForTerminalScrolling() + } + + // Listen to stdin directly so scrolling is detected even when the pointer is + // outside the rendered Ink tree, including blank areas of the terminal viewport. + stdin.on('data', handleTerminalInput) + return () => { + stdin.off('data', handleTerminalInput) + } + }, [allowTerminalScrolling, mouseTrackingMode, releaseMouseForTerminalScrolling, stdin]) + + return ( + + + + {children} + + + + ) +} + +export function useOnClick(ref: ElementRef, handler: ClickHandler | null | undefined): void { + const offsetRef = useOffsetRef(ref) + useInkOnClick(offsetRef, handler) +} + +export function useOnMouseEnter(ref: ElementRef, handler: MouseEnterHandler | null | undefined): void { + const offsetRef = useOffsetRef(ref) + useInkOnMouseEnter(offsetRef, handler) +} + +function useOffsetRef(ref: ElementRef): ElementRef { + const verticalOffset = useContext(MouseOriginContext) + return useMemo(() => { + return { + get current() { + const element = ref.current as DOMElement | null + const bounds = getBoundingClientRect(element) + if (!bounds) return null + + return { + yogaNode: { + getComputedLayout: () => ({ + left: bounds.left - 1, + top: bounds.top - 1 + verticalOffset, + // ink-mouse treats right and bottom edges as inclusive. Yoga dimensions + // are counts, so subtract one to prevent adjacent terminal cells from overlapping. + width: Math.max(0, bounds.width - 1), + height: Math.max(0, bounds.height - 1), + }), + }, + parentNode: null, + } + }, + } + }, [ref, verticalOffset]) +} diff --git a/packages/cli-kit/src/private/node/ui/components/Prompts/PromptLayout.tsx b/packages/cli-kit/src/private/node/ui/components/Prompts/PromptLayout.tsx index fe035922d80..38b6f404f91 100644 --- a/packages/cli-kit/src/private/node/ui/components/Prompts/PromptLayout.tsx +++ b/packages/cli-kit/src/private/node/ui/components/Prompts/PromptLayout.tsx @@ -1,5 +1,6 @@ import {InfoTable, InfoTableProps} from './InfoTable.js' import {InfoMessage, InfoMessageProps} from './InfoMessage.js' +import {MouseProvider} from '../Mouse.js' import {TokenizedText} from '../TokenizedText.js' import {messageWithPunctuation} from '../../utilities.js' import {AbortSignal} from '../../../../../public/node/abort.js' @@ -90,7 +91,9 @@ const PromptLayout = ({ // Object.keys on an array returns the indices as strings const showInfoTable = infoTable && Object.keys(infoTable).length > 0 - return isAborted ? null : ( + if (isAborted) return null + + const prompt = ( @@ -133,6 +136,8 @@ const PromptLayout = ({ )} ) + + return state === PromptState.Submitted ? prompt : {prompt} } export {PromptLayout} diff --git a/packages/cli-kit/src/private/node/ui/components/SelectInput.test.tsx b/packages/cli-kit/src/private/node/ui/components/SelectInput.test.tsx index 252fbfe17ca..b83a753d882 100644 --- a/packages/cli-kit/src/private/node/ui/components/SelectInput.test.tsx +++ b/packages/cli-kit/src/private/node/ui/components/SelectInput.test.tsx @@ -1,9 +1,12 @@ import {SelectInput} from './SelectInput.js' +import {MouseProvider} from './Mouse.js' import { sendInputAndWait, sendInputAndWaitForChange, + waitFor, + waitForChange, waitForInputsToBeReady, - render, + render as renderUI, getLastFrameAfterUnmount, } from '../../testing/ui.js' import {platformAndArch} from '../../../../public/node/os.js' @@ -11,9 +14,47 @@ import {describe, expect, test, vi} from 'vitest' import React from 'react' +const render = (element: React.ReactElement) => renderUI({element}, {stdoutIsTTY: true}) + const ARROW_UP = '\u001B[A' const ARROW_DOWN = '\u001B[B' const ENTER = '\r' +const CURSOR_POSITION_REQUEST = '\u001B[6n' + +function mouseClick(column: number, row: number): [string, string] { + return [`\u001B[<0;${column};${row}M`, `\u001B[<0;${column};${row}m`] +} + +function mouseMove(column: number, row: number): string { + return `\u001B[<35;${column};${row}M` +} + +async function clickWhenMouseOriginIsReady( + renderInstance: ReturnType, + onSubmit: ReturnType, + column: number, + row: number, +): Promise { + await vi.waitFor(() => { + if (onSubmit.mock.calls.length === 0) { + mouseClick(column, row).forEach((input) => renderInstance.stdin.write(input)) + } + expect(onSubmit).toHaveBeenCalled() + }) +} + +async function respondToCursorPositionRequest( + renderInstance: ReturnType, + requestNumber: number, +): Promise { + await waitFor( + () => {}, + () => + renderInstance.stdout.controlSequences.filter((sequence) => sequence === CURSOR_POSITION_REQUEST).length >= + requestNumber, + ) + await sendInputAndWait(renderInstance, 10, '\u001B[40;1R') +} describe('SelectInput', async () => { test('move up with up arrow key', async () => { @@ -46,7 +87,7 @@ describe('SelectInput', async () => { > Second Third - Press ↑↓ arrows to select, enter to confirm." + Press ↑↓ arrows to select, enter to confirm, or click an option." `) expect(onChange).toHaveBeenLastCalledWith(items[1]) }) @@ -79,11 +120,101 @@ describe('SelectInput', async () => { > Second Third - Press ↑↓ arrows to select, enter to confirm." + Press ↑↓ arrows to select, enter to confirm, or click an option." `) expect(onChange).toHaveBeenCalledWith(items[1]) }) + test('selects and submits an option when clicked', async () => { + const onChange = vi.fn() + const onSubmit = vi.fn() + const items = [ + {label: 'First', value: 'first'}, + {label: 'Second', value: 'second'}, + ] + const renderInstance = render() + + await waitForInputsToBeReady() + await waitFor( + () => mouseClick(4, 2).forEach((input) => renderInstance.stdin.write(input)), + () => onChange.mock.calls.some(([item]) => item === items[1]), + ) + + expect(onChange).toHaveBeenLastCalledWith(items[1]) + expect(onSubmit).toHaveBeenCalledWith(items[1]) + }) + + test('selects an option when hovered', async () => { + const onChange = vi.fn() + const items = [ + {label: 'First', value: 'first'}, + {label: 'Second', value: 'second'}, + ] + const renderInstance = render() + + await waitForInputsToBeReady() + await sendInputAndWaitForChange(renderInstance, mouseMove(4, 2)) + + expect(renderInstance.lastFrame()).toContain('>') + expect(onChange).toHaveBeenLastCalledWith(items[1]) + }) + + test('selects each row at the correct boundary when hovering upward', async () => { + const onChange = vi.fn() + const items = [ + {label: 'First', value: 'first'}, + {label: 'Second', value: 'second'}, + {label: 'Third', value: 'third'}, + ] + const renderInstance = render() + + await waitForInputsToBeReady() + await sendInputAndWaitForChange(renderInstance, mouseMove(4, 3)) + expect(onChange).toHaveBeenLastCalledWith(items[2]) + + await sendInputAndWaitForChange(renderInstance, mouseMove(4, 2)) + expect(onChange).toHaveBeenLastCalledWith(items[1]) + }) + + test('accounts for output rendered before the interactive list', async () => { + const onSubmit = vi.fn() + const items = [ + {label: 'First', value: 'first'}, + {label: 'Second', value: 'second'}, + ] + const renderInstance = render() + + await waitForInputsToBeReady() + await respondToCursorPositionRequest(renderInstance, 1) + await clickWhenMouseOriginIsReady(renderInstance, onSubmit, 4, 37) + + expect(onSubmit).toHaveBeenCalledWith(items[1]) + }) + + test('recalculates the terminal origin when a list grows after loading', async () => { + const onSubmit = vi.fn() + let showAllItems = () => {} + const initialItems = [ + {label: 'First', value: 'first'}, + {label: 'Second', value: 'second'}, + ] + const loadedItems = [...initialItems, {label: 'Third', value: 'third'}, {label: 'Fourth', value: 'fourth'}] + const DynamicSelectInput = () => { + const [items, setItems] = React.useState(initialItems) + showAllItems = () => setItems(loadedItems) + return + } + const renderInstance = render() + + await waitForInputsToBeReady() + await respondToCursorPositionRequest(renderInstance, 1) + await waitForChange(showAllItems, renderInstance.lastFrame) + await respondToCursorPositionRequest(renderInstance, 2) + await clickWhenMouseOriginIsReady(renderInstance, onSubmit, 4, 35) + + expect(onSubmit).toHaveBeenCalledWith(loadedItems[1]) + }) + test('throws an error if a key has more than 1 character', async () => { const onChange = vi.fn() @@ -160,7 +291,7 @@ describe('SelectInput', async () => { Second Tenth - Press ↑↓ arrows to select, enter to confirm." + Press ↑↓ arrows to select, enter to confirm, or click an option." `) expect(onChange).not.toHaveBeenCalled() }) @@ -202,7 +333,7 @@ describe('SelectInput', async () => { ninth tenth - Press ↑↓ arrows to select, enter to confirm." + Press ↑↓ arrows to select, enter to confirm, or click an option." `) await waitForInputsToBeReady() @@ -226,7 +357,7 @@ describe('SelectInput', async () => { ninth tenth - Press ↑↓ arrows to select, enter to confirm." + Press ↑↓ arrows to select, enter to confirm, or click an option." `) expect(onChange).toHaveBeenLastCalledWith(items[2]) }) @@ -263,7 +394,7 @@ describe('SelectInput', async () => { - Press ↑↓ arrows to select, enter to confirm." + Press ↑↓ arrows to select, enter to confirm, or click an option." `) expect(onChange).not.toHaveBeenCalled() }) @@ -299,7 +430,7 @@ describe('SelectInput', async () => { item3 item5 - Press ↑↓ arrows to select, enter to confirm." + Press ↑↓ arrows to select, enter to confirm, or click an option." `) expect(onChange).not.toHaveBeenCalled() }) @@ -332,7 +463,7 @@ describe('SelectInput', async () => { Second Third - Press ↑↓ arrows to select, enter to confirm." + Press ↑↓ arrows to select, enter to confirm, or click an option." `) expect(onChange).not.toHaveBeenCalled() }) @@ -362,7 +493,7 @@ describe('SelectInput', async () => { > Second Third - Press ↑↓ arrows to select, enter to confirm." + Press ↑↓ arrows to select, enter to confirm, or click an option." `) }) @@ -398,7 +529,7 @@ describe('SelectInput', async () => { Second Third - Press ↑↓ arrows to select, enter to confirm. + Press ↑↓ arrows to select, enter to confirm, or click an option. 1-3 of many Keep scrolling to see more items" `) }) @@ -431,7 +562,7 @@ describe('SelectInput', async () => { Other   first   - Press ↑↓ arrows to select, enter to confirm." + Press ↑↓ arrows to select, enter to confirm, or click an option." `) await waitForInputsToBeReady() @@ -453,7 +584,7 @@ describe('SelectInput', async () => { first   > second   - Press ↑↓ arrows to select, enter to confirm." + Press ↑↓ arrows to select, enter to confirm, or click an option." `) }) @@ -564,7 +695,7 @@ describe('SelectInput', async () => { Second > Third - Press ↑↓ arrows to select, enter to confirm." + Press ↑↓ arrows to select, enter to confirm, or click an option." `) await sendInputAndWait(renderInstance, 10, ENTER) @@ -599,7 +730,7 @@ describe('SelectInput', async () => { Second Third - Press ↑↓ arrows to select, enter to confirm." + Press ↑↓ arrows to select, enter to confirm, or click an option." `) await waitForInputsToBeReady() @@ -639,7 +770,7 @@ describe('SelectInput', async () => { (s) Second > (t) Third - Press ↑↓ arrows to select, enter or a shortcut to confirm." + Use ↑↓ to select; press enter, use a shortcut, or click an option." `) await sendInputAndWait(renderInstance, 10, ENTER) diff --git a/packages/cli-kit/src/private/node/ui/components/SelectInput.tsx b/packages/cli-kit/src/private/node/ui/components/SelectInput.tsx index e23e8d1dae5..4cc7b92568c 100644 --- a/packages/cli-kit/src/private/node/ui/components/SelectInput.tsx +++ b/packages/cli-kit/src/private/node/ui/components/SelectInput.tsx @@ -1,9 +1,12 @@ import {Scrollbar} from './Scrollbar.js' +import {useOnClick, useOnMouseEnter} from './Mouse.js' +import {getMouseEnabled} from '../../conf-store.js' import {handleCtrlC} from '../../ui.js' import useLayout from '../hooks/use-layout.js' import {useSelectState} from '../hooks/use-select-state.js' import React, {useCallback, useEffect} from 'react' import {Box, Key, useInput, Text, DOMElement} from 'ink' +import {type InkMouseEvent} from '@ink-tools/ink-mouse' import chalk from 'chalk' import figures from 'figures' import sortBy from 'lodash/sortBy.js' @@ -74,6 +77,8 @@ interface ItemProps { enableShortcuts: boolean hasAnyGroup: boolean index: number + onClick?: () => void + onHover?: () => void } function Item({ @@ -85,7 +90,19 @@ function Item({ items, hasAnyGroup, index, + onClick, + onHover, }: ItemProps): React.ReactElement { + const itemRef = React.useRef(null) + const handleClick = useCallback( + (event: InkMouseEvent) => { + if (event.button === 'left') onClick?.() + }, + [onClick], + ) + useOnClick(itemRef, onClick ? handleClick : undefined) + useOnMouseEnter(itemRef, onHover) + const label = highlightedLabel(item.label, highlightedTerm) let title: string | undefined let labelColor @@ -115,7 +132,7 @@ function Item({ ) : null} - + {isSelected ? {`>`} : } {showKey ? `(${item.key}) ${label}` : label} @@ -127,6 +144,18 @@ function Item({ const MAX_AVAILABLE_LINES = 25 +function selectInputHelpText(itemsHaveKeys: boolean, mouseEnabled: boolean): string { + if (mouseEnabled && itemsHaveKeys) { + return `Use ${figures.arrowUp}${figures.arrowDown} to select; press enter, use a shortcut, or click an option.` + } + if (mouseEnabled) { + return `Press ${figures.arrowUp}${figures.arrowDown} arrows to select, enter to confirm, or click an option.` + } + return `Press ${figures.arrowUp}${figures.arrowDown} arrows to select, enter ${ + itemsHaveKeys ? 'or a shortcut ' : '' + }to confirm.` +} + function SelectInput({ items: rawItems, initialItems = rawItems, @@ -146,6 +175,7 @@ function SelectInput({ ref, groupOrder, }: SelectInputProps): React.ReactElement | null { + const mouseEnabled = getMouseEnabled() let noItems = false if (rawItems.length === 0) { @@ -222,6 +252,23 @@ function SelectInput({ [items, onSubmit, state], ) + const handleClick = useCallback( + (item: Item) => { + if (item.disabled) return + + if (onSubmit) onSubmit(item) + state.selectOption({option: item}) + }, + [onSubmit, state], + ) + + const handleHover = useCallback( + (item: Item) => { + if (!item.disabled) state.selectOption({option: item}) + }, + [state], + ) + useInput( (input, key) => { handleCtrlC(input, key) @@ -277,6 +324,8 @@ function SelectInput({ enableShortcuts={enableShortcuts} hasAnyGroup={hasAnyGroup} index={index} + onClick={focus ? () => handleClick(item) : undefined} + onHover={focus ? () => handleHover(item) : undefined} /> ))} @@ -298,11 +347,7 @@ function SelectInput({ ) : ( - - {`Press ${figures.arrowUp}${figures.arrowDown} arrows to select, enter ${ - itemsHaveKeys ? 'or a shortcut ' : '' - }to confirm.`} - + {selectInputHelpText(itemsHaveKeys, mouseEnabled)} {hasMorePages ? ( 1-{items.length} of many diff --git a/packages/cli-kit/src/private/node/ui/components/SelectPrompt.test.tsx b/packages/cli-kit/src/private/node/ui/components/SelectPrompt.test.tsx index 0f3d4164a9e..7819042b7c1 100644 --- a/packages/cli-kit/src/private/node/ui/components/SelectPrompt.test.tsx +++ b/packages/cli-kit/src/private/node/ui/components/SelectPrompt.test.tsx @@ -105,7 +105,7 @@ describe('SelectPrompt', async () => { ninth tenth - Press ↑↓ arrows to select, enter to confirm. + Press ↑↓ arrows to select, enter to confirm, or click an option. " `) }) @@ -143,10 +143,10 @@ describe('SelectPrompt', async () => { expect(renderInstance.lastFrame()).toMatchInlineSnapshot(` "? Associate your project with the org Castile Ventures? - ┃ \u001b[1mAdd\u001b[22m + ┃ Add ┃ + new-ext ┃ - ┃ \u001b[1mRemove\u001b[22m + ┃ Remove ┃ - integrated-demand-ext ┃ - order-discount (1) @@ -155,7 +155,7 @@ describe('SelectPrompt', async () => { third fourth - Press ↑↓ arrows to select, enter to confirm. + Press ↑↓ arrows to select, enter to confirm, or click an option. " `) }) @@ -197,7 +197,7 @@ describe('SelectPrompt', async () => { third fourth - Press ↑↓ arrows to select, enter to confirm. + Press ↑↓ arrows to select, enter to confirm, or click an option. " `) }) @@ -231,7 +231,7 @@ describe('SelectPrompt', async () => { > a - Press ↑↓ arrows to select, enter to confirm. + Press ↑↓ arrows to select, enter to confirm, or click an option. " `) }) @@ -253,7 +253,7 @@ describe('SelectPrompt', async () => { a > b - Press ↑↓ arrows to select, enter to confirm. + Press ↑↓ arrows to select, enter to confirm, or click an option. " `) @@ -283,7 +283,7 @@ describe('SelectPrompt', async () => { > a b - Press ↑↓ arrows to select, enter to confirm. + Press ↑↓ arrows to select, enter to confirm, or click an option. " `) @@ -313,7 +313,7 @@ describe('SelectPrompt', async () => { > (a) a (b) b - Press ↑↓ arrows to select, enter or a shortcut to confirm. + Use ↑↓ to select; press enter, use a shortcut, or click an option. " `) @@ -364,7 +364,7 @@ describe('SelectPrompt', async () => {   Merchant Admin   - Press ↑↓ arrows to select, enter to confirm. + Press ↑↓ arrows to select, enter to confirm, or click an option. " `) }) @@ -390,7 +390,7 @@ describe('SelectPrompt', async () => { yes > no - Press ↑↓ arrows to select, enter to confirm. + Press ↑↓ arrows to select, enter to confirm, or click an option. " `) }) @@ -418,7 +418,7 @@ describe('SelectPrompt', async () => { yes > no - Press ↑↓ arrows to select, enter to confirm. + Press ↑↓ arrows to select, enter to confirm, or click an option. " `) @@ -430,7 +430,7 @@ describe('SelectPrompt', async () => { > yes no - Press ↑↓ arrows to select, enter to confirm. + Press ↑↓ arrows to select, enter to confirm, or click an option. " `) }) @@ -460,7 +460,7 @@ describe('SelectPrompt', async () => { > a b - Press ↑↓ arrows to select, enter to confirm. + Press ↑↓ arrows to select, enter to confirm, or click an option. " `) diff --git a/packages/cli-kit/src/private/node/ui/components/TextInput.test.tsx b/packages/cli-kit/src/private/node/ui/components/TextInput.test.tsx index 0beaf7668bb..2b6c4d2e6bf 100644 --- a/packages/cli-kit/src/private/node/ui/components/TextInput.test.tsx +++ b/packages/cli-kit/src/private/node/ui/components/TextInput.test.tsx @@ -126,6 +126,28 @@ describe('TextInput', () => { expect(renderInstance.lastFrame()).toMatchInlineSnapshot('"Hello█"') }) + test('ignores terminal cursor position responses', async () => { + const onChange = vi.fn() + const renderInstance = render() + + await waitForInputsToBeReady() + await sendInputAndWait(renderInstance, 10, '\u001B[40;1R') + + expect(onChange).not.toHaveBeenCalled() + expect(renderInstance.lastFrame()).toMatchInlineSnapshot('"█"') + }) + + test('ignores terminal mouse responses', async () => { + const onChange = vi.fn() + const renderInstance = render() + + await waitForInputsToBeReady() + await sendInputAndWait(renderInstance, 10, '\u001B[<0;4;32M', '\u001B[<0;4;32m') + + expect(onChange).not.toHaveBeenCalled() + expect(renderInstance.lastFrame()).toMatchInlineSnapshot('"█"') + }) + test('onChange', async () => { const onChange = vi.fn() diff --git a/packages/cli-kit/src/private/node/ui/components/TextInput.tsx b/packages/cli-kit/src/private/node/ui/components/TextInput.tsx index fa74d41c75b..ef0fc4638b7 100644 --- a/packages/cli-kit/src/private/node/ui/components/TextInput.tsx +++ b/packages/cli-kit/src/private/node/ui/components/TextInput.tsx @@ -1,4 +1,5 @@ /* eslint-disable no-nested-ternary */ +import {removeTerminalInputResponses} from './Mouse.js' import {shouldDisplayColors} from '../../../../public/node/output.js' import React, {useLayoutEffect, useState} from 'react' import {Text, useInput} from 'ink' @@ -74,7 +75,16 @@ const TextInput: FunctionComponent = ({ useInput( (input, key) => { - if (key.upArrow || key.downArrow || (key.ctrl && input === 'c') || (key.shift && key.tab) || key.return) { + const sanitizedInput = removeTerminalInputResponses(input) + if (input.length > 0 && sanitizedInput.length === 0) return + + if ( + key.upArrow || + key.downArrow || + (key.ctrl && sanitizedInput === 'c') || + (key.shift && key.tab) || + key.return + ) { return } else if (key.tab) { if (originalValue.length === 0 && placeholderText) { @@ -105,9 +115,9 @@ const TextInput: FunctionComponent = ({ } else { nextValue = originalValue.slice(0, clampedCursorOffset) + - input + + sanitizedInput + originalValue.slice(clampedCursorOffset, originalValue.length) - nextCursorOffset += input.length + nextCursorOffset += sanitizedInput.length } setCursorOffset(nextCursorOffset) diff --git a/packages/cli-kit/src/public/node/ink.ts b/packages/cli-kit/src/public/node/ink.ts index 452e670e246..b30acb6c9e7 100644 --- a/packages/cli-kit/src/public/node/ink.ts +++ b/packages/cli-kit/src/public/node/ink.ts @@ -1 +1,3 @@ export {Box, Text, Static, useInput, useStdin, useStdout, measureElement} from 'ink' +export type {DOMElement} from 'ink' +export {MouseProvider, useOnClick, useOnMouseEnter} from '../../private/node/ui/components/Mouse.js' diff --git a/packages/cli-kit/src/public/node/mouse.ts b/packages/cli-kit/src/public/node/mouse.ts new file mode 100644 index 00000000000..230bc332645 --- /dev/null +++ b/packages/cli-kit/src/public/node/mouse.ts @@ -0,0 +1,3 @@ +import {getMouseEnabled, setMouseEnabled} from '../../private/node/conf-store.js' + +export {getMouseEnabled, setMouseEnabled} diff --git a/packages/cli-kit/src/public/node/ui.tsx b/packages/cli-kit/src/public/node/ui.tsx index 12fb8b6d3f2..86347f6defe 100644 --- a/packages/cli-kit/src/public/node/ui.tsx +++ b/packages/cli-kit/src/public/node/ui.tsx @@ -269,7 +269,8 @@ export interface RenderSelectPromptOptions extends Omit, * seventh * tenth * - * Press ↑↓ arrows to select, enter to confirm. + * Press ↑↓ arrows to select, enter to confirm, or click an + * option. * */ @@ -322,8 +323,8 @@ export interface RenderConfirmationPromptOptions extends Pick< * > (y) Yes, confirm changes * (n) Cancel * - * Press ↑↓ arrows to select, enter or a shortcut to - * confirm. + * Use ↑↓ to select; press enter, use a shortcut, or click + * an option. * */ export async function renderConfirmationPrompt({ @@ -403,7 +404,8 @@ export interface RenderAutocompleteOptions extends PartialBy< * twenty-fourth * twenty-fifth * - * Press ↑↓ arrows to select, enter to confirm. + * Press ↑↓ arrows to select, enter to confirm, or click an + * option. * */ diff --git a/packages/cli/README.md b/packages/cli/README.md index 445154f2fa8..7d45cd20d35 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -40,6 +40,8 @@ * [`shopify config autoupgrade off`](#shopify-config-autoupgrade-off) * [`shopify config autoupgrade on`](#shopify-config-autoupgrade-on) * [`shopify config autoupgrade status`](#shopify-config-autoupgrade-status) +* [`shopify config mouse off`](#shopify-config-mouse-off) +* [`shopify config mouse on`](#shopify-config-mouse-on) * [`shopify doc fetch`](#shopify-doc-fetch) * [`shopify doc search`](#shopify-doc-search) * [`shopify help [command] [flags]`](#shopify-help-command-flags) @@ -2130,6 +2132,43 @@ DESCRIPTION Run `shopify config autoupgrade on` or `shopify config autoupgrade off` to configure it. ``` +## `shopify config mouse off` + +Disable mouse interactions in Shopify CLI. + +``` +USAGE + $ shopify config mouse off + +DESCRIPTION + Disable mouse interactions in Shopify CLI. + + Disable mouse interactions in Shopify CLI. + + When mouse interactions are disabled, standard terminal text selection and scrolling are restored. + + To enable clickable prompt options and app dev tabs, run `shopify config mouse on`. +``` + +## `shopify config mouse on` + +Enable mouse interactions in Shopify CLI. + +``` +USAGE + $ shopify config mouse on + +DESCRIPTION + Enable mouse interactions in Shopify CLI. + + Enable mouse interactions in Shopify CLI. + + Mouse interactions are enabled by default and allow you to click prompt options and app dev tabs. To select text while + they are enabled, hold Option in iTerm2 or Shift in most other terminals while dragging. + + To restore standard terminal text selection and scrolling, run `shopify config mouse off`. +``` + ## `shopify doc fetch` Download a complete document from shopify.dev. Every page on shopify.dev has a Markdown version, and that is what this tool returns. Use this to pull an entire document verbatim — for example, a set of instructions an agent follows like a centrally-served skill. For finding the relevant pieces of content across shopify.dev instead, use `doc search`. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 45ea376b354..6e3ee0a09fe 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -3748,6 +3748,46 @@ "strict": true, "summary": "Check whether auto-upgrade is enabled, disabled, or not yet configured." }, + "config:mouse:off": { + "aliases": [ + ], + "args": { + }, + "description": "Disable mouse interactions in Shopify CLI.\n\n When mouse interactions are disabled, standard terminal text selection and scrolling are restored.\n\n To enable clickable prompt options and app dev tabs, run `shopify config mouse on`.\n", + "descriptionWithMarkdown": "Disable mouse interactions in Shopify CLI.\n\n When mouse interactions are disabled, standard terminal text selection and scrolling are restored.\n\n To enable clickable prompt options and app dev tabs, run `shopify config mouse on`.\n", + "enableJsonFlag": false, + "flags": { + }, + "hasDynamicHelp": false, + "hiddenAliases": [ + ], + "id": "config:mouse:off", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Disable mouse interactions in Shopify CLI." + }, + "config:mouse:on": { + "aliases": [ + ], + "args": { + }, + "description": "Enable mouse interactions in Shopify CLI.\n\n Mouse interactions are enabled by default and allow you to click prompt options and app dev tabs. To select text while they are enabled, hold Option in iTerm2 or Shift in most other terminals while dragging.\n\n To restore standard terminal text selection and scrolling, run `shopify config mouse off`.\n", + "descriptionWithMarkdown": "Enable mouse interactions in Shopify CLI.\n\n Mouse interactions are enabled by default and allow you to click prompt options and app dev tabs. To select text while they are enabled, hold Option in iTerm2 or Shift in most other terminals while dragging.\n\n To restore standard terminal text selection and scrolling, run `shopify config mouse off`.\n", + "enableJsonFlag": false, + "flags": { + }, + "hasDynamicHelp": false, + "hiddenAliases": [ + ], + "id": "config:mouse:on", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Enable mouse interactions in Shopify CLI." + }, "debug:command-flags": { "aliases": [ ], diff --git a/packages/cli/package.json b/packages/cli/package.json index a2deeee34e0..4ea53fdc1da 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -137,6 +137,9 @@ "config:autocorrect": { "description": "Command autocorrection options. If enabled, Shopify CLI will automatically run a corrected version of your command if a correction is available. Off by default." }, + "config:mouse": { + "description": "Mouse interaction options. Enabled by default; hold Option in iTerm2 or Shift in most other terminals while dragging to select text." + }, "kitchen-sink": { "description": "View the available UI kit components.", "hidden": true diff --git a/packages/cli/src/cli/commands/config/mouse/constants.ts b/packages/cli/src/cli/commands/config/mouse/constants.ts new file mode 100644 index 00000000000..dc2af7b1846 --- /dev/null +++ b/packages/cli/src/cli/commands/config/mouse/constants.ts @@ -0,0 +1,4 @@ +export const mouseStatus = { + on: 'Mouse interactions on. To select text, hold Option in iTerm2 or Shift in most other terminals while dragging.', + off: 'Mouse interactions off.', +} as const diff --git a/packages/cli/src/cli/commands/config/mouse/off.test.ts b/packages/cli/src/cli/commands/config/mouse/off.test.ts new file mode 100644 index 00000000000..8c097fe8d2d --- /dev/null +++ b/packages/cli/src/cli/commands/config/mouse/off.test.ts @@ -0,0 +1,19 @@ +import MouseOff from './off.js' +import {setMouseEnabled} from '@shopify/cli-kit/node/mouse' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import {Config} from '@oclif/core' +import {describe, expect, test, vi} from 'vitest' + +vi.mock('@shopify/cli-kit/node/mouse') + +describe('MouseOff', () => { + test('disables mouse interactions', async () => { + const config = new Config({root: __dirname}) + const outputMock = mockAndCaptureOutput() + + await new MouseOff([], config).run() + + expect(setMouseEnabled).toHaveBeenCalledWith(false) + expect(outputMock.info()).toContain('Mouse interactions off.') + }) +}) diff --git a/packages/cli/src/cli/commands/config/mouse/off.ts b/packages/cli/src/cli/commands/config/mouse/off.ts new file mode 100644 index 00000000000..d9fb35b6c1d --- /dev/null +++ b/packages/cli/src/cli/commands/config/mouse/off.ts @@ -0,0 +1,22 @@ +import {mouseStatus} from './constants.js' +import {setMouseEnabled} from '@shopify/cli-kit/node/mouse' +import Command from '@shopify/cli-kit/node/base-command' +import {renderInfo} from '@shopify/cli-kit/node/ui' + +export default class MouseOff extends Command { + static summary = 'Disable mouse interactions in Shopify CLI.' + + static descriptionWithMarkdown = `Disable mouse interactions in Shopify CLI. + + When mouse interactions are disabled, standard terminal text selection and scrolling are restored. + + To enable clickable prompt options and app dev tabs, run \`shopify config mouse on\`. +` + + static description = this.descriptionWithoutMarkdown() + + async run(): Promise { + setMouseEnabled(false) + renderInfo({body: mouseStatus.off}) + } +} diff --git a/packages/cli/src/cli/commands/config/mouse/on.test.ts b/packages/cli/src/cli/commands/config/mouse/on.test.ts new file mode 100644 index 00000000000..10e521d0434 --- /dev/null +++ b/packages/cli/src/cli/commands/config/mouse/on.test.ts @@ -0,0 +1,21 @@ +import MouseOn from './on.js' +import {setMouseEnabled} from '@shopify/cli-kit/node/mouse' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import {Config} from '@oclif/core' +import {describe, expect, test, vi} from 'vitest' + +vi.mock('@shopify/cli-kit/node/mouse') + +describe('MouseOn', () => { + test('enables mouse interactions', async () => { + const config = new Config({root: __dirname}) + const outputMock = mockAndCaptureOutput() + + await new MouseOn([], config).run() + + expect(setMouseEnabled).toHaveBeenCalledWith(true) + expect(outputMock.info()).toContain('Mouse interactions on.') + expect(outputMock.info()).toContain('To select text, hold Option in iTerm2 or Shift in') + expect(outputMock.info()).toContain('most other terminals while dragging.') + }) +}) diff --git a/packages/cli/src/cli/commands/config/mouse/on.ts b/packages/cli/src/cli/commands/config/mouse/on.ts new file mode 100644 index 00000000000..ca80d5d738e --- /dev/null +++ b/packages/cli/src/cli/commands/config/mouse/on.ts @@ -0,0 +1,22 @@ +import {mouseStatus} from './constants.js' +import {setMouseEnabled} from '@shopify/cli-kit/node/mouse' +import Command from '@shopify/cli-kit/node/base-command' +import {renderInfo} from '@shopify/cli-kit/node/ui' + +export default class MouseOn extends Command { + static summary = 'Enable mouse interactions in Shopify CLI.' + + static descriptionWithMarkdown = `Enable mouse interactions in Shopify CLI. + + Mouse interactions are enabled by default and allow you to click prompt options and app dev tabs. To select text while they are enabled, hold Option in iTerm2 or Shift in most other terminals while dragging. + + To restore standard terminal text selection and scrolling, run \`shopify config mouse off\`. +` + + static description = this.descriptionWithoutMarkdown() + + async run(): Promise { + setMouseEnabled(true) + renderInfo({body: mouseStatus.on}) + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 027585eac79..f767a4117ca 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -21,6 +21,8 @@ import ClearCache from './cli/commands/cache/clear.js' import AutoupgradeOff from './cli/commands/config/autoupgrade/off.js' import AutoupgradeOn from './cli/commands/config/autoupgrade/on.js' import AutoupgradeStatus from './cli/commands/config/autoupgrade/status.js' +import MouseOff from './cli/commands/config/mouse/off.js' +import MouseOn from './cli/commands/config/mouse/on.js' import {createGlobalProxyAgent} from 'global-agent' import StoreCommands from '@shopify/store' import ThemeCommands from '@shopify/theme' @@ -170,6 +172,8 @@ export const COMMANDS: any = { 'config:autoupgrade:off': AutoupgradeOff, 'config:autoupgrade:on': AutoupgradeOn, 'config:autoupgrade:status': AutoupgradeStatus, + 'config:mouse:off': MouseOff, + 'config:mouse:on': MouseOn, } export default runShopifyCLI diff --git a/packages/e2e/data/snapshots/commands.txt b/packages/e2e/data/snapshots/commands.txt index 488d7481cb0..47102ee2fe3 100644 --- a/packages/e2e/data/snapshots/commands.txt +++ b/packages/e2e/data/snapshots/commands.txt @@ -46,10 +46,13 @@ │ │ ├─ off │ │ ├─ on │ │ └─ status -│ └─ autoupgrade +│ ├─ autoupgrade +│ │ ├─ off +│ │ ├─ on +│ │ └─ status +│ └─ mouse │ ├─ off -│ ├─ on -│ └─ status +│ └─ on ├─ doc │ ├─ fetch │ └─ search diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db56f130db8..6e0ed5c61dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -316,6 +316,9 @@ importers: '@iarna/toml': specifier: 2.2.5 version: 2.2.5 + '@ink-tools/ink-mouse': + specifier: 2.1.0 + version: 2.1.0(ink@6.8.0(@types/react@18.3.12)(react@19.2.4))(react@19.2.4) '@oclif/core': specifier: 4.8.3 version: 4.8.3 @@ -2662,6 +2665,13 @@ packages: '@iarna/toml@2.2.5': resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==, tarball: https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz} + '@ink-tools/ink-mouse@2.1.0': + resolution: {integrity: sha512-6EfKsu6R3VYRgRRWsQ7PI5kJy+OsS/VAVhbG+YvUeZePKuHSS2AvstFD5yU/748iQS6wBBENtMoCcBvCjbylog==, tarball: https://registry.npmjs.org/@ink-tools/ink-mouse/-/ink-mouse-2.1.0.tgz} + engines: {node: '>=20'} + peerDependencies: + ink: '>=6' + react: '>=17' + '@inquirer/ansi@1.0.2': resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==, tarball: https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz} engines: {node: '>=18'} @@ -8814,6 +8824,10 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==, tarball: https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz} engines: {node: '>=0.4'} + xterm-mouse@1.0.0: + resolution: {integrity: sha512-A+PkxOzlwgnhfeZclQvCNfLIcLbtTyDSgWs//adOYYUKdjhU9Lejot6wbnW5GEyNCWFFkfLHO8wdQ8eg35BMUg==, tarball: https://registry.npmjs.org/xterm-mouse/-/xterm-mouse-1.0.0.tgz} + engines: {node: '>=20'} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==, tarball: https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz} engines: {node: '>=10'} @@ -11254,6 +11268,12 @@ snapshots: '@iarna/toml@2.2.5': {} + '@ink-tools/ink-mouse@2.1.0(ink@6.8.0(@types/react@18.3.12)(react@19.2.4))(react@19.2.4)': + dependencies: + ink: 6.8.0(@types/react@18.3.12)(react@19.2.4) + react: 19.2.4 + xterm-mouse: 1.0.0 + '@inquirer/ansi@1.0.2': {} '@inquirer/ansi@2.0.7': {} @@ -18269,6 +18289,8 @@ snapshots: xtend@4.0.2: {} + xterm-mouse@1.0.0: {} + y18n@5.0.8: {} yallist@3.1.1: {}