diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
 # Changelog
 
+## 0.3.1.0 — 2026-06-06
+
+* Add mouse click simulation. DSL: `click`/`clickWith` (0-based coordinate) and `clickSelector`/`clickSelectorWith` (Playwright-style, resolves a selector to its match origin and honors `ambiguityMode`). New `MouseButton`, `MouseEncoding`, and `ClickOptions`/`defaultClickOptions` types. JSON-RPC: a `click` method accepting either `{col,row}` (1-based) or `{selector}`, with optional `button` and `encoding`. Clicks emit a press + release; SGR encoding is the default (what `vty`/Brick enable), with an `x10` override.
+
 ## 0.3.0.0 — 2026-04-30
 
 * **Breaking:** the `App` type is now a sum (`App` | `HaskellApp`) so it no longer derives `Eq`. The `Show` instance is kept (now hand-written). `command`, `args`, `appName`, and `appAction` accessors are partial — pattern match on the constructor when both shapes are possible.
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -86,10 +86,18 @@
 app       :: FilePath -> [String] -> App
 press     :: Tui -> Key -> IO ()
 pressCombo :: Tui -> [Modifier] -> Key -> IO ()
+click     :: Tui -> Int -> Int -> IO ()      -- left-click at (col, row), 0-based
+clickSelector :: Tui -> Selector -> IO ()    -- click the element a selector matches
 typeText  :: Tui -> Text -> IO ()
 sendLine  :: Tui -> Text -> IO ()
 ```
 
+Mouse clicks are written to the PTY just like keys, so the target app only
+reacts if it has enabled mouse tracking (anything built on `vty`/Brick does
+once mouse mode is on). The default SGR encoding suits any modern TUI; use
+`clickWith`/`clickSelectorWith` with `ClickOptions` to pick the button
+(`MouseLeft`/`MouseMiddle`/`MouseRight`) or the legacy X10 encoding.
+
 ### Waits and assertions
 
 ```haskell
@@ -191,7 +199,11 @@
 {"jsonrpc":"2.0","id":7,"method":"sendKey","params":{"key":"+"}}
 {"jsonrpc":"2.0","id":8,"method":"sendKey","params":{"key":"Ctrl+C"}}
 {"jsonrpc":"2.0","id":9,"method":"sendText","params":{"text":"hello"}}
+{"jsonrpc":"2.0","id":10,"method":"click","params":{"col":12,"row":7}}
+{"jsonrpc":"2.0","id":11,"method":"click","params":{"selector":{"type":"exact","text":"OK"}}}
 ```
+
+(Server `click` coordinates are 1-based, like the `at` selector.)
 
 Replay a recording JSONL file:
 
diff --git a/SERVER.md b/SERVER.md
--- a/SERVER.md
+++ b/SERVER.md
@@ -82,6 +82,16 @@
 - `sendKey`
   - params: `key`
 
+- `click`
+  - params (one of two targets):
+    - coordinate form: `col`, `row` (both 1-based)
+    - selector form: `selector`
+    - optional `button` (`left` | `middle` | `right`, default `left`)
+    - optional `encoding` (`sgr` | `x10`, default `sgr`)
+  - result: `ok`
+  - emits a button press and release; the target app must have mouse
+    tracking enabled (vty/Brick apps do once mouse mode is turned on)
+
 - `sendText`
   - params: `text`
 
diff --git a/SKILL.md b/SKILL.md
--- a/SKILL.md
+++ b/SKILL.md
@@ -214,6 +214,23 @@
 - `Ctrl+X`, `Alt+X`, `Shift+X`
 - single character like `"a"`
 
+### Simulating mouse clicks with `click`
+
+The `click` method synthesizes a mouse click (button press + release). Target
+either a 1-based coordinate or a selector:
+
+```json
+{"jsonrpc":"2.0","id":10,"method":"click","params":{"col":12,"row":7}}
+{"jsonrpc":"2.0","id":11,"method":"click","params":{"selector":{"type":"exact","text":"OK"}}}
+{"jsonrpc":"2.0","id":12,"method":"click","params":{"selector":{"type":"exact","text":"Menu"},"button":"right"}}
+```
+
+The target application only reacts if it has enabled mouse tracking
+(`vty`/Brick apps do once mouse mode is turned on). The default `sgr` encoding
+suits any modern TUI; pass `"encoding":"x10"` only for apps that enable just
+legacy `\ESC[?1000h` tracking. As with key input, follow a click with
+`waitForStable` before dumping the view.
+
 ### Dump locations
 
 For `initialize` name `demo`:
diff --git a/SPEC.md b/SPEC.md
--- a/SPEC.md
+++ b/SPEC.md
@@ -98,6 +98,10 @@
 - `app :: FilePath -> [String] -> App`
 - `press :: Tui -> Key -> IO ()`
 - `pressCombo :: Tui -> [Modifier] -> Key -> IO ()`
+- `click :: Tui -> Int -> Int -> IO ()`
+- `clickWith :: Tui -> ClickOptions -> Int -> Int -> IO ()`
+- `clickSelector :: Tui -> Selector -> IO ()`
+- `clickSelectorWith :: Tui -> ClickOptions -> Selector -> IO ()`
 - `typeText :: Tui -> Text -> IO ()`
 - `sendLine :: Tui -> Text -> IO ()`
 
@@ -162,6 +166,27 @@
 - `[Alt] + CharKey c`
 - `[Shift] + CharKey c`
 
+### 4.6a Mouse input model
+
+`click` and `clickSelector` synthesize a mouse click (a button press followed
+by a release) written to the PTY, mirroring how key input is sent.
+
+- `ClickOptions`: `clickButton` (`MouseLeft` | `MouseMiddle` | `MouseRight`)
+  and `clickEncoding` (`MouseSGR` | `MouseX10`).
+- `defaultClickOptions`: left button, SGR encoding.
+- DSL coordinates are 0-based, matching the `At` selector (top-left is `0 0`).
+- `clickSelector` resolves the selector to its first match's origin and clicks
+  there; it honors `ambiguityMode` like other selector assertions.
+
+Encoding notes:
+- `MouseSGR` (default) emits `\ESC[<btn;col;rowM` / `...m`; 1-based on the wire,
+  no coordinate limit. This is what `vty`/Brick apps enable.
+- `MouseX10` emits the legacy `\ESC[M` + offset-byte form for apps that enable
+  only `\ESC[?1000h` tracking; it cannot address columns or rows beyond 223.
+
+The target application only reacts to a click if it has enabled mouse
+tracking; a click on an app without mouse tracking is silently ignored.
+
 ### 4.7 Snapshot DSL
 
 - `expectSnapshot :: Tui -> SnapshotName -> IO ()`
@@ -294,6 +319,7 @@
 - `initialize`
 - `launch`
 - `sendKey`
+- `click`
 - `sendText`
 - `sendLine`
 - `currentView`
@@ -348,6 +374,23 @@
 Accepted string forms:
 - base: `Enter`, `Esc`, `Tab`, `Backspace`, arrows, `F1..F12`, single char
 - combos: `Ctrl+X`, `Alt+X`, `Shift+X`
+
+### 9.2a `click` format
+
+`click` accepts one of two targets plus optional button/encoding:
+- coordinate form: `col` and `row` (both 1-based)
+- selector form: `selector` (see §9.3)
+- optional `button`: `left` (default) | `middle` | `right`
+- optional `encoding`: `sgr` (default) | `x10`
+
+```json
+{"method":"click","params":{"col":12,"row":7}}
+{"method":"click","params":{"selector":{"type":"exact","text":"OK"},"button":"right"}}
+```
+
+Server coordinates are 1-based (converted to the 0-based DSL internally, like
+the `at` selector). The click emits a press and a release; the target app must
+have mouse tracking enabled to react.
 
 ### 9.3 Selector JSON format
 
diff --git a/src/TuiSpec.hs b/src/TuiSpec.hs
--- a/src/TuiSpec.hs
+++ b/src/TuiSpec.hs
@@ -32,6 +32,10 @@
     launchAndWaitWith,
     press,
     pressCombo,
+    click,
+    clickWith,
+    clickSelector,
+    clickSelectorWith,
     typeText,
     sendLine,
     loadEnvFile,
@@ -83,6 +87,10 @@
     haskellApp,
     Key (..),
     Modifier (..),
+    MouseButton (..),
+    MouseEncoding (..),
+    ClickOptions (..),
+    defaultClickOptions,
     Selector (..),
     Rect (..),
     SnapshotName (..),
diff --git a/src/TuiSpec/Replay.hs b/src/TuiSpec/Replay.hs
--- a/src/TuiSpec/Replay.hs
+++ b/src/TuiSpec/Replay.hs
@@ -245,8 +245,8 @@
                     when (delta > 0) $ threadDelay (fromIntegral delta)
 
 {- | Extract a human-readable input label from a JSON-RPC request line.
-Returns @Just label@ for input methods (@sendKey@, @sendText@, @sendLine@),
-@Nothing@ for non-input methods.
+Returns @Just label@ for input methods (@sendKey@, @click@, @sendText@,
+@sendLine@), @Nothing@ for non-input methods.
 -}
 extractInputLabel :: Text -> Maybe Text
 extractInputLabel rawLine =
@@ -266,6 +266,7 @@
                         case KM.lookup "key" params of
                             Just (String k) -> Just ("Key: " <> k)
                             _ -> Nothing
+                    "click" -> Just ("Click: " <> clickLabel params)
                     "sendText" ->
                         case KM.lookup "text" params of
                             Just (String t) -> Just ("Text: " <> showTextValue t)
@@ -280,6 +281,24 @@
         | t == " " = "<Space>"
         | t == "\t" = "<Tab>"
         | otherwise = "\"" <> t <> "\""
+
+    clickLabel params =
+        case (KM.lookup "col" params, KM.lookup "row" params) of
+            (Just (Number c), Just (Number r)) ->
+                showNumber c <> "," <> showNumber r
+            _ ->
+                case KM.lookup "selector" params of
+                    Just (Object sel) -> selectorLabel sel
+                    _ -> "?"
+
+    selectorLabel sel =
+        case (KM.lookup "text" sel, KM.lookup "pattern" sel, KM.lookup "type" sel) of
+            (Just (String t), _, _) -> "\"" <> t <> "\""
+            (_, Just (String p), _) -> "/" <> p <> "/"
+            (_, _, Just (String ty)) -> ty
+            _ -> "?"
+
+    showNumber n = T.pack (show (round n :: Int))
 
 {- | Compute a line-level delta between two viewport texts. Returns
 @Nothing@ when the frames are identical, or @Just encodedDelta@ with a
diff --git a/src/TuiSpec/Runner.hs b/src/TuiSpec/Runner.hs
--- a/src/TuiSpec/Runner.hs
+++ b/src/TuiSpec/Runner.hs
@@ -11,6 +11,10 @@
 module TuiSpec.Runner (
     artifactFile,
     artifactRoot,
+    click,
+    clickWith,
+    clickSelector,
+    clickSelectorWith,
     closeSession,
     currentView,
     currentViewRect,
@@ -28,6 +32,7 @@
     pressCombo,
     prependPathEntry,
     renderAnsiViewportText,
+    selectorOrigin,
     sendLine,
     serializeAnsiSnapshot,
     step,
@@ -51,13 +56,14 @@
 import Control.Monad (forM_, unless, when)
 import Data.Aeson (encode, object, (.=))
 import Data.ByteString qualified as BS
+import Data.ByteString.Char8 qualified as BS8
 import Data.ByteString.Lazy qualified as BL
 import Data.Char (chr, ord, toLower)
 import Data.IORef (modifyIORef', newIORef, readIORef, writeIORef)
 import Data.Int (Int64)
 import Data.IntMap.Strict qualified as IM
 import Data.List (intercalate, nub, sort)
-import Data.Maybe (fromMaybe, isJust)
+import Data.Maybe (fromMaybe, isJust, listToMaybe)
 import Data.Text (Text)
 import Data.Text qualified as T
 import Data.Text.Encoding qualified as TE
@@ -265,6 +271,81 @@
         Nothing ->
             throwIO (AssertionError "PTY backend unavailable during combo key press")
 
+{- | Left-click at a viewport coordinate.
+
+Coordinates are 0-based, matching the 'At' selector (the top-left cell is
+@0 0@). Uses SGR mouse encoding with the left button; see 'clickWith' for
+explicit button and encoding control.
+
+The target application only reacts to clicks if it has enabled mouse tracking
+(anything built on @vty@\/Brick does once mouse mode is turned on). A click on
+an application without mouse tracking is silently ignored by that application.
+-}
+click :: Tui -> Int -> Int -> IO ()
+click tui = clickWith tui defaultClickOptions
+
+{- | Click at a viewport coordinate with explicit button and encoding.
+
+Coordinates are 0-based. A click emits both a button press and a release so
+that applications reacting to either edge observe the event.
+-}
+clickWith :: Tui -> ClickOptions -> Int -> Int -> IO ()
+clickWith tui options col row = do
+    appendAction tui $
+        "click "
+            <> T.pack (show (clickButton options))
+            <> " "
+            <> T.pack (show col)
+            <> ","
+            <> T.pack (show row)
+    failIfLaunchedProcessExited tui "click"
+    pty <- readPty tui
+    case pty of
+        Just ptyHandle -> do
+            sendPtyBytes ptyHandle (encodeMouseClick options (col + 1) (row + 1))
+            threadDelay settleDelayMicros
+            _ <- syncVisibleBuffer tui
+            pure ()
+        Nothing ->
+            throwIO (AssertionError "PTY backend unavailable during click")
+
+{- | Click on the element matched by a selector (Playwright-style).
+
+The selector is resolved against the current viewport to its match origin and
+that coordinate is clicked. Honors the run's 'ambiguityMode': under
+'FailOnAmbiguous' a non-explicit selector matching more than one element
+fails. Resolves to the first match's origin (matching is line-oriented, so a
+match spanning a line boundary is reported as no match). Uses SGR mouse
+encoding; see 'clickSelectorWith' for button and encoding control.
+-}
+clickSelector :: Tui -> Selector -> IO ()
+clickSelector tui = clickSelectorWith tui defaultClickOptions
+
+-- | Click on the element matched by a selector with explicit button and encoding.
+clickSelectorWith :: Tui -> ClickOptions -> Selector -> IO ()
+clickSelectorWith tui options selector = do
+    appendAction tui ("clickSelector " <> T.pack (show selector))
+    failIfLaunchedProcessExited tui "clickSelector"
+    viewportTextValue <- syncVisibleBuffer tui
+    let runOptions = tuiOptions tui
+        viewport =
+            Viewport
+                { viewportCols = terminalCols runOptions
+                , viewportRows = terminalRows runOptions
+                , viewportText = viewportTextValue
+                }
+    assertNotAmbiguousWithMode (tuiName tui) (ambiguityMode runOptions) selector viewport
+    case selectorOrigin selector viewport of
+        Just (col, row) -> clickWith tui options col row
+        Nothing ->
+            throwIO
+                ( AssertionError
+                    ( "clickSelector found no match for selector in test '"
+                        <> tuiName tui
+                        <> "'."
+                    )
+                )
+
 -- | Send literal text to the PTY application.
 typeText :: Tui -> Text -> IO ()
 typeText tui textValue = do
@@ -1102,10 +1183,53 @@
         | c >= 'a' && c <= 'z' = chr (ord c - 32)
         | otherwise = c
 
+{- | Encode a mouse click (button press followed by release) as terminal
+input bytes.
+
+Takes 1-based column and row. SGR encoding (the default) has no coordinate
+limit; X10 encoding offsets each value by 32 and clamps to a single byte, so
+it cannot address columns or rows beyond 223.
+-}
+encodeMouseClick :: ClickOptions -> Int -> Int -> BS.ByteString
+encodeMouseClick options col row =
+    case clickEncoding options of
+        MouseSGR ->
+            let sgr final =
+                    BS8.pack
+                        ( "\ESC[<"
+                            <> show buttonCode
+                            <> ";"
+                            <> show col
+                            <> ";"
+                            <> show row
+                            <> [final]
+                        )
+             in sgr 'M' <> sgr 'm'
+        MouseX10 ->
+            let clampByte v = min 255 (max 32 v)
+                cx = clampByte (col + 32)
+                cy = clampByte (row + 32)
+                x10 code =
+                    BS.pack
+                        [0x1b, 0x5b, 0x4d, fromIntegral (clampByte (code + 32)), fromIntegral cx, fromIntegral cy]
+             in x10 buttonCode <> x10 releaseCode
+  where
+    buttonCode :: Int
+    buttonCode =
+        case clickButton options of
+            MouseLeft -> 0
+            MouseMiddle -> 1
+            MouseRight -> 2
+    releaseCode = 3 :: Int
+
 sendPtyText :: PtyHandle -> Text -> IO ()
 sendPtyText ptyHandle textValue =
     Pty.writePty (ptyMaster ptyHandle) (TE.encodeUtf8 textValue)
 
+-- | Write raw bytes to the PTY (used for mouse reports, which are not text).
+sendPtyBytes :: PtyHandle -> BS.ByteString -> IO ()
+sendPtyBytes ptyHandle = Pty.writePty (ptyMaster ptyHandle)
+
 drainPtyOutput :: Pty.Pty -> IO BS.ByteString
 drainPtyOutput pty = BS.concat . reverse <$> go 0 []
   where
@@ -1810,6 +1934,73 @@
             if selectorMatches selector viewport then 1 else 0
         Nth _ _ ->
             if selectorMatches selector viewport then 1 else 0
+
+{- | Resolve a selector to the 0-based @(col, row)@ origin of its first match
+in the viewport, or 'Nothing' when nothing matches.
+
+Used by 'clickSelector'. Origins are 0-based to match the 'At' selector.
+'Regex' resolves to the first non-blank column of the first matching line.
+
+Matching is line-oriented: a match that spans a line boundary (a multi-line
+'Exact' needle, or a 'Regex' whose @.*@ crosses a newline) resolves to
+'Nothing' even though the whole-text matchers treat it as a hit.
+'clickSelector' turns that into a \"no match\" error.
+-}
+selectorOrigin :: Selector -> Viewport -> Maybe (Int, Int)
+selectorOrigin selector viewport =
+    case selector of
+        Exact textValue ->
+            listToMaybe (exactOrigins textValue (viewportText viewport))
+        At col row -> Just (col, row)
+        Within rect nested -> do
+            (col, row) <-
+                selectorOrigin
+                    nested
+                    (viewport{viewportText = cropRect rect (viewportText viewport)})
+            pure (col + rectCol rect, row + rectRow rect)
+        Nth idx nested ->
+            case nested of
+                Exact textValue ->
+                    safeIndex idx (exactOrigins textValue (viewportText viewport))
+                Regex patternText ->
+                    safeIndex idx (regexOrigins patternText (viewportText viewport))
+                _ ->
+                    if matchCount nested viewport > idx
+                        then selectorOrigin nested viewport
+                        else Nothing
+        Regex patternText ->
+            listToMaybe (regexOrigins patternText (viewportText viewport))
+
+{- | All 0-based @(col, row)@ origins of a lightweight regex pattern, one per
+matching line, taken at the line's first non-blank column.
+-}
+regexOrigins :: Text -> Text -> [(Int, Int)]
+regexOrigins patternText textValue =
+    [ (T.length (T.takeWhile (== ' ') line), rowIdx)
+    | (rowIdx, line) <- zip [0 ..] (T.lines textValue)
+    , regexLikeMatch patternText line
+    ]
+
+-- | All 0-based @(col, row)@ origins of a literal substring within viewport text.
+exactOrigins :: Text -> Text -> [(Int, Int)]
+exactOrigins needle textValue
+    | T.null needle = []
+    | otherwise =
+        [ (col, rowIdx)
+        | (rowIdx, line) <- zip [0 ..] (T.lines textValue)
+        , col <- lineOccurrences line
+        ]
+  where
+    needleLen = T.length needle
+    lineOccurrences = go 0
+      where
+        go base hay =
+            let (prefix, suffix) = T.breakOn needle hay
+             in if T.null suffix
+                    then []
+                    else
+                        let col = base + T.length prefix
+                         in col : go (col + needleLen) (T.drop (T.length prefix + needleLen) hay)
 
 regexAlternativeCount :: Text -> Text -> Int
 regexAlternativeCount alternative haystack
diff --git a/src/TuiSpec/Server.hs b/src/TuiSpec/Server.hs
--- a/src/TuiSpec/Server.hs
+++ b/src/TuiSpec/Server.hs
@@ -46,8 +46,8 @@
 import TuiSpec.Internal (regexLikeMatch, safeFileStem, safeIndex, snapshotMetadataPath)
 import TuiSpec.Render (renderAnsiSnapshotFileWithFont)
 import TuiSpec.Replay (RecordingDirection (DirectionFrame, DirectionFrameDelta, DirectionNotification, DirectionRequest, DirectionResponse), RecordingHandle, ReplaySpeed (ReplayAsFastAsPossible, ReplayRealTime), appendRecordingEvent, closeRecording, computeFrameDelta, openRecording, streamReplayRequests)
-import TuiSpec.Runner (currentView, defaultWaitOptionsFor, dumpView, expectNotVisible, expectSnapshot, expectVisible, killSessionChildrenNow, launch, openSession, press, pressCombo, renderAnsiViewportText, sendLine, serializeAnsiSnapshot, typeText, waitForSelectorWithAmbiguity, waitForStable)
-import TuiSpec.Types (AmbiguityMode (FailOnAmbiguous, FirstVisibleMatch, LastVisibleMatch), App (..), Key (..), Modifier (Alt, Control, Shift), Rect (Rect), RunOptions (..), Selector (..), SnapshotName (SnapshotName), Tui (..), WaitOptions (..), defaultRunOptions, tuispecVersion)
+import TuiSpec.Runner (clickSelectorWith, clickWith, currentView, defaultWaitOptionsFor, dumpView, expectNotVisible, expectSnapshot, expectVisible, killSessionChildrenNow, launch, openSession, press, pressCombo, renderAnsiViewportText, sendLine, serializeAnsiSnapshot, typeText, waitForSelectorWithAmbiguity, waitForStable)
+import TuiSpec.Types (AmbiguityMode (FailOnAmbiguous, FirstVisibleMatch, LastVisibleMatch), App (..), ClickOptions (..), Key (..), Modifier (Alt, Control, Shift), MouseButton (..), MouseEncoding (..), Rect (Rect), RunOptions (..), Selector (..), SnapshotName (SnapshotName), Tui (..), WaitOptions (..), defaultClickOptions, defaultRunOptions, tuispecVersion)
 
 -- | Configuration for the JSON-RPC server.
 data ServerOptions = ServerOptions
@@ -170,6 +170,7 @@
         "initialize" -> dispatchInitialize state paramsValue
         "launch" -> dispatchLaunch state paramsValue
         "sendKey" -> dispatchSendKey state paramsValue
+        "click" -> dispatchClick state paramsValue
         "sendText" -> dispatchSendText state paramsValue
         "sendLine" -> dispatchSendLine state paramsValue
         "currentView" -> dispatchCurrentView state paramsValue
@@ -280,6 +281,62 @@
                             emitViewChangedNotification state tui
                             pure (object ["ok" .= True])
 
+dispatchClick :: ServerState -> Value -> IO (Either RpcFailure DispatchOutcome)
+dispatchClick state paramsValue =
+    withActiveSession state $ \active ->
+        case decodeParamsValue paramsValue of
+            Left err -> pure (Left err)
+            Right params ->
+                case resolveClickOptions params of
+                    Left optionErr -> pure (Left (invalidParams optionErr))
+                    Right options ->
+                        case resolveClickTarget params of
+                            Left targetErr -> pure (Left (invalidParams targetErr))
+                            Right target ->
+                                runMethod $ do
+                                    let tui = activeTui active
+                                    case target of
+                                        ClickAtCoordinate col row ->
+                                            clickWith tui options (col - 1) (row - 1)
+                                        ClickAtSelector selector ->
+                                            clickSelectorWith tui options selector
+                                    emitViewChangedNotification state tui
+                                    pure (object ["ok" .= True])
+
+-- | Where a @click@ request should land: an explicit coordinate or a selector.
+data ClickTarget
+    = ClickAtCoordinate Int Int
+    | ClickAtSelector Selector
+
+resolveClickTarget :: ClickParams -> Either String ClickTarget
+resolveClickTarget params =
+    case (clickParamSelector params, clickParamCol params, clickParamRow params) of
+        (Just selector, _, _) -> Right (ClickAtSelector selector)
+        (Nothing, Just col, Just row)
+            | col < 1 || row < 1 ->
+                Left "click uses 1-based coordinates; col/row must be >= 1"
+            | otherwise -> Right (ClickAtCoordinate col row)
+        (Nothing, _, _) ->
+            Left "click requires either a selector or both col and row"
+
+resolveClickOptions :: ClickParams -> Either String ClickOptions
+resolveClickOptions params = do
+    button <- maybe (Right (clickButton defaultClickOptions)) parseMouseButton (clickParamButton params)
+    encoding <- maybe (Right (clickEncoding defaultClickOptions)) parseMouseEncoding (clickParamEncoding params)
+    pure ClickOptions{clickButton = button, clickEncoding = encoding}
+  where
+    parseMouseButton textValue =
+        case T.toLower textValue of
+            "left" -> Right MouseLeft
+            "middle" -> Right MouseMiddle
+            "right" -> Right MouseRight
+            _ -> Left ("unknown mouse button: " <> T.unpack textValue)
+    parseMouseEncoding textValue =
+        case T.toLower textValue of
+            "sgr" -> Right MouseSGR
+            "x10" -> Right MouseX10
+            _ -> Left ("unknown mouse encoding: " <> T.unpack textValue)
+
 dispatchSendText :: ServerState -> Value -> IO (Either RpcFailure DispatchOutcome)
 dispatchSendText state paramsValue =
     withActiveSession state $ \active ->
@@ -962,6 +1019,24 @@
     parseJSON =
         withObject "SendKeyParams" $ \o ->
             SendKeyParams <$> o .: "key"
+
+data ClickParams = ClickParams
+    { clickParamSelector :: Maybe Selector
+    , clickParamCol :: Maybe Int
+    , clickParamRow :: Maybe Int
+    , clickParamButton :: Maybe Text
+    , clickParamEncoding :: Maybe Text
+    }
+
+instance FromJSON ClickParams where
+    parseJSON =
+        withObject "ClickParams" $ \o ->
+            ClickParams
+                <$> (o .:? "selector" >>= traverse parseSelector)
+                <*> o .:? "col"
+                <*> o .:? "row"
+                <*> o .:? "button"
+                <*> o .:? "encoding"
 
 data SendTextParams = SendTextParams
     { sendTextValue :: Text
diff --git a/src/TuiSpec/Types.hs b/src/TuiSpec/Types.hs
--- a/src/TuiSpec/Types.hs
+++ b/src/TuiSpec/Types.hs
@@ -11,8 +11,12 @@
     App (..),
     app,
     haskellApp,
+    ClickOptions (..),
+    defaultClickOptions,
     Key (..),
     Modifier (..),
+    MouseButton (..),
+    MouseEncoding (..),
     PtyHandle (..),
     Rect (..),
     RunOptions (..),
@@ -164,7 +168,7 @@
 
 -- | The tuispec library/protocol version.
 tuispecVersion :: String
-tuispecVersion = "0.3.0.0"
+tuispecVersion = "0.3.1.0"
 
 -- | Key modifiers for combo key presses.
 data Modifier
@@ -189,6 +193,45 @@
     | CharKey Char
     | NamedKey Text
     deriving (Eq, Show, Read)
+
+-- | Which mouse button a synthesized click uses.
+data MouseButton
+    = -- | Primary (left) button.
+      MouseLeft
+    | -- | Middle button.
+      MouseMiddle
+    | -- | Secondary (right) button.
+      MouseRight
+    deriving (Eq, Show, Read)
+
+{- | Wire encoding for synthesized mouse events.
+
+Most modern TUIs (anything built on @vty@\/Brick) enable SGR extended mouse
+mode, so 'MouseSGR' is the default. 'MouseX10' is the legacy fixed-byte
+encoding for applications that only enable @\\ESC[?1000h@ tracking; it cannot
+address columns or rows beyond 223.
+-}
+data MouseEncoding
+    = -- | SGR extended mouse mode (@\\ESC[\<btn;col;rowM@). 1-based, unbounded.
+      MouseSGR
+    | -- | Legacy X10 mouse mode (@\\ESC[M@ + offset bytes). Capped at 223.
+      MouseX10
+    deriving (Eq, Show, Read)
+
+-- | Button and encoding options for a synthesized mouse click.
+data ClickOptions = ClickOptions
+    { clickButton :: MouseButton
+    , clickEncoding :: MouseEncoding
+    }
+    deriving (Eq, Show, Read)
+
+-- | Default click options: left button, SGR encoding.
+defaultClickOptions :: ClickOptions
+defaultClickOptions =
+    ClickOptions
+        { clickButton = MouseLeft
+        , clickEncoding = MouseSGR
+        }
 
 -- | A rectangular region within the terminal viewport.
 data Rect = Rect
diff --git a/tuispec.cabal b/tuispec.cabal
--- a/tuispec.cabal
+++ b/tuispec.cabal
@@ -1,6 +1,6 @@
 cabal-version: 3.8
 name: tuispec
-version: 0.3.0.0
+version: 0.3.1.0
 build-type: Simple
 license: MIT
 license-file: LICENSE
