Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
1e8eac4
feat(core,terminal): a mouse gesture belongs to whoever started it
softov Aug 25, 2026
8c33e63
feat(widgets): TextArea takes the mouse, and menus stop repeating the…
softov Aug 25, 2026
35d4485
feat(chat): slash commands are ours, and a tool call is something tha…
softov Aug 25, 2026
1b04139
feat(chat): the catalogue keeps up, and the conversation says what it is
softov Aug 25, 2026
0a3111e
fix(widgets): a Feed counts its children, not the arrays they arrived in
softov Aug 25, 2026
973d3b8
fix(textide): the active-rule test reads the glyph rather than repeat…
softov Aug 25, 2026
b31fcb3
feat(widgets): a List row is the caller's, and the list keeps the rest
softov Aug 25, 2026
ce8ad49
feat(core): markdown grows tables, and a theme says how ruled they are
softov Aug 25, 2026
1e9ab75
feat(core): the arrows come in fours
softov Aug 25, 2026
9ca2874
feat(chat): a session takes two lines, the detail is a drawer, and ma…
softov Aug 25, 2026
c02b843
feat(playground): a Pattern can be spaced, and jittered out of its la…
softov Aug 25, 2026
a417a9b
feat(playground): a persona, assembled rather than drawn
softov Aug 25, 2026
a879f87
feat(playground): a scene, with somebody standing in it
softov Aug 25, 2026
353d1b5
fix(chat): the detail pane keeps its column, and markdown gets a key …
softov Aug 25, 2026
1c24a97
docs: copy and paste belongs to the terminal, and the keys have one p…
softov Aug 25, 2026
aff0946
ColorText: a colour per cell, and the ink example
softov Aug 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion docs/components/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Every node also accepts `role`, `label`, `focusable`, `onKey`, `onClick`, `disab

<!-- props:start -->

~96 components, and growing.
~97 components, and growing.

### The four primitives

Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions docs/components/base-props.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |

Expand Down
4 changes: 3 additions & 1 deletion docs/components/display.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
102 changes: 102 additions & 0 deletions docs/components/display/color-text.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- docs:setup
declare const banner: string;
-->

```tsx
import { ColorText } from '@textui/widgets';

<ColorText ink={{ gradient: ['cyan', 'magenta'] }} content={banner} alignBlock />
```

## Props

<!-- props:start -->
| 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).
<!-- props:end -->

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:

<!-- docs:local
import { ColorText } from '@textui/widgets';
import type { Color } from '@textui/core';
declare const text: string;
declare const palette: Color[];
-->

```tsx
<box direction="column">
<ColorText ink={{ gradient: ['#ff5f6d', '#ffc371'], axis: 'y' }} content={text} />
<ColorText ink={['danger', 'warning', 'success']} content={text} />
<ColorText ink={{ cycle: palette, every: [4, 3] }} content={text} />
<ColorText ink={(cell) => (cell.index % 2 ? 'muted' : 'accent')} content={text} />
</box>
```

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
1 change: 1 addition & 0 deletions docs/components/display/feed.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
Loading
Loading