diff --git a/CHANGELOG.md b/CHANGELOG.md
index a74109a..ade71f7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,84 @@ This file records the set. Anything package-specific says which package.
## Unreleased
+### A catalogue is only as fresh as what it was last told
+
+The client subscribed to one session's channel and to nothing else, so a session appearing, finishing or starting to wait was invisible until somebody navigated away and came back - a reader doing by hand what the host had already said. `HostConnection.onSessions` is the catalogue moving, as opposed to one session's channel; the live client raises it from the root channel it was already draining for something else.
+
+The header had the other half of it: `openSession` is a plain read, so a title or a status arriving changed the store and left the row showing what it said when the session was opened.
+
+### `pageKeys: 'always'` on a Feed
+
+For the feed that *is* the screen, with a text field under it. Somebody typing a message who presses page up means the conversation above them, and taking the keyboard off the field to use those keys is the thing they are avoiding. Only those two keys, and only after the focused node has declined them - so a field that pages its own content keeps them.
+
+### Hover works on things that are not focusable
+
+`onHover` was declared on every node and called from nowhere, and the `hover` style overlay was driven by comparing a focus id against `props.id` - so only a focusable node could ever be hovered. A row that is clicked rather than focused, which is most of them, had a `hover` style nothing could trigger.
+
+Hover is now a hit test over laid-out boxes and inherits down the chain the way it does in a browser: a row is hovered while the pointer is over the label inside it, because the label is what the hit test finds. `onHover` fires once on the way in and once on the way out.
+
+### A selection made with the keyboard copies
+
+`shift` with the arrows highlighted something that was not on the clipboard, which is a selection you have to make again with the mouse. `ctrl+left` and `ctrl+right` also move a word at a time now, and select one with `shift` held.
+
+### Double click takes a word, a third click takes the line
+
+In `TextArea`. Letters with letters, spaces with spaces, punctuation with punctuation - so a double click in the gap between two words takes the gap. A newline joins nothing, so a word never runs across a line break, and the third click takes the logical line rather than the row it was drawn on. A fourth comes back round to a caret.
+
+None of that arrives from the terminal: the wire reports presses and releases and has no notion of a double click. `MouseEvent.at` is a timestamp stamped by whatever produced the event, and the count is same-cell-within-450ms arithmetic on top of it. `@textui/testing` gains `clickRepeat(x, y, times)`; `click` deliberately steps its clock past the window, so two clicks in a test are two clicks.
+
+### OSC 52 was switched off in the one place it exists for
+
+The clipboard capability required a recognised terminal, and none of the variables that name one survive an ssh hop - `TERM_PROGRAM`, `KITTY_WINDOW_ID` and `WT_SESSION` are set by the terminal you are sitting at, not by the machine the program runs on. So a remote session saw a bare `xterm-256color`, decided the terminal could not take a clipboard write, and dropped every copy. Reaching the clipboard of a machine the program is *not* running on is the whole reason OSC 52 was specified.
+
+It is on now for anything not known to mangle it, which is `screen` alone: a terminal that does not implement OSC 52 ignores the whole string rather than printing part of it, because an OSC runs to its terminator.
+
+### A picker opens on the answer that is in force
+
+`ArgSpec.default` is where a command says what its argument is currently set to, and the palette now starts the cursor there instead of at the top of the list. Opening at the top says the first option is the current one, which is wrong on every list where it is not.
+
+A panel opened from a control is also that control's toggle: opening the same one again closes it, rather than closing and reopening it - which looks exactly like the click doing nothing.
+
+### A palette sizes to what it holds
+
+`width` left off, the panel is as wide as its widest row and no wider than `maxWidth` (60 by default). A constant is too wide for a list of one-word answers and too narrow for a list of sentences, and it is the same constant either way. A stated `width` is still a width.
+
+A description on its own line is a `Marquee` like the inline one, so the row under the cursor slides what it had to truncate.
+
+### A description can have a line of its own
+
+`descriptions: 'below'` on `Menu` and `CommandPalette` gives each row's description a line under the label instead of a column beside it. `ArgSpec.descriptions` says it per argument, because the argument is what knows: a list of branch names has nothing to say under each one, and a list of approval modes is *only* told apart by what is under each one. Inline, that sentence shares the width with the label and every answer shows the same truncated half.
+
+### A narrow `List` row gives up the description, not the status
+
+All three columns shrank together, so a catalogue at 58 columns cut the label to "Kqueue events on Li…" *and* the status to "waiting on y…" in order to keep a workspace path nobody was scanning for. The description yields first now, and `meta` yields nothing - the same rule `Menu` already followed.
+
+### The palette says a category once, over its group
+
+The category was in every row's right-hand column, so four screens read "Screens, Screens, Screens, Screens" - in the width the rows needed for saying what they *do*, and still without marking where a group started. It is a heading over the group now, and the column is back to the command's `description`.
+
+`MenuItem` gains `sectionBefore`, which takes the line `separatorBefore` would have used rather than adding one. Typing turns the headings off: a query sorts by relevance, which interleaves the categories, and a heading over a single row is not a group.
+
+### A mouse gesture belongs to whoever started it
+
+Mouse dispatch is a hit test, so a `drag` only ever reached the node the pointer happened to be over - which is not the node the drag is *about* the moment the pointer leaves it. An `onMouse` that returns `true` on a `down` now claims the `drag`s and the `up` that follow, wherever they land, until the button comes back up. See [`onMouse`](docs/components/base-props.md).
+
+Nothing had a drag handler before this, so nothing changes for anything that does not want one. `@textui/testing` gains `drag(from, ...to)`, which sends the whole gesture - the press, the points between and the release - because the parts in the middle are the only ones a handler can be wrong about.
+
+### `TextArea` takes the mouse
+
+A click puts the caret where it landed, a drag selects, and the release puts the selection on the system clipboard over OSC 52 and into the store. Dragging past the edge of the field scrolls it. `shift` with the movement keys extends the selection, typing and `backspace` replace it, and `escape` clears it before it reaches `onCancel`.
+
+That is a debt being paid rather than a feature: reporting mouse events takes the terminal's own select-and-copy away, so an application that reads the mouse has to hand one back. `copyOnSelect={false}` opts out of the clipboard half.
+
+### A theme can say what shape the caret is
+
+`cursor` on a theme is `block`, `underline` or `bar`, applied to the terminal's own caret through `TerminalAdapter.setCursorShape` (DECSCUSR) and reset at teardown only if the session set it. It is also the default for `TextArea`'s drawn caret, so the two do not disagree about the same caret - `bar` arrives there as an underline, since a bar between two characters is the one caret that occupies a cell of its own.
+
+### A divider is not a border
+
+`divider` and `dividerChars` are their own theme setting with their own six sets, resolved down the `extends` chain the way borders are. A borderless theme can still separate with a line, which it could not when the rule was drawn from the border style. `Divider` takes `rule` rather than `style`, which collided with `BoxProps.style`.
+
Publishing moved to npm trusted publishing: GitHub Actions exchanges an OIDC token for a short-lived credential, and there is no `NPM_TOKEN` in the repository any more.
Two things made that more than a flag. Trusted publishing cannot *create* a package - npm only attaches a trusted publisher to a name that already exists, so 0.1.0 still had to be bootstrapped with a token, which was then deleted. And `pnpm publish` has no OIDC support, while `npm publish` cannot read `workspace:^`; `scripts/release-publish.mjs` resolves the ranges and publishes each package from its own directory in dependency order.
diff --git a/docs/components/README.md b/docs/components/README.md
index d88d2f5..54a57da 100644
--- a/docs/components/README.md
+++ b/docs/components/README.md
@@ -31,7 +31,7 @@ Every node also accepts `role`, `label`, `focusable`, `onKey`, `onClick`, `disab
-~96 components, and growing.
+~97 components, and growing.
### The four primitives
@@ -65,6 +65,7 @@ Every node also accepts `role`, `label`, `focusable`, `onKey`, `onClick`, `disab
| [`Badge`](display/badge.md) | Small status marker; carries a glyph as well as a colour. |
| [`StatusDot`](display/status-dot.md) | The shared status vocabulary: up, degraded, down. |
| [`Card`](display/card.md) | Bordered block with a title. |
+| [`ColorText`](display/color-text.md) | Multiline text coloured cell by cell - a ramp, a palette per line, or a function. |
| [`Marquee`](display/marquee.md) | Text too long for its box, read by sliding it while it has the cursor. |
### Data
diff --git a/docs/components/base-props.md b/docs/components/base-props.md
index 96cd706..f5c0b42 100644
--- a/docs/components/base-props.md
+++ b/docs/components/base-props.md
@@ -91,9 +91,9 @@ Props every node accepts. Style arrives three ways on purpose: the full `style`
| `onKey` | `(event: KeyEvent) => boolean \| void` | |
| `onFocus` | `() => void` | |
| `onBlur` | `() => void` | |
-| `onMouse` | `(event: MouseEvent) => boolean \| void` | |
-| `onClick` | `Action \| ((event: MouseEvent) => void)` | |
-| `onHover` | `(hovering: boolean) => void` | |
+| `onMouse` | `(event: MouseEvent) => boolean \| void` | Every mouse action on this node, innermost first. Returning `true` stops it going any further - and on a `down`, **claims the rest of the gesture**: the `drag`s and the `up` that follow come here whatever they are over, until the button comes back up. Dispatch is otherwise a hit test, so without that a drag would stop at the edge of the node it started in, which is where a drag starts being worth having. |
+| `onClick` | `Action \| ((event: MouseEvent) => void)` | The left button going down - a third of a gesture. `onMouse` for the rest. |
+| `onHover` | `(hovering: boolean) => void` | The pointer entered or left this node. Called once each way, not per cell. Hover is inherited the way it is in a browser: a row is hovered while the pointer is over the label inside it, because the label is what a hit test finds. A `style` with a `hover` overlay needs nothing else - this is for the cases where something other than a colour has to happen. |
| `link` | `string` | OSC 8 link target, where the terminal supports hyperlinks. |
| `breakpoints` | `{ compact?: number; minimal?: number }` | Below this width the node renders `compact`; below that, `minimal`. |
diff --git a/docs/components/display.md b/docs/components/display.md
index 67fccbf..1335da1 100644
--- a/docs/components/display.md
+++ b/docs/components/display.md
@@ -11,10 +11,12 @@ What shows a value: a label, a table of them, or a shape drawn from a series.
## Display
-`Heading` `Label` `Badge` `StatusDot` `Card` `KeyValue` `Timeline`
+`Heading` `Label` `Badge` `StatusDot` `Card` `ColorText` `Marquee` `KeyValue` `Timeline`
`StatusDot` is the shared status vocabulary. A status is a glyph *and* a colour, because a 16-colour session, a colourblind reader and a piped log all lose the colour and keep the glyph.
+`ColorText` is the exception that proves that rule: it colours a block of text cell by cell - a ramp, a palette walked in runs, or a function handed each cell - and everything it does is decoration. A banner, a title, ascii art. What a reader has to know still has to be in the words, because the ramp is the first thing an ssh session flattens.
+
## Data
`List` `Table` `Tree` `Pagination` `LogViewer` `CodeViewer` `MarkdownView` `Feed`
diff --git a/docs/components/display/color-text.md b/docs/components/display/color-text.md
new file mode 100644
index 0000000..6b88b40
--- /dev/null
+++ b/docs/components/display/color-text.md
@@ -0,0 +1,102 @@
+---
+title: ColorText
+parent: Display and data
+grand_parent: Components
+---
+
+# ColorText
+{: .no_toc }
+
+Multiline text coloured cell by cell - a ramp, a palette per line, or a function.
+
+
+
+```tsx
+import { ColorText } from '@textui/widgets';
+
+
+```
+
+## Props
+
+
+| Prop | Type | Default | |
+| --- | --- | --- | --- |
+| `ink` | `Ink` | | Left unset, this is an ordinary block of text. |
+| `alignBlock` | `boolean` | | Align the block as one thing, rather than each line on its own. `textAlign` centres every line over its own middle, which is right for prose and shears a picture: five rows of block letters do not have equal widths once the trailing spaces are gone, so each row lands somewhere slightly different and the letters lean. Under this, the whole block is placed once and the lines keep their offsets from each other. Off by default, because that is what `text` does and the two are supposed to mean the same thing by the same prop. |
+
+Plus everything on [`TextProps`](../base-props.md).
+
+
+A `text` takes one colour for the whole run, which is right for nearly
+everything and no answer at all where the colour *is* the content - a banner, a
+ramp across a title, a palette walked down a block of ascii art. Everything else
+about this is a `text`: `wrap`, `truncate`, `textAlign` and the style keys
+all mean here what they mean there.
+
+`ink` is spelled three ways, and the first two are data:
+
+
+
+```tsx
+
+
+
+
+ (cell.index % 2 ? 'muted' : 'accent')} content={text} />
+
+```
+
+A **ramp** runs across the columns, down the lines, or corner to corner. It is
+measured against the widest line of the block, so the rows of a banner share one
+gradient and the colours line up down it; `per: 'line'` restarts it on each
+line, which is what ragged prose wants.
+
+A **cycle** walks a palette. An array on its own is the short spelling of one
+colour per line. `every` is how much of the text each colour takes - a number,
+or a repeating pattern of runs, so `[4, 3]` is four cells then three. The count
+restarts on each line, which is what keeps the bands vertical; `continuous`
+carries it over the line breaks and leans them into a diagonal. `unit` picks
+what advances the colour: `cell` (the default, and the one that keeps a block
+aligned), `grapheme`, `letter`, `word` or `line`.
+
+A **function** is handed each cell and answers with a colour, a whole
+`CellStyle`, or nothing - and nothing means "leave this one alone", which is
+what makes an ink that colours only the vowels two lines long. The cell carries
+a `col` *and* an `index` because they stop being the same number the moment
+the text is not plain ascii: colour by `index` and paint at `col`, or a
+gradient shears through the first wide character it meets.
+
+Only the data forms survive being written in a JSON screen. A function prop
+cannot be serialized - the same trade [`canvas`](../primitives/canvas.md)
+makes with `draw`, and for the same reason: this paints on one.
+
+Two consequences of that canvas are worth knowing. **Inherited colour stops
+here**: a cell the ink declines takes this component's own `fg`, not the one a
+parent row would have handed a `text`, so a `ColorText` inside something that
+recolours its children when selected has to be told. And **a block that wraps
+asks for no width** - it fills what it is given, the way anything that wraps
+must; a block that does not wrap is as wide as its widest line and says so.
+
+`alignBlock` is for pictures. `textAlign` centres every line over its own
+middle, which is right for prose and shears a banner, because five rows of block
+letters do not have equal widths once the trailing spaces are gone. Under
+`alignBlock` the whole block is placed once and the lines keep their offsets
+from each other.
+
+The colour is decoration and never the message. A 16-colour session flattens a
+six-stop ramp into a couple of bands and a piped log loses all of it, so
+anything the reader has to know has to be in the words.
+
+## See also
+
+- [text](../primitives/text.md) - one colour, and the right answer nearly always
+- [canvas](../primitives/canvas.md) - the primitive underneath, for cells that are not text
+- [Themes](../../themes/tokens.md) - the tokens an ink can name
diff --git a/docs/components/display/feed.md b/docs/components/display/feed.md
index a534bb6..e3b1b7b 100644
--- a/docs/components/display/feed.md
+++ b/docs/components/display/feed.md
@@ -30,6 +30,7 @@ import { Feed } from '@textui/widgets';
| `onSelect` | `(index: number) => void` | | |
| `onActivate` | `(index: number) => void` | | |
| `scrollbar` | `boolean` | `true` | |
+| `pageKeys` | `'focused' \| 'always'` | `'focused'` | Who `pageup` and `pagedown` belong to. `focused` is the ordinary answer: the keys go to whatever has the keyboard. `always` claims them even while something else does - for the feed that *is* the screen, with a text field under it. Somebody typing a message who presses page up means the conversation above them; there is nothing else on that screen those keys could be for, and taking the keyboard away from the field to use them is the thing they are avoiding. Only those two keys, and only when the focused node has declined them first - so a field that pages its own content keeps them. |
| `focusable` | `boolean` | `true` | |
| `autoFocus` | `boolean` | | |
| `focusId` | `string` | | So a command can send the reader here by name. |
diff --git a/docs/components/display/list.md b/docs/components/display/list.md
index a794f76..c18148f 100644
--- a/docs/components/display/list.md
+++ b/docs/components/display/list.md
@@ -27,16 +27,18 @@ import { List } from '@textui/widgets';
| Prop | Type | Default | |
| --- | --- | --- | --- |
-| `items` | `ListItem[]` | **required** | |
+| `items` | `T[]` | **required** | The rows. `T` is whatever the caller's own row type is, so long as it is a `ListItem` - which is what the built-in row needs and what `id` being the selection's name needs. Passing plain `ListItem`s is the ordinary case and `T` costs nothing there; a caller with a `renderItem` gets its own fields back on the way in rather than a lookup by id. |
| `selectedId` | `string` | | |
-| `onSelect` | `(id: string, item: ListItem) => void` | | |
-| `onActivate` | `(id: string, item: ListItem) => void` | | |
+| `onSelect` | `(id: string, item: T) => void` | | |
+| `onActivate` | `(id: string, item: T) => void` | | |
| `visibleRows` | `number` | | Rows visible at once. Scrolls when there are more. |
| `emptyMessage` | `string` | `'Nothing here'` | |
-| `marker` | `boolean` | `true` | Draw a marker column for the selected row. |
+| `marker` | `boolean` | `true` | Draw a marker column for the selected row. Drawn either way. |
| `focusable` | `boolean` | `true` | |
| `autoFocus` | `boolean` | | |
| `focusId` | `string` | | A stable focus id, so a command - or the screen that owns this - can put the reader here by name. Without one the id comes from the instance, which nothing outside the render can know. |
+| `renderItem` | `(item: T, state: ListItemState) => RenderOutput` | | Draw one row's contents. The built-in row - icon, title, description, meta, on one line - is the shape most catalogues are, and it is what you get by leaving this alone. The moment a caller wants a different one, the repair is *not* another field on `ListItem` and another flag saying where to put it: that road ends with a component whose props are a small layout language, and it still cannot draw the row after next. So the row is the caller's, and everything a row cannot do for itself stays here: the selection, the keys that move it, the window that scrolls, the highlight, the marker column and the click. `state` is what the row cannot know - whether it is the selected one, and whether that selection is live. A row taller than one line has to say so with `itemHeight`. |
+| `itemHeight` | `number` | `1` | Lines one row occupies, when `renderItem` draws more than one. The list scrolls by arithmetic rather than by measurement - it decides how many rows fit *before* anything is drawn, which is the only way a thousand rows cost the same as ten. That arithmetic is in lines, so a row that is two of them has to be declared, not discovered. |
Plus everything on [`BoxProps`](../base-props.md).
diff --git a/docs/components/input/button.md b/docs/components/input/button.md
index f2863be..cf09d81 100644
--- a/docs/components/input/button.md
+++ b/docs/components/input/button.md
@@ -202,6 +202,7 @@ A theme asking for `none` or `ascii` borders keeps them: both are deliberate loo
## See also
+- [Editing keys and selection](editing.md) - the keys, the selection and the clipboard
- [Checkbox](checkbox.md), [Switch](switch.md) - a state to toggle rather than an action to run
- [FormActions](form-actions.md) - the submit/cancel row, already laid out
- [Nodes](../nodes.md) - actions and bindings in general
diff --git a/docs/components/input/checkbox.md b/docs/components/input/checkbox.md
index 40864f6..997c145 100644
--- a/docs/components/input/checkbox.md
+++ b/docs/components/input/checkbox.md
@@ -36,6 +36,7 @@ Use a checkbox when the options are independent. When exactly one of several mus
## See also
+- [Editing keys and selection](editing.md) - the keys, the selection and the clipboard
- [Switch](switch.md) - the same boolean, different promise
- [RadioGroup](radio-group.md) - one of several
- [Field](field.md) - wrapping it in a form
diff --git a/docs/components/input/danger-zone.md b/docs/components/input/danger-zone.md
index f539ba1..ce5f6d7 100644
--- a/docs/components/input/danger-zone.md
+++ b/docs/components/input/danger-zone.md
@@ -40,5 +40,6 @@ Put it last. A destructive action among ordinary fields is one mis-aimed keystro
## See also
+- [Editing keys and selection](editing.md) - the keys, the selection and the clipboard
- [Dialog](../navigation/dialog.md) - confirming something less final
- [FormActions](form-actions.md) - the ordinary submit row
diff --git a/docs/components/input/editing.md b/docs/components/input/editing.md
new file mode 100644
index 0000000..fccf49b
--- /dev/null
+++ b/docs/components/input/editing.md
@@ -0,0 +1,146 @@
+---
+title: Editing keys and selection
+parent: Controls and forms
+grand_parent: Components
+nav_order: 1
+---
+
+# Editing keys and selection
+{: .no_toc }
+
+What every control does with the keyboard, in one place, so a component page
+can say what is different about *it* rather than repeat this.
+
+## The focused node is offered a key first
+
+Before any keybinding. While a text field has the keyboard, `q` is a letter -
+which is what lets an application with a composer in it keep `n`, `r` and `d`
+as commands, and why a global single-letter binding only fires where nothing
+happens to be reading it.
+
+That is the rule behind most of the surprises on this page. A key that "does
+nothing" in a field is usually a key the field claimed, and a key that does
+nothing *outside* one is usually a binding scoped to somewhere else. See
+[Keybindings](../../platform/keybindings.md).
+
+## Moving
+
+| Key | What it does |
+| --- | --- |
+| `left` `right` | One character |
+| `ctrl+left` `ctrl+right` | One word |
+| `up` `down` | One **row**, not one line - a soft-wrapped line is several rows |
+| `home` `end` | Start and end of the **line**, not of the field |
+| `pageup` `pagedown` | A screenful, where the control scrolls |
+
+A line break is a step of its own for the word keys: walking right stops at the
+end of a line, and the next press crosses to the one below. The end of a line
+is somewhere people mean to be.
+
+`up` and `down` counting rows rather than lines is the same decision made
+everywhere in this library: everything that counts rows counts rows. A
+paragraph that wrapped into four rows is four presses to cross, because that is
+what it looks like on the screen.
+
+## Selecting
+
+| Key | What it does |
+| --- | --- |
+| `shift` + any move key | Extends the selection from where it was anchored |
+| A move key without `shift` | Collapses it |
+| `escape` | Clears it |
+| Any character | Replaces it |
+| `backspace` `delete` | Removes it |
+
+A selection made with the keyboard **goes to the clipboard as it is made**.
+Highlighted and copied are the same thing here, or it is a selection you would
+have to make again with the mouse to be able to use.
+
+`escape` clearing the selection is why `onCancel` is documented as escape *when
+there is nothing inside the field to cancel*: the field spends the key first.
+
+## Selecting with the mouse
+
+| Gesture | What it takes |
+| --- | --- |
+| Click | Puts the caret there |
+| Drag | Selects, and scrolls the field if it leaves the edge |
+| Double click | The **run** under it - letters, spaces or punctuation |
+| Triple click | The **logical line**, with its break |
+| Fourth click | Back to a caret |
+
+A double click takes a run rather than a word, so a double click in the gap
+between two words takes the gap rather than guessing which word was meant. A
+newline joins nothing, so a word selection never runs across a line break. A
+triple click takes the line somebody wrote, not the row it was drawn on - a
+wrapped paragraph is one thing.
+
+None of that arrives from the terminal. The wire reports presses and releases
+and has no notion of a double click, so it is arithmetic on `MouseEvent.at` and
+the cell: the same cell within 450ms, or it is a new gesture.
+
+The drag arrives at all because the application holds the pointer for whoever
+took the button down. Mouse dispatch is otherwise a hit test, and a selection
+dragged past the bottom of a field is the pointer being somewhere the field is
+not.
+
+## Getting the text out
+
+Copy and paste are the **terminal's**, not the application's, and the shortcut
+depends on the emulator - `Ctrl+Shift+C` on most Linux terminals, `Cmd+C` on
+macOS, `Shift+Insert` as the most portable paste. The table is on
+[The clipboard](../../terminal/clipboard.md), along with why `Ctrl+C` could
+never have been it.
+
+Two consequences matter while writing an application:
+
+- **Reporting the mouse takes the terminal's own select-and-copy away.** A
+ control that reads the mouse owes a selection back, or text on the screen
+ cannot leave it. Where no component owns the text - a transcript, a viewer,
+ the output of a tool call - **`shift` and drag** bypasses mouse reporting and
+ gives the terminal's selection back. It is worth saying in an application's
+ key hints, because nobody guesses it.
+- **An application can write the clipboard but not read it.** `OSC 52` has no
+ usable read half, on purpose. `useClipboard` writes to the terminal *and* to
+ the store, and the store is the half a paste inside the application can read.
+
+## Enter, and the key that does not exist
+
+Enter submits where a control offers `onSubmit`; otherwise it is a newline like
+any other key.
+
+A newline in a multi-line field is `ctrl+enter`, with `alt+enter` as the one
+that cannot fail. **Never `shift+enter`** - there is no encoding in which it
+differs from plain `enter`, so a field claiming it would be claiming a key that
+cannot arrive.
+
+`ctrl+enter` is three different byte sequences depending on the terminal, and
+`@textui/terminal` decodes all three: the kitty protocol's `CSI 13;5u`, xterm's
+`modifyOtherKeys` `CSI 27;5;13~`, and a bare LF. The last is the common one,
+and it is *not* the Return key: in raw mode Return sends CR, so an LF reaching
+an application is `ctrl+Return`.
+
+The same collision catches `ctrl+m`, `ctrl+i` and `ctrl+j`. They are the bytes
+for Return, Tab and `ctrl+Return`, so binding one of them means binding those
+keys - only a terminal speaking the kitty protocol or `modifyOtherKeys` can
+tell them apart. **`alt+` has no such problem**: it arrives as `ESC`
+then the letter, which survives `ssh`, tmux and a console that has never heard
+of either protocol. It is the modifier to reach for when a plain letter is
+already a letter.
+
+## Leaving a control
+
+`onOverflow` fires when the cursor tries to leave the top or the bottom, and
+`onEdge` when it walks off the front or the end of the text. They mean
+different things: up and down walk a history, and left off the front of a field
+is "I am done here" - which is how a composer hands the reader back to what is
+beside it without anybody reaching for escape.
+
+## See also
+
+- [TextArea](text-area.md), [TextInput](text-input.md) - the controls this
+ mostly describes
+- [The clipboard](../../terminal/clipboard.md) - the copy and paste table
+- [Keybindings](../../platform/keybindings.md) - scopes, and why the focused
+ node wins
+- [Focus](../../platform/focus.md) - what has the keyboard in the first place
diff --git a/docs/components/input/field.md b/docs/components/input/field.md
index dfd1cc1..5a0b961 100644
--- a/docs/components/input/field.md
+++ b/docs/components/input/field.md
@@ -38,5 +38,6 @@ Plus everything on [`BoxProps`](../base-props.md).
## See also
+- [Editing keys and selection](editing.md) - the keys, the selection and the clipboard
- [Form](form.md) - the context it reads from
- [TextInput](text-input.md), [Select](select.md) - what usually goes inside
diff --git a/docs/components/input/form-actions.md b/docs/components/input/form-actions.md
index fe63ee8..6101c1b 100644
--- a/docs/components/input/form-actions.md
+++ b/docs/components/input/form-actions.md
@@ -35,5 +35,6 @@ Inside a [`Form`](form.md) it wires itself to the form's submit and disables the
## See also
+- [Editing keys and selection](editing.md) - the keys, the selection and the clipboard
- [Form](form.md) - what it submits
- [Button](button.md) - for an action that is not a form submission
diff --git a/docs/components/input/form-section.md b/docs/components/input/form-section.md
index 2614627..ed6c4bd 100644
--- a/docs/components/input/form-section.md
+++ b/docs/components/input/form-section.md
@@ -34,5 +34,6 @@ Worth reaching for once a form is long enough that a reader scrolls it. Below ab
## See also
+- [Editing keys and selection](editing.md) - the keys, the selection and the clipboard
- [Field](field.md) - the rows inside it
- [Panel](../layout/panel.md) - when the group wants a frame
diff --git a/docs/components/input/form.md b/docs/components/input/form.md
index 4fe82f1..3f83bcd 100644
--- a/docs/components/input/form.md
+++ b/docs/components/input/form.md
@@ -49,6 +49,7 @@ Validation runs over the **whole values object** rather than per field, because
## See also
+- [Editing keys and selection](editing.md) - the keys, the selection and the clipboard
- [Field](field.md) - one labelled input inside it
- [FormActions](form-actions.md) - the submit row
- [Controls and forms](../input.md) - `validators` and `fieldValidators`
diff --git a/docs/components/input/radio-group.md b/docs/components/input/radio-group.md
index 4830dc2..7577760 100644
--- a/docs/components/input/radio-group.md
+++ b/docs/components/input/radio-group.md
@@ -45,5 +45,6 @@ The whole group is one stop in the tab order and the arrow keys move within it,
## See also
+- [Editing keys and selection](editing.md) - the keys, the selection and the clipboard
- [Select](select.md) - the same choice, collapsed
- [Checkbox](checkbox.md) - when more than one may be true
diff --git a/docs/components/input/search-box.md b/docs/components/input/search-box.md
index c7c3d9c..d8ff63f 100644
--- a/docs/components/input/search-box.md
+++ b/docs/components/input/search-box.md
@@ -31,5 +31,7 @@ Filtering is not its job. It reports what was typed; what that matches is the sc
## See also
+- [Editing keys and selection](editing.md) - the keys, the selection and the clipboard
+- [The clipboard](../../terminal/clipboard.md) - copy and paste belong to the terminal
- [TextInput](text-input.md) - the full prop list
- [List](../display/list.md), [Table](../display/table.md) - what a search box usually filters
diff --git a/docs/components/input/select.md b/docs/components/input/select.md
index aa52963..32718f0 100644
--- a/docs/components/input/select.md
+++ b/docs/components/input/select.md
@@ -48,5 +48,6 @@ Closed it is one row; open it is a panel on the floating [layer](../../platform/
## See also
+- [Editing keys and selection](editing.md) - the keys, the selection and the clipboard
- [RadioGroup](radio-group.md) - when there are few enough to show at once
- [CommandPalette](../navigation/command-palette.md) - searching commands rather than choosing a value
diff --git a/docs/components/input/slider.md b/docs/components/input/slider.md
index 1f6e85b..99ab6bc 100644
--- a/docs/components/input/slider.md
+++ b/docs/components/input/slider.md
@@ -40,5 +40,6 @@ A terminal slider is coarse: `trackWidth` cells for the whole range, so 20 cells
## See also
+- [Editing keys and selection](editing.md) - the keys, the selection and the clipboard
- [Progress](../display/progress.md) - reporting a value rather than setting one
- [TextInput](text-input.md) - when the precise number matters
diff --git a/docs/components/input/switch.md b/docs/components/input/switch.md
index 62489c5..5e3a88e 100644
--- a/docs/components/input/switch.md
+++ b/docs/components/input/switch.md
@@ -36,5 +36,6 @@ The difference from [`Checkbox`](checkbox.md) is a promise to the reader, not a
## See also
+- [Editing keys and selection](editing.md) - the keys, the selection and the clipboard
- [Checkbox](checkbox.md) - when submission is what applies it
- [Button](button.md) - when it is an action rather than a state
diff --git a/docs/components/input/text-area.md b/docs/components/input/text-area.md
index 550d64f..fc53446 100644
--- a/docs/components/input/text-area.md
+++ b/docs/components/input/text-area.md
@@ -32,7 +32,8 @@ import { TextArea } from '@textui/widgets';
| `autoFocus` | `boolean` | | |
| `focusId` | `string` | | |
| `caretTone` | `'default' \| 'primary' \| 'secondary' \| 'accent' \| 'success' \| 'warning' \| 'danger' \| 'info' \| 'muted'` | | The caret's colour. `cursor` by default, which is the theme's own. A composer usually wants `accent`: the caret is the one thing on the screen saying where typing goes, and the field it sits in is the point of the screen. |
-| `caretStyle` | `'underline' \| 'block'` | `'underline'` | What the caret looks like. An underline under the character it is on by default; `block` fills the cell instead. Both **mark a cell rather than occupying one**. The caret used to be a glyph pushed in between the text before it and the text after, so every character to its right sat one column off from where it would be once the caret moved on, and the row was a cell wider than its own text. On a wrapped row that extra cell is the one that does not fit. |
+| `caretStyle` | `'underline' \| 'block'` | | What the caret looks like. **The theme's `cursor` by default**, so the drawn caret and the terminal's own are the same shape, and an underline when the theme leaves it to the terminal. Both **mark a cell rather than occupying one**. The caret used to be a glyph pushed in between the text before it and the text after, so every character to its right sat one column off from where it would be once the caret moved on, and the row was a cell wider than its own text. On a wrapped row that extra cell is the one that does not fit. That is also why the theme's `bar` arrives here as an underline: a bar *between* two characters is exactly the caret this one is not. |
+| `copyOnSelect` | `boolean` | `true` | Put a selection on the system clipboard as it is made. On by default. Selecting with the mouse is how text leaves a terminal, and an application that reports mouse events has taken the terminal's own select-and-copy away - so it owes one back. The copy goes out over OSC 52 and into the store, which is the half a paste inside the application can read. |
| `blink` | `boolean` | `true` | Blink the caret while the field has the keyboard. On by default. Driven by the animation ticker, so it stops with every other animation - a still, a test and a terminal that has animation off all draw the caret solid rather than at whatever phase the clock happened to be in. |
Plus everything on [`BoxProps`](../base-props.md).
@@ -52,7 +53,41 @@ It also settles the question a single-letter keybinding raises. The focused node
`onOverflow` fires when the cursor tries to leave the top or the bottom, which is how a composer inside a list hands focus back.
+## Selecting with the mouse
+
+The full set of editing and selection keys is
+[Editing keys and selection](editing.md); what follows is what this control
+does with them.
+
+A click puts the caret where it landed. A **drag selects**, and the release puts what was selected on the system clipboard over OSC 52 - and into the store, which is the half a paste inside the application can read back. `copyOnSelect={false}` keeps the selection and skips the clipboard.
+
+That is a debt rather than a feature. Reporting mouse events takes the terminal's own select-and-copy away, so an application that reads the mouse has to hand one back, or text that is on the screen cannot leave it.
+
+The terminal's own copy and paste keys are the reader's, not ours - `Ctrl+Shift+C` on most Linux terminals, `Cmd+C` on macOS, `Shift+Insert` as the most portable paste. [The clipboard](../../terminal/clipboard.md) has the table, and why `Ctrl+C` could never have been it.
+
+Dragging past the edge of the field **scrolls it** rather than stopping at the last row on screen. The drag arrives at all because the application holds the pointer for whoever took the button down: mouse dispatch is otherwise a hit test, and a selection dragged past the field is the pointer being somewhere the field is not.
+
+A **double click takes the run under it** - letters with letters, spaces with spaces, punctuation with punctuation, so a double click in the gap between two words takes the gap rather than one of the words. A newline joins nothing, so a word selection never runs across a line break. A **third click takes the logical line** with its break, not the row it happened to be drawn on: a wrapped paragraph is one thing somebody wrote. A fourth comes back round to a caret.
+
+None of that arrives from the terminal. The wire reports presses and releases and has no notion of a double click, so it is arithmetic on `MouseEvent.at` and the cell: same cell, inside 450ms, or it is a new gesture.
+
+`shift` with `left`, `right`, `up`, `down`, `home` and `end` extends the selection from wherever it was anchored; the same keys without `shift` collapse it. A selection made this way **copies too** - highlighted and on the clipboard are the same thing, or it is a selection you have to make again with the mouse.
+
+`ctrl+left` and `ctrl+right` move a word at a time, and with `shift` select one. A line break is a step of its own: walking right stops at the end of the line and the next press crosses to the one below, because the end of a line is somewhere people mean to be.
+
+Typing replaces a selection, `backspace` and `delete` remove it, and `escape` clears it - which is why `onCancel` is documented as escape *when there is nothing inside the field to cancel*.
+
+## Selecting text this field does not own
+
+Only this component has a selection. The transcript, a viewer, the output of a tool call - anything drawn by something that is not a text field - has no selection of its own yet.
+
+**`shift` and drag** is the answer meanwhile, and it needs no code. An application that reports mouse events has taken the terminal's own select-and-copy, and every terminal worth using keeps a way to get it back: holding `shift` while dragging bypasses mouse reporting entirely and gives you the terminal's own selection, its own highlight and its own copy. xterm, iTerm2, Ghostty, WezTerm, Kitty, Alacritty and the VS Code terminal all do it, and the text it copies is whatever is on the screen - agent output included.
+
+It is worth saying in an application's own key hints, because a reader whose first drag selected nothing has no way to guess it.
+
## See also
+- [Editing keys and selection](editing.md) - the keys, the selection and the clipboard
+- [The clipboard](../../terminal/clipboard.md) - copy and paste belong to the terminal
- [TextInput](text-input.md) - one line, and enter submits
- [Keybindings](../../platform/keybindings.md) - why the focused node wins
diff --git a/docs/components/input/text-input.md b/docs/components/input/text-input.md
index dbe68af..c7592f7 100644
--- a/docs/components/input/text-input.md
+++ b/docs/components/input/text-input.md
@@ -46,6 +46,8 @@ It publishes a **real cursor position** when the terminal has a cursor, so the c
## See also
+- [Editing keys and selection](editing.md) - the keys, the selection and the clipboard
+- [The clipboard](../../terminal/clipboard.md) - copy and paste belong to the terminal
- [TextArea](text-area.md) - more than one line
- [SearchBox](search-box.md) - the same field with a glyph and a count
- [Field](field.md) - label, hint and validation around it
diff --git a/docs/components/layout/divider.md b/docs/components/layout/divider.md
index a41591a..8dbcf3d 100644
--- a/docs/components/layout/divider.md
+++ b/docs/components/layout/divider.md
@@ -23,6 +23,7 @@ import { Divider } from '@textui/widgets';
| `direction` | `'horizontal' \| 'vertical'` | `'horizontal'` | A divider runs across the flow, so it names its own axis. |
| `label` | `string` | | Text set into the rule. |
| `labelAlign` | `'left' \| 'center' \| 'right'` | `'left'` | |
+| `rule` | `'none' \| 'single' \| 'double' \| 'dashed' \| 'thick' \| 'ascii'` | | The rule style. The theme's own is the default, so a borderless theme still gets the line it asked for. |
| `char` | `string` | | |
Plus everything on [`BoxProps`](../base-props.md).
diff --git a/docs/components/layout/scroll-view.md b/docs/components/layout/scroll-view.md
index 4002747..715e473 100644
--- a/docs/components/layout/scroll-view.md
+++ b/docs/components/layout/scroll-view.md
@@ -27,6 +27,7 @@ import { ScrollView } from '@textui/widgets';
| `scrollbar` | `boolean` | `true` | Draw a scrollbar on the right when the content overflows. |
| `focusable` | `boolean` | `true` | A tab stop, so the keys that scroll it can reach it. On by default: a viewport had the arrow handlers all along and registered nothing, so unless the caller happened to make it focusable itself the only way to scroll was the wheel - which is to say, on a keyboard, not at all. Turn it off for a view that scrolls inside something already focused. |
| `autoFocus` | `boolean` | | |
+| `focusId` | `string` | | A stable focus id, so a command - or the screen that owns this - can put the reader here by name. Without one the id comes from the instance, which nothing outside the render can know: "scroll the preview" has nothing to name and the key that would do it cannot be written. Every other focusable control takes one; this was the exception, and there was no reason for it. |
Plus everything on [`BoxProps`](../base-props.md).
diff --git a/docs/components/navigation/command-palette.md b/docs/components/navigation/command-palette.md
index 964a59c..1bc300b 100644
--- a/docs/components/navigation/command-palette.md
+++ b/docs/components/navigation/command-palette.md
@@ -25,9 +25,11 @@ import { CommandPalette } from '@textui/widgets';
| `onRun` | `(id: string, args?: Record) => void` | | Notified after a command runs. The palette runs it itself. |
| `onClose` | `() => void` | | |
| `execute` | `boolean` | `true` | Off makes this a picker: it reports the choice and runs nothing. |
-| `grouped` | `boolean` | `true` | Group the list by `category`, with a rule between groups. |
+| `grouped` | `boolean` | `true` | Group the list by `category`, with the category named above each group. Only while nothing is typed. A query sorts by relevance, which interleaves the categories - and a heading over one row is not a group. |
| `visibleRows` | `number` | `8` | |
-| `width` | `number` | `60` | |
+| `width` | `number` | | A fixed width, in cells. Left off, the panel is as wide as its widest row and no wider than `maxWidth` - which is what a list of five short answers wants, and what a list of five sentences needs. A number here is a number: the panel is that wide whether the rows fill it or overflow it. |
+| `maxWidth` | `number` | `60` | The widest the panel may grow when `width` is left off. 60 by default. There is always a limit: a description is prose, and prose has no width it stops at. Past this the rows truncate, and the row under the cursor slides what it truncated. |
+| `descriptions` | `'inline' \| 'below'` | `'inline'` | Where a row's description goes. `inline` right-aligns it beside the label; `below` gives it a line of its own. `below` for a question whose answers differ by a sentence rather than by a word - four approval modes named in two words each are told apart by the line under them, and inline that line is the half that gets truncated. Every row costs two lines, so `visibleRows` buys half as many. |
| `openAt` | `string` | | Open already drilled into this command's choices. For a caller that has decided *which* question is being asked and only wants the palette to ask it - a menu item for "Theme" should offer the themes, not the whole command list with "Theme" typed into the search box. |
Plus everything on [`BoxProps`](../base-props.md).
diff --git a/docs/components/navigation/menu.md b/docs/components/navigation/menu.md
index 0e35f0a..f6d3991 100644
--- a/docs/components/navigation/menu.md
+++ b/docs/components/navigation/menu.md
@@ -31,6 +31,7 @@ import { Menu } from '@textui/widgets';
| `visibleRows` | `number` | | Rows shown at once. |
| `activeId` | `string` | | |
| `autoFocus` | `boolean` | | |
+| `descriptions` | `'inline' \| 'below'` | `'inline'` | Where a row's description goes. `inline` right-aligns it on the row, sharing the width with the label - which is the right shape for a word or two of state. `below` gives it a line of its own under the label, indented to it, which is the only shape that fits a sentence: inline, a list of modes whose whole difference is the sentence under each shows the same truncated half of every one. `below` makes every row two lines, so `visibleRows` buys half as much. |
| `interactive` | `boolean` | `true` | Take focus and handle keys. Off when something else drives the selection - a command palette, where typing belongs to the search field and the list only follows. |
Plus everything on [`BoxProps`](../base-props.md).
@@ -40,7 +41,7 @@ Role: `menu`.
`shortcut` draws the chord; it does not register it. The keybinding is still [`app.keybindings.register`](../../platform/keybindings.md), and the menu is saying out loud what the chord already does.
-`separatorBefore` puts a rule above an item, which is how a destructive action gets separated from the ones above it. `children` nests a submenu.
+`separatorBefore` puts a rule above an item, which is how a destructive action gets separated from the ones above it. `sectionBefore` puts a **heading** there instead, naming the group the item starts - said once above the group rather than repeated in a column on every row, and taking the line the rule would have used rather than adding one. `children` nests a submenu.
`interactive={false}` renders it as a static list - for a cheat sheet or a help pane rather than a menu.
diff --git a/docs/terminal/clipboard.md b/docs/terminal/clipboard.md
new file mode 100644
index 0000000..b78fd64
--- /dev/null
+++ b/docs/terminal/clipboard.md
@@ -0,0 +1,90 @@
+---
+title: The clipboard
+parent: Terminal
+nav_order: 5
+---
+
+# The clipboard
+
+Copy and paste belong to the terminal emulator, not to the application running
+inside it. There is no key an application can bind to mean "copy": the emulator
+intercepts its own shortcut before any byte reaches the program, and what that
+shortcut is depends on which emulator somebody is using.
+
+`Ctrl+C` and `Ctrl+V` are not available to be that shortcut. `Ctrl+C` is
+`0x03`, which is SIGINT, and `Ctrl+V` is `0x16`, which is literal-next in
+readline and in vi's insert mode. Both bytes were spoken for decades before
+anybody expected a terminal to talk to a system clipboard, so every emulator
+had to pick something else - and they did not all pick the same thing.
+
+## What the user's own terminal does
+
+| Where | Copy | Paste |
+| --- | --- | --- |
+| GNOME Terminal, Konsole, most Linux | `Ctrl+Shift+C` | `Ctrl+Shift+V` |
+| xterm | `Ctrl+Insert` | `Shift+Insert` |
+| macOS Terminal.app, iTerm2 | `Cmd+C` | `Cmd+V` |
+| Windows Terminal | `Ctrl+Shift+C` | `Ctrl+Shift+V` |
+| X11, any terminal | select with the mouse | middle-click |
+
+`Shift+Insert` is the most portable paste: xterm, GNOME Terminal, Konsole,
+PuTTY and Windows Terminal all take it.
+
+Inside a multiplexer it is a different mechanism again, because the multiplexer
+has a buffer of its own between the application and the emulator:
+
+- **tmux** - `prefix [` enters copy mode, `Space` starts the selection, `Enter`
+ copies; `prefix ]` pastes. With `mode-keys vi`, `v` selects and `y` yanks.
+- **screen** - `Ctrl+a [` for copy mode, `Ctrl+a ]` to paste.
+
+None of this is configurable from here, and none of it should be documented as
+though it were an application's key. It is worth knowing because it is the
+answer to "how do I get this text out", and because an application can take it
+away by accident - which is the next section.
+
+## Mouse reporting takes the terminal's selection away
+
+While an application reports mouse events, dragging sends those events to the
+application instead of selecting text in the terminal. A reader whose first
+drag selected nothing has no way to guess why.
+
+**Holding `shift` while dragging** bypasses mouse reporting entirely and gives
+back the terminal's own selection, highlight and copy. xterm, iTerm2, Ghostty,
+WezTerm, Kitty, Alacritty and the VS Code terminal all honour it, and what it
+copies is whatever is on the screen - including output no component owns.
+
+An application that reports the mouse owes a selection back wherever it
+reasonably can. [`TextArea`](../components/input/text-area.md) has one; a
+transcript or a viewer does not yet, and `shift`-drag is the answer there.
+
+## What an application *can* do
+
+**Write.** `OSC 52` asks the terminal to put a string on the system clipboard,
+and `useClipboard` uses it. That is how a selection made inside a component
+reaches the clipboard without anybody pressing the emulator's copy key.
+
+**Not read.** There is no read half in practice. `OSC 52` has a query form and
+terminals disable it, because a page of output that could ask for the clipboard
+is a page of output that could exfiltrate it. An application therefore cannot
+"read the clipboard" - which is why `useClipboard` also writes into the store,
+so a paste *inside* the application has something to read back.
+
+Detection is not worth doing. Whether the terminal on the far end of an `ssh`
+session will honour `OSC 52` cannot be told from the environment: `ssh` does
+not forward `TERM_PROGRAM`, `KITTY_WINDOW_ID` or `WT_SESSION`, so a modern
+terminal two hops away looks exactly like a dumb one. The sequence is ignored
+harmlessly where it is unsupported, so it is sent unless the terminal is known
+to mangle it.
+
+Paste arrives on its own. The terminal types it at the application wrapped in
+bracketed-paste markers, and `@textui/terminal` buffers the whole thing and
+emits one paste event rather than a hundred keystrokes - which is what stops a
+pasted newline from submitting a form halfway through the second line.
+
+## See also
+
+- [Capabilities](capabilities.md) - what else is detected, and what is assumed
+- [Editing keys and selection](../components/input/editing.md) - the keys
+ inside a text field, which *are* the application's
+- [`TextArea`](../components/input/text-area.md) - the component with a
+ selection model
diff --git a/examples/README.md b/examples/README.md
index 6459525..cd6b4b5 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -54,7 +54,7 @@ Bigger programs, bundled by esbuild because they are many files rather than
because textui needs it.
```bash
-pnpm example todo # or arcade, chat, surfaces
+pnpm example todo # or arcade, chat, surfaces, ink
```
| | |
@@ -63,4 +63,5 @@ pnpm example todo # or arcade, chat, surfaces
| [`arcade`](arcade) | Frame loops, canvas painting, input timing |
| [`chat`](chat) | `Feed`, `TextArea`, markdown, and a fake host |
| [`surfaces`](surfaces) | An application with no shell, arranging its own chrome |
+| [`ink`](ink) | `ColorText`: a colour per cell, over a banner and over prose |
| [`showcase`](showcase) | The catalog on one screen, as a row that wraps. Writes its own screenshot |
diff --git a/examples/chat/src/ahp/connection.ts b/examples/chat/src/ahp/connection.ts
index f9a3882..a0aae7c 100644
--- a/examples/chat/src/ahp/connection.ts
+++ b/examples/chat/src/ahp/connection.ts
@@ -75,6 +75,20 @@ export interface HostConnection {
*/
subscribe(uri: SessionUri, observer: (event: HostEvent) => void): { close(): void };
+ /**
+ * The catalogue moved: a session appeared, finished, or is now waiting.
+ *
+ * Separate from `subscribe`, which is one session's channel and says nothing
+ * about the ninety-nine a client is not watching. Without this the only way
+ * a list gets fresh is somebody navigating away and back, which is a reader
+ * doing by hand what the host already said.
+ *
+ * It carries no payload on purpose. The host owns the catalogue and
+ * `listSessions` is how you read it; an event that carried a row would be a
+ * second, staler source of the same answer.
+ */
+ onSessions(observer: () => void): { close(): void };
+
/** Begin a turn. Any turn - this is not only how the first one starts. */
say(uri: SessionUri, text: string, model?: string): void;
stopTurn(uri: SessionUri): void;
diff --git a/examples/chat/src/ahp/fake.ts b/examples/chat/src/ahp/fake.ts
index 96e72f0..99496e7 100644
--- a/examples/chat/src/ahp/fake.ts
+++ b/examples/chat/src/ahp/fake.ts
@@ -34,6 +34,14 @@ export interface FakeHost extends HostConnection {
/** Everything the script can emit without being answered. */
drain(limit?: number): void;
pending(): number;
+ /**
+ * Retitle a session, the way a host does once it has read the first message.
+ *
+ * Not on `HostConnection`: a client never renames a session, it is told.
+ * Here so that "the host changed something about a session nobody is
+ * watching" is a thing a test can make happen.
+ */
+ rename(uri: SessionUri, title: string): void;
}
type Step = () => void;
@@ -108,6 +116,15 @@ export function fakeHost(): FakeHost {
return activity | (flags.get(uri) ?? 0);
};
+ /**
+ * Watchers of the catalogue itself, as opposed to of one session.
+ *
+ * A real host says this on its root channel; here it is said by whatever
+ * changed a summary, which is the same thing from the outside.
+ */
+ const catalogue = new Set<() => void>();
+ const moved = (): void => { for (const listener of catalogue) listener(); };
+
/** Recompute, and tell anyone watching if it moved. */
const touch = (uri: SessionUri): void => {
const summary = summaries.get(uri);
@@ -115,6 +132,7 @@ export function fakeHost(): FakeHost {
const status = statusOf(uri);
summaries.set(uri, { ...summary, status, modifiedAt: AT });
emit(uri, { type: 'status', status });
+ moved();
};
const setFlag = (uri: SessionUri, flag: number, on: boolean): void => {
@@ -632,11 +650,13 @@ export function fakeHost(): FakeHost {
title: 'New session',
dir: workingDirectory ? `file://${workingDirectory}` : '',
});
+ moved();
return uri;
},
disposeSession: async (uri) => {
summaries.delete(uri);
+ moved();
turns.delete(uri);
active.delete(uri);
inputs.delete(uri);
@@ -648,6 +668,18 @@ export function fakeHost(): FakeHost {
setArchived: (uri, archived) => setFlag(uri, SessionFlag.IsArchived, archived),
setRead: (uri, read) => setFlag(uri, SessionFlag.IsRead, read),
+ rename: (uri, title) => {
+ const summary = summaries.get(uri);
+ if (!summary) return;
+ summaries.set(uri, { ...summary, title });
+ moved();
+ },
+
+ onSessions: (observer) => {
+ catalogue.add(observer);
+ return { close: () => { catalogue.delete(observer); } };
+ },
+
subscribe: (uri, observer) => {
let set = observers.get(uri);
if (!set) { set = new Set(); observers.set(uri, set); }
diff --git a/examples/chat/src/ahp/live.ts b/examples/chat/src/ahp/live.ts
index 9a89867..d4899b4 100644
--- a/examples/chat/src/ahp/live.ts
+++ b/examples/chat/src/ahp/live.ts
@@ -456,12 +456,28 @@ export async function liveHost(options: LiveHostOptions): Promise void>();
+
const root = await client.subscribe(ROOT);
if (root.result.snapshot) mirror.applySnapshot(root.result.snapshot);
void (async () => {
try {
for await (const event of root.subscription) {
- if (event.type === 'action') mirror.apply(event.params);
+ if (event.type !== 'action') continue;
+ mirror.apply(event.params);
+ // Every action, without inspecting it. What a root action means is the
+ // host's business and it grows new kinds; "something over there moved,
+ // read it again" is true of all of them, and the read is one request.
+ for (const listener of catalogue) listener();
}
} catch { moveTo('offline'); }
})();
@@ -639,6 +655,11 @@ export async function liveHost(options: LiveHostOptions): Promise {
+ catalogue.add(observer);
+ return { close: () => { catalogue.delete(observer); } };
+ },
+
subscribe: (uri, observer) => {
let live = true;
const closers: (() => void)[] = [];
diff --git a/examples/chat/src/app.tsx b/examples/chat/src/app.tsx
index a3e4526..736115a 100644
--- a/examples/chat/src/app.tsx
+++ b/examples/chat/src/app.tsx
@@ -4,6 +4,7 @@ import {
defineComponent,
useApp,
useTheme,
+ useStoreSubtree,
useStoreValue,
} from '@textui/core';
import { KeyHints, Row, registerBuiltins } from '@textui/widgets';
@@ -11,8 +12,8 @@ import { CONTROLLER, createController } from './control.js';
import { fakeHost } from './ahp/fake.js';
import type { HostConnection } from './ahp/connection.js';
import {
- FOCUS, HOST, HOST_ERROR, INPUT, OPEN, RUNNING, SCREEN, STATUS, WORKSPACE,
- openSession, workspaceName,
+ FOCUS, HOST, HOST_ERROR, INPUT, OPEN, RUNNING, SCREEN, SESSIONS, SPLIT_AT, SPLIT_DEFAULT,
+ STATUS, WORKSPACE, openSession, workspaceName,
} from './state.js';
import type { HostState } from './state.js';
import { decodeStatus } from './ahp/status.js';
@@ -53,21 +54,32 @@ const Header = defineComponent>('ChatHeader', () => {
// remounted to notice.
useStoreValue(SCREEN, null);
useStoreValue(OPEN, null);
+ // And to the summaries themselves. `openSession` is a plain read, so a
+ // title or a status arriving from the host changed the store and left this
+ // row showing what it said when the session was opened - which is why the
+ // header only caught up when navigating away and back remounted it.
+ useStoreSubtree(SESSIONS);
const session = openSession(app.store);
const decoded = decodeStatus(status);
return (
-
-
-
+ // Only the title gives way. Everything else on this row is fixed-width and
+ // says what the application *is* - a header that truncates its own name to
+ // "Assist…" in order to fit more of a session title has given up the one
+ // part that is the same on every screen. The workspace yields after the
+ // title, and the status glyph never does: it is one cell and it is the
+ // thing the row is scanned for.
+
+
+
{session ? (
<>
-
+
-
+
>
) : (
-
+
)}
);
@@ -93,7 +105,8 @@ const Hints = defineComponent('ChatHints', (props) => {
*/
const newline = 'ctrl+enter';
const waiting = useStoreValue<{ kind: string } | null>(INPUT, null);
- const arrows = `${theme.glyphs.arrowUp}${theme.glyphs.arrowDown}`;
+ const upDown = `${theme.glyphs.arrowUp}${theme.glyphs.arrowDown}`;
+ const leftRight = `${theme.glyphs.arrowLeft}${theme.glyphs.arrowRight}`;
// Which keys exist is a property of where you are, not of what is open: a
// session stays open while its changes are on screen, and `i write` there
// is an offer nothing honours.
@@ -152,7 +165,7 @@ const Hints = defineComponent('ChatHints', (props) => {
{ keys: 'ctrl+c', label: running ? 'stop' : 'quit' },
]
: [
- { keys: arrows, label: 'move' },
+ { keys: upDown, label: 'move' },
{ keys: 'enter', label: 'expand' },
{ keys: 'i', label: 'write' },
{ keys: 'G', label: 'follow' },
@@ -200,7 +213,10 @@ const Hints = defineComponent('ChatHints', (props) => {
>(SETTINGS) ?? {};
+ return values[property.key];
+ },
// The host's own words, all three of them. "Auto Mode" and "Plan
// Mode" are two words apart and mean entirely different things; the
// sentence under each is what tells them apart, and it is the
// difference between picking and guessing.
+ //
+ // Which is why the sentence gets a line of its own as soon as one of
+ // the values has one. Beside the label it shares the width with it,
+ // and a column of "Every tool call is c…" / "File edits run; com…"
+ // truncates away the exact part the reader is choosing on.
+ ...(property.values.some((value) => value.description)
+ ? { descriptions: 'below' as const }
+ : {}),
choices: () => property.values.map((value) => ({
value: value.value,
label: value.label,
@@ -214,6 +235,39 @@ export function createController(
app.store.set(HOST_ERROR, typeof rpc?.code === 'number' ? `${message} (${rpc.code})` : message);
};
+ /**
+ * Read the catalogue again, at most once per turn of the loop.
+ *
+ * The host says "something moved" per action, and a turn finishing is
+ * several of them - so the unguarded version is one `listSessions` per
+ * delta. Coalescing makes a burst one read, and the read is what the list
+ * is drawn from either way.
+ */
+ let pending: ReturnType | null = null;
+ const refreshSoon = (): void => {
+ if (pending) return;
+ pending = setTimeout(() => { pending = null; void reread(); }, 120);
+ // Nothing here should hold a process open: this is a repaint, not work.
+ (pending as unknown as { unref?(): void }).unref?.();
+ };
+ bag.add({ dispose: () => { if (pending) clearTimeout(pending); pending = null; } });
+
+ /**
+ * Read the catalogue, and say nothing about anything else.
+ *
+ * `refresh` clears the last refusal, because a person pressing `r` is asking
+ * whether the host is answering again and a fresh list is the answer. This
+ * one is not that: it runs whenever the host says something moved, and a
+ * catalogue that answered says nothing about a session channel that refused
+ * - so clearing it here wiped "Authentication is required" off the status
+ * bar the moment any other session ticked.
+ */
+ const reread = async (): Promise => {
+ try {
+ writeSessions(app.store, await host.listSessions());
+ } catch (error) { failed(error); }
+ };
+
const controller: Controller = {
async refresh() {
try {
@@ -237,7 +291,10 @@ export function createController(
app.store.set(INPUT, null);
subscription = host.subscribe(uri, (event) => {
model = applyEvent(app.store, event, model);
- if (event.type === 'status') void controller.refresh();
+ // The open session's own status still refreshes the list here: the
+ // catalogue watch below covers what the *host* says moved, and this
+ // covers what this client is watching happen.
+ if (event.type === 'status') refreshSoon();
});
// The composer's row is about the next message, so on an open session it
@@ -250,9 +307,11 @@ export function createController(
app.store.set(PROVIDER, summary.provider);
app.store.set(WORKSPACE, (summary.workingDirectories[0] ?? '').replace(/^file:\/\//, ''));
}
+ app.store.set(CHAT_URI, null);
void host.detail(uri).then((detail) => {
if (app.store.get(OPEN) !== uri) return;
app.store.set(SETTINGS, detail.config.values);
+ app.store.set(CHAT_URI, detail.chat);
if (detail.model) app.store.set(MODEL, detail.model);
offer(detail.config, uri);
}).catch(failed);
@@ -389,6 +448,11 @@ export function createController(
};
bag.add({ dispose: () => subscription?.close() });
+ // What the host says about sessions this client is not watching: one
+ // appearing, one finishing, one starting to wait. Without it the catalogue
+ // is only ever as fresh as the last time somebody navigated to it.
+ const watching = host.onSessions(refreshSoon);
+ bag.add({ dispose: () => watching.close() });
for (const command of commands(app, controller)) bag.add(app.commands.register(command));
for (const binding of keys()) bag.add(app.keybindings.register(binding));
@@ -490,7 +554,7 @@ function commands(app: TextUIApp, controller: Controller): CommandDefinition[] {
{
id: 'app.palette',
title: 'Command Palette',
- category: 'Go',
+ category: 'Navigation',
slots: [],
run: () => {
app.layers.open({
@@ -511,7 +575,8 @@ function commands(app: TextUIApp, controller: Controller): CommandDefinition[] {
{
id: 'go.back',
title: 'Back',
- category: 'Go',
+ category: 'Navigation',
+ description: 'Return to the previous screen',
slots: ['palette'],
run: () => {
// The composer is the root, so there is nothing under it to pop to -
@@ -524,21 +589,24 @@ function commands(app: TextUIApp, controller: Controller): CommandDefinition[] {
{
id: 'go.sessions',
title: 'Sessions',
- category: 'Go',
+ category: 'Screens',
+ description: 'List all sessions',
slots: ['palette'],
run: () => toSessions(),
},
{
id: 'go.new',
title: 'New session',
- category: 'Go',
+ category: 'Screens',
+ description: 'Start a new conversation',
slots: ['palette'],
run: () => { app.screens.reset('new'); app.focus.focus('chat.composer'); },
},
{
id: 'go.changes',
title: 'What this session changed',
- category: 'Go',
+ category: 'Screens',
+ description: 'Show the files',
slots: ['palette'],
when: `${OPEN}`,
run: () => app.screens.push('changes'),
@@ -546,13 +614,45 @@ function commands(app: TextUIApp, controller: Controller): CommandDefinition[] {
{
id: 'go.settings',
title: 'Session settings',
- category: 'Go',
+ category: 'Screens',
+ description: 'Settings for this session',
slots: ['palette'],
when: `${OPEN}`,
run: () => app.screens.push('settings'),
},
- { id: 'go.hosts', title: 'Hosts', category: 'Go', slots: ['palette'], run: () => app.screens.push('hosts') },
+ {
+ id: 'go.hosts',
+ title: 'Hosts',
+ category: 'Screens',
+ description: 'Manage all hosts',
+ slots: ['palette'],
+ run: () => app.screens.push('hosts')
+ },
+ /**
+ * Markdown, or the characters the agent typed.
+ *
+ * An agent writes markdown, so drawing it is the default - reading
+ * `**this**` is reading the punctuation instead of the sentence. Off is
+ * for when the punctuation *is* what you are after: copying a fenced
+ * block out with its fence, checking whether a table is a table or four
+ * lines with pipes in them, reading a link's target rather than its
+ * label.
+ *
+ * A toggle rather than two commands, because there are two states and the
+ * screen shows you which one you are in.
+ */
+ {
+ id: 'view.markdown',
+ title: 'Markdown or raw text',
+ category: 'View',
+ description: 'Draw what the agent said as markdown, or as it typed it',
+ slots: ['palette'],
+ run: () => {
+ const on = app.store.get(MARKDOWN) ?? true;
+ app.store.set(MARKDOWN, !on);
+ },
+ },
// Appearance is a registration, not a rewrite. The same graph is mounted
// under whichever theme and shell are chosen, which is the claim the
// runtime makes and the one an example is meant to be evidence for.
@@ -560,6 +660,7 @@ function commands(app: TextUIApp, controller: Controller): CommandDefinition[] {
id: 'view.theme',
title: 'Theme',
category: 'View',
+ description: 'Change the colors and shapes',
slots: ['palette'],
// The command says what it needs and the palette asks. Wearing it while
// the highlight moves is what makes a theme choosable at all: the names
@@ -589,6 +690,7 @@ function commands(app: TextUIApp, controller: Controller): CommandDefinition[] {
id: 'view.shell',
title: 'Layout',
category: 'View',
+ description: 'Change the layout and controls',
slots: ['palette'],
args: [{
name: 'id',
@@ -618,6 +720,7 @@ function commands(app: TextUIApp, controller: Controller): CommandDefinition[] {
id: 'compose.harness',
title: 'Harness',
category: 'Compose',
+ description: 'Select the agent harness',
slots: ['palette'],
// Fixed once a session exists: it is the process the conversation is
// running in, and a chip offering to change it would be offering a lie.
@@ -642,6 +745,7 @@ function commands(app: TextUIApp, controller: Controller): CommandDefinition[] {
id: 'compose.model',
title: 'Model',
category: 'Compose',
+ description: 'Select the model',
slots: ['palette'],
args: [{
name: 'id',
@@ -661,6 +765,7 @@ function commands(app: TextUIApp, controller: Controller): CommandDefinition[] {
id: 'compose.workspace',
title: 'Workspace',
category: 'Compose',
+ description: 'Select the workspace',
slots: ['palette'],
when: `!${OPEN}`,
// No `choices`, so the palette asks for it as text - the same overlay,
@@ -701,6 +806,7 @@ function commands(app: TextUIApp, controller: Controller): CommandDefinition[] {
id: 'session.open',
title: 'Open session',
category: 'Session',
+ description: 'Show the conversation',
slots: ['palette'],
run: (args: Record) => {
const uri = (typeof args.uri === 'string' ? args.uri : null) ?? selected();
@@ -714,6 +820,7 @@ function commands(app: TextUIApp, controller: Controller): CommandDefinition[] {
id: 'session.new',
title: 'New session',
category: 'Session',
+ description: 'Start a new conversation',
slots: ['palette'],
run: () => {
// Nothing open, so the control row describes a session that does not
@@ -727,6 +834,7 @@ function commands(app: TextUIApp, controller: Controller): CommandDefinition[] {
id: 'session.refresh',
title: 'Refresh the catalogue',
category: 'Session',
+ description: 'Reload list from the host',
slots: ['palette'],
keepOpen: true,
run: () => void controller.refresh(),
@@ -735,6 +843,7 @@ function commands(app: TextUIApp, controller: Controller): CommandDefinition[] {
id: 'session.archive',
title: 'Archive / unarchive',
category: 'Session',
+ description: 'Hide or show this session',
slots: ['palette'],
run: () => {
const uri = target();
@@ -751,6 +860,7 @@ function commands(app: TextUIApp, controller: Controller): CommandDefinition[] {
id: 'session.read',
title: 'Mark read / unread',
category: 'Session',
+ description: 'Mark this session read or unread',
slots: ['palette'],
run: () => {
const uri = target();
@@ -763,6 +873,7 @@ function commands(app: TextUIApp, controller: Controller): CommandDefinition[] {
id: 'session.dispose',
title: 'Dispose session',
category: 'Session',
+ description: 'Delete this session',
slots: ['palette'],
run: async () => {
const uri = target();
@@ -783,6 +894,7 @@ function commands(app: TextUIApp, controller: Controller): CommandDefinition[] {
id: 'session.toggleArchived',
title: 'Show archived sessions',
category: 'Session',
+ description: 'List archived sessions',
slots: ['palette'],
keepOpen: true,
run: () => app.store.set(ARCHIVED, !(app.store.get(ARCHIVED) ?? false)),
@@ -792,6 +904,7 @@ function commands(app: TextUIApp, controller: Controller): CommandDefinition[] {
id: 'chat.stop',
title: 'Stop the turn',
category: 'Chat',
+ description: 'Force session to stop running',
slots: ['palette'],
// On the screen that is showing the turn. A session left open behind
// you keeps its status - a blocked one reads 24 for ever - so a clause
@@ -805,15 +918,24 @@ function commands(app: TextUIApp, controller: Controller): CommandDefinition[] {
id: 'chat.approve',
title: 'Approve what the agent is waiting on',
category: 'Chat',
+ description: 'Approve the tool call ',
slots: ['palette'],
run: (args: Record) => controller.approve(typeof args.option === 'string' ? args.option : undefined),
args: [{ name: 'option', type: 'string' as const }],
},
- { id: 'chat.deny', title: 'Deny it', category: 'Chat', slots: ['palette'], run: () => controller.deny() },
+ {
+ id: 'chat.deny',
+ title: 'Deny it',
+ category: 'Chat',
+ description: 'Deny the tool call',
+ slots: ['palette'],
+ run: () => controller.deny()
+ },
{
id: 'chat.send',
title: 'Send a message',
category: 'Chat',
+ description: 'Send a message',
slots: ['palette'],
args: [{ name: 'text', type: 'string' as const, required: true, description: 'What to say' }],
run: (args: Record) => controller.send(String(args.text ?? '')),
@@ -822,6 +944,7 @@ function commands(app: TextUIApp, controller: Controller): CommandDefinition[] {
id: 'chat.focusComposer',
title: 'Write a message',
category: 'Chat',
+ description: 'Focus the composer',
slots: ['palette'],
run: () => app.focus.focus('chat.composer'),
},
@@ -829,13 +952,60 @@ function commands(app: TextUIApp, controller: Controller): CommandDefinition[] {
id: 'session.filter',
title: 'Filter the catalogue',
category: 'Session',
+ description: 'Filter the catalogue',
slots: ['palette'],
run: () => app.focus.focus('chat.filter'),
},
+ /**
+ * The detail pane, as a drawer.
+ *
+ * Right opens it and left puts it away: the key points at the pane. It is
+ * on the right of the screen, so right is where you go to read it and
+ * left is the way back to the list - which is also what makes the pair
+ * work above `splitAt`, where the pane is always out and the same two
+ * keys just move the keyboard between the halves.
+ *
+ * Opening is going there. There is no state where the pane is out and
+ * nobody is reading it, because the width follows the focus and a wide
+ * pane nobody asked for is the session list being narrow for nothing.
+ *
+ * Tab still walks the screen. It is the way *out* of a text field, where
+ * left and right are letters, and taking it away would strand anyone who
+ * had reached the filter box.
+ */
+ {
+ id: 'session.openDetails',
+ title: 'Open the session detail',
+ category: 'Session',
+ description: 'Show the detail pane, and read it',
+ slots: ['palette'],
+ run: () => {
+ app.store.set(SIDEBAR, true);
+ app.focus.focus('chat.details');
+ },
+ },
+ {
+ id: 'session.closeDetails',
+ title: 'Put the session detail away',
+ category: 'Session',
+ description: 'Hide the detail pane, and give the list the width',
+ slots: ['palette'],
+ run: () => {
+ // The focus first. Unmounting the pane the keyboard is in leaves the
+ // focus on a node that is no longer there, and the next key goes
+ // nowhere at all.
+ app.focus.focus('chat.sessions');
+ // Only below the split, where the pane is a drawer. Above it, both
+ // panes are always drawn and this key is the way back to the list.
+ const width = app.store.get(SPLIT_AT) ?? SPLIT_DEFAULT;
+ if (app.size.width <= width) app.store.set(SIDEBAR, false);
+ },
+ },
{
id: 'chat.focusTranscript',
title: 'Read the transcript',
category: 'Chat',
+ description: 'Focus the transcript',
slots: ['palette'],
run: () => app.focus.focus('chat.transcript'),
},
@@ -843,6 +1013,7 @@ function commands(app: TextUIApp, controller: Controller): CommandDefinition[] {
id: 'chat.clearQueue',
title: 'Drop queued messages',
category: 'Chat',
+ description: 'Drop queued messages',
slots: ['palette'],
when: `${QUEUE}`,
run: () => app.store.set(QUEUE, []),
@@ -851,6 +1022,7 @@ function commands(app: TextUIApp, controller: Controller): CommandDefinition[] {
id: 'chat.expand',
title: 'Expand / collapse the selected block',
category: 'Chat',
+ description: 'Expand / collapse the selected block',
slots: [],
run: (args: Record) => {
const id = String(args.id ?? '');
@@ -864,6 +1036,7 @@ function commands(app: TextUIApp, controller: Controller): CommandDefinition[] {
id: 'chat.running',
title: 'Is a turn running',
category: 'Chat',
+ description: 'Is a turn running',
slots: [],
run: () => running(),
},
@@ -897,6 +1070,29 @@ function keys(): {
{ keys: 'ctrl+n', commandId: 'session.new' },
{ keys: 'ctrl+r', commandId: 'session.refresh' },
{ keys: 'ctrl+t', commandId: 'view.theme' },
+ /*
+ * Three, and `alt+m` is the one to reach for.
+ *
+ * Ctrl+M and Return are the same byte. In raw mode the Return key sends
+ * CR, `0x0d`, and this decoder names that `enter` - deliberately, and
+ * with a comment saying so. A terminal speaking the kitty protocol or
+ * xterm's `modifyOtherKeys` sends the two apart and `ctrl+m` arrives as
+ * itself; everywhere else it cannot, and no binding here can change that.
+ * It is kept for the terminals that can, and it is not the one to
+ * document.
+ *
+ * `alt+m` has no such collision: it arrives as ESC then `m`, which this
+ * decoder reads as alt+the key - so it works over SSH, in tmux and on a
+ * console that has never heard of the kitty protocol. Global, because
+ * nothing types it.
+ *
+ * And `m` on its own in the chat scope, where letters are letters only
+ * while the composer holds them - the same bargain `c`, `s` and `t`
+ * already made on this screen.
+ */
+ { keys: 'alt+m', commandId: 'view.markdown' },
+ { keys: 'ctrl+m', commandId: 'view.markdown' },
+ { keys: 'm', commandId: 'view.markdown', scopeId: CHAT_SCOPE },
{ keys: 'escape', commandId: 'go.back' },
// The catalogue.
@@ -907,6 +1103,11 @@ function keys(): {
{ keys: 'x', commandId: 'session.toggleArchived', scopeId: SESSIONS_SCOPE },
{ keys: 'd', commandId: 'session.dispose', scopeId: SESSIONS_SCOPE },
{ keys: '/', commandId: 'session.filter', scopeId: SESSIONS_SCOPE },
+ // Scoped, not global, and after the focused node has had its turn: while
+ // the filter box has the keyboard these two are caret movement, and the
+ // runtime offers the key there first.
+ { keys: 'right', commandId: 'session.openDetails', scopeId: SESSIONS_SCOPE },
+ { keys: 'left', commandId: 'session.closeDetails', scopeId: SESSIONS_SCOPE },
// The conversation. `i` is the one that gets you into the composer, and
// out of it is escape - the pair that makes every other letter reachable.
diff --git a/examples/chat/src/screens.tsx b/examples/chat/src/screens.tsx
index df67338..e884110 100644
--- a/examples/chat/src/screens.tsx
+++ b/examples/chat/src/screens.tsx
@@ -13,12 +13,12 @@ import {
useStoreValue,
useTheme,
} from '@textui/core';
-import { Badge, Column, EmptyState, Panel, RadioGroup, Row, SearchBox } from '@textui/widgets';
+import { Badge, Column, Divider, EmptyState, Panel, RadioGroup, Row, SearchBox, argumentOf } from '@textui/widgets';
import { CHAT_SCOPE, CONTROLLER, SESSIONS_SCOPE, settingCommand } from './control.js';
import {
ARCHIVED, CHANGES, DRAFT, EXPANDED, FILTER, FOCUS, HISTORY, HOST, INPUT, MODEL, OPEN,
- PROVIDER, QUEUE, SELECTED, SESSIONS, SETTINGS, TURNS, WORKSPACE,
- openSession, visibleSessions, workspaceName,
+ CHAT_URI, PROVIDER, QUEUE, SELECTED, SESSIONS, SETTINGS, SIDEBAR, SPLIT_AT, SPLIT_DEFAULT,
+ TURNS, WORKSPACE, openSession, visibleSessions, workspaceName,
} from './state.js';
import type { HostState } from './state.js';
import { toBlocks } from './blocks.js';
@@ -28,6 +28,7 @@ import type {
import { decodeStatus } from './ahp/status.js';
import { ChatTranscript } from './view/transcript.js';
import { ChatComposer } from './view/composer.js';
+import { ChatSessionHead } from './view/sessionhead.js';
import { Creature } from './view/creature.js';
import { settingIcon, valueIcon } from './view/icons.js';
import { ChatHitl } from './view/hitl.js';
@@ -146,12 +147,30 @@ export const SessionsScreen: (props: Record) => RenderOutput =
* left a session list too narrow to read a title in, and widening the list
* would truncate the URIs the detail pane exists to let you copy. Neither
* of those is a problem while you are looking at the *other* one, so the
- * space follows the reader - and walking out of the details with tab or
- * escape gives the list its width back on the way past.
+ * space follows the reader.
+ *
+ * Below `splitAt` there is not enough of it to divide at all: forty cells
+ * of detail take the list down to a column that cuts every title, and the
+ * detail pane they were taken for is still too narrow to hold the URIs it
+ * exists to show. Two truncated halves are worse than one whole one, so
+ * under that width the catalogue is one pane and the detail is a drawer.
+ *
+ * Right opens it and left puts it away: the key points at the pane, which
+ * is on the right of the screen. Above the split both panes are always
+ * drawn and the same two keys only move the keyboard between them.
*/
+ const width = useSize().width;
+ const splitAt = useStoreValue(SPLIT_AT, SPLIT_DEFAULT) ?? SPLIT_DEFAULT;
+ // Three states: out, away, and nobody has said. The last one follows the
+ // window, so a terminal being dragged wider opens the pane - and a person
+ // who put it away keeps it away, which a plain boolean defaulted from the
+ // width could not do.
+ const asked = useStoreValue(SIDEBAR, null);
+ const open = asked ?? width > splitAt;
+
const focused = useStoreValue(FOCUS, null);
- const reading = focused === 'chat.details';
- const aside = Math.max(34, Math.min(56, Math.round(useSize().width * 0.4)));
+ const reading = open && focused === 'chat.details';
+ const aside = Math.max(34, Math.min(56, Math.round(width * 0.4)));
// What the pane without the keyboard keeps: two fifths, and never less
// than a session title fits in. Capped as the terminal grows, because the
// pane being read has a use for the rest and this one does not.
@@ -211,6 +230,7 @@ export const SessionsScreen: (props: Record) => RenderOutput =
{!archived ? : null}
+ {open ? (
) => RenderOutput =
{status.archived ? : null}
- {/* Tab reaches this, arrows walk it, enter copies the row. The
- identifiers are the reason: they are what gets pasted into a
- shell, and they are exactly what does not fit on one line. */}
-
+ {/* Right arrow reaches this, up and down walk it, enter copies
+ the row. The identifiers are the reason: they are what gets
+ pasted into a shell, and they are exactly what does not fit
+ on one line. */}
+ {/* Opened by asking, so the cursor goes with it - and `asked`
+ rather than `open`, so a terminal dragged past the split
+ width reveals the pane without taking the keyboard off
+ whatever was holding it. */}
+
) => RenderOutput =
)}
+ ) : null}
);
});
@@ -318,6 +354,8 @@ function useComposerOptions(): ComposerOption[] {
: undefined)
?? settingIcon(unicode, property.key, property.title),
label: chosen?.label ?? value ?? property.title,
+ // The question, for anything showing these with room for the pair.
+ title: property.title,
// Shown but not asked where the host says it cannot be changed on a
// running session: offering it produces a refusal, not an edit.
...(open && !property.sessionMutable ? {} : { commandId: settingCommand(property.key) }),
@@ -353,9 +391,16 @@ export const ChatScreen: (props: Record) => RenderOutput =
const [cursor, setCursor] = useStore('$/screen.chat/cursor' as BindingPath, 0);
const session = openSession(app.store);
+ const model = useStoreValue(MODEL, '') ?? '';
+ const chat = useStoreValue(CHAT_URI, null) ?? null;
const running = turns.some((turn) => turn.state === 'running');
const blocks = toBlocks(turns, queued);
const options = useComposerOptions();
+ // The same answers the chips are showing, with the question beside each -
+ // read off one source rather than asked for a second time.
+ const settingRows = options
+ .filter((option) => option.title !== undefined)
+ .map((option) => ({ label: option.title as string, value: option.label }));
if (!session) {
return ;
@@ -364,6 +409,18 @@ export const ChatScreen: (props: Record) => RenderOutput =
return (
+
+
+
+ )}
flex={1}
blocks={blocks}
expanded={expanded}
@@ -405,6 +462,18 @@ export const ChatScreen: (props: Record) => RenderOutput =
commands={app.commands.list({ slot: 'palette', enabledOnly: true })
.map((command) => ({ id: command.id, title: command.title, ...(command.description ? { description: command.description } : {}) }))}
onChange={(value: string) => app.store.set(DRAFT, value)}
+ onCommand={(id: string) => {
+ app.store.set(DRAFT, '');
+ const command = app.commands.get(id);
+ // A command that still has a question to ask cannot just be run -
+ // `execute` refuses a missing required argument, loudly - so it
+ // gets its picker, the same one the chip above would have opened.
+ if (command && argumentOf(command)) {
+ openPicker(app, { commandId: id, anchorId: 'chat.composer' });
+ return;
+ }
+ void app.execute(id, undefined, 'palette');
+ }}
onSubmit={(value: string) => { controller.send(value); setRecall(history.length + 1); }}
onCancel={() => app.focus.focus('chat.transcript')}
onHistory={(direction: -1 | 1) => {
diff --git a/examples/chat/src/state.ts b/examples/chat/src/state.ts
index 5c1a2ec..e61905e 100644
--- a/examples/chat/src/state.ts
+++ b/examples/chat/src/state.ts
@@ -45,6 +45,15 @@ export const RUNNING = '$/chat/conv/running' as BindingPath;
*/
export const PROVIDER = '$/chat/compose/provider' as BindingPath;
export const MODEL = '$/chat/compose/model' as BindingPath;
+/**
+ * The chat the open session dispatches to.
+ *
+ * A session is not a conversation - it holds chats - and the uri of the one
+ * being read is what gets pasted into a shell or a bug report. Held because
+ * only `detail` knows it, and asking again to draw a caption is a round trip
+ * for something that does not change.
+ */
+export const CHAT_URI = '$/chat/conv/chat' as BindingPath;
export const WORKSPACE = '$/chat/compose/workspace' as BindingPath;
/**
* Everything else the host asks about, keyed by the host's own keys.
@@ -69,6 +78,37 @@ export const HISTORY = '$/chat/ui/history' as BindingPath;
export const FILTER = '$/chat/ui/filter' as BindingPath;
export const ARCHIVED = '$/chat/ui/archived' as BindingPath;
export const EXPANDED = '$/chat/ui/expanded' as BindingPath;
+/**
+ * Whether what the agent said is drawn as markdown, or as what it typed.
+ *
+ * On, because an agent writes markdown and reading `**this**` is reading the
+ * punctuation instead of the sentence. Off is for the times the punctuation is
+ * the point: copying a fenced block out with the fence, seeing whether a table
+ * is a table or four lines that happen to have pipes in them, and reading a
+ * link's target rather than its label.
+ */
+export const MARKDOWN = '$/chat/ui/markdown' as BindingPath;
+/**
+ * Whether the catalogue's detail pane is out, or `null` for "whatever the
+ * terminal is wide enough for".
+ *
+ * Three states rather than two, because "nobody has said" and "somebody said
+ * no" are different: the first still follows the window as it is resized, and
+ * the second has to survive a resize or the key that closed the pane would be
+ * undone by dragging the corner.
+ */
+export const SIDEBAR = '$/chat/ui/sidebar' as BindingPath;
+/**
+ * The width at which the detail pane is out to begin with.
+ *
+ * Under it the two panes are each other's problem: forty cells of detail take
+ * the session list down to a column that cuts every title, and the detail
+ * pane they were taken for is itself too narrow to hold the URIs it exists to
+ * show. So below this the catalogue is one pane, and the detail is something
+ * you open.
+ */
+export const SPLIT_AT = '$/chat/ui/splitAt' as BindingPath;
+export const SPLIT_DEFAULT = 140;
/**
* The runtime's own state, read rather than asked for.
diff --git a/examples/chat/src/view/bubble.tsx b/examples/chat/src/view/bubble.tsx
index 40d1c3b..99a82ae 100644
--- a/examples/chat/src/view/bubble.tsx
+++ b/examples/chat/src/view/bubble.tsx
@@ -1,6 +1,7 @@
import type { BoxProps, RenderOutput, SemanticVariant, StyleColor } from '@textui/core';
-import { defineComponent, useFrame, useTheme } from '@textui/core';
+import { defineComponent, useFrame, useStoreValue, useTheme } from '@textui/core';
import { Column, MarkdownView, Row } from '@textui/widgets';
+import { MARKDOWN } from '../state.js';
/**
* One thing said, and the two ways it is still being said.
@@ -79,6 +80,14 @@ export interface StreamingTextProps extends BoxProps {
streaming?: boolean;
quiet?: boolean;
maxLines?: number;
+ /**
+ * Draw it as markdown, or as the characters that arrived.
+ *
+ * Unstated it follows the application's own switch, which is what the key
+ * that toggles it moves - so a caller has to say something here only when
+ * it wants one or the other regardless.
+ */
+ markdown?: boolean;
}
/**
@@ -93,14 +102,37 @@ export interface StreamingTextProps extends BoxProps {
*/
export const StreamingText: (props: StreamingTextProps) => RenderOutput =
defineComponent('StreamingText', (props) => {
- const { content, streaming, quiet, maxLines, ...rest } = props;
+ const { content, streaming, quiet, maxLines, markdown, ...rest } = props;
const theme = useTheme();
const frame = useFrame(2);
const caret = streaming && frame % 2 === 0 ? theme.glyphs.caret : '';
+ // Read unconditionally: `??` short-circuits, and a hook that is only
+ // reached when a prop is absent is a hook that changes position between
+ // renders. The prop still wins - it is just decided after the read.
+ const preference = useStoreValue(MARKDOWN, true) ?? true;
+ const rendered = markdown ?? preference;
+ const shown = streaming ? `${content}${caret}` : content;
+
+ // Raw is a `text`, not a `MarkdownView` that was told not to parse: the
+ // point of turning it off is to see the characters that arrived, and
+ // anything that lays the document out has already decided some of them
+ // were structure. `wrap` rather than truncate, because the lines being
+ // read are the long ones - a fenced block and a table are exactly what is
+ // wider than the pane.
+ if (!rendered) {
+ return (
+
+ );
+ }
return (
RenderOutput =
defineComponent('ChatComposer', (props) => {
const {
value, onChange, onSubmit, onCancel, onHistory, onLeave, running, queued = 0,
- options = [], onOption, placeholder, commands = [], autoFocus,
+ options = [], onOption, placeholder, commands = [], onCommand, autoFocus,
focusId = 'chat.composer', ...rest
} = props;
const theme = useTheme();
@@ -63,6 +75,26 @@ export const ChatComposer: (props: ChatComposerProps) => RenderOutput =
meta: command.title,
}));
+ // Which completion is under the cursor. Clamped rather than reset, so a
+ // list that shrinks as more is typed keeps a valid row instead of
+ // snapping back to the top on every keystroke.
+ const [highlight, setHighlight] = useState(0);
+ const index = Math.max(0, Math.min(highlight, matches.length - 1));
+ const chosen = matches[index];
+
+ /**
+ * Up and down, while the menu is open.
+ *
+ * They arrive as `onOverflow` - the field reports the key rather than
+ * handling it once there is no row above or below the caret, which for a
+ * `/word` draft is immediately. The same pair walks the history when there
+ * is no menu, and the menu is the nearer of the two things they could
+ * mean.
+ */
+ const step = (direction: -1 | 1): void => {
+ setHighlight((matches.length + index + direction) % matches.length);
+ };
+
return (
{matches.length > 0 ? (
@@ -71,17 +103,36 @@ export const ChatComposer: (props: ChatComposerProps) => RenderOutput =
// do either, and an airy theme gets a line it deliberately does not
// draw anywhere else.
-
+ onCommand?.(id)}
+ emptyMessage="no command"
+ />
) : null}
-
+
+