diff --git a/ChangeLog.md b/ChangeLog.md
--- a/ChangeLog.md
+++ b/ChangeLog.md
@@ -4,6 +4,45 @@
 
 ## Functional Versions
 
+### 0.9.0.5 -- 2026-10-21
+* Pinned the macOS CI job to `macos-15`. The `macos-latest` image ships an SDK that the GHC 9.14.1 linker cannot parse, so the build failed before the tests ran.
+
+### 0.9.0.4 -- 2026-10-21
+* Fixed a Windows crash when rendering to a temporary handle. `termWOn` and `termHOn` now query the terminal size only when the handle is a terminal. The `stty` fallback in `terminal-size` can close a non-console handle on Windows, which broke `renderPage`, `renderSection` and `renderPrinter` on that platform.
+
+### 0.9.0.3 -- 2026-10-21
+* Fixed the test suite on Windows. `captureStdout` used lazy `readFile` and tried to remove the temporary file before it was closed; Windows cannot delete an open file. It now uses strict `readFile'`.
+* The first Windows CI run exposed this test-only failure.
+
+### 0.9.0.2 -- 2026-10-21
+* Added macOS and Windows jobs to the CI matrix for build, test, package check, Haddock and source distribution.
+* The cache now uses the Cabal store reported by `haskell-actions/setup`, which works on all three operating systems.
+* GHC 9.2.8 is excluded on the arm64 macOS runner, which has no aarch64-darwin bindist; macOS runs GHC 9.14.1.
+* Updated the platform and release notes to describe the new matrix.
+
+### 0.9.0.1 -- 2026-10-21
+* Automatic colour detection now uses `hSupportsANSI`, so Windows consoles and MinTTY are recognised; `NO_COLOR` still disables styling.
+* Escape-sequence timeouts use `hWaitForInput` instead of an asynchronous timeout around `getChar`, which is portable and does not interrupt a blocked read.
+* Added `docs/platforms.md` with the Linux, macOS and Windows behaviour, terminal requirements and known limitations.
+* The demo and tutorial enable the `winio` I/O manager on Windows, and the demo version label follows the package version.
+* Added a regression test that documents the raw `readKey` output for an arrow-key sequence.
+
+### 0.9.0 -- 2026-10-21
+* Added `readKeyPress` to `Ansiform.Input`, a key reader for menus that consumes and ignores terminal escape sequences such as arrow keys.
+* Fixed the tutorial and the demo treating arrow-key sequences as `A` or `D` shortcuts, which opened the add or edit form instead of moving the selection.
+* The tutorial, the demo and the README quick start now use `readKeyPress` in their command loops.
+* Updated the demo version label to follow the package version.
+
+### 0.8.0 -- 2026-10-21
+* Added `promptWithInitial` to `Ansiform.Input`, opening a form field with an editable initial value.
+* Added a single-line editor with insertion, Backspace, Delete, arrows, Home, End, Enter to submit and Esc to cancel the whole form.
+* Added `FormCancelled` to `FormError`; code that matches `FormError` exhaustively must handle the new constructor.
+* Added `Ansiform.Line` with the pure `LineState`, `LineKey`, `applyLineKey` and `visibleLine` core.
+* The editor scrolls horizontally and keeps Unicode widths, so wide characters and combining marks stay aligned.
+* Non-terminal input still uses `readLine` and ignores the initial value.
+* The tutorial gained an `E` edit mode that prefills the selected bookmark; its form page now takes the footer instruction as a parameter.
+* Added docs for editable fields and updated the architecture and roadmap.
+
 ### 0.7.10.4 -- 2026-10-21
 * Removed the obsolete URL readability comment from the tutorial example and its documentation snippet.
 
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -10,21 +10,24 @@
 
 > **Formerly known as Vis.** Ansiform is a terminal formatting library, unrelated to the JavaScript `vis-network` graph visualization library or to the Hackage package [`Vis`](https://hackage.haskell.org/package/Vis) (3D graphics). See [Naming](https://github.com/Tralen/ansiform/blob/master/docs/naming.md).
 
-Current version: **0.7.10.4**.
+Current version: **0.9.0.5**.
 
 Repository: <https://github.com/Tralen/ansiform>.
 
 ## Features
 
 - ANSI colors, bold, italic and underline, with automatic suppression on non-TTY output, `NO_COLOR` and `TERM=dumb`.
-- Immediate single-key input: a key press changes the screen without Enter.
+- Immediate single-key input: a key press changes the screen without Enter, and shortcut loops ignore arrow-key escape sequences.
 - Line input for prompts and numeric selection, including prompts printed inside a page and typed in place.
 - Forms that draw a page and read several prompts, with the cursor arithmetic handled by the library.
+- Editable form fields that open with an initial value, with arrows, Home, End, Delete and Esc cancellation.
+- A pure line-editing core in `Ansiform.Line` that can be tested without a terminal.
 - A step-by-step tutorial and a runnable bookmarks example.
 - Unicode-aware display widths for alignment, wrapping, lists and tables.
 - Rendering to any `Handle` or to a `String` for embedding and golden tests.
 - Per-column table alignment, maximum widths and truncation with an ellipsis.
 - Optional ASCII, light, heavy or double box borders for tables and groups.
+- A true borderless table mode that keeps aligned columns without border characters or column bands.
 - Automatic padding of unequal groups in bordered layouts, so the border stays rectangular.
 - A reusable `Theme` record for consistent headers, footers, menus and tables.
 - Horizontal rules, solid backgrounds, offsets and padding.
@@ -51,7 +54,7 @@
 
 loop :: State -> IO ()
 loop state = do
-  key <- readKey
+  key <- readKeyPress
   case fmap toLower key of
     Just 'b' -> draw bodyPage state >>= loop
     Just 'h' -> draw examplePage state >>= loop
@@ -101,6 +104,7 @@
 - [Sections](https://github.com/Tralen/ansiform/blob/master/docs/sections.md): grouping printers and reserving minimum line counts.
 - [Pages and drawing](https://github.com/Tralen/ansiform/blob/master/docs/pages.md): pages, state and the drawing lifecycle.
 - [Input](https://github.com/Tralen/ansiform/blob/master/docs/input.md): immediate and line input, forms, and the standard interaction loop.
+- [Platforms](https://github.com/Tralen/ansiform/blob/master/docs/platforms.md): Linux, macOS and Windows behaviour, terminal requirements and known limitations.
 - [Render](https://github.com/Tralen/ansiform/blob/master/docs/render.md): rendering pages, sections and printers to a `Handle` or a `String`.
 - [Settings](https://github.com/Tralen/ansiform/blob/master/docs/settings.md): width, spacing and group overflow configuration.
 - [Numbers](https://github.com/Tralen/ansiform/blob/master/docs/number.md): non-negative arithmetic, saturation and checked subtraction.
diff --git a/ansiform.cabal b/ansiform.cabal
--- a/ansiform.cabal
+++ b/ansiform.cabal
@@ -1,6 +1,6 @@
 cabal-version:       3.0
 name:                ansiform
-version:             0.7.10.4
+version:             0.9.0.5
 synopsis:            Display formatting for command line interfaces.
 description:         Basic format DSL for building an ANSI command line interface
                      with colors, alignment, tables, paragraphs and more.
@@ -22,6 +22,7 @@
                      docs/naming.md
                      docs/number.md
                      docs/pages.md
+                     docs/platforms.md
                      docs/printers.md
                      docs/releasing.md
                      docs/render.md
@@ -45,6 +46,7 @@
                      , Ansiform.Content
                      , Ansiform.Draw
                      , Ansiform.Input
+                     , Ansiform.Line
                      , Ansiform.Number
                      , Ansiform.Page
                      , Ansiform.Printer
@@ -60,6 +62,7 @@
   other-modules:       Ansiform.Internal
                      , Ansiform.Internal.Content
                      , Ansiform.Internal.Input
+                     , Ansiform.Internal.Line
                      , Ansiform.Internal.Number
                      , Ansiform.Internal.Output
                      , Ansiform.Internal.Page
@@ -90,6 +93,8 @@
   main-is:             Demo.hs
   hs-source-dirs:      examples
   build-depends:       ansiform
+  if os(windows)
+    ghc-options:       -with-rtsopts=--io-manager=winio
 
 executable ansiform-tutorial
   import:              shared
@@ -97,6 +102,8 @@
   main-is:             Tutorial.hs
   hs-source-dirs:      examples
   build-depends:       ansiform
+  if os(windows)
+    ghc-options:       -with-rtsopts=--io-manager=winio
 
 test-suite ansiform-test
   import:              shared
diff --git a/docs/architecture.md b/docs/architecture.md
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -19,6 +19,7 @@
 | `Ansiform.Table` | Per-column table formatting. | `Ansiform.Internal.Table` |
 | `Ansiform.Theme` | Reusable styles for interface components. | `Ansiform.Internal.Theme` |
 | `Ansiform.Input` | Immediate single-key input, line input and page forms. | `Ansiform.Internal.Input` |
+| `Ansiform.Line` | Pure single-line editing state and width-aware windowing. | `Ansiform.Internal.Line` |
 | `Ansiform.Render` | Rendering to a `Handle` or a `String`. | `Ansiform.Internal.Output`, `Ansiform.Internal.Render` |
 | `Ansiform.Width` | Display width of characters and strings. | `Ansiform.Internal.Width` |
 | `Ansiform.Sample` | Inspection helpers for debugging. | `Ansiform.Internal.Sample` |
@@ -46,7 +47,7 @@
 
 The default runners write to stdout. The `On` variants accept any handle, and `Ansiform.Render` captures the same output in a `String` through a temporary handle.
 
-`Ansiform.Input` is the other IO boundary. It configures stdin for immediate single-key reads or line input and restores the terminal state when the interaction ends.
+`Ansiform.Input` is the other IO boundary. It configures stdin for immediate single-key reads or line input and restores the terminal state when the interaction ends. The prefilled field editor lives behind `promptWithInitial`; its buffer operations and visible window are pure functions in `Ansiform.Line`, and the IO loop only decodes keys, applies them and redraws.
 
 ## Invariants
 
diff --git a/docs/demo.md b/docs/demo.md
--- a/docs/demo.md
+++ b/docs/demo.md
@@ -6,7 +6,7 @@
 cabal run ansiform-demo
 ```
 
-The header and footer are shared components; only the body changes between screens. The footer always lists the keys accepted by the current screen. The demo uses `withImmediateInput` and `readKey` for navigation, and `readForm` with `promptAt` for the philosopher prompt.
+The header and footer are shared components; only the body changes between screens. The footer always lists the keys accepted by the current screen. The demo uses `withImmediateInput` and `readKeyPress` for navigation, and `readForm` with `promptAt` for the philosopher prompt.
 
 ## Keys
 
@@ -24,7 +24,7 @@
 
 The menu lists each shortcut next to its option, and the footer shows the same keys. On the main menu, the footer commands are split into two rows so longer labels do not crowd. Press a key directly; the screen changes immediately, with no prompt and no Enter. The demo restores the terminal settings when it exits. When stdin is not a terminal, for example with piped input, line breaks are ignored and each character is handled as a key.
 
-On the Groups screen, the colored numbers 06 (Cicero) and 14 (Locke) identify selectable entries. The footer adds `C Choose`, which asks for a number on a single prompt line inside the footer. A blank line follows the prompt, then the `B`/`R`/`X` commands, and nothing is printed below the footer. Type any philosopher number from 01 to 15 and press Enter. The demo draws the prompt page, uses `readForm` with a section-relative `promptAt` position, then restores the immediate settings before opening the article. The article footer uses `B`/`R`/`X`, and `B` returns to the Groups screen, where `C` still works and another philosopher can be chosen.
+On the Groups screen, the colored numbers 06 (Cicero) and 14 (Locke) identify selectable entries. The footer adds `C Choose`, which asks for a number on a single prompt line inside the footer. A blank line follows the prompt, then the `B`/`R`/`X` commands, and nothing is printed below the footer. Type any philosopher number from 01 to 15 and press Enter. The demo draws the prompt page, uses `readForm` with a section-relative `promptAt` position, then restores the immediate settings before opening the article. The prompt is section 2, row 2: the footer prints the rule on row 0, a blank line on row 1 and the prompt on row 2. Update `promptAt` if the footer composition changes. The article footer uses `B`/`R`/`X`, and `B` returns to the Groups screen, where `C` still works and another philosopher can be chosen.
 
 ## Source
 
diff --git a/docs/input.md b/docs/input.md
--- a/docs/input.md
+++ b/docs/input.md
@@ -5,6 +5,7 @@
 ```haskell
 withImmediateInput :: IO a -> IO a
 readKey :: IO (Maybe Char)
+readKeyPress :: IO (Maybe Char)
 
 withLineInput :: IO a -> IO a
 readLine :: IO (Maybe String)
@@ -14,8 +15,9 @@
 
 data Prompt  -- opaque
 promptAt :: Number -> Number -> Offset -> Prompt
+promptWithInitial :: Number -> Number -> Offset -> String -> Prompt
 
-data FormError = PromptOutsidePage Prompt | FormInputEnded
+data FormError = PromptOutsidePage Prompt | FormInputEnded | FormCancelled
 
 readForm :: Page -> [Prompt] -> State -> IO (State, Either FormError [String])
 readFormOn :: Handle -> Page -> [Prompt] -> State -> IO (State, Either FormError [String])
@@ -34,14 +36,14 @@
 
 ## The standard loop
 
-`readKey` returns one character, or `Nothing` at end of input. Lowercase the key when shortcuts are case-insensitive:
+`readKeyPress` returns one key press, or `Nothing` at end of input. It discards terminal escape sequences, such as the ones sent by arrow keys, Home, End and function keys, so they cannot trigger shortcuts by accident. Lowercase the key when shortcuts are case-insensitive:
 
 ```haskell
 import Data.Char (toLower)
 
 loop :: State -> IO ()
 loop state = do
-  key <- readKey
+  key <- readKeyPress
   case fmap toLower key of
     Just 'n' -> draw secondPage state >>= loop
     Just 'p' -> draw firstPage state >>= loop
@@ -51,6 +53,8 @@
 
 Pressing `N` or `P` changes the page immediately. The loop ignores unknown keys and keeps the current state.
 
+`readKey` is the raw reader: it returns the character as typed, including the individual characters of an escape sequence. Use it only when the application interprets those characters itself, such as the line editor behind `readForm`. `readKeyPress` returns a lone Escape as `Just '\ESC'`, because it cannot be part of a longer sequence.
+
 ## Line input
 
 Some screens need a value typed by the user. `withLineInput` temporarily restores line buffering and echo, and `readLine` reads until Enter:
@@ -97,22 +101,78 @@
     Left _ -> retry state'
 ```
 
-`promptAt section row column` places a prompt on a zero-based section index, a zero-based row inside that section and an absolute column. Section-relative rows stay correct when the other sections change height. Prompts are read in list order, should each occupy a different page line, and the helper moves the cursor up or down between them as needed. Line input is enabled once for the whole form, so callers must not wrap `readForm` in `withLineInput`.
+`promptAt section row column` places a prompt on a zero-based section index, a zero-based row inside that section and an absolute column. Section-relative rows stay correct when the other sections change height. Prompts are read in list order, should each occupy a different page line, and the helper moves the cursor up or down between them as needed. A form without editable fields enables line input once for the whole form. Editable fields switch to immediate input per field and restore the previous mode afterwards. In both cases, callers must not wrap `readForm` in `withLineInput`.
 
-The result is an ADT: `Right values` contains one string per prompt, `Left (PromptOutsidePage p)` reports a prompt that does not fit the drawn page, and `Left FormInputEnded` reports end of input. When a prompt does not fit, no input is read. The returned state is the one produced by drawing the page, so the caller can redraw or amend it.
+The result is an ADT: `Right values` contains one string per prompt, `Left (PromptOutsidePage p)` reports a prompt that does not fit the drawn page, `Left FormInputEnded` reports end of input, and `Left FormCancelled` reports that the user pressed Esc in an editable field. When a prompt does not fit, no input is read. The returned state is the one produced by drawing the page, so the caller can redraw or amend it.
 
 Both helpers assume the typed text does not wrap to another terminal line.
 
+## Editable fields
+
+A field that edits an existing value needs the text to be in the editable buffer when it opens. The terminal's line discipline has no concept of an initial buffer, so `promptWithInitial` opts into a single-line editor owned by Ansiform:
+
+```haskell
+let prompts =
+      [ promptWithInitial 2 0 14 (taskTitle task)
+      , promptWithInitial 3 0 14 (taskContent task)
+      ]
+```
+
+`promptWithInitial section row column initial` places the cursor at the end of `initial`, so the user can backspace, move and insert without retyping the value. Non-printable characters are removed, because the editor is single-line. An empty initial value is still meaningful: it opens the editor with an empty buffer, which lets a form use the same editor for every field.
+
+The editor owns the region from the prompt column to the end of the line. It clears that region and redraws the visible window on every key, scrolling horizontally when the value is wider than the terminal. The window uses `charWidth`, so wide characters and combining marks keep the cursor aligned.
+
+| Key | Action |
+| --- | --- |
+| Printable character | Insert at the cursor. |
+| `Backspace` | Delete the character before the cursor. |
+| `Delete` | Delete the character at the cursor. |
+| `Left` / `Right` | Move the cursor one code point. |
+| `Home` / `End` | Move to the start or end of the buffer. |
+| `Enter` | Submit the field. |
+| `Esc` | Cancel the whole form. |
+
+`Enter` prints a newline after the field, matching the line counts used by `readForm`. `Esc` cancels the whole form and returns `Left FormCancelled`; a partially completed form is rarely useful, and the caller can reopen it. Unknown escape sequences are ignored.
+
+When stdin is not a terminal, as in pipes and CI, `readForm` falls back to `readLine` and ignores the initial value. The editor is a UI affordance; non-interactive consumers should send explicit values.
+
+The form returns the exact edited buffers. It does not apply "empty means keep" semantics; that decision belongs to the caller. Prompts built with `promptAt` keep reading through the terminal's own line discipline, so existing forms behave as before.
+
+The pure editing core lives in `Ansiform.Line`:
+
+```haskell
+data LineState = LineState
+  { lineText :: String
+  , lineCursor :: Int
+  }
+
+data LineKey
+  = LineInsert Char
+  | LineBackspace
+  | LineDelete
+  | LineMoveLeft
+  | LineMoveRight
+  | LineMoveHome
+  | LineMoveEnd
+
+applyLineKey :: LineKey -> LineState -> LineState
+visibleLine :: Int -> LineState -> (String, Int)
+```
+
+`lineCursor` is a code-point index. `applyLineKey` and `visibleLine` are pure and testable without a terminal, while the IO loop only decodes keys, calls them and redraws.
+
 ## Terminal state
 
 `withImmediateInput` changes stdin only when it is a terminal:
 
-- it disables line buffering, so `readKey` does not wait for Enter;
+- it disables line buffering, so `readKey` and `readKeyPress` do not wait for Enter;
 - it disables echo, so typed keys are not shown twice;
 - it restores the original buffering and echo settings with `bracket` when the action ends, including on exceptions.
 
-When stdin is not a terminal, as in pipes and CI, the settings are left untouched. `readKey` still reads one character at a time and returns `Nothing` at end of input.
+When stdin is not a terminal, as in pipes and CI, the settings are left untouched. `readKey` and `readKeyPress` still read one key at a time and return `Nothing` at end of input.
 
+On Windows, immediate input also requires an I/O manager that delivers console input without waiting for Enter, normally `winio`. See [Platforms](platforms.md) for the runtime option, the terminal requirements and the known limitations.
+
 ## Keys
 
-`readKey` returns the character as typed. It does not filter newlines, spaces or control keys; the loop decides which keys are meaningful. This keeps the input layer small and lets every screen define its own shortcuts.
+`readKey` returns the character as typed. `readKeyPress` goes one level up: it consumes complete escape sequences and returns only the keys a shortcut map should see. Neither filters newlines, spaces or control keys; the loop decides which keys are meaningful. This keeps the input layer small and lets every screen define its own shortcuts.
diff --git a/docs/pages.md b/docs/pages.md
--- a/docs/pages.md
+++ b/docs/pages.md
@@ -71,7 +71,7 @@
 
 loop :: State -> IO ()
 loop state = do
-  key <- readKey
+  key <- readKeyPress
   case fmap toLower key of
     Just 'n' -> draw secondPage state >>= loop
     Just 'e' -> amend newBody 1 state >>= loop
diff --git a/docs/platforms.md b/docs/platforms.md
new file mode 100644
--- /dev/null
+++ b/docs/platforms.md
@@ -0,0 +1,75 @@
+# Platforms
+
+Ansiform targets Linux, macOS and Windows. The pure builders, layout and rendering code are portable. The differences are in terminal detection, key delivery and terminal size.
+
+## Summary
+
+| Area | Linux | macOS | Windows |
+| --- | --- | --- | --- |
+| ANSI styling | Native. | Native. | Native on Windows 10 and later; emulated on legacy consoles. |
+| Terminal size | `ioctl`. | `ioctl`. | Win32 console API. |
+| Immediate keys | Yes. | Yes. | Yes with the `winio` I/O manager and a native console or Windows Terminal. |
+| Forms and line editor | Yes. | Yes. | Yes under the same conditions as immediate keys. |
+| UTF-8 widths | Yes. | Yes. | Depends on the console code page and font. |
+
+## Dependencies
+
+- `ansi-terminal` writes ANSI sequences and provides the same operations through the Windows console API when native support is missing.
+- `terminal-size` queries the size through `ioctl` on POSIX and the Win32 console API on Windows.
+- `base` supplies handles, echo control, buffering and `hWaitForInput`.
+- Nothing depends on `unix`. On Windows the dependency graph includes `Win32` only through `terminal-size`.
+
+## Linux and macOS
+
+Everything in this documentation applies as written. `hIsTerminalDevice` recognises the terminal, `hSetBuffering` and `hSetEcho` change the terminal mode, and escape sequences arrive as ordinary input. Terminal.app and iTerm2 send the CSI sequences listed in [Input](input.md).
+
+Configured colour detection uses `hSupportsANSI`, which checks that the output handle is a terminal and that `TERM` is not `dumb`. `NO_COLOR` still disables styling.
+
+Escape-sequence timeouts use `hWaitForInput`, so a lone Escape or an arrow key never interrupts a blocked read with an asynchronous exception. Without `-threaded`, `hWaitForInput` blocks other Haskell threads for the length of the timeout; this is fine for the single-threaded interaction loop and avoids a thread per keystroke.
+
+## Windows
+
+ANSI output, cursor movement and width queries work through `ansi-terminal` and `terminal-size` in `cmd.exe`, Windows Terminal and applications built with a native console. On Windows 10 and later the console processes ANSI sequences; on older consoles `ansi-terminal` emulates the operations it supports.
+
+The size query runs only when the handle is a terminal. This avoids the `stty` fallback in `terminal-size`, which on Windows can close a non-console handle. As a consequence, MinTTY and MSYS sessions, whose handles look like pipes, use the 80 by 24 default instead of a detected size.
+
+### Immediate input
+
+Immediate input needs console input that does not wait for Enter. In GHC 9.14.1 the Windows build of the runtime enables the `winio` I/O manager but keeps `mio` as the default for the threaded runtime and `win32-legacy` as the default for the non-threaded runtime. With those defaults, `hSetBuffering stdin NoBuffering` may not take effect and `getChar` can wait for Enter, which is the long-standing behaviour tracked as GHC issue 2189.
+
+Select the native manager when building an interactive program:
+
+```cabal
+if os(windows)
+  ghc-options: -with-rtsopts=--io-manager=winio
+```
+
+The bundled `ansiform-demo` and `ansiform-tutorial` executables already set this option on Windows. Libraries cannot set RTS options, so applications that embed Ansiform must add it themselves.
+
+`hIsTerminalDevice stdin` is reliable for the native console and Windows Terminal. MinTTY, MSYS and Cygwin connect standard input through pipes, so Ansiform sees a non-interactive stdin: immediate input is not enabled, `readKeyPress` returns characters only when a line is available, and forms fall back to line reading. Use Windows Terminal or `cmd.exe` for interactive screens.
+
+The library handles the common Windows key codes: Enter arrives as `\r` or `\n`, and Backspace as `\b` or `DEL`. Arrow keys reach `readKeyPress` and the line editor only when the console delivers them as escape sequences. If the console instead reports them as key events, `getChar` cannot return them and the application sees no input for those keys; this requires Win32 input APIs, which Ansiform does not use. `readKeyPress` still protects every shortcut from any sequence that is delivered.
+
+### Text
+
+GHC uses the console code page for console input and output. Run `chcp 65001` and use a font with the required glyphs to display wide characters. The layout code measures them correctly even when the console cannot render them.
+
+## Unsupported
+
+- Immediate input through MinTTY, MSYS or Cygwin pipes.
+- Mouse input.
+- Bracketed paste.
+- Grapheme-cluster cursor movement. Editing and cursor movement work by Unicode code point, so combining marks may be split.
+
+## Testing
+
+The CI matrix builds, tests, checks, documents and packages the library on Linux, macOS and Windows. Linux and Windows use GHC 9.2.8 and GHC 9.14.1; macOS uses `macos-15` and only GHC 9.14.1, because GHC 9.2.8 has no aarch64-darwin bindist and the SDK on `macos-latest` is too new for the GHC 9.14.1 linker. CI still cannot press keys, so the interaction statements above come from the GHC, `ansi-terminal`, `terminal-size` and base documentation and the manual checklist below.
+
+A manual checklist for each platform:
+
+1. Draw a page, press a key and confirm that the screen changes without Enter.
+2. Press the four arrows in a menu and confirm that no shortcut runs.
+3. Open a form with `promptWithInitial`, move, insert, Backspace, Delete, Home, End, submit with Enter and cancel with Esc.
+4. Type a value wider than the terminal and confirm that the window scrolls with the cursor.
+5. Confirm that colours appear on a terminal and disappear when output is redirected.
+6. Resize the terminal and confirm that the layout uses the new width.
diff --git a/docs/releasing.md b/docs/releasing.md
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -9,12 +9,12 @@
 - License: MIT.
 - `cabal sdist` produces a complete source distribution with sources, docs, examples, tests, README, ChangeLog and LICENSE.
 - Haddock builds with 100% coverage for the public functions and types. The remaining warnings are only about module re-exports.
-- CI: `.github/workflows/ci.yml` builds, tests, checks, documents and creates the source distribution with GHC 9.2.8 and GHC 9.14.1.
+- CI: `.github/workflows/ci.yml` builds, tests, checks, documents and creates the source distribution on Linux, macOS and Windows, with GHC 9.2.8 and GHC 9.14.1. macOS uses `macos-15` and only GHC 9.14.1: GHC 9.2.8 has no aarch64-darwin bindist, and the SDK on `macos-latest` is too new for the GHC 9.14.1 linker.
 - GitHub: public at <https://github.com/Tralen/ansiform>; the local remote is `git@github.com:Tralen/ansiform.git`.
 - `source-repository`, `homepage` and `bug-reports` are set to the GitHub URL.
 - The history has been rewritten and contains no personal paths.
-- Tag `0.7.10.3` and its GitHub release are published. Tag `0.7.10.4` marks the next release.
-- Hackage: 0.7.10.3 is published at <https://hackage.haskell.org/package/ansiform-0.7.10.3>, with documentation uploaded; 0.7.10.4 follows this checklist.
+- Tag `0.7.10.4` and its GitHub release are published.
+- Hackage: 0.7.10.4 is published at <https://hackage.haskell.org/package/ansiform-0.7.10.4>, with documentation uploaded.
 - The 0.7.10.4 release removes the obsolete tutorial comment. The 0.7.10.3 release added the tutorial, the bookmarks example, the readable muted theme and the borderless `BordersNone` layout.
 
 ## Credentials
diff --git a/docs/roadmap.md b/docs/roadmap.md
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -26,13 +26,21 @@
 
 ## Implemented
 
+### Shortcut key reader
+
+Released in 0.9.0. `readKeyPress` reads one key press and consumes terminal escape sequences, so arrow keys, Home, End and function keys cannot trigger the shortcuts of a menu. `readKey` remains the raw reader used by the line editor. See [Input](input.md).
+
+### Editable form fields
+
+Released in 0.8.0. `promptWithInitial` opens a form field with an editable initial value. The field supports insertion, Backspace, Delete, arrows, Home, End, Enter to submit and Esc to cancel the form, and it scrolls horizontally with Unicode-aware widths. The pure editing core is public in `Ansiform.Line`. See [Input](input.md).
+
 ### Borderless bordered layout
 
 Released in 0.7.10.3. Passing `BordersNone` to the bordered table and group printers keeps every column on one row without border characters, horizontal separators or column bands, instead of falling back to the column-band layout. Columns are separated by two spaces. See [Table formatting](table-format.md).
 
 ### Tutorial
 
-Released in 0.7.10.0. `docs/tutorial.md` builds a small bookmarks program step by step, and `examples/Tutorial.hs` is compiled by `cabal build all`. See [Tutorial](tutorial.md).
+Released in 0.7.10.3. `docs/tutorial.md` builds a small bookmarks program step by step, and `examples/Tutorial.hs` is compiled by `cabal build all`. See [Tutorial](tutorial.md).
 
 ### Page forms
 
diff --git a/docs/tutorial.md b/docs/tutorial.md
--- a/docs/tutorial.md
+++ b/docs/tutorial.md
@@ -8,7 +8,7 @@
 cabal run ansiform-tutorial
 ```
 
-The keys are `A` to add, `J` and `K` to move the selection, `D` to delete, and `X` to exit.
+The keys are `A` to add, `J` and `K` to move the selection, `D` to delete, `E` to edit, and `X` to exit.
 
 ## 1. Set up the project
 
@@ -55,6 +55,25 @@
   where
     kept = take selected bookmarks ++ drop (selected + 1) bookmarks
 
+updateSelected :: String -> String -> Model -> Model
+updateSelected title url model@(Model bookmarks selected) = case bookmarks of
+  [] -> model
+  _ -> let index = clampSelection (length bookmarks) selected
+       in Model (replaceAt index (Bookmark title url) bookmarks) index
+
+replaceAt :: Int -> a -> [a] -> [a]
+replaceAt index value values =
+  take index values ++ [value] ++ drop (index + 1) values
+
+selectedBookmark :: Model -> Maybe Bookmark
+selectedBookmark (Model bookmarks selected) =
+  bookmarkAt (clampSelection (length bookmarks) selected) bookmarks
+
+bookmarkAt :: Int -> [Bookmark] -> Maybe Bookmark
+bookmarkAt index bookmarks = case drop index bookmarks of
+  [] -> Nothing
+  (bookmark:_) -> Just bookmark
+
 moveSelection :: Int -> Model -> Model
 moveSelection delta (Model bookmarks selected) =
   Model bookmarks (clampSelection (length bookmarks) (selected + delta))
@@ -146,6 +165,7 @@
       , blockMenuStyle "J" "Down" (themeAccent theme)
       , blockMenuStyle "K" "Up" (themeAccent theme)
       , blockMenuStyle "D" "Delete" (themeDanger theme)
+      , blockMenuStyle "E" "Edit" (themeAccent theme)
       , blockMenuStyle "X" "Exit" (themeDanger theme) ]
   ]
 ```
@@ -154,11 +174,11 @@
 
 ## 5. Build the form
 
-The add flow needs two values. Its page reuses the same header and bottom, and adds a body with two labeled lines:
+The add and edit flows need two values and share the same page. The page reuses the header and bottom, adds a body with two labeled lines and takes its footer instruction as a parameter, so each flow explains its own keys:
 
 ```haskell
-formPage :: Page
-formPage = page [header, formBody, formFooter, bottom]
+formPage :: String -> Page
+formPage instruction = page [header, formBody, formFooter instruction, bottom]
 
 formBody :: Section
 formBody = sectionBodyRule 1 ruleBody
@@ -167,13 +187,17 @@
   , printerLeft 2 (blockSimple "URL:   " styleDefault)
   ]
 
-formFooter :: Section
-formFooter = sectionBodyRule 1 ruleFooter
+formFooter :: String -> Section
+formFooter instruction = sectionBodyRule 1 ruleFooter
   [ printerLineCenter 4
-      [ blockSimple
-          "Type a value and press Enter for each field; empty fields cancel."
-          (themeFooter theme) ]
+      [ blockSimple instruction (themeFooter theme) ]
   ]
+
+addInstruction, editInstruction :: String
+addInstruction =
+  "Type a value and press Enter for each field; empty fields cancel."
+editInstruction =
+  "Edit the values and press Enter; Esc cancels."
 ```
 
 Now describe where the input goes. A `Prompt` is placed by **section index** and by **row inside that section**, which is easier to keep correct than counting all the lines below it:
@@ -186,6 +210,11 @@
 formPrompts =
   [ promptAt 1 2 (fieldColumn "Title: ")
   , promptAt 1 4 (fieldColumn "URL:   ") ]
+
+editPrompts :: Bookmark -> [Prompt]
+editPrompts (Bookmark title url) =
+  [ promptWithInitial 1 2 (fieldColumn "Title: ") title
+  , promptWithInitial 1 4 (fieldColumn "URL:   ") url ]
 ```
 
 The form body is section `1`. `sectionBodyRule 1` prints a rule on row `0` and an empty line on row `1`, so the title is row `2` and the URL is row `4`. The footer carries the instruction, so the body only reserves the two editable lines. The column is the label offset plus the display width of the label. `textWidth` understands wide characters and combining marks, so the cursor stays aligned even with Unicode labels.
@@ -196,10 +225,22 @@
 readForm :: Page -> [Prompt] -> State -> IO (State, Either FormError [String])
 ```
 
-The result is an ADT. `Right values` contains one string per prompt, `Left (PromptOutsidePage prompt)` means the prompt does not fit the drawn page, and `Left FormInputEnded` means stdin ended. Empty field values are still valid strings; this program decides to cancel the add when either value is empty.
+The result is an ADT. `Right values` contains one string per prompt, `Left (PromptOutsidePage prompt)` means the prompt does not fit the drawn page, `Left FormInputEnded` means stdin ended, and `Left FormCancelled` means the user pressed Esc in an editable field. Empty field values are still valid strings; this program decides to cancel the add when either value is empty.
 
-Line input is enabled once for the whole form. Do not wrap `readForm` in `withLineInput`.
+A form without editable fields enables line input once for the whole form. Do not wrap `readForm` in `withLineInput`.
 
+### Editing an existing value
+
+The edit flow uses `promptWithInitial` instead of `promptAt`:
+
+```haskell
+promptWithInitial :: Number -> Number -> Offset -> String -> Prompt
+```
+
+Each field opens with its current value in the editable buffer and the cursor at the end. The user can backspace, insert and move with the arrows, `Home`, `End` and `Delete`, then press Enter to submit. Esc cancels the whole form. The editor scrolls horizontally when a value is wider than the terminal, so a long URL stays editable. The prompt column and row are the same as in the add form, because both use the same page.
+
+When stdin is not a terminal, `readForm` reads a line per prompt and ignores the initial value, which keeps the program usable in pipes and tests.
+
 ## 6. Run the interaction loop
 
 The program starts in immediate input mode, draws the first page and reads one key at a time:
@@ -212,27 +253,28 @@
 
 loop :: Model -> State -> IO ()
 loop model state = do
-  key <- readKey
+  key <- readKeyPress
   case fmap toLower key of
     Nothing -> pure ()
     Just 'a' -> addFlow model state
     Just 'j' -> open (moveSelection 1 model) state
     Just 'k' -> open (moveSelection (-1) model) state
     Just 'd' -> open (deleteSelected model) state
+    Just 'e' -> editFlow model state
     Just 'x' -> pure ()
     _ -> loop model state
   where
     open next st = draw (listPage next) st >>= loop next
 ```
 
-Each action returns a new `Model`, draws the next page and continues the loop. The next `readKey` needs no Enter.
+Each action returns a new `Model`, draws the next page and continues the loop. The next `readKeyPress` needs no Enter. It also discards escape sequences, so pressing an arrow key by mistake does not reach the shortcut map as an `A` or `D`.
 
 The add flow combines the form with the model update:
 
 ```haskell
 addFlow :: Model -> State -> IO ()
 addFlow model state = do
-  (state', result) <- readForm formPage formPrompts state
+  (state', result) <- readForm (formPage addInstruction) formPrompts state
   let next = case result of
         Right [title, url]
           | not (null title) && not (null url) ->
@@ -241,6 +283,23 @@
   draw (listPage next) state' >>= loop next
 ```
 
+The edit flow prefills both fields with the selected bookmark and writes the result back:
+
+```haskell
+editFlow :: Model -> State -> IO ()
+editFlow model state = case selectedBookmark model of
+  Nothing -> draw (listPage model) state >>= loop model
+  Just bookmark -> do
+    (state', result) <- readForm (formPage editInstruction)
+                                  (editPrompts bookmark) state
+    let next = case result of
+          Right [title, url]
+            | not (null title) && not (null url) ->
+                updateSelected title url model
+          _ -> model
+    draw (listPage next) state' >>= loop next
+```
+
 `readForm` restores the immediate input settings when it returns, so the list keeps responding to single keys. If the form is cancelled or stdin ends, the model is unchanged and the list is drawn again.
 
 ## 7. Try it
@@ -257,7 +316,9 @@
 2. Type a title and press Enter.
 3. Type a URL and press Enter.
 4. Use `J` and `K` to move the selection.
-5. Press `D` to delete the selected bookmark, or `X` to exit.
+5. Press `E` to edit the selected bookmark, change part of a value and press Enter.
+6. Press Esc during an edit to cancel it.
+7. Press `D` to delete the selected bookmark, or `X` to exit.
 
 The program has no persistence and no error dialogs; it is intentionally small. The important part is the boundary: pure builders describe the screen, the drawing layer prints it, and the loop decides what to draw next.
 
@@ -266,5 +327,6 @@
 - Add persistence by reading and writing a file outside the pure model functions. Keep the builders pure.
 - Replace `printerColumn` with `printerTable` or `printerSequenceSel` to experiment with tables and highlighted paragraphs. See [Content](content.md) and [Printers](printers.md).
 - Use `renderPage` or `renderPrinter` from [Render](render.md) to capture output in a `String` for golden tests.
-- Read [Input](input.md) for the full input API and [Numbers](number.md) for checked arithmetic.
+- Read [Input](input.md) for the full input API, including editable fields, and [Numbers](number.md) for checked arithmetic.
+- Use `Ansiform.Line` directly to embed the pure line editor in a screen with its own layout.
 - Copy `examples/Tutorial.hs` into your own project and change the model. The example is compiled with the library, so it stays aligned with the documented API.
diff --git a/examples/Demo.hs b/examples/Demo.hs
--- a/examples/Demo.hs
+++ b/examples/Demo.hs
@@ -16,7 +16,7 @@
 
 -- Version of this demo, following the package version.
 demoVersion :: String
-demoVersion = "0.7.10.4"
+demoVersion = "0.9.0.5"
 
 -- The philosophers shown in the Groups screen.
 data Philosopher
@@ -37,7 +37,7 @@
 
 loop :: Screen -> State -> IO ()
 loop screen state = do
-  key <- readKey
+  key <- readKeyPress
   case fmap toLower key of
     Nothing -> finish
     Just 'l' -> open List state
diff --git a/examples/Tutorial.hs b/examples/Tutorial.hs
--- a/examples/Tutorial.hs
+++ b/examples/Tutorial.hs
@@ -36,6 +36,25 @@
   where
     kept = take selected bookmarks ++ drop (selected + 1) bookmarks
 
+updateSelected :: String -> String -> Model -> Model
+updateSelected title url model@(Model bookmarks selected) = case bookmarks of
+  [] -> model
+  _ -> let index = clampSelection (length bookmarks) selected
+       in Model (replaceAt index (Bookmark title url) bookmarks) index
+
+replaceAt :: Int -> a -> [a] -> [a]
+replaceAt index value values =
+  take index values ++ [value] ++ drop (index + 1) values
+
+selectedBookmark :: Model -> Maybe Bookmark
+selectedBookmark (Model bookmarks selected) =
+  bookmarkAt (clampSelection (length bookmarks) selected) bookmarks
+
+bookmarkAt :: Int -> [Bookmark] -> Maybe Bookmark
+bookmarkAt index bookmarks = case drop index bookmarks of
+  [] -> Nothing
+  (bookmark:_) -> Just bookmark
+
 moveSelection :: Int -> Model -> Model
 moveSelection delta (Model bookmarks selected) =
   Model bookmarks (clampSelection (length bookmarks) (selected + delta))
@@ -109,6 +128,7 @@
       , blockMenuStyle "J" "Down" (themeAccent theme)
       , blockMenuStyle "K" "Up" (themeAccent theme)
       , blockMenuStyle "D" "Delete" (themeDanger theme)
+      , blockMenuStyle "E" "Edit" (themeAccent theme)
       , blockMenuStyle "X" "Exit" (themeDanger theme) ]
   ]
 
@@ -116,8 +136,8 @@
 -- Form page
 --------------------------------------------------------------------------------
 
-formPage :: Page
-formPage = page [header, formBody, formFooter, bottom]
+formPage :: String -> Page
+formPage instruction = page [header, formBody, formFooter instruction, bottom]
 
 formBody :: Section
 formBody = sectionBodyRule 1 ruleBody
@@ -126,14 +146,18 @@
   , printerLeft 2 (blockSimple "URL:   " styleDefault)
   ]
 
-formFooter :: Section
-formFooter = sectionBodyRule 1 ruleFooter
+formFooter :: String -> Section
+formFooter instruction = sectionBodyRule 1 ruleFooter
   [ printerLineCenter 4
-      [ blockSimple
-          "Type a value and press Enter for each field; empty fields cancel."
-          (themeFooter theme) ]
+      [ blockSimple instruction (themeFooter theme) ]
   ]
 
+addInstruction, editInstruction :: String
+addInstruction =
+  "Type a value and press Enter for each field; empty fields cancel."
+editInstruction =
+  "Edit the values and press Enter; Esc cancels."
+
 fieldColumn :: String -> Offset
 fieldColumn label = formNumber (2 + textWidth label)
 
@@ -142,6 +166,11 @@
   [ promptAt 1 2 (fieldColumn "Title: ")
   , promptAt 1 4 (fieldColumn "URL:   ") ]
 
+editPrompts :: Bookmark -> [Prompt]
+editPrompts (Bookmark title url) =
+  [ promptWithInitial 1 2 (fieldColumn "Title: ") title
+  , promptWithInitial 1 4 (fieldColumn "URL:   ") url ]
+
 --------------------------------------------------------------------------------
 -- Application
 --------------------------------------------------------------------------------
@@ -153,13 +182,14 @@
 
 loop :: Model -> State -> IO ()
 loop model state = do
-  key <- readKey
+  key <- readKeyPress
   case fmap toLower key of
     Nothing -> pure ()
     Just 'a' -> addFlow model state
     Just 'j' -> open (moveSelection 1 model) state
     Just 'k' -> open (moveSelection (-1) model) state
     Just 'd' -> open (deleteSelected model) state
+    Just 'e' -> editFlow model state
     Just 'x' -> pure ()
     _ -> loop model state
   where
@@ -167,10 +197,23 @@
 
 addFlow :: Model -> State -> IO ()
 addFlow model state = do
-  (state', result) <- readForm formPage formPrompts state
+  (state', result) <- readForm (formPage addInstruction) formPrompts state
   let next = case result of
         Right [title, url]
           | not (null title) && not (null url) ->
               addBookmark title url model
         _ -> model
   draw (listPage next) state' >>= loop next
+
+editFlow :: Model -> State -> IO ()
+editFlow model state = case selectedBookmark model of
+  Nothing -> draw (listPage model) state >>= loop model
+  Just bookmark -> do
+    (state', result) <- readForm (formPage editInstruction)
+                                  (editPrompts bookmark) state
+    let next = case result of
+          Right [title, url]
+            | not (null title) && not (null url) ->
+                updateSelected title url model
+          _ -> model
+    draw (listPage next) state' >>= loop next
diff --git a/src/Ansiform.hs b/src/Ansiform.hs
--- a/src/Ansiform.hs
+++ b/src/Ansiform.hs
@@ -16,6 +16,7 @@
 import           Ansiform.Content   as All
 import           Ansiform.Draw      as All
 import           Ansiform.Input     as All
+import           Ansiform.Line      as All
 import           Ansiform.Number    as All
 import           Ansiform.Page      as All
 import           Ansiform.Printer   as All
diff --git a/src/Ansiform/Input.hs b/src/Ansiform/Input.hs
--- a/src/Ansiform/Input.hs
+++ b/src/Ansiform/Input.hs
@@ -1,7 +1,9 @@
 -- | Immediate single-key input and line input for interactive interfaces.
-module Ansiform.Input ( withImmediateInput, readKey, withLineInput, readLine
+module Ansiform.Input ( withImmediateInput, readKey, readKeyPress
+                      , withLineInput, readLine
                       , readLineAt, readLineAtOn
-                      , Prompt, promptAt, promptSection, promptRow, promptColumn
+                      , Prompt, promptAt, promptWithInitial
+                      , promptSection, promptRow, promptColumn, promptInitial
                       , FormError (..), readForm, readFormOn
                       ) where
 
diff --git a/src/Ansiform/Internal.hs b/src/Ansiform/Internal.hs
--- a/src/Ansiform/Internal.hs
+++ b/src/Ansiform/Internal.hs
@@ -3,6 +3,7 @@
 --------------------------------------------------------------------------------
 import           Ansiform.Internal.Content   as All
 import           Ansiform.Internal.Input     as All
+import           Ansiform.Internal.Line      as All
 import           Ansiform.Internal.Number    as All
 import           Ansiform.Internal.Output    as All
 import           Ansiform.Internal.Page      as All
diff --git a/src/Ansiform/Internal/Input.hs b/src/Ansiform/Internal/Input.hs
--- a/src/Ansiform/Internal/Input.hs
+++ b/src/Ansiform/Internal/Input.hs
@@ -1,20 +1,28 @@
 -- | Immediate single-key input and line input for interactive interfaces.
-module Ansiform.Internal.Input (withImmediateInput, readKey, withLineInput, readLine,
+module Ansiform.Internal.Input (withImmediateInput, readKey, readKeyPress,
+                                withLineInput, readLine,
                                 readLineAt, readLineAtOn,
-                                Prompt, promptAt, promptSection, promptRow, promptColumn,
+                                Prompt, promptAt, promptWithInitial,
+                                promptSection, promptRow, promptColumn, promptInitial,
                                 FormError(..), readForm, readFormOn) where
 
 --------------------------------------------------------------------------------
-import           Control.Exception (bracket_)
+import           Control.Exception (bracket_, tryJust)
 import           Control.Monad (when)
-import           System.Console.ANSI (hCursorUp, hCursorDown, hSetCursorColumn)
+import           Data.Char (isPrint)
+import           Data.Maybe (isJust)
+import           System.Console.ANSI (hClearFromCursorToLineEnd, hCursorDown,
+                                      hCursorUp, hSetCursorColumn)
 import           System.IO (BufferMode (..), Handle, hFlush, hGetBuffering,
-                            hGetEcho, hIsTerminalDevice, hSetBuffering,
-                            hSetEcho, isEOF, stdin, stdout)
+                            hGetEcho, hIsTerminalDevice, hPutStr, hSetBuffering,
+                            hSetEcho, hWaitForInput, isEOF, stdin, stdout)
+import           System.IO.Error (isEOFError)
 import           Ansiform.Draw (drawOn)
+import           Ansiform.Internal.Line
 import           Ansiform.Internal.Number
 import           Ansiform.Internal.Page
 import           Ansiform.Internal.State
+import           Ansiform.Internal.Terminal (termWOn)
 import           Ansiform.Internal.Utils
 
 
@@ -39,11 +47,77 @@
 
 -- | Reads one key without waiting for Enter.
 --   Returns 'Nothing' at end of input.
+--   This is the raw reader: it returns every character, including the parts of
+--   an escape sequence. Use 'readKeyPress' in menus and command loops.
 readKey :: IO (Maybe Char)
 readKey = do
     eof <- isEOF
     if eof then return Nothing else Just <$> getChar
 
+-- | Delay in milliseconds used to tell a lone Escape from an escape sequence.
+escapeDelay :: Int
+escapeDelay = 50
+
+-- | Result of waiting for the next key within a time limit.
+data KeyWait = KeyReady Char | KeyTimeout | KeyEnded
+
+-- | Waits up to the given number of milliseconds for the next key.
+--   Uses 'hWaitForInput', which is portable and avoids interrupting a blocked
+--   read with an asynchronous exception. Only an end-of-file exception is
+--   turned into data; other I/O errors still propagate.
+waitKey :: Int -> IO KeyWait
+waitKey ms = do
+    eof <- isEOF
+    if eof
+        then return KeyEnded
+        else do
+            ready <- tryJust eofOnly (hWaitForInput stdin ms)
+            case ready of
+                Left _ -> return KeyEnded
+                Right False -> return KeyTimeout
+                Right True -> KeyReady <$> getChar
+  where
+    eofOnly e = if isEOFError e then Just e else Nothing
+
+-- | Reads one key press for shortcut handling.
+--   Arrow keys, Home, End, function keys and other escape sequences consume
+--   their whole sequence and are ignored, so they cannot trigger the shortcuts
+--   of a menu. A lone Escape is returned as @Just '\ESC'@.
+--   Returns 'Nothing' at end of input. Use 'readKey' when the raw characters
+--   are needed, such as inside a text editor.
+readKeyPress :: IO (Maybe Char)
+readKeyPress = do
+    key <- readKey
+    case key of
+        Nothing -> return Nothing
+        Just '\ESC' -> do
+            next <- waitKey escapeDelay
+            case next of
+                KeyTimeout -> return (Just '\ESC')
+                KeyEnded -> return Nothing
+                KeyReady '[' -> skipControlSequence >> readKeyPress
+                KeyReady 'O' -> skipFinalByte >> readKeyPress
+                KeyReady _ -> readKeyPress
+        Just c -> return (Just c)
+
+-- Skips the rest of a CSI sequence, whose final byte is in 0x40 to 0x7E.
+skipControlSequence :: IO ()
+skipControlSequence = do
+    next <- waitKey escapeDelay
+    case next of
+        KeyTimeout -> return ()
+        KeyEnded -> return ()
+        KeyReady c
+            | c >= '\x40' && c <= '\x7E' -> return ()
+            | otherwise -> skipControlSequence
+
+-- Skips the final byte of an SS3 sequence, such as an arrow in application
+-- cursor mode.
+skipFinalByte :: IO ()
+skipFinalByte = do
+    _ <- waitKey escapeDelay
+    return ()
+
 -- | Runs an action with stdin configured for line input with echo.
 --   This is useful for prompts that ask the user to type a value and press Enter.
 withLineInput :: IO a -> IO a
@@ -97,6 +171,8 @@
       -- ^ Zero-based row inside the section.
     , promptColumn :: Offset
       -- ^ Absolute column where typing starts.
+    , promptInitial :: Maybe String
+      -- ^ Optional initial value read by the line editor.
     } deriving (Eq, Show)
 
 -- | Builds a prompt from a zero-based section index, a zero-based row inside
@@ -104,19 +180,31 @@
 --   correct than the number of lines below the prompt, and it survives
 --   changes to the other sections of the page.
 promptAt :: Number -> Number -> Offset -> Prompt
-promptAt = Prompt
+promptAt section row column = Prompt section row column Nothing
 
+-- | Builds a prompt that opens with an editable initial value.
+--   Non-printable characters are removed, because the editor is single-line.
+--   An empty initial value is still meaningful: it opens the line editor with
+--   an empty buffer instead of falling back to the terminal line discipline.
+promptWithInitial :: Number -> Number -> Offset -> String -> Prompt
+promptWithInitial section row column initial =
+    Prompt section row column (Just (filter isPrint initial))
+
 -- | Why a form could not be completed.
 data FormError
     = PromptOutsidePage Prompt
     | FormInputEnded
+    | FormCancelled
     deriving (Eq, Show)
 
 -- | Draws a page and reads one line for every prompt, in the order given.
 --   The function computes the cursor movement from the line counts recorded
 --   by the draw, so callers do not count the lines below each prompt.
---   Line input is enabled once for the whole form and prompts are read with
---   echo, without nesting 'withLineInput'.
+--   Prompts without an initial value are read with 'readLine', and line input
+--   is enabled once for the whole form, without nesting 'withLineInput'.
+--   Prompts built with 'promptWithInitial' use the Ansiform line editor when
+--   stdin is a terminal, so the value can be changed in place. When stdin is
+--   not a terminal, they fall back to 'readLine' and ignore the initial value.
 --   Returns the state from drawing the page and either the values or the
 --   reason the form stopped. A prompt outside the page fails before any input
 --   is read.
@@ -133,17 +221,38 @@
     case placed of
         Left bad -> return (st', Left (PromptOutsidePage bad))
         Right lines' -> do
-            values <- withLineInput $ readAll total (zip lines' prompts)
-            return (st', maybe (Left FormInputEnded) Right values)
-  where
-    readAll _ [] = return (Just [])
-    readAll current ((line,prompt):rest) = do
-        moveCursorBy h (line - current) (promptColumn prompt)
-        value <- readLine
-        case value of
-            Nothing -> return Nothing
-            Just v -> fmap (fmap (v:)) (readAll (line + 1) rest)
+            interactive <- hIsTerminalDevice stdin
+            let useEditor = interactive && any (isJust . promptInitial) prompts
+                fields = zip lines' prompts
+                readFields = if useEditor
+                    then readEditorFields h total fields
+                    else withLineInput (readPlainFields h total fields)
+            values <- readFields
+            return (st', values)
 
+-- Reads prompts through the terminal line discipline.
+readPlainFields :: Handle -> Int -> [(Int, Prompt)] -> IO (Either FormError [String])
+readPlainFields _ _ [] = return (Right [])
+readPlainFields h current ((line,prompt):rest) = do
+    moveCursorBy h (line - current) (promptColumn prompt)
+    value <- readLine
+    case value of
+        Nothing -> return (Left FormInputEnded)
+        Just v -> fmap (fmap (v:)) (readPlainFields h (line + 1) rest)
+
+-- Reads prompts in order, choosing the line editor or the line discipline.
+readEditorFields :: Handle -> Int -> [(Int, Prompt)] -> IO (Either FormError [String])
+readEditorFields _ _ [] = return (Right [])
+readEditorFields h current ((line,prompt):rest) = do
+    moveCursorBy h (line - current) (promptColumn prompt)
+    value <- case promptInitial prompt of
+        Nothing -> fmap (maybe (Left FormInputEnded) Right) (withLineInput readLine)
+        Just initial ->
+            withImmediateInput (readLineEditor h (promptColumn prompt) initial)
+    case value of
+        Left err -> return (Left err)
+        Right v -> fmap (fmap (v:)) (readEditorFields h (line + 1) rest)
+
 -- Converts a section and row to an absolute page line.
 promptLine :: [Int] -> Prompt -> Either Prompt Int
 promptLine counts p = case atMay counts section of
@@ -164,3 +273,110 @@
         when (delta < 0) $ hCursorUp h (negate delta)
         hSetCursorColumn h (unn column)
         hFlush h
+
+
+-- Line Editor
+--------------------------------------------------------------------------------
+
+-- | One decoded key press for the line editor.
+data LineEvent = LineSubmit | LineCancel | LineEdit LineKey | LineIgnore
+
+-- | Reads one field with an editable initial value.
+--   When stdin is not a terminal the initial value is ignored and a regular
+--   line is read, keeping non-interactive consumers neutral. The caller is
+--   responsible for the immediate input mode of the interactive branch.
+readLineEditor :: Handle -> Offset -> String -> IO (Either FormError String)
+readLineEditor h column initial = do
+    interactive <- hIsTerminalDevice stdin
+    if not interactive
+        then fmap (maybe (Left FormInputEnded) Right) readLine
+        else do
+            width <- termWOn h
+            let start = LineState initial (length initial)
+            redrawLine h width column start
+            editLoop h width column start
+
+-- Applies decoded keys until the field is submitted, cancelled or input ends.
+editLoop :: Handle -> Int -> Offset -> LineState -> IO (Either FormError String)
+editLoop h width column state = do
+    event <- readLineEvent
+    case event of
+        Nothing -> return (Left FormInputEnded)
+        Just LineCancel -> do
+            newline h
+            return (Left FormCancelled)
+        Just LineSubmit -> do
+            newline h
+            return (Right (lineText state))
+        Just (LineEdit key) -> do
+            let next = applyLineKey key state
+            redrawLine h width column next
+            editLoop h width column next
+        Just LineIgnore -> editLoop h width column state
+
+-- Moves to the prompt column, draws the visible window and clears the rest of
+-- the line, then places the cursor at its display offset.
+redrawLine :: Handle -> Int -> Offset -> LineState -> IO ()
+redrawLine h width column state = do
+    let available = max 0 (width - unn column)
+        (window, cursorOffset) = visibleLine available state
+    hSetCursorColumn h (unn column)
+    hPutStr h window
+    hClearFromCursorToLineEnd h
+    hSetCursorColumn h (unn column + cursorOffset)
+    hFlush h
+
+newline :: Handle -> IO ()
+newline h = hPutStr h "\n" >> hFlush h
+
+-- Decodes one key press, reading escape sequences with a short timeout.
+readLineEvent :: IO (Maybe LineEvent)
+readLineEvent = do
+    key <- readKey
+    case key of
+        Nothing -> return Nothing
+        Just '\r' -> return (Just LineSubmit)
+        Just '\n' -> return (Just LineSubmit)
+        Just '\DEL' -> return (Just (LineEdit LineBackspace))
+        Just '\b' -> return (Just (LineEdit LineBackspace))
+        Just '\ESC' -> readEscapeSequence
+        Just c
+            | isPrint c -> return (Just (LineEdit (LineInsert c)))
+            | otherwise -> return (Just LineIgnore)
+
+-- A lone Escape cancels; a bracket starts a control sequence.
+readEscapeSequence :: IO (Maybe LineEvent)
+readEscapeSequence = do
+    next <- waitKey escapeDelay
+    case next of
+        KeyTimeout -> return (Just LineCancel)
+        KeyEnded -> return Nothing
+        KeyReady '[' -> readControlSequence
+        KeyReady _ -> return (Just LineIgnore)
+
+-- Decodes the final byte of a CSI sequence. Unknown sequences are ignored.
+readControlSequence :: IO (Maybe LineEvent)
+readControlSequence = do
+    next <- waitKey escapeDelay
+    case next of
+        KeyTimeout -> return (Just LineIgnore)
+        KeyEnded -> return Nothing
+        KeyReady c -> case c of
+            'D' -> return (Just (LineEdit LineMoveLeft))
+            'C' -> return (Just (LineEdit LineMoveRight))
+            'H' -> return (Just (LineEdit LineMoveHome))
+            'F' -> return (Just (LineEdit LineMoveEnd))
+            '1' -> readTildeSequence LineMoveHome
+            '4' -> readTildeSequence LineMoveEnd
+            '3' -> readTildeSequence LineDelete
+            _ -> return (Just LineIgnore)
+
+-- Reads the final tilde of a numbered CSI sequence.
+readTildeSequence :: LineKey -> IO (Maybe LineEvent)
+readTildeSequence key = do
+    next <- waitKey escapeDelay
+    case next of
+        KeyTimeout -> return (Just LineIgnore)
+        KeyEnded -> return Nothing
+        KeyReady '~' -> return (Just (LineEdit key))
+        KeyReady _ -> return (Just LineIgnore)
diff --git a/src/Ansiform/Internal/Line.hs b/src/Ansiform/Internal/Line.hs
new file mode 100644
--- /dev/null
+++ b/src/Ansiform/Internal/Line.hs
@@ -0,0 +1,105 @@
+-- | Pure single-line editing state, keys and width-aware windowing.
+module Ansiform.Internal.Line (LineState (..), LineKey (..), applyLineKey,
+                               visibleLine) where
+
+--------------------------------------------------------------------------------
+import           Data.Char (isPrint)
+import           Ansiform.Internal.Width (charWidth, textWidth)
+
+
+-- Line State
+--------------------------------------------------------------------------------
+
+-- | An immutable single-line buffer and its cursor.
+--   The cursor is a zero-based code-point index.
+data LineState = LineState
+    { lineText :: String
+      -- ^ Current contents of the buffer.
+    , lineCursor :: Int
+      -- ^ Cursor position as a code-point index into 'lineText'.
+    } deriving (Eq, Show)
+
+-- | Editing keys understood by the line editor.
+data LineKey
+    = LineInsert Char
+      -- ^ Insert a printable character at the cursor.
+    | LineBackspace
+      -- ^ Delete the character before the cursor.
+    | LineDelete
+      -- ^ Delete the character at the cursor.
+    | LineMoveLeft
+      -- ^ Move the cursor one code point to the left.
+    | LineMoveRight
+      -- ^ Move the cursor one code point to the right.
+    | LineMoveHome
+      -- ^ Move the cursor to the start of the buffer.
+    | LineMoveEnd
+      -- ^ Move the cursor to the end of the buffer.
+    deriving (Eq, Show)
+
+
+-- Pure Editing
+--------------------------------------------------------------------------------
+
+-- | Applies one key to the buffer, returning a new state.
+--   Movement stops at the buffer boundaries, deletion at either boundary is a
+--   no-op, and non-printable characters are ignored by 'LineInsert'.
+applyLineKey :: LineKey -> LineState -> LineState
+applyLineKey key (LineState text cursor) = case key of
+    LineInsert c
+        | isPrint c ->
+            let (before, after) = splitAt position text
+            in LineState (before ++ (c : after)) (position + 1)
+        | otherwise -> LineState text position
+    LineBackspace
+        | position <= 0 -> LineState text position
+        | otherwise ->
+            let (before, after) = splitAt position text
+            in LineState (init before ++ after) (position - 1)
+    LineDelete
+        | position >= length text -> LineState text position
+        | otherwise ->
+            LineState (take position text ++ drop (position + 1) text) position
+    LineMoveLeft -> LineState text (max 0 (position - 1))
+    LineMoveRight -> LineState text (min (length text) (position + 1))
+    LineMoveHome -> LineState text 0
+    LineMoveEnd -> LineState text (length text)
+  where
+    position = max 0 (min (length text) cursor)
+
+-- | Selects the substring and cursor offset that fit in a terminal width.
+--   The first argument is the number of columns available to the editor.
+--   One column is reserved for the cursor, so a full window leaves the cursor
+--   inside the terminal. When the buffer does not fit, the window scrolls so
+--   that the cursor stays visible. Widths come from 'charWidth', so wide
+--   characters and combining marks keep the cursor aligned.
+visibleLine :: Int -> LineState -> (String, Int)
+visibleLine width (LineState text cursor) = (window, windowCursor)
+  where
+    usable = max 1 (width - 1)
+    position = max 0 (min (length text) cursor)
+    cursorColumn = textWidth (take position text)
+    start = windowStart usable cursorColumn text
+    afterStart = drop start text
+    window = takeWindow usable afterStart
+    windowCursor = textWidth (take (position - start) afterStart)
+
+-- Finds the first code point that leaves at most the usable width before the
+-- cursor.
+windowStart :: Int -> Int -> String -> Int
+windowStart usable = go 0
+  where
+    go index remaining _
+        | remaining <= usable = index
+    go index remaining (c:cs) =
+        go (index + 1) (remaining - charWidth c) cs
+    go index _ [] = index
+
+-- Takes code points from the start while they fit in the usable width.
+takeWindow :: Int -> String -> String
+takeWindow usable = go 0
+  where
+    go _ [] = []
+    go used (c:cs)
+        | used + charWidth c > usable = []
+        | otherwise = c : go (used + charWidth c) cs
diff --git a/src/Ansiform/Internal/Settings.hs b/src/Ansiform/Internal/Settings.hs
--- a/src/Ansiform/Internal/Settings.hs
+++ b/src/Ansiform/Internal/Settings.hs
@@ -2,8 +2,9 @@
 
 --------------------------------------------------------------------------------
 import qualified Data.List as List
+import           System.Console.ANSI (hSupportsANSI)
 import           System.Environment (lookupEnv)
-import           System.IO (Handle, hIsTerminalDevice, stdout)
+import           System.IO (Handle, stdout)
 import           Ansiform.Internal.Number
 import           Ansiform.Internal.Sample
 import           Ansiform.Internal.Terminal
@@ -117,14 +118,13 @@
 colorsSupported = colorsSupportedOn stdout
 
 -- Checks whether a handle can safely receive ANSI styles.
+--   Uses 'hSupportsANSI', which also recognises Windows consoles and MinTTY,
+--   and keeps the NO_COLOR opt-out on top of it.
 colorsSupportedOn :: Handle -> IO Bool
 colorsSupportedOn h = do
     noColor <- lookupEnv "NO_COLOR"
-    term <- lookupEnv "TERM"
-    terminal <- hIsTerminalDevice h
-    return $ terminal
-          && not (maybe False (not . null) noColor)
-          && term /= Just "dumb"
+    terminal <- hSupportsANSI h
+    return $ terminal && not (maybe False (not . null) noColor)
 
 -- Support Functions
 --------------------------------------------------------------------------------
diff --git a/src/Ansiform/Internal/Terminal.hs b/src/Ansiform/Internal/Terminal.hs
--- a/src/Ansiform/Internal/Terminal.hs
+++ b/src/Ansiform/Internal/Terminal.hs
@@ -8,7 +8,7 @@
 --------------------------------------------------------------------------------
 import           System.Console.ANSI as ANSI
 import           System.Console.Terminal.Size
-import           System.IO (Handle, stdout)
+import           System.IO (Handle, hIsTerminalDevice, stdout)
 import           Ansiform.Internal.Number
 
 
@@ -45,14 +45,28 @@
 
 -- Terminal Size
 --------------------------------------------------------------------------------
+
 termW :: IO Int
 termW = termWOn stdout
 
+-- | Width of the terminal behind a handle, or 80 when it is not a terminal.
+--   The terminal check keeps size queries away from regular files and pipes.
+--   On Windows, the stty fallback in terminal-size can close a non-console
+--   handle, so it must not run for output captured in a temporary file.
 termWOn :: Handle -> IO Int
-termWOn h = hSize h >>= \mr -> return $ maybe 80 width mr
+termWOn h = do
+    terminal <- hIsTerminalDevice h
+    if terminal
+        then hSize h >>= \mr -> return $ maybe 80 width mr
+        else return 80
 
 termH :: IO Int
 termH = termHOn stdout
 
+-- | Height of the terminal behind a handle, or 24 when it is not a terminal.
 termHOn :: Handle -> IO Int
-termHOn h = hSize h >>= \mr -> return $ maybe 24 height mr
+termHOn h = do
+    terminal <- hIsTerminalDevice h
+    if terminal
+        then hSize h >>= \mr -> return $ maybe 24 height mr
+        else return 24
diff --git a/src/Ansiform/Line.hs b/src/Ansiform/Line.hs
new file mode 100644
--- /dev/null
+++ b/src/Ansiform/Line.hs
@@ -0,0 +1,6 @@
+-- | Pure single-line editing for prefilled form fields.
+module Ansiform.Line (LineState (..), LineKey (..), applyLineKey,
+                      visibleLine) where
+
+--------------------------------------------------------------------------------
+import           Ansiform.Internal.Line
diff --git a/test/Spec.hs b/test/Spec.hs
--- a/test/Spec.hs
+++ b/test/Spec.hs
@@ -8,7 +8,7 @@
 import           System.Directory (getTemporaryDirectory, removeFile)
 import           System.Exit (exitFailure)
 import           System.IO (hClose, hFlush, hPutStr, hSeek, openTempFile,
-                            SeekMode (AbsoluteSeek), stdin, stdout)
+                            readFile', SeekMode (AbsoluteSeek), stdin, stdout)
 import           Ansiform
 
 main :: IO ()
@@ -83,6 +83,44 @@
         (charWidth '\x1161' == 0)
     , test "Display width of fullwidth forms"
         (charWidth '\xFF21' == 2)
+    , test "Line editor inserts at the end"
+        (applyLineKey (LineInsert 'c') (LineState "ab" 2) == LineState "abc" 3)
+    , test "Line editor inserts in the middle"
+        (applyLineKey (LineInsert 'b') (LineState "ac" 1) == LineState "abc" 2)
+    , test "Line editor inserts at the start"
+        (applyLineKey (LineInsert 'a') (LineState "bc" 0) == LineState "abc" 1)
+    , test "Line editor backspace removes the previous character"
+        (applyLineKey LineBackspace (LineState "abc" 2) == LineState "ac" 1)
+    , test "Line editor backspace at the start is a no-op"
+        (applyLineKey LineBackspace (LineState "abc" 0) == LineState "abc" 0)
+    , test "Line editor delete removes the character at the cursor"
+        (applyLineKey LineDelete (LineState "abc" 1) == LineState "ac" 1)
+    , test "Line editor delete at the end is a no-op"
+        (applyLineKey LineDelete (LineState "abc" 3) == LineState "abc" 3)
+    , test "Line editor movement stops at the buffer boundaries"
+        (applyLineKey LineMoveLeft (LineState "ab" 0) == LineState "ab" 0
+          && applyLineKey LineMoveRight (LineState "ab" 2) == LineState "ab" 2)
+    , test "Line editor moves home and end"
+        (lineCursor (applyLineKey LineMoveHome (LineState "ab" 2)) == 0
+          && lineCursor (applyLineKey LineMoveEnd (LineState "ab" 0)) == 2)
+    , test "Line editor ignores non-printable characters"
+        (applyLineKey (LineInsert '\t') (LineState "a" 1) == LineState "a" 1)
+    , test "Line window shows text that fits"
+        (visibleLine 10 (LineState "abc" 3) == ("abc", 3))
+    , test "Line window scrolls to keep the cursor visible"
+        (visibleLine 5 (LineState "abcdef" 6) == ("cdef", 4))
+    , test "Line window keeps wide characters aligned"
+        (visibleLine 5 (LineState "漢漢漢" 3) == ("漢漢", 4))
+    , test "Line window keeps combining marks attached"
+        (visibleLine 5 (LineState "e\x0301x" 3) == ("e\x0301x", 2))
+    , test "Line window keeps the cursor at the start"
+        (visibleLine 5 (LineState "abcde" 0) == ("abcd", 0))
+    , test "Line window of an empty buffer"
+        (visibleLine 5 (LineState "" 0) == ("", 0))
+    , test "Prompt without an initial value has no buffer"
+        (promptInitial (promptAt 0 0 0) == Nothing)
+    , test "Prompt with an initial value removes non-printable characters"
+        (promptInitial (promptWithInitial 0 0 0 "a\tb\nc") == Just "abc")
     , testIO "Render printer without styling" $ do
         out <- renderPrinter (printerItem (item "plain" (bold red)))
                              (settings [neverColors])
@@ -173,6 +211,35 @@
           (_, formResult) <- readForm p prompts st
           return formResult
         return $ result == Right ["Ada", "42"]
+    , testIO "Form ignores initial values when stdin is not a terminal" $ do
+        let p = page [section
+              [ printerLeft 0 (blockSimple "first" styleDefault)
+              , printerLeft 0 (blockSimple "second" styleDefault) ]]
+            prompts = [ promptWithInitial 0 0 7 "old"
+                      , promptWithInitial 0 1 8 "note" ]
+        (result, _) <- withStdin "Ada\n42\n" $ captureStdout $ do
+          st <- start p (settings [neverColors])
+          (_, formResult) <- readForm p prompts st
+          return formResult
+        return $ result == Right ["Ada", "42"]
+    , testIO "Shortcut reader returns a plain key" $ do
+        result <- withStdin "x" readKeyPress
+        return $ result == Just 'x'
+    , testIO "Shortcut reader ignores arrow escape sequences" $ do
+        result <- withStdin "\ESC[Aq" readKeyPress
+        return $ result == Just 'q'
+    , testIO "Shortcut reader ignores application cursor sequences" $ do
+        result <- withStdin "\ESCOBr" readKeyPress
+        return $ result == Just 'r'
+    , testIO "Shortcut reader ignores parameterized sequences" $ do
+        result <- withStdin "\ESC[1;5Bz" readKeyPress
+        return $ result == Just 'z'
+    , testIO "Shortcut reader reports end of input" $ do
+        result <- withStdin "" readKeyPress
+        return $ result == Nothing
+    , testIO "Raw key reader returns escape sequence characters" $ do
+        result <- withStdin "\ESC[A" (sequence [readKey, readKey, readKey])
+        return $ result == [Just '\ESC', Just '[', Just 'A']
     , testIO "Wide text wraps by display width" $ do
         (_, out) <- captureStdout
           $ runPrinter (settings [width 5, neverColors])
@@ -211,7 +278,7 @@
   hDuplicateTo original stdout
   hClose original
   hClose handle
-  output <- readFile path
+  output <- readFile' path
   removeFile path
   return (result, output)
 
