From 1e8eac443aee0d2e7c10a7ec7bfa8d1ccd689bdc Mon Sep 17 00:00:00 2001 From: Softov Date: Tue, 25 Aug 2026 01:48:20 -0400 Subject: [PATCH 01/16] feat(core,terminal): 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` returning true on a `down` now claims the drags and the up that follow, wherever they land. Hover was worse: `onHover` was declared on every node and called from nowhere, and the `hover` style overlay compared a focus id against `props.id`, so only a focusable node could ever be hovered. It is a hit test over laid-out boxes now, inheriting 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. `MouseEvent.at` is a timestamp, stamped by whatever produced the event. The wire reports presses and releases and has no notion of a double click, so telling one gesture from two is arithmetic on when they arrived and where. Also here, all of it about the terminal rather than the tree: - 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 the machine the program runs on. A remote session saw a bare xterm-256color and dropped every copy. On now for everything but screen, which shows the payload as text. - A theme can say what shape the caret is: `cursor` is block, underline or bar, applied through `TerminalAdapter.setCursorShape` (DECSCUSR) and reset at teardown only if the session set it. - A divider is not a border. `divider` and `dividerChars` are their own theme setting with their own six sets, resolved down the extends chain, so a borderless theme can still separate with a line. The harness gains `drag(from, ...to)` and `clickRepeat(x, y, times)`; `click` steps its clock past the repeat window, so two clicks in a test are two clicks. --- .../test/capabilities-clipboard.test.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 packages/terminal/test/capabilities-clipboard.test.ts diff --git a/packages/terminal/test/capabilities-clipboard.test.ts b/packages/terminal/test/capabilities-clipboard.test.ts new file mode 100644 index 0000000..5d0aab4 --- /dev/null +++ b/packages/terminal/test/capabilities-clipboard.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { detectCapabilities } from '../src/capabilities.js'; + +/** + * OSC 52 over ssh. + * + * 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 - in the one situation OSC 52 was specified for. + */ +describe('the clipboard capability', () => { + const caps = (env: Record) => + detectCapabilities({ env: { TERM: 'xterm-256color', ...env }, isTTY: true }); + + it('is on for a terminal that names nothing about itself', () => { + expect(caps({}).clipboard).toBe(true); + }); + + it('is on at the far end of an ssh session', () => { + // What the remote actually sees: a TERM, and nothing that identifies the + // terminal on the other end of the pipe. + expect(caps({ SSH_TTY: '/dev/pts/3', SSH_CONNECTION: '10.0.0.1 22 10.0.0.2 22' }).clipboard).toBe(true); + }); + + it('is on inside tmux, which forwards it', () => { + expect(caps({ TMUX: '/tmp/tmux-1000/default,1,0' }).clipboard).toBe(true); + }); + + it('is off under screen, which shows the payload as text', () => { + expect(caps({ TERM: 'screen-256color' }).clipboard).toBe(false); + }); + + it('is off with no tty at all', () => { + expect(detectCapabilities({ env: { TERM: 'xterm-256color' }, isTTY: false }).clipboard).toBe(false); + }); +}); From 8c33e632e47f0a6b5aaa2c72991c7b2e7171eae8 Mon Sep 17 00:00:00 2001 From: Softov Date: Tue, 25 Aug 2026 01:48:33 -0400 Subject: [PATCH 02/16] feat(widgets): TextArea takes the mouse, and menus stop repeating themselves 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 scrolls the field. Double click takes the run under it - letters with letters, spaces with spaces - and a third takes the logical line rather than the row it was drawn on. shift with the movement keys extends and copies; ctrl+left and ctrl+right move a word at a time. 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. The palette and the menu: - The category names the group, once, above it. In every row's right-hand column 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. - `descriptions: 'below'` gives a row's description a line of its own. Four approval modes named in two words each are told apart entirely by the sentence under them, and inline that sentence shows the same truncated half of every answer. - The panel fits what it holds, up to `maxWidth`. A constant is too wide for one-word answers and too narrow for sentences, and it is the same constant either way. - It opens on the answer in force, which `ArgSpec.default` is where a command says. Opening at the top says the first option is the current one, which is wrong on every list where it is not. And two things that were wrong about width and space: - A narrow `List` row gives up its description, not its status. All three columns shrank together, so a catalogue at 58 columns cut the label *and* the status to keep a workspace path nobody was scanning for. - The workbench shell had one pad on the left of the main area and none on the right, in every theme. --- packages/testing/test/commands.test.ts | 228 ++++++++++ packages/testing/test/list-columns.test.ts | 54 +++ .../testing/test/text-area-selection.test.ts | 383 ++++++++++++++++ packages/widgets/src/control/text-area.ts | 425 ++++++++++++++++-- packages/widgets/src/data/list.ts | 14 +- packages/widgets/src/layout/divider.ts | 22 +- packages/widgets/src/navigation/menu.ts | 103 ++++- .../widgets/src/overlay/command-palette.ts | 136 +++++- .../widgets/src/shells/workbench-shell.ts | 19 +- 9 files changed, 1316 insertions(+), 68 deletions(-) create mode 100644 packages/testing/test/list-columns.test.ts create mode 100644 packages/testing/test/text-area-selection.test.ts diff --git a/packages/testing/test/commands.test.ts b/packages/testing/test/commands.test.ts index 357c4da..8825a0a 100644 --- a/packages/testing/test/commands.test.ts +++ b/packages/testing/test/commands.test.ts @@ -190,6 +190,234 @@ describe('the palette component', () => { await t.unmount(); }); + /** + * The category names the group, above it, once. + * + * It used to sit in every row's right-hand column, so a list of four + * screens said "Screens" four times - in the column the rows needed for + * saying what they do, and still without marking where the group began. + */ + const grouped = async (extra: Record = {}) => renderApp({ + width: 60, + height: 20, + onBoot: (app) => { + app.commands.register({ id: 'go.back', title: 'Back', category: 'Navigation', description: 'Return to the previous screen', slots: ['palette'], run: () => {} }); + app.commands.register({ id: 'go.sessions', title: 'Sessions', category: 'Screens', description: 'List all sessions', slots: ['palette'], run: () => {} }); + app.commands.register({ id: 'go.hosts', title: 'Hosts', category: 'Screens', description: 'Manage all hosts', slots: ['palette'], run: () => {} }); + app.open({ surface: 'main', key: 'p', target: { component: 'CommandPalette', ...extra } }); + }, + }); + + it('names each category once, above its group', async () => { + const t = await grouped(); + await t.settle(); + + const category = (name: string): number => + t.lines().filter((line) => line.includes(name)).length; + + expect(category('Navigation')).toBe(1); + expect(category('Screens')).toBe(1); + + // On a line of its own, above the first row of the group - not in a row. + const heading = t.lines().findIndex((line) => line.includes('Screens')); + expect(t.lines()[heading]).not.toContain('Sessions'); + expect(t.lines()[heading]).not.toContain('Hosts'); + expect(t.lines()[heading + 1]).toContain('Sessions'); + + // And the column the category used to occupy says what the row does. + expect(t.hasText('List all sessions')).toBe(true); + await t.unmount(); + }); + + it('drops the headings once a query sorts the rows', async () => { + const t = await grouped(); + t.type('s'); + await t.settle(); + + // Relevance order interleaves the categories, so a heading would sit over + // one row and claim to start a group. + expect(t.hasText('Screens')).toBe(false); + expect(t.hasText('Navigation')).toBe(false); + await t.unmount(); + }); + + it('leaves the headings out when grouping is off', async () => { + const t = await grouped({ grouped: false }); + await t.settle(); + + expect(t.hasText('Screens')).toBe(false); + expect(t.hasText('Sessions')).toBe(true); + await t.unmount(); + }); + + /** + * A sentence needs a line, not a column. + * + * Four approval modes named in two words each are told apart entirely by + * what is written under them. Beside the label that sentence shares the + * width with it, so every row shows the same truncated half and the reader + * is choosing on the part that was cut. + */ + it('puts each description on its own line when the argument asks', async () => { + const t = await renderApp({ + width: 50, + height: 16, + onBoot: (app) => { + app.commands.register({ + id: 'set.mode', + title: 'Permissions', + slots: ['palette'], + args: [{ + name: 'value', + type: 'string', + required: true, + descriptions: 'below', + choices: [ + { value: 'ask', label: 'Ask each time', description: 'Every tool call is confirmed' }, + { value: 'edits', label: 'Accept edits', description: 'File edits run; commands still ask' }, + ], + }], + run: () => {}, + }); + app.open({ surface: 'main', key: 'p', target: { component: 'CommandPalette', openAt: 'set.mode', width: 50 } }); + }, + }); + await t.settle(); + + const label = t.lines().findIndex((line) => line.includes('Ask each time')); + expect(label).toBeGreaterThan(-1); + // The sentence is not on the label's line - it is on the next one, and + // whole rather than cut to whatever the label left over. + expect(t.lines()[label]).not.toContain('Every tool call'); + expect(t.lines()[label + 1]).toContain('Every tool call is confirmed'); + // Indented to the label, not to the cursor's gutter. + expect(t.lines()[label + 1]?.indexOf('Every')).toBe(t.lines()[label]?.indexOf('Ask each')); + await t.unmount(); + }); + + /** + * The picker opens on the answer already in force. + * + * A question about a setting is asked in order to change it *from* + * something, and that something is where the reader is looking. Opening at + * the top says the first option is the current one, which is wrong on every + * list where it is not - and costs a press to get back to where you began. + */ + const choosing = async (extra: Record = {}, width?: number) => { + const t = await renderApp({ + width: width ?? 60, + height: 18, + onBoot: (app) => { + app.commands.register({ + id: 'set.mode', + title: 'Permissions', + slots: ['palette'], + args: [{ + name: 'value', + type: 'string', + required: true, + choices: [ + { value: 'ask', label: 'Ask each time' }, + { value: 'edits', label: 'Accept edits' }, + { value: 'plan', label: 'Plan only' }, + ], + ...extra, + }], + run: () => {}, + }); + app.open({ surface: 'main', key: 'p', target: { component: 'CommandPalette', openAt: 'set.mode' } }); + }, + }); + for (let i = 0; i < 4; i++) await t.settle(); + return t; + }; + + /** The row the cursor is on, which is the one carrying the marker. */ + const marked = (t: Awaited>): string => + t.lines().find((line) => line.includes('\u25b8')) ?? ''; + + it('starts on the value the argument calls its default', async () => { + const t = await choosing({ default: 'plan' }); + expect(marked(t)).toContain('Plan only'); + await t.unmount(); + }); + + it('starts at the top when the argument names no default', async () => { + const t = await choosing(); + expect(marked(t)).toContain('Ask each time'); + await t.unmount(); + }); + + /** + * How wide the panel is. + * + * 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. + */ + const panel = (t: Awaited>): number => { + const line = t.lines().find((row) => row.includes('Plan only')) ?? ''; + return line.trimEnd().length; + }; + + it('fits the panel to its widest row', async () => { + const narrow = await choosing({}, 100); + const wide = await choosing({ + choices: [ + { value: 'ask', label: 'Ask each time' }, + { value: 'edits', label: 'Accept edits' }, + { value: 'plan', label: 'Plan only, and do not touch a single file until it is agreed' }, + ], + }, 100); + + expect(panel(wide)).toBeGreaterThan(panel(narrow)); + await narrow.unmount(); + await wide.unmount(); + }); + + it('stops at maxWidth, however long the rows are', async () => { + const t = await renderApp({ + width: 100, + height: 18, + onBoot: (app) => { + app.commands.register({ + id: 'set.mode', title: 'Permissions', slots: ['palette'], + args: [{ + name: 'value', type: 'string', required: true, + choices: [{ value: 'a', label: 'A'.repeat(200) }], + }], + run: () => {}, + }); + app.open({ surface: 'main', key: 'p', target: { component: 'CommandPalette', openAt: 'set.mode', maxWidth: 40 } }); + }, + }); + for (let i = 0; i < 4; i++) await t.settle(); + + expect(t.lines().every((line) => line.trimEnd().length <= 40)).toBe(true); + await t.unmount(); + }); + + it('takes a stated width as stated', async () => { + const t = await renderApp({ + width: 100, + height: 18, + onBoot: (app) => { + app.commands.register({ + id: 'set.mode', title: 'Permissions', slots: ['palette'], + args: [{ name: 'value', type: 'string', required: true, choices: [{ value: 'a', label: 'A' }] }], + run: () => {}, + }); + app.open({ surface: 'main', key: 'p', target: { component: 'CommandPalette', openAt: 'set.mode', width: 70 } }); + }, + }); + for (let i = 0; i < 4; i++) await t.settle(); + + // One row of one letter, and the panel is still 70 wide because it was + // told to be. + const widest = Math.max(...t.lines().map((line) => line.trimEnd().length)); + expect(widest).toBe(70); + await t.unmount(); + }); + it('filters as you type and runs the choice', async () => { const ran: string[] = []; const t = await renderApp({ diff --git a/packages/testing/test/list-columns.test.ts b/packages/testing/test/list-columns.test.ts new file mode 100644 index 0000000..81c65e4 --- /dev/null +++ b/packages/testing/test/list-columns.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { h } from '@textui/core'; +import { List } from '@textui/widgets'; +import { renderApp } from '../src/index.js'; + +/** + * What a narrow row gives up first. + * + * All three columns used to shrink together, so a catalogue at 58 columns cut + * the label to "Kqueue events on Li…" *and* the status to "waiting on y…" to + * keep a workspace path nobody was scanning for. The elaboration is the part + * that yields: the label is the thing being chosen and the meta is the column + * the row is being read for. + */ +describe('a List row under pressure', () => { + const open = async (width: number) => { + const t = await renderApp({ + width, + height: 6, + root: h(List, { + items: [{ + id: 'a', + label: 'Kqueue events on Linux', + description: 'claude · brb_framework', + meta: 'waiting on you', + }], + }), + }); + await t.settle(); + return t; + }; + + it('keeps the label and the status, and cuts the description', async () => { + const t = await open(58); + const row = t.line(0); + + expect(row).toContain('Kqueue events on Linux'); + expect(row).toContain('waiting on you'); + // The one that gave way, and it did give way - otherwise this is only a + // test that 58 columns was enough for all three. + expect(row).not.toContain('brb_framework'); + await t.unmount(); + }); + + it('draws all three when there is room', async () => { + const t = await open(90); + const row = t.line(0); + + expect(row).toContain('Kqueue events on Linux'); + expect(row).toContain('brb_framework'); + expect(row).toContain('waiting on you'); + await t.unmount(); + }); +}); diff --git a/packages/testing/test/text-area-selection.test.ts b/packages/testing/test/text-area-selection.test.ts new file mode 100644 index 0000000..cb82825 --- /dev/null +++ b/packages/testing/test/text-area-selection.test.ts @@ -0,0 +1,383 @@ +import { describe, expect, it } from 'vitest'; +import { CLIPBOARD_PATH, h, useState } from '@textui/core'; +import { TextArea } from '@textui/widgets'; +import { renderApp } from '../src/index.js'; + +/* + * Selecting text with the mouse. + * + * An application that reports mouse events has taken the terminal's own + * select-and-copy away, so it has to offer one back: a drag selects, and the + * release puts what was selected on the system clipboard over OSC 52. + * + * The drag half of that only works because the application holds the pointer + * for whoever took the button down - dispatch is otherwise a hit test, and a + * selection dragged past the edge of the field is the pointer being somewhere + * the field is not. + */ +describe('TextArea selection', () => { + const open = async (value: string, extra: Record = {}, width = 30) => { + const t = await renderApp({ + width, + height: 8, + root: h(function Host() { + const [text, setText] = useState(value); + return h(TextArea, { + value: text, onChange: setText, blink: false, + focusId: 'f', autoFocus: true, ...extra, + }); + }, {}), + }); + await t.settle(); + t.focus('f'); + await t.settle(); + return t; + }; + + const clipboard = (t: Awaited>): string => + t.app.store.get(CLIPBOARD_PATH) ?? ''; + + /** + * The background colour of a cell, which is what a selection paints. + * + * As a string, because a colour is a structure and two cells painted the + * same colour are not the same object. + */ + const bgAt = (t: Awaited>, x: number, y = 0): string => + JSON.stringify(t.app.buffer().get(x, y)?.bg ?? null); + + it('copies what a drag covered', async () => { + const t = await open('hello world'); + t.drag([0, 0], [3, 0], [5, 0]); + await t.settle(); + + expect(clipboard(t)).toBe('hello'); + await t.unmount(); + }); + + it('copies the same text dragged the other way', async () => { + const t = await open('hello world'); + t.drag([11, 0], [8, 0], [6, 0]); + await t.settle(); + + expect(clipboard(t)).toBe('world'); + await t.unmount(); + }); + + it('keeps going once the pointer leaves the field', async () => { + const t = await open('hello world'); + // Off the right of the field entirely: without pointer capture the field + // never hears about this and the selection stops at the last cell inside. + t.drag([6, 0], [40, 0], [60, 3]); + await t.settle(); + + expect(clipboard(t)).toBe('world'); + await t.unmount(); + }); + + it('takes the line break with a selection that spans lines', async () => { + const t = await open('ab\ncd'); + t.drag([1, 0], [1, 1]); + await t.settle(); + + expect(clipboard(t)).toBe('b\nc'); + await t.unmount(); + }); + + it('selects by cells, not characters', async () => { + const t = await open('日本語ab'); + // Two columns per glyph: this is 日本, not 日本語ab's first four characters. + t.drag([0, 0], [4, 0]); + await t.settle(); + + expect(clipboard(t)).toBe('日本'); + await t.unmount(); + }); + + it('leaves the clipboard alone for a click that selects nothing', async () => { + const t = await open('hello world'); + t.app.store.set(CLIPBOARD_PATH, 'untouched'); + t.click(3, 0); + await t.settle(); + + expect(clipboard(t)).toBe('untouched'); + await t.unmount(); + }); + + it('does not copy when copyOnSelect is off', async () => { + const t = await open('hello world', { copyOnSelect: false }); + t.drag([0, 0], [5, 0]); + await t.settle(); + + expect(clipboard(t)).toBe(''); + await t.unmount(); + }); + + it('paints the selection and nothing either side of it', async () => { + const t = await open('hello world'); + t.drag([6, 0], [11, 0]); + await t.settle(); + + const inside = bgAt(t, 7); + expect(inside).not.toBe('null'); + expect(bgAt(t, 6)).toBe(inside); + expect(bgAt(t, 10)).toBe(inside); + // The cell before the selection and the empty field beyond it are not it. + expect(bgAt(t, 5)).not.toBe(inside); + expect(bgAt(t, 12)).not.toBe(inside); + await t.unmount(); + }); + + it('replaces the selection with what is typed next', async () => { + const t = await open('hello world'); + t.drag([6, 0], [11, 0]); + await t.settle(); + t.type('there'); + await t.settle(); + + expect(t.line(0)).toBe('hello there'); + await t.unmount(); + }); + + it('deletes the selection on backspace, and only the selection', async () => { + const t = await open('hello world'); + t.drag([5, 0], [11, 0]); + await t.settle(); + t.press('backspace'); + await t.settle(); + + expect(t.line(0)).toBe('hello'); + await t.unmount(); + }); + + it('extends with shift and collapses without it', async () => { + const t = await open('hello world'); + t.click(0, 0); + await t.settle(); + t.pressAll('shift+right', 'shift+right', 'shift+right'); + await t.settle(); + expect(bgAt(t, 0)).toBe(bgAt(t, 2)); + + const selection = bgAt(t, 0); + t.press('right'); + await t.settle(); + // Collapsed: the first three cells are back to the field's own background. + expect(bgAt(t, 0)).not.toBe(selection); + await t.unmount(); + }); + + it('collapses to the near edge on a plain arrow', async () => { + const t = await open('hello world'); + t.drag([6, 0], [11, 0]); + await t.settle(); + // Left goes to the start of the selection, not one back from where the + // drag ended. + t.press('left'); + await t.settle(); + t.type('X'); + await t.settle(); + + expect(t.line(0)).toBe('hello Xworld'); + await t.unmount(); + }); + + it('scrolls the field when the drag goes past the edge of it', async () => { + // Six lines in a field three rows tall, and the caret starts at the end - + // so the field opens on the last three and the first three are off-screen. + const t = await open('one\ntwo\nthree\nfour\nfive\nsix', { maxRows: 3 }); + expect(t.hasText('one')).toBe(false); + + // From the last row shown, up past the top of the field. Clamping the row + // to what is on screen would stop this dead at the first visible line. + t.drag([0, 2], [0, 0], [0, -3]); + await t.settle(); + + expect(clipboard(t)).toBe('one\ntwo\nthree\nfour\nfive\n'); + expect(t.hasText('one')).toBe(true); + await t.unmount(); + }); + + /* + * Two clicks and three, which a terminal has no notion of. + * + * The wire says press and release; "double click" is arithmetic on when the + * presses arrived and where. Which is why `click` here steps the clock past + * the window and `clickRepeat` does not - two clicks and one double click + * are different tests, not a race. + */ + it('takes the word under a double click', async () => { + const t = await open('hello brave world'); + t.clickRepeat(8, 0, 2); + await t.settle(); + + expect(clipboard(t)).toBe('brave'); + await t.unmount(); + }); + + it('takes the run of spaces when the double click lands between words', async () => { + const t = await open('hello world'); + t.clickRepeat(6, 0, 2); + await t.settle(); + + // The gap, not either of the words either side of it. + expect(clipboard(t)).toBe(' '); + await t.unmount(); + }); + + it('does not run a word across a line break', async () => { + const t = await open('hello\nworld'); + t.clickRepeat(2, 0, 2); + await t.settle(); + + expect(clipboard(t)).toBe('hello'); + await t.unmount(); + }); + + it('takes the whole line on a third click', async () => { + const t = await open('first line\nsecond line\nthird line'); + t.clickRepeat(3, 1, 3); + await t.settle(); + + // The logical line and its break, not the row it happened to be drawn on. + expect(clipboard(t)).toBe('second line\n'); + await t.unmount(); + }); + + it('takes the wrapped paragraph, not the row under the pointer', async () => { + // Twelve columns of field: this is one line over three rows. + const t = await open('aaaa bbbb cccc dddd', {}, 12); + t.clickRepeat(2, 1, 3); + await t.settle(); + + expect(clipboard(t)).toBe('aaaa bbbb cccc dddd'); + await t.unmount(); + }); + + it('starts over when the clicks are apart in time', async () => { + const t = await open('hello brave world'); + t.click(8, 0); + t.click(8, 0); + await t.settle(); + + // Two clicks, a second apart: a caret, twice - not a word. + expect(clipboard(t)).toBe(''); + await t.unmount(); + }); + + it('starts over when the second click is somewhere else', async () => { + const t = await open('hello brave world'); + // Inside the window but on another cell, which is two clicks. + t.clickRepeat(8, 0, 1); + t.clickRepeat(2, 0, 1); + await t.settle(); + + expect(clipboard(t)).toBe(''); + await t.unmount(); + }); + + it('comes back round to a caret on the fourth', async () => { + const t = await open('hello brave world'); + t.clickRepeat(8, 0, 4); + await t.settle(); + t.type('X'); + await t.settle(); + + // A caret in the middle of "brave", not a word or a line replaced. + expect(t.line(0)).toBe('hello brXave world'); + await t.unmount(); + }); + + it('copies a selection made with the keyboard, like one made with the mouse', async () => { + const t = await open('hello world'); + t.click(0, 0); + await t.settle(); + t.pressAll('shift+right', 'shift+right', 'shift+right', 'shift+right', 'shift+right'); + await t.settle(); + + // Highlighted and on the clipboard are the same thing. They were not: a + // keyboard selection showed and never copied. + expect(clipboard(t)).toBe('hello'); + await t.unmount(); + }); + + describe('a word at a time', () => { + const caretAfter = async (t: Awaited>, mark: string) => { + t.type(mark); + await t.settle(); + return t.line(0); + }; + + it('jumps forward to the start of the next word', async () => { + const t = await open('hello brave world'); + t.press('home'); + await t.settle(); + t.press('ctrl+right'); + await t.settle(); + + // The start of "brave", not the gap in front of it. + expect(await caretAfter(t, '|')).toBe('hello |brave world'); + await t.unmount(); + }); + + it('jumps back to the start of the word it is in', async () => { + const t = await open('hello brave world'); + t.press('end'); + await t.settle(); + t.pressAll('ctrl+left', 'ctrl+left'); + await t.settle(); + + expect(await caretAfter(t, '|')).toBe('hello |brave world'); + await t.unmount(); + }); + + it('treats a line break as a step of its own', async () => { + const t = await open('one two\nthree'); + // The very start. `home` is the start of the *line*, and the caret opens + // at the end of the value - which is line two. + t.click(0, 0); + await t.settle(); + // "two" is two jumps, and the break is a third - so the caret rests at + // the end of the line before it crosses. + t.pressAll('ctrl+right', 'ctrl+right'); + await t.settle(); + t.type('|'); + await t.settle(); + expect(t.lines()[0]).toBe('one two|'); + + t.press('backspace'); + t.press('ctrl+right'); + await t.settle(); + t.type('|'); + await t.settle(); + expect(t.lines()[1]).toBe('|three'); + await t.unmount(); + }); + + it('extends the selection when shift is held, and copies it', async () => { + const t = await open('hello brave world'); + t.press('home'); + await t.settle(); + t.pressAll('shift+ctrl+right', 'shift+ctrl+right'); + await t.settle(); + + expect(clipboard(t)).toBe('hello brave '); + await t.unmount(); + }); + }); + + it('gives escape to the selection before it gives it to onCancel', async () => { + let cancelled = 0; + const t = await open('hello world', { onCancel: () => { cancelled += 1; } }); + t.drag([0, 0], [5, 0]); + await t.settle(); + + t.press('escape'); + await t.settle(); + expect(cancelled).toBe(0); + + t.press('escape'); + await t.settle(); + expect(cancelled).toBe(1); + await t.unmount(); + }); +}); diff --git a/packages/widgets/src/control/text-area.ts b/packages/widgets/src/control/text-area.ts index 8e945ac..8b7c759 100644 --- a/packages/widgets/src/control/text-area.ts +++ b/packages/widgets/src/control/text-area.ts @@ -1,14 +1,17 @@ -import type { BoxProps, KeyEvent, SemanticVariant } from '@textui/core'; +import type { BoxProps, KeyEvent, MouseEvent, SemanticVariant, Style } from '@textui/core'; import { defineComponent, graphemes, h, stringWidth, + useClipboard, useEffect, useFocus, useInput, useMeasure, + useRef, useState, + useTheme, useTicker, } from '@textui/core'; @@ -60,16 +63,29 @@ export interface TextAreaProps extends BoxProps { */ caretTone?: SemanticVariant; /** - * What the caret looks like. An underline under the character it is on by - * default; `block` fills the cell instead. + * 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. */ caretStyle?: 'underline' | 'block'; + /** + * 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. + */ + copyOnSelect?: boolean; /** * Blink the caret while the field has the keyboard. On by default. * @@ -101,9 +117,11 @@ export const TextArea = defineComponent('TextArea', (props) => { const { value, onChange, onSubmit, onCancel, onOverflow, onEdge, placeholder, maxRows = 6, maxLength, autoFocus, focusId, disabled, caretTone, blink = true, wrap = 'word', - caretStyle = 'underline', ...rest + caretStyle, copyOnSelect = true, ...rest } = props; + const theme = useTheme(); + const clipboard = useClipboard(); const focus = useFocus({ ...(focusId ? { id: focusId } : {}), disabled, @@ -112,6 +130,33 @@ export const TextArea = defineComponent('TextArea', (props) => { const [caret, setCaret] = useState(value.length); const [lit, setLit] = useState(true); + /** + * Where the selection was started from; `null` when there is none. + * + * The caret is the other end, so a selection is a pair of offsets that + * remembers which way round it was made - which is what lets shift+left + * shrink a rightward selection instead of starting a new one. + * + * Kept in a ref as well because a drag can outrun the frame loop: `down` + * and `up` can both land inside one frame, and the handler that has to + * decide whether anything was selected would be reading last frame's state. + */ + const anchorRef = useRef(null); + /** + * The last press, so a second one on the same cell can be told from a first. + * + * A terminal has no notion of a double click - it reports presses - so the + * count is kept here and reset by either moving or waiting. + */ + const repeat = useRef<{ at: number; x: number; y: number; count: number; dragged: boolean }>( + { at: Number.NEGATIVE_INFINITY, x: -1, y: -1, count: 0, dragged: false }, + ); + const [anchor, setAnchorState] = useState(null); + const setAnchor = (next: number | null): void => { + anchorRef.current = next; + setAnchorState(next); + }; + // Only while it has the keyboard, and solid the moment it loses it: a caret // blinking in a field nobody is typing into is two carets on one screen. useTicker(() => setLit((on) => !on), { fps: 2, enabled: blink && focus.focused }); @@ -124,12 +169,42 @@ export const TextArea = defineComponent('TextArea', (props) => { * whatever the cell already had - a caret over selected or coloured text * stays legible instead of painting the theme's cursor colour over it. */ - const mark = caretStyle === 'block' + // `bar` has no drawn form that does not occupy a cell of its own, so it + // marks the cell the way an underline does - which is what a bar caret + // looks like anyway once it has nowhere of its own to stand. + const shape = caretStyle ?? (theme.cursor === 'block' ? 'block' : 'underline'); + const mark: Style = shape === 'block' ? { inverse: true, fg: caretTone ?? 'cursor' } : { underline: true, fg: caretTone ?? 'cursor' }; const chars = graphemes(value); const position = Math.min(caret, chars.length); + + /* + * The selection, as the two offsets it covers. + * + * Clamped against the value the same way the caret is: `value` is a prop, so + * it can be replaced under a live selection - a history walked with up and + * down does exactly that - and an anchor past the end of the new text would + * select backwards into nothing. + */ + const anchoredAt = anchor === null ? null : Math.min(anchor, chars.length); + const selectionStart = anchoredAt === null ? position : Math.min(anchoredAt, position); + const selectionEnd = anchoredAt === null ? position : Math.max(anchoredAt, position); + const selected = selectionEnd > selectionStart; + + /** + * How a selected cell is drawn. + * + * The same pair a selected list row uses, and dimmer once the field loses + * the keyboard: a selection left visible in an unfocused field says what is + * on the clipboard, and saying it as loudly as the live one would put two + * selections on the screen. + */ + const selectionStyle: Style = focus.focused + ? { bg: 'selected', fg: 'inverted' } + : { bg: 'active' }; + const before = chars.slice(0, position).join(''); const lines = value === '' ? [''] : value.split('\n'); const caretLine = before.split('\n').length - 1; @@ -152,7 +227,10 @@ export const TextArea = defineComponent('TextArea', (props) => { * 0 and nothing wraps, which is precisely the behaviour above - and the pass * that `useMeasure` schedules draws the wrapped version. */ - const width = useMeasure().width; + // The content rect, not the border box: rows are drawn inside the padding, + // so this is the origin a pointer has to be measured against. + const rect = useMeasure(); + const width = rect.width; const mode = width > 0 && (wrap === 'word' || wrap === 'char') ? wrap : 'none'; const visual: { line: number; start: number; cells: string[] }[] = []; @@ -196,15 +274,142 @@ export const TextArea = defineComponent('TextArea', (props) => { return lineStart(row.line) + row.start + column; }; + /** + * The offset a pointer landed on. + * + * Cells rather than characters: a wide glyph is two columns, so the column + * a click is in is not its index in the row. Past the end of a row the + * caret goes after its last character, which is where a click in the empty + * part of a line is asking for. + */ + const offsetAt = (cellX: number, rowIndex: number): number => { + const row = visual[Math.max(0, Math.min(rowIndex, visual.length - 1))]; + if (!row) return position; + let used = 0; + let column = row.cells.length; + for (let i = 0; i < row.cells.length; i++) { + const w = stringWidth(row.cells[i] as string); + // The half-way point: clicking the right half of a glyph puts the caret + // after it, which is what every other editor does. + if (cellX < used + w - (w > 1 ? 0 : 0.5)) { column = i; break; } + used += w; + } + return lineStart(row.line) + row.start + column; + }; + const replace = (next: string, at: number): void => { const capped = maxLength !== undefined ? next.slice(0, maxLength) : next; + setAnchor(null); onChange(capped); setCaret(Math.max(0, Math.min(graphemes(capped).length, at))); }; + /** Typing over a selection replaces it - which is what deletes it, too. */ const insert = (text: string): void => { const inserted = graphemes(text); - replace([...chars.slice(0, position), ...inserted, ...chars.slice(position)].join(''), position + inserted.length); + replace( + [...chars.slice(0, selectionStart), ...inserted, ...chars.slice(selectionEnd)].join(''), + selectionStart + inserted.length, + ); + }; + + /** + * Move the caret, taking the selection with it when shift is held. + * + * Without shift the selection collapses, because that is what an arrow key + * means everywhere: stop selecting, and go here. + */ + const move = (to: number, extend: boolean): void => { + if (!extend) { setAnchor(null); setCaret(to); return; } + + const from = anchorRef.current ?? position; + if (anchorRef.current === null) setAnchor(from); + setCaret(to); + // A selection made with the keyboard is a selection, and goes the same + // place one made with the mouse goes. Copying only on a mouse release + // meant shift+right showed something highlighted that was not on the + // clipboard - which is a selection you have to redo with the mouse. + copy(chars.slice(Math.min(from, to), Math.max(from, to)).join('')); + }; + + const copy = (text: string): void => { + if (copyOnSelect && text !== '') clipboard.write(text); + }; + + /** Select `[from, to)`, and copy it - a word picked out is a word taken. */ + const select = (from: number, to: number): void => { + setAnchor(from); + setCaret(to); + copy(chars.slice(from, to).join('')); + }; + + /** + * The run a double click means. + * + * 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, which is what makes "select the word, then extend" + * behave. A newline is a class of its own and joins nothing: a word + * selection that ran across one would be a paragraph, and there is a + * separate gesture for that. + */ + const runAt = (at: number): [number, number] => { + if (chars.length === 0) return [0, 0]; + const index = Math.max(0, Math.min(at, chars.length - 1)); + const kind = classOf(chars[index] as string); + if (kind === 'break') return [index, index + 1]; + + let from = index; + while (from > 0 && classOf(chars[from - 1] as string) === kind) from -= 1; + let to = index + 1; + while (to < chars.length && classOf(chars[to] as string) === kind) to += 1; + return [from, to]; + }; + + /** + * The offset one word away, in `step`'s direction. + * + * Skips whatever is under the caret to the far side of it, then any run of + * whitespace - so ctrl+right lands at the start of the next word rather than + * in the gap before it, which is where "skip the run you are in" alone would + * leave it. + * + * A newline is one step of its own. Walking right stops at the end of the + * line, and the next press crosses to the start of the one below - rather + * than the break and the first word going by together. The end of a line is + * somewhere people mean to be, which is why it costs a press to leave. + */ + const wordStep = (from: number, step: -1 | 1): number => { + let at = from; + const peek = (): string | undefined => chars[step < 0 ? at - 1 : at]; + + if (step < 0) { + while (at > 0 && classOf(peek() as string) === 'space') at -= 1; + if (at > 0 && classOf(peek() as string) === 'break') return at - 1; + const kind = at > 0 ? classOf(peek() as string) : 'word'; + while (at > 0 && classOf(peek() as string) === kind) at -= 1; + return at; + } + + if (at < chars.length && classOf(peek() as string) === 'break') return at + 1; + const kind = at < chars.length ? classOf(peek() as string) : 'word'; + while (at < chars.length && classOf(peek() as string) === kind) at += 1; + while (at < chars.length && classOf(peek() as string) === 'space') at += 1; + return at; + }; + + /** + * The logical line a triple click means - with its newline, when it has one. + * + * The line rather than the row: a wrapped paragraph is one thing somebody + * wrote, and taking the third of it that happened to fit on one row is not + * a selection anybody asked for. + */ + const lineAt = (at: number): [number, number] => { + const index = chars.slice(0, at).join('').split('\n').length - 1; + const start = lineStart(index); + const end = start + graphemes(lines[index] ?? '').length; + return [start, Math.min(chars.length, end + (index < lines.length - 1 ? 1 : 0))]; }; useInput( @@ -213,17 +418,28 @@ export const TextArea = defineComponent('TextArea', (props) => { switch (event.name) { case 'left': + // A word at a time, the way every field with a caret in it does. + if (event.ctrl || event.alt) { move(wordStep(position, -1), event.shift); return true; } + // A plain arrow collapses to the edge it is moving towards rather + // than stepping one from the caret: with three words selected, left + // means "back to the start of that", not "one before wherever the + // drag happened to end". + if (selected && !event.shift) { move(selectionStart, false); return true; } // Off the front of the field is the caller's key, the same way up at // the top is - so "left, left, left" walks out of the composer. - if (position === 0) { onEdge?.('start'); return true; } - setCaret(position - 1); + if (position === 0) { if (!event.shift) setAnchor(null); onEdge?.('start'); return true; } + move(position - 1, event.shift); return true; case 'right': - if (position === chars.length) { onEdge?.('end'); return true; } - setCaret(position + 1); + if (event.ctrl || event.alt) { move(wordStep(position, 1), event.shift); return true; } + if (selected && !event.shift) { move(selectionEnd, false); return true; } + if (position === chars.length) { if (!event.shift) setAnchor(null); onEdge?.('end'); return true; } + move(position + 1, event.shift); + return true; + case 'home': move(lineStart(caretLine), event.shift); return true; + case 'end': + move(lineStart(caretLine) + graphemes(lines[caretLine] ?? '').length, event.shift); return true; - case 'home': setCaret(lineStart(caretLine)); return true; - case 'end': setCaret(lineStart(caretLine) + graphemes(lines[caretLine] ?? '').length); return true; // By *row*, not by line. In a wrapped paragraph moving by line jumps // the whole paragraph, and what is above the caret on the screen is @@ -231,20 +447,24 @@ export const TextArea = defineComponent('TextArea', (props) => { case 'up': { // At the top there is nowhere to go inside the field, so the caller // gets the key - which is where "the last thing you sent" comes from. - if (caretRow === 0) { onOverflow?.(-1); return true; } - setCaret(rowCaret(caretRow - 1)); + if (caretRow === 0) { if (!event.shift) setAnchor(null); onOverflow?.(-1); return true; } + move(rowCaret(caretRow - 1), event.shift); return true; } case 'down': { - if (caretRow === visual.length - 1) { onOverflow?.(1); return true; } - setCaret(rowCaret(caretRow + 1)); + if (caretRow === visual.length - 1) { if (!event.shift) setAnchor(null); onOverflow?.(1); return true; } + move(rowCaret(caretRow + 1), event.shift); return true; } case 'backspace': + // A selection is what gets deleted when there is one - the character + // before the caret is only the fallback. + if (selected) { insert(''); return true; } if (position > 0) replace([...chars.slice(0, position - 1), ...chars.slice(position)].join(''), position - 1); return true; case 'delete': + if (selected) { insert(''); return true; } if (position < chars.length) replace([...chars.slice(0, position), ...chars.slice(position + 1)].join(''), position); return true; @@ -254,10 +474,17 @@ export const TextArea = defineComponent('TextArea', (props) => { // binding it produces a key that works on one machine. if (!onSubmit || event.alt || event.ctrl) { insert('\n'); return true; } onSubmit(value); + setAnchor(null); setCaret(0); return true; - case 'escape': onCancel?.(); return true; + // `onCancel` is documented as escape *when there is nothing inside the + // field to cancel*, and a live selection is something inside the field + // to cancel. + case 'escape': + if (selected) { setAnchor(null); return true; } + onCancel?.(); + return true; case 'paste': insert(event.char ?? ''); return true; default: break; } @@ -287,11 +514,88 @@ export const TextArea = defineComponent('TextArea', (props) => { // nothing else, because one row is what the field asked for and the border // spent it. Nobody saw it because a field without a border looks the same // either way, and `TextInput` fixes no height at all. + /** + * A click puts the caret where it landed; a drag selects to where it got to. + * + * `onMouse` rather than `onClick` because a field that is not focused has to + * take the keyboard first - otherwise the caret moves somewhere the next + * keystroke will not go - and because `onClick` is the button going down, + * which is a third of a drag. + * + * The drag arrives at all because the application holds the pointer for + * whoever took the button down. + * + * The row is deliberately not clamped to what is on screen. Dragging below + * the field asks for a row below the field, the caret follows it there, and + * the scroll offset is computed from the caret - so a selection dragged off + * the bottom scrolls the field, which is the whole reason to drag off the + * bottom. `offsetAt` clamps to the text, and that is the only clamp there is. + */ + const pointerOffset = (event: MouseEvent): number => { + const column = Math.max(0, event.x - rect.x); + return Math.max(0, Math.min(chars.length, offsetAt(column, first + (event.y - rect.y)))); + }; + + const onMouse = (event: MouseEvent): boolean => { + if (disabled || event.button !== 'left' || rect.width <= 0) return false; + + switch (event.action) { + case 'down': { + // Only a press *inside* the field starts anything. Drags and releases + // are not tested that way: by then the pointer is wherever it has got + // to, and the gesture is this field's regardless. + if (event.x < rect.x || event.y < rect.y) return false; + if (!focus.focused) focus.focus(); + const at = pointerOffset(event); + + // One cell, one window, or it is a new gesture. Position matters as + // much as time: two clicks half a second apart on different words are + // two clicks, and a terminal that reports no timestamp gets `0` for + // both, which lands on "same instant" - so the cell is what saves it. + const stamp = event.at ?? 0; + const same = event.x === repeat.current.x && event.y === repeat.current.y; + const soon = stamp - repeat.current.at <= 450; + const count = same && soon ? (repeat.current.count % 3) + 1 : 1; + repeat.current = { at: stamp, x: event.x, y: event.y, count, dragged: false }; + + if (count === 2) { const [from, to] = runAt(at); select(from, to); return true; } + if (count === 3) { const [from, to] = lineAt(at); select(from, to); return true; } + + // Anchored, but empty - so a click that never turns into a drag is a + // caret and nothing else. + setAnchor(at); + setCaret(at); + return true; + } + case 'drag': + repeat.current.dragged = true; + setCaret(pointerOffset(event)); + return true; + case 'up': { + // A word or a line was decided when the button went down, and the + // release is the same press ending. Re-reading the pointer here would + // collapse the selection back to the cell it was made from - which is + // what "double click selects two letters" was. + if (repeat.current.count > 1 && !repeat.current.dragged) return true; + + const at = pointerOffset(event); + const from = anchorRef.current; + setCaret(at); + if (from === null || from === at) { setAnchor(null); return true; } + copy(chars.slice(Math.min(from, at), Math.max(from, at)).join('')); + return true; + } + default: + return false; + } + }; + return h('box', { id: focus.id, role: 'textbox', label: placeholder, direction: 'column', + onMouse, ...rest, }, h('box', { direction: 'column', height: rows }, value === '' @@ -316,22 +620,69 @@ export const TextArea = defineComponent('TextArea', (props) => { })() : shown.map((row, i) => { const index = first + i; - const text = row.cells.join(''); - if (index !== caretRow || !focus.focused) { - return h('text', { key: index, content: text === '' ? ' ' : text, wrap: 'none', truncate: 'end' }); - } + const base = lineStart(row.line) + row.start; + const end = base + row.cells.length; // The caret's column inside this row. The row has already been broken // to the width it will be drawn at, so the split lands where it looks. - // - // Three parts, and the middle one is the cell the caret is *on* - not - // an extra cell between them. Past the last character there is nothing - // to mark, so a space stands in; that is the one case where the caret - // adds a column, and it adds it at the end where nothing moves. - const at = caretColumn - row.start; + const at = index === caretRow && focus.focused ? caretColumn - row.start : -1; + + // One entry per drawn cell, carrying what is true of it. The caret is + // a cell the row already has rather than an extra one between two of + // them - that is what keeps a wrapped row as wide as its own text. + const cells = row.cells.map((char, column) => ({ + char, + selected: selected && base + column >= selectionStart && base + column < selectionEnd, + caret: column === at, + })); + + // The cell after the last character. It exists when the caret is past + // the end and has nothing to mark, when the newline this row ends on + // is inside the selection - a selected line break has to show, or + // three selected lines read as three unrelated runs - and when the row + // is empty and needs something to occupy it. + const breaks = visual[index + 1]?.line !== row.line; + const tail = { + char: ' ', + selected: selected && breaks && end >= selectionStart && end < selectionEnd, + caret: at === row.cells.length, + }; + if (tail.caret || tail.selected || cells.length === 0) cells.push(tail); + + if (!cells.some((cell) => cell.selected || cell.caret)) { + return h('text', { key: index, content: cells.map((c) => c.char).join(''), wrap: 'none', truncate: 'end' }); + } + + // Neighbouring cells that look the same are drawn as one run, so a + // selected row is a handful of nodes rather than one per column. + const runs: { content: string; style: Style }[] = []; + let previous = ''; + for (const cell of cells) { + const lift = `${cell.selected ? 's' : ''}${cell.caret && lit ? 'c' : ''}`; + const last = runs[runs.length - 1]; + if (last && lift === previous) last.content += cell.char; + else { + runs.push({ + content: cell.char, + style: { + ...(cell.selected ? selectionStyle : {}), + ...(cell.caret && lit ? mark : {}), + }, + }); + previous = lift; + } + } + return h('box', { key: index, direction: 'row' }, - h('text', { content: row.cells.slice(0, at).join('') }), - h('text', { content: row.cells[at] ?? ' ', ...(lit ? mark : {}) }), - h('text', { content: row.cells.slice(at + 1).join(''), flex: 1, truncate: 'end' })); + ...runs.map((run, r) => h('text', { + key: r, + content: run.content, + wrap: 'none', + truncate: 'end', + ...run.style, + })), + // The rest of the row, unstyled: flexing the last run instead would + // drag a selection's background out to the edge of the field. + h('text', { key: 'rest', content: '', flex: 1, truncate: 'end' })); }), )); }); @@ -388,3 +739,15 @@ function rowWidth(cells: string[]): number { for (const cell of cells) width += stringWidth(cell); return width; } + +/** + * What kind of character this is, for the purpose of "the run around it". + * + * Four classes, and a newline is its own: joining it to the whitespace either + * side would make a double click in the margin select across two lines. + */ +function classOf(cell: string): 'word' | 'space' | 'break' | 'other' { + if (cell === '\n') return 'break'; + if (/\s/.test(cell)) return 'space'; + return /[\p{L}\p{N}_]/u.test(cell) ? 'word' : 'other'; +} diff --git a/packages/widgets/src/data/list.ts b/packages/widgets/src/data/list.ts index 5ea422c..8684db3 100644 --- a/packages/widgets/src/data/list.ts +++ b/packages/widgets/src/data/list.ts @@ -141,8 +141,18 @@ export const List = defineComponent('List', (props) => { h('text', { content: item.label, flex: 1, truncate: 'end' }), // The secondary columns keep the row's colour once it is selected; // `muted` on a selected background is unreadable. - item.description ? h('text', { content: item.description, fg: active ? undefined : 'muted', truncate: 'end' }) : null, - item.meta ? h('text', { content: item.meta, fg: active ? undefined : 'muted' }) : null, + // + // The description yields first, and by a lot: it is the elaboration, + // and a row reading "brb_fram…" beside a status cut to "waiting on y…" + // has spent the width on the wrong two things. `meta` gives up none of + // it - a status is three words at most, and it is the column the row + // is being scanned for. + item.description + ? h('text', { content: item.description, fg: active ? undefined : 'muted', truncate: 'end', shrink: 8 }) + : null, + item.meta + ? h('text', { content: item.meta, fg: active ? undefined : 'muted', shrink: 0 }) + : null, ); }), ); diff --git a/packages/widgets/src/layout/divider.ts b/packages/widgets/src/layout/divider.ts index 9fce516..68acab7 100644 --- a/packages/widgets/src/layout/divider.ts +++ b/packages/widgets/src/layout/divider.ts @@ -1,4 +1,4 @@ -import type { BoxProps } from '@textui/core'; +import type { BoxProps, DividerStyle } from '@textui/core'; import { defineComponent, h, useTheme } from '@textui/core'; export interface DividerProps extends Omit { @@ -7,25 +7,33 @@ export interface DividerProps extends Omit { /** Text set into the rule. */ label?: string; labelAlign?: 'left' | 'center' | 'right'; + /** + * The rule style. The theme's own is the default, so a borderless theme + * still gets the line it asked for. + */ + rule?: DividerStyle; char?: string; } export const Divider = defineComponent('Divider', (props) => { const theme = useTheme(); - const { direction = 'horizontal', label, labelAlign = 'left', char, ...rest } = props; - const chars = theme.borderChars(); + const { + direction = 'horizontal', label, labelAlign = 'left', + char, rule: ruleStyle, fg = 'divider', ...rest + } = props; + const rule = theme.dividerChars(ruleStyle); if (direction === 'vertical') { - return h('box', { role: 'separator', width: 1, fill: char ?? chars.left, fg: 'border', ...rest }); + return h('box', { role: 'separator', width: 1, fill: char ?? rule.vertical, fg, ...rest }); } if (!label) { - return h('box', { role: 'separator', height: 1, fill: char ?? chars.top, fg: 'border', ...rest }); + return h('box', { role: 'separator', height: 1, fill: char ?? rule.horizontal, fg, ...rest }); } return h('box', { direction: 'row', gap: 1, height: 1, ...rest }, - labelAlign !== 'left' ? h('box', { flex: 1, fill: char ?? chars.top, fg: 'border' }) : null, + labelAlign !== 'left' ? h('box', { flex: 1, fill: char ?? rule.horizontal, fg }) : null, h('text', { content: label, fg: 'muted' }), - labelAlign !== 'right' ? h('box', { flex: 1, fill: char ?? chars.top, fg: 'border' }) : null, + labelAlign !== 'right' ? h('box', { flex: 1, fill: char ?? rule.horizontal, fg }) : null, ); }); diff --git a/packages/widgets/src/navigation/menu.ts b/packages/widgets/src/navigation/menu.ts index 5b71d63..0dd4952 100644 --- a/packages/widgets/src/navigation/menu.ts +++ b/packages/widgets/src/navigation/menu.ts @@ -1,5 +1,5 @@ import type { BoxProps, SemanticVariant } from '@textui/core'; -import { defineComponent, h, useFocus, useInput, useState, useTheme } from '@textui/core'; +import { defineComponent, h, stringWidth, useFocus, useInput, useState, useTheme } from '@textui/core'; import { Marquee } from '../display/index.js'; import { TONE } from '../tone.js'; @@ -13,6 +13,18 @@ export interface MenuItem { disabled?: boolean; tone?: SemanticVariant; separatorBefore?: boolean; + /** + * A heading on the line above this row, naming the group it starts. + * + * The name of a group belongs to the group, so it is said once at the top + * of it rather than repeated on every row - a column reading "Screens, + * Screens, Screens" spends the width that the rows themselves need, and + * still does not say where one group ends. + * + * It takes the line a `separatorBefore` would have used rather than adding + * one, so a grouped menu is the same height either way. + */ + sectionBefore?: string; /** * A switch, and whether it is on. Absent means the row is not a switch, so * a menu of ordinary commands keeps its left edge rather than indenting @@ -29,6 +41,18 @@ export interface MenuProps extends BoxProps { visibleRows?: number; activeId?: string; autoFocus?: boolean; + /** + * 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. + */ + descriptions?: 'inline' | 'below'; /** * 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 @@ -40,7 +64,8 @@ export interface MenuProps extends BoxProps { export const Menu = defineComponent('Menu', (props) => { const theme = useTheme(); const { - items, onSelect, visibleRows, activeId, autoFocus, interactive = true, ...rest + items, onSelect, visibleRows, activeId, autoFocus, interactive = true, + descriptions = 'inline', ...rest } = props; const focus = useFocus({ autoFocus, disabled: !interactive }); const selectable = items.filter((i) => !i.disabled); @@ -82,17 +107,9 @@ export const Menu = defineComponent('Menu', (props) => { return h('box', { id: focus.id, role: 'menu', direction: 'column', ...rest }, ...window.flatMap((item, i) => { const active = start + i === highlight; - const row = h('box', { - key: item.id, - role: 'menuitem', - label: item.label, - selected: active, - direction: 'row', - gap: 1, - bg: active ? 'selected' : undefined, - fg: item.disabled ? 'disabled' : active ? 'inverted' : item.tone ? TONE[item.tone] : undefined, - onClick: () => { if (!item.disabled) onSelect?.(item.id, item); }, - }, + const below = descriptions === 'below' && item.description !== undefined; + + const head = h('box', { direction: 'row', gap: 1 }, h('text', { content: active ? theme.glyphs.chevronRight : ' ', shrink: 0 }), switches ? h('text', { content: item.checked === true ? theme.glyphs.check : ' ', shrink: 0 }) @@ -108,7 +125,7 @@ export const Menu = defineComponent('Menu', (props) => { // The description yields first, and by a lot. It is the elaboration; // the label is the thing being chosen, and a row reading "Accept ed…" // beside a full sentence has given up the wrong half. - item.description + item.description && !below ? h(Marquee, { content: item.description, active, @@ -123,6 +140,49 @@ export const Menu = defineComponent('Menu', (props) => { item.children ? h('text', { content: theme.glyphs.chevronRight }) : null, ); + const row = h('box', { + key: item.id, + role: 'menuitem', + label: item.label, + selected: active, + direction: 'column', + // One background over both lines: a highlight that stopped after the + // label would split the row it is highlighting in two. + bg: active ? 'selected' : undefined, + fg: item.disabled ? 'disabled' : active ? 'inverted' : item.tone ? TONE[item.tone] : undefined, + onClick: () => { if (!item.disabled) onSelect?.(item.id, item); }, + }, + head, + // Under the label rather than under the cursor: the sentence is about + // the thing being chosen, so it starts where that thing starts. + below + ? h('box', { direction: 'row' }, + h('text', { content: ' '.repeat(leading(item, switches)), shrink: 0 }), + // A `Marquee`, like the inline one: a sentence too long for the + // panel is still readable on the row the cursor is on, by sliding + // it. Truncated and still is right for the rows being scanned past + // and useless for the one that has been stopped on. + h(Marquee, { + content: item.description as string, + active, + fg: active ? 'inverted' : 'muted', + flex: 1, + })) + : null, + ); + + if (item.sectionBefore) { + return [ + h('box', { key: `${item.id}-sec`, role: 'heading', direction: 'row', gap: 1 }, + // The same leading columns the rows have, so a heading sits over + // the labels it names rather than over the cursor's gutter. + h('text', { content: ' ', shrink: 0 }), + switches ? h('text', { content: ' ', shrink: 0 }) : null, + h('text', { content: item.sectionBefore, bold: true, fg: 'muted', truncate: 'end' })), + row, + ]; + } + return item.separatorBefore ? [h('box', { key: `${item.id}-sep`, height: 1, fill: theme.borderChars().top, fg: 'borderSubtle' }), row] : [row]; @@ -132,3 +192,18 @@ export const Menu = defineComponent('Menu', (props) => { : null, ); }); + +/** + * The columns before a row's label, so a second line can start under it. + * + * The cursor's column and the switch's are the same on every row - that is + * what keeps a menu's left edge straight - but the icon is per-row and may be + * two cells wide, so it has to be measured rather than assumed. + */ +function leading(item: MenuItem, switches: boolean): number { + // The marker, plus the gap after it. + let width = 2; + if (switches) width += 2; + if (item.icon) width += stringWidth(item.icon) + 1; + return width; +} diff --git a/packages/widgets/src/overlay/command-palette.ts b/packages/widgets/src/overlay/command-palette.ts index 82d9902..2aa9fb5 100644 --- a/packages/widgets/src/overlay/command-palette.ts +++ b/packages/widgets/src/overlay/command-palette.ts @@ -2,6 +2,7 @@ import type { ArgChoice, ArgSpec, BoxProps, CommandDefinition, TextUIApp } from import { defineComponent, h, + stringWidth, useEffect, useFocusScope, useInput, @@ -29,10 +30,41 @@ export interface CommandPaletteProps extends BoxProps { onClose?(): void; /** Off makes this a picker: it reports the choice and runs nothing. */ execute?: boolean; - /** Group the list by `category`, with a rule between groups. */ + /** + * 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. + */ grouped?: boolean; visibleRows?: 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. + */ width?: number; + /** + * 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. + */ + maxWidth?: number; + /** + * 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. + */ + descriptions?: 'inline' | 'below'; /** * Open already drilled into this command's choices. * @@ -62,7 +94,8 @@ export const CommandPalette = defineComponent('CommandPalet const runtime = useRuntime(); const { commands, placeholder, onRun, onClose, execute = true, - grouped = true, visibleRows = 8, width = 60, openAt, ...rest + grouped = true, visibleRows = 8, width, maxWidth = 60, openAt, + descriptions = 'inline', ...rest } = props; const [query, setQuery] = useState(''); @@ -124,17 +157,28 @@ export const CommandPalette = defineComponent('CommandPalet : matches.map((command, i) => ({ id: command.id, label: command.title, - // `badge` when the row has state to report, the category otherwise. - // The icon stays put either way: it is what the row *is*. - description: command.badge ?? command.category, + // What this row does, or the state it is reporting. The category is + // not here: it names the *group*, so it is said once above it. + description: command.badge ?? command.description, icon: command.icon, // A row may stand for a command registered under another id, and the // key a person would press belongs to that one. shortcut: command.shortcut ?? app?.keybindings.forCommand(command.id)[0], // A chevron, from `Menu`, for anything that will ask a question. children: argumentOf(command) ? [] : undefined, - separatorBefore: - grouped && i > 0 && (matches[i - 1] as CommandDefinition).category !== command.category, + // The heading goes on the first row of each group, including the + // first - a group with no name over it is the one the reader has to + // work out from the rows in it. + // + // Sorted matches interleave the categories, so a query turns the + // headings off rather than repeating them: with the rows in relevance + // order, "Screens" over a single row is noise, and the group it claims + // to start is one row long. + ...(grouped && query.trim() === '' + && (i === 0 || (matches[i - 1] as CommandDefinition).category !== command.category) + && command.category + ? { sectionBefore: command.category } + : {}), })); const back = (): void => { @@ -193,18 +237,42 @@ export const CommandPalette = defineComponent('CommandPalet const resolved = typeof arg.choices === 'function' ? arg.choices() : arg.choices ?? []; setPending({ command, arg, collected }); setQuery(''); - setHighlight(0); + + /* + * Open on the answer that is already in force. + * + * A question about a setting is nearly always asked in order to change it + * *from* something, and the row that something is on is where the reader + * is looking. Starting at the top instead says the first option is the + * current one, which is wrong on every list where it is not - and it + * costs an extra press to get back to where you began. + * + * `default` is the argument's own word for it, and the same one the row + * labelled "default" already used. + */ + const startAt = (list: ArgChoice[]): number => { + const at = list.findIndex((choice) => choice.value === arg.default); + return at < 0 ? 0 : at; + }; + if (Array.isArray(resolved)) { setAsking(false); - setChoices(resolved.map(asChoice)); + const list = resolved.map(asChoice); + setChoices(list); + setHighlight(startAt(list)); return; } setChoices([]); + setHighlight(0); setAsking(true); // Answered either way: a `choices` function that rejects leaves the panel // saying "nothing to choose", which is true of what it can offer. void resolved - .then((list) => setChoices(list.map(asChoice))) + .then((list) => { + const choices = list.map(asChoice); + setChoices(choices); + setHighlight(startAt(choices)); + }) .catch(() => setChoices([])) .finally(() => setAsking(false)); }; @@ -322,12 +390,33 @@ export const CommandPalette = defineComponent('CommandPalet ?? pending.arg.description ?? `${pending.command.title} needs a ${pending.arg.name}` : highlighted?.description ?? highlighted?.id ?? ''; + /* + * How wide the panel wants to be. + * + * A menu sized to a constant is a menu that is too wide for a list of + * one-word answers and too narrow for a list of sentences, and it is the + * same menu either way. So it asks for what it holds - the widest row, plus + * what the row draws around it - and takes `maxWidth` when that is more than + * there is any point having. + * + * `minWidth` keeps the search field, the hint row and the crumb from being + * the things that decide it: a question with two short answers still needs + * somewhere to type and a line saying what the keys do. + */ + const content = Math.max( + ...items.map((item) => rowWidth(item, descriptions)), + ...(pending ? [stringWidth(pending.command.title) + 12] : [stringWidth(placeholder ?? '') + 4]), + ); + return h('box', { role: 'dialog', label: 'Commands', border: theme.border, bg: 'overlay', - width, + // A stated width is a width. Left off, it fits what it holds. + ...(width !== undefined + ? { width } + : { minWidth: Math.min(28, maxWidth), maxWidth, width: Math.min(content, maxWidth) }), direction: 'column', // A border is a gutter as well as a line. Without one - `paper` sets // `border: 'none'` - the rows run flush to the panel edge and the last @@ -376,6 +465,9 @@ export const CommandPalette = defineComponent('CommandPalet h(Menu, { items, visibleRows, + // The argument gets the last word: only it knows whether its answers are + // told apart by a word or by a sentence. + descriptions: pending?.arg.descriptions ?? descriptions, interactive: false, activeId: rows[index], onSelect: (id: string) => { @@ -483,3 +575,25 @@ function subsequenceScore(haystack: string, needle: string): number { } return score; } + +/** + * The cells one row would like, drawn the way this menu draws it. + * + * Mirrors `Menu`'s own layout rather than guessing: the cursor's column and + * the gap after it, the icon when there is one, the label, and then either the + * description beside it or a line of its own under it. A description on its + * own line does not widen the row past its own indent, which is why `below` + * is the layout a long sentence wants. + */ +function rowWidth(item: MenuItem, descriptions: 'inline' | 'below'): number { + // The marker and its gap; a switch column when the menu has one. + const lead = 2 + (item.checked !== undefined ? 2 : 0) + + (item.icon ? stringWidth(item.icon) + 1 : 0); + const label = stringWidth(item.label); + const trail = (item.shortcut ? stringWidth(item.shortcut) + 1 : 0) + (item.children ? 2 : 0); + const description = item.description ? stringWidth(item.description) : 0; + + return descriptions === 'below' + ? Math.max(lead + label + trail, lead + description) + : lead + label + (description > 0 ? description + 2 : 0) + trail; +} diff --git a/packages/widgets/src/shells/workbench-shell.ts b/packages/widgets/src/shells/workbench-shell.ts index 7504808..c139ee5 100644 --- a/packages/widgets/src/shells/workbench-shell.ts +++ b/packages/widgets/src/shells/workbench-shell.ts @@ -19,6 +19,8 @@ export const WorkbenchShell = defineComponent('WorkbenchShell', (pro const sidebar = useSurfaceMounted('sidebar'); const aside = useSurfaceMounted('aside'); const narrow = size.width < 90; + const showSidebar = Boolean(sidebar) && !sidebarCollapsed && !narrow; + const showAside = Boolean(aside) && asideVisible && !narrow; return h('box', { direction: 'column', @@ -34,7 +36,7 @@ export const WorkbenchShell = defineComponent('WorkbenchShell', (pro h('box', { direction: 'row', flex: 1 }, h(SurfaceArea, { surface: 'rail' }), - sidebar && !sidebarCollapsed && !narrow + showSidebar ? h('box', { width: 24, border: { style: theme.border, sides: { right: true } }, @@ -43,11 +45,22 @@ export const WorkbenchShell = defineComponent('WorkbenchShell', (pro }, h(SurfaceArea, { surface: 'sidebar', flex: 1 })) : null, - h('box', { flex: 1, direction: 'column', padding: { left: 1 } }, + // A gutter separates main from the pane beside it, so it belongs on the + // sides that have one. Applied unconditionally it insets every screen by + // a cell on the left and nothing on the right - hidden under a theme + // that draws a frame, and plainly lopsided under one that does not. + h('box', { + flex: 1, + direction: 'column', + padding: { + ...(showSidebar ? { left: 1 } : {}), + ...(showAside ? { right: 1 } : {}), + }, + }, h(SurfaceArea, { surface: 'main', flex: 1 }), h(SurfaceArea, { surface: 'panel' })), - aside && asideVisible && !narrow + showAside ? h('box', { width: 30, border: { style: theme.border, sides: { left: true } }, From 35d44852bda01341fce16a56254c812b20b9127c Mon Sep 17 00:00:00 2001 From: Softov Date: Tue, 25 Aug 2026 01:48:43 -0400 Subject: [PATCH 03/16] feat(chat): slash commands are ours, and a tool call is something that was done The slash menu listed the client's own commands and then sent whatever was typed down the session channel, so `/go.sessions` went to the agent as a message - the one place it could not possibly mean anything. It also could not be navigated: the list was `focusable={false}` with no highlight and no click, so it was decoration over a field. Arrows walk it, a click chooses, and enter runs the command. A slash the menu does not match is left alone and sent, because that is how a command the *agent* offers reaches it. One that still has a question to ask opens its picker rather than `execute` refusing a missing argument. A tool call is something the agent did, not something it said. It was drawn inside the speech gutter, one indent in, as though it were a paragraph of the answer - and its input went straight onto the row, newlines and all, so a three-line JSON object made the row three lines tall with the tool's name floating beside the middle brace. It sits at the turn's own left edge now with its status glyph where the header's bullet is, a chevron trailing when there is something under it, a one-line summary on the row, and the whole input on its own lines once opened. Clicking it opens it, and it lights up under the pointer. A chip's panel is also that chip's toggle, and the settings it offers now declare what they are currently set to. --- CHANGELOG.md | 68 ++++++++ docs/components/base-props.md | 6 +- docs/components/input/text-area.md | 29 +++- docs/components/layout/divider.md | 1 + docs/components/navigation/command-palette.md | 6 +- docs/components/navigation/menu.md | 3 +- examples/chat/src/app.tsx | 2 +- examples/chat/src/control.ts | 76 ++++++++- examples/chat/src/screens.tsx | 14 +- examples/chat/src/view/composer.tsx | 65 +++++++- examples/chat/src/view/picker.ts | 29 +++- examples/chat/src/view/toolcall.tsx | 51 +++++- examples/chat/src/view/transcript.tsx | 15 +- examples/chat/test/smoke.test.tsx | 153 +++++++++++++++++- examples/chat/test/toolcall.test.tsx | 111 +++++++++++++ packages/core/src/app/app.ts | 123 +++++++++++++- packages/core/src/jsx/intrinsics.ts | 18 +++ packages/core/src/themes/builtin.ts | 66 +++++++- packages/core/src/themes/dividers.ts | 48 ++++++ packages/core/src/themes/index.ts | 1 + packages/core/src/themes/registry.ts | 26 ++- packages/core/src/types/command.ts | 19 ++- packages/core/src/types/input.ts | 9 ++ packages/core/src/types/style.ts | 26 ++- packages/core/src/types/terminal.ts | 9 ++ packages/core/src/types/theme.ts | 13 +- packages/terminal/src/ansi.ts | 3 + packages/terminal/src/capabilities.ts | 20 ++- packages/terminal/src/input.ts | 4 + packages/terminal/src/node.ts | 16 +- packages/testing/src/index.ts | 57 ++++++- 31 files changed, 1033 insertions(+), 54 deletions(-) create mode 100644 examples/chat/test/toolcall.test.tsx create mode 100644 packages/core/src/themes/dividers.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a74109a..11f8e2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,74 @@ This file records the set. Anything package-specific says which package. ## Unreleased +### 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/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/input/text-area.md b/docs/components/input/text-area.md index 550d64f..e5292b9 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,6 +53,32 @@ 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 + +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. + +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 - [TextInput](text-input.md) - one line, and enter submits 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/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/examples/chat/src/app.tsx b/examples/chat/src/app.tsx index a3e4526..5f2ede3 100644 --- a/examples/chat/src/app.tsx +++ b/examples/chat/src/app.tsx @@ -57,7 +57,7 @@ const Header = defineComponent>('ChatHeader', () => { const decoded = decodeStatus(status); return ( - + {session ? ( diff --git a/examples/chat/src/control.ts b/examples/chat/src/control.ts index 68e7863..bdf101e 100644 --- a/examples/chat/src/control.ts +++ b/examples/chat/src/control.ts @@ -169,10 +169,30 @@ export function createController( type: 'string' as const, required: true, description: property.description ?? `Choose ${property.title.toLowerCase()}`, + /* + * What it is set to now, so the picker opens on that row. + * + * Read through a getter rather than captured: `offer` runs when the + * schema arrives and the value changes every time one of these is + * answered, so a value read at registration time is the answer that + * was in force when the host first replied. + */ + get default(): string | undefined { + const values = app.store.get>(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, @@ -490,7 +510,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 +531,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 +545,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,12 +570,20 @@ 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') + }, // Appearance is a registration, not a rewrite. The same graph is mounted // under whichever theme and shell are chosen, which is the claim the @@ -560,6 +592,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 +622,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 +652,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 +677,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 +697,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 +738,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 +752,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 +766,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 +775,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 +792,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 +805,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 +826,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 +836,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 +850,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 +876,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,6 +884,7 @@ 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'), }, @@ -836,6 +892,7 @@ function commands(app: TextUIApp, controller: Controller): CommandDefinition[] { id: 'chat.focusTranscript', title: 'Read the transcript', category: 'Chat', + description: 'Focus the transcript', slots: ['palette'], run: () => app.focus.focus('chat.transcript'), }, @@ -843,6 +900,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 +909,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 +923,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(), }, diff --git a/examples/chat/src/screens.tsx b/examples/chat/src/screens.tsx index df67338..9239c7b 100644 --- a/examples/chat/src/screens.tsx +++ b/examples/chat/src/screens.tsx @@ -13,7 +13,7 @@ import { useStoreValue, useTheme, } from '@textui/core'; -import { Badge, Column, EmptyState, Panel, RadioGroup, Row, SearchBox } from '@textui/widgets'; +import { Badge, Column, 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, @@ -405,6 +405,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/view/composer.tsx b/examples/chat/src/view/composer.tsx index dd27552..aa2a91b 100644 --- a/examples/chat/src/view/composer.tsx +++ b/examples/chat/src/view/composer.tsx @@ -1,5 +1,5 @@ import type { BoxProps, RenderOutput } from '@textui/core'; -import { defineComponent, useTheme } from '@textui/core'; +import { defineComponent, useState, useTheme } from '@textui/core'; import type { ListItem } from '@textui/widgets'; import { Column, Divider, List, TextArea } from '@textui/widgets'; import { ComposerBar } from './controls.js'; @@ -38,6 +38,18 @@ export interface ChatComposerProps extends BoxProps { placeholder?: string; /** Offered when the draft starts with a slash. */ commands?: { id: string; title: string; description?: string }[]; + /** + * One of `commands` was chosen from the slash menu. + * + * Not `onSubmit`. A slash command of ours is *ours*: it opens a screen, + * changes a setting or picks a theme, and none of that is a message. Sending + * it down the session channel puts "/theme" in the transcript and asks the + * agent to make sense of it. + * + * A slash the menu does not match is left alone and sent, because that is + * how a command the *agent* offers reaches it. + */ + onCommand?(id: string): void; autoFocus?: boolean; focusId?: string; } @@ -46,7 +58,7 @@ export const ChatComposer: (props: ChatComposerProps) => 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} - + +