@textui/chat 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +76 -0
  3. package/dist/blocks.d.ts +75 -0
  4. package/dist/blocks.d.ts.map +1 -0
  5. package/dist/blocks.js +50 -0
  6. package/dist/bubble.d.ts +122 -0
  7. package/dist/bubble.d.ts.map +1 -0
  8. package/dist/bubble.js +108 -0
  9. package/dist/composer.d.ts +67 -0
  10. package/dist/composer.d.ts.map +1 -0
  11. package/dist/composer.js +194 -0
  12. package/dist/controls.d.ts +66 -0
  13. package/dist/controls.d.ts.map +1 -0
  14. package/dist/controls.js +77 -0
  15. package/dist/details.d.ts +66 -0
  16. package/dist/details.d.ts.map +1 -0
  17. package/dist/details.js +65 -0
  18. package/dist/diff.d.ts +45 -0
  19. package/dist/diff.d.ts.map +1 -0
  20. package/dist/diff.js +111 -0
  21. package/dist/filediff.d.ts +30 -0
  22. package/dist/filediff.d.ts.map +1 -0
  23. package/dist/filediff.js +24 -0
  24. package/dist/hitl.d.ts +85 -0
  25. package/dist/hitl.d.ts.map +1 -0
  26. package/dist/hitl.js +134 -0
  27. package/dist/icons.d.ts +14 -0
  28. package/dist/icons.d.ts.map +1 -0
  29. package/dist/icons.js +71 -0
  30. package/dist/index.d.ts +17 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +16 -0
  33. package/dist/measure.d.ts +14 -0
  34. package/dist/measure.d.ts.map +1 -0
  35. package/dist/measure.js +19 -0
  36. package/dist/picker.d.ts +43 -0
  37. package/dist/picker.d.ts.map +1 -0
  38. package/dist/picker.js +79 -0
  39. package/dist/sessionhead.d.ts +42 -0
  40. package/dist/sessionhead.d.ts.map +1 -0
  41. package/dist/sessionhead.js +58 -0
  42. package/dist/sessions.d.ts +35 -0
  43. package/dist/sessions.d.ts.map +1 -0
  44. package/dist/sessions.js +58 -0
  45. package/dist/toolcall.d.ts +28 -0
  46. package/dist/toolcall.d.ts.map +1 -0
  47. package/dist/toolcall.js +54 -0
  48. package/dist/transcript.d.ts +53 -0
  49. package/dist/transcript.d.ts.map +1 -0
  50. package/dist/transcript.js +67 -0
  51. package/dist/types.d.ts +176 -0
  52. package/dist/types.d.ts.map +1 -0
  53. package/dist/types.js +11 -0
  54. package/package.json +58 -0
  55. package/src/blocks.ts +73 -0
  56. package/src/bubble.tsx +266 -0
  57. package/src/composer.tsx +302 -0
  58. package/src/controls.tsx +222 -0
  59. package/src/details.tsx +162 -0
  60. package/src/diff.ts +132 -0
  61. package/src/filediff.tsx +118 -0
  62. package/src/hitl.tsx +392 -0
  63. package/src/icons.ts +109 -0
  64. package/src/index.ts +16 -0
  65. package/src/measure.ts +21 -0
  66. package/src/picker.ts +105 -0
  67. package/src/sessionhead.tsx +105 -0
  68. package/src/sessions.tsx +146 -0
  69. package/src/toolcall.tsx +136 -0
  70. package/src/transcript.tsx +221 -0
  71. package/src/types.ts +171 -0
package/src/blocks.ts ADDED
@@ -0,0 +1,73 @@
1
+ import type { ChatToolCall } from './types.js';
2
+
3
+ /**
4
+ * What the transcript draws, one entry per row group.
5
+ *
6
+ * A turn is not a block: an agent turn is a header, some prose, a reasoning
7
+ * fold and a row per tool call, and each of those scrolls, folds and selects
8
+ * on its own. The client that owns the turns turns them into these.
9
+ */
10
+ export type Block =
11
+ | { kind: 'said'; id: string; turnId: string; text: string }
12
+ | {
13
+ kind: 'header'; id: string; turnId: string; model?: string;
14
+ settings?: string;
15
+ meta: string; state: 'running' | 'complete' | 'cancelled' | 'failed';
16
+ }
17
+ | { kind: 'prose'; id: string; turnId: string; content: string; streaming: boolean }
18
+ | { kind: 'reasoning'; id: string; turnId: string; content: string; streaming: boolean }
19
+ | { kind: 'notice'; id: string; turnId: string; content: string }
20
+ | { kind: 'failure'; id: string; turnId: string; content: string; resumable: boolean }
21
+ | { kind: 'tool'; id: string; turnId: string; call: ChatToolCall }
22
+ | { kind: 'queued'; id: string; messageId: string; text: string };
23
+
24
+ /** The blocks a cursor can land on: the ones that open, or can be withdrawn. */
25
+ export function selectable(block: Block): boolean {
26
+ return block.kind === 'tool' || block.kind === 'reasoning' || block.kind === 'queued';
27
+ }
28
+
29
+ /**
30
+ * Everything in a block that a person could be looking for.
31
+ *
32
+ * A tool call is its name, its command and what came back, because all three
33
+ * are things somebody searches a transcript for - the file a command touched
34
+ * is in the output and nowhere else. A header is the model and the settings,
35
+ * which is how "where did I switch to opus" is answered.
36
+ */
37
+ export function blockText(block: Block): string {
38
+ switch (block.kind) {
39
+ case 'said':
40
+ case 'queued':
41
+ return block.text;
42
+ case 'prose':
43
+ case 'reasoning':
44
+ case 'notice':
45
+ case 'failure':
46
+ return block.content;
47
+ case 'header':
48
+ return [block.model, block.settings, block.meta].filter(Boolean).join(' ');
49
+ case 'tool':
50
+ return [
51
+ block.call.name, block.call.toolName, block.call.input,
52
+ block.call.intention, block.call.outcome, block.call.output,
53
+ ...(block.call.files ?? []),
54
+ ].filter(Boolean).join(' ');
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Where in the conversation a query appears, as block indices in order.
60
+ *
61
+ * Case-insensitive, and a blank query matches nothing rather than everything:
62
+ * a find with no term is a find that has not been typed yet, and lighting up
63
+ * every block for it is the opposite of what the box is for.
64
+ */
65
+ export function findBlocks(blocks: Block[], query: string): number[] {
66
+ const needle = query.trim().toLowerCase();
67
+ if (needle === '') return [];
68
+ const found: number[] = [];
69
+ blocks.forEach((block, index) => {
70
+ if (blockText(block).toLowerCase().includes(needle)) found.push(index);
71
+ });
72
+ return found;
73
+ }
package/src/bubble.tsx ADDED
@@ -0,0 +1,266 @@
1
+ import type { BoxProps, RenderOutput, ResolvedTheme, SemanticVariant, StyleColor } from '@textui/core';
2
+ import { defineComponent, useFrame, useTheme } from '@textui/core';
3
+ import { Column, Divider, MarkdownView, Row } from '@textui/widgets';
4
+
5
+ /**
6
+ * One thing said, and the two ways it is still being said.
7
+ *
8
+ * A bubble in a terminal is not a rounded rectangle. It is a gutter that says
9
+ * who is speaking and a body that owns the rest of the width - because the
10
+ * width is 80 cells and half of it spent on alignment is half the conversation
11
+ * gone.
12
+ */
13
+
14
+ export type Speaker = 'user' | 'agent' | 'system';
15
+
16
+ export interface GutterProps extends BoxProps {
17
+ /**
18
+ * The transcript's cursor is on this block.
19
+ *
20
+ * A heavy bar in the accent colour, down the whole block. A different glyph
21
+ * rather than only a different colour, so it survives a session without
22
+ * colour - which a background does not.
23
+ */
24
+ active?: boolean;
25
+ /**
26
+ * No rule at rest. For the blocks that are not something said - a tool
27
+ * row, a turn header - and still need the column, so the bar has a place
28
+ * to be drawn and their text starts where the prose does.
29
+ */
30
+ blank?: boolean;
31
+ }
32
+
33
+ /**
34
+ * The glyph the transcript's cursor is drawn with, down the left of the block
35
+ * it is on.
36
+ *
37
+ * The `bold` border's left rule: the heavy line of the same family as the
38
+ * rule at rest, from the theme, so an ascii terminal gets the glyph it can
39
+ * draw rather than a question mark. One place, because the gutter draws it
40
+ * and so does whatever glyph already holds a block's left column - the
41
+ * header's bullet, the user line's chevron - while the cursor is there.
42
+ */
43
+ export function cursorBar(theme: ResolvedTheme): string {
44
+ return theme.borderChars('bold').left;
45
+ }
46
+
47
+ /**
48
+ * The rule down the left of everything one speaker said.
49
+ *
50
+ * A box that fills rather than a `text`: the text is one row tall and the
51
+ * paragraph beside it is nine, so a rule written as a character marks the
52
+ * first line of a wrapped answer and abandons the rest of it.
53
+ */
54
+ export const Gutter: (props: GutterProps) => RenderOutput = defineComponent<GutterProps>('ChatGutter', (props) => {
55
+ const { active, blank, ...rest } = props;
56
+ const theme = useTheme();
57
+ const fill = active ? cursorBar(theme) : blank ? ' ' : theme.borderChars().left;
58
+ // `alignSelf` because `Row` centres its children: a one-cell box in a
59
+ // centred row is one cell tall, wherever the rule was meant to reach.
60
+ return <box width={1} alignSelf="stretch" fill={fill} fg={active ? 'accent' : 'borderSubtle'} {...rest} />;
61
+ });
62
+
63
+ export interface ChatBubbleProps extends BoxProps {
64
+ speaker: Speaker;
65
+ /** The name, when the speaker is not enough: a model, a person, a host. */
66
+ author?: string;
67
+ /** Right of the author line: a time, a duration, a model. */
68
+ meta?: string;
69
+ tone?: SemanticVariant;
70
+ /**
71
+ * The transcript's cursor is on this block: the bar runs down its left
72
+ * column, in place of the speaker's glyph on the first row and in the
73
+ * gutter under it.
74
+ */
75
+ active?: boolean;
76
+ children?: unknown;
77
+ }
78
+
79
+ const SPEAKER: Record<Speaker, { fg: StyleColor; label: string }> = {
80
+ user: { fg: 'primary', label: 'you' },
81
+ agent: { fg: 'accent', label: 'agent' },
82
+ system: { fg: 'muted', label: 'system' },
83
+ };
84
+
85
+ export const ChatBubble: (props: ChatBubbleProps) => RenderOutput =
86
+ defineComponent<ChatBubbleProps>('ChatBubble', (props) => {
87
+ const { speaker, author, meta, tone, active, children, ...rest } = props;
88
+ const theme = useTheme();
89
+ const look = SPEAKER[speaker];
90
+ const glyph = speaker === 'user' ? theme.glyphs.chevronRight
91
+ : speaker === 'agent' ? theme.glyphs.bulletFilled
92
+ : theme.glyphs.info;
93
+
94
+ // The gutter is one column of glyph and one of rule. It is what makes a
95
+ // wrapped paragraph read as one person talking rather than as the page
96
+ // starting again, and it survives losing colour - which a tinted
97
+ // background does not. The cursor is drawn in it for the same reason,
98
+ // rather than as a background over what was said.
99
+ return (
100
+ <Column {...rest}>
101
+ <Row gap={1}>
102
+ {/* The glyph's cell is the block's gutter on this row, so the bar
103
+ takes it rather than a second column before it: the block does
104
+ not move when the cursor arrives. */}
105
+ <text content={active ? cursorBar(theme) : glyph} fg={active ? 'accent' : tone ?? look.fg} />
106
+ <text content={author ?? look.label} bold fg={tone ?? look.fg} />
107
+ {meta ? <text content={meta} fg="subtle" flex={1} truncate="end" /> : <text content="" flex={1} />}
108
+ </Row>
109
+ <Row gap={1} flex={1}>
110
+ <Gutter {...(active ? { active: true } : {})} />
111
+ <Column flex={1} gap={1}>{children}</Column>
112
+ </Row>
113
+ </Column>
114
+ );
115
+ });
116
+
117
+ export interface StreamingTextProps extends BoxProps {
118
+ content: string;
119
+ /** Still arriving. Draws a caret and keeps it on the last word. */
120
+ streaming?: boolean;
121
+ quiet?: boolean;
122
+ maxLines?: number;
123
+ /**
124
+ * Draw it as markdown, or as the characters that arrived.
125
+ *
126
+ * Markdown unless told otherwise. An application with a switch for this
127
+ * passes it here; nothing is read from anywhere else.
128
+ */
129
+ markdown?: boolean;
130
+ /** Text to pick out, for the find box. Coloured wherever it appears. */
131
+ match?: string;
132
+ }
133
+
134
+ /**
135
+ * Text that is still being said.
136
+ *
137
+ * The caret is part of the content rather than a node beside it, because a
138
+ * caret placed after the block sits under the last line instead of at the end
139
+ * of it - and the end of the sentence is the only place it means anything.
140
+ *
141
+ * It blinks on the theme's own ticker, so animation being off (a pipe, a test,
142
+ * a `--static` capture) leaves a steady caret rather than a missing one.
143
+ */
144
+ export const StreamingText: (props: StreamingTextProps) => RenderOutput =
145
+ defineComponent<StreamingTextProps>('StreamingText', (props) => {
146
+ const { content, streaming, quiet, maxLines, markdown, match, ...rest } = props;
147
+ const theme = useTheme();
148
+ // Only while something is arriving. A ticker marks its component dirty
149
+ // whether or not the frame it produces differs, so an unconditional one
150
+ // here meant every settled paragraph in the transcript asked the
151
+ // application to redraw twice a second, for ever - a conversation that
152
+ // got heavier to sit in the longer it got.
153
+ const frame = useFrame(2, { enabled: streaming === true });
154
+ const caret = streaming && frame % 2 === 0 ? theme.glyphs.caret : '';
155
+ const rendered = markdown ?? true;
156
+ const shown = streaming ? `${content}${caret}` : content;
157
+
158
+ // Raw is a `text`, not a `MarkdownView` that was told not to parse: the
159
+ // point of turning it off is to see the characters that arrived, and
160
+ // anything that lays the document out has already decided some of them
161
+ // were structure. `wrap` rather than truncate, because the lines being
162
+ // read are the long ones - a fenced block and a table are exactly what is
163
+ // wider than the pane.
164
+ if (!rendered) {
165
+ return (
166
+ <text
167
+ content={shown}
168
+ wrap="word"
169
+ {...(quiet ? { fg: 'muted' as const } : {})}
170
+ {...(match ? { match } : {})}
171
+ {...rest}
172
+ />
173
+ );
174
+ }
175
+
176
+ return (
177
+ <MarkdownView
178
+ content={shown}
179
+ {...(quiet ? { quiet: true } : {})}
180
+ {...(maxLines !== undefined ? { maxLines } : {})}
181
+ {...(match ? { match } : {})}
182
+ {...rest}
183
+ />
184
+ );
185
+ });
186
+
187
+ export interface ReasoningBlockProps extends BoxProps {
188
+ content: string;
189
+ expanded?: boolean;
190
+ streaming?: boolean;
191
+ /** Shown collapsed: "thought for 12s". */
192
+ summary?: string;
193
+ /** Passed to the text once opened. */
194
+ markdown?: boolean;
195
+ /** Text to pick out, for the find box. Handed to the text inside it. */
196
+ match?: string;
197
+ /** Clicking the summary row opens it, and closes it again. */
198
+ onToggle?(): void;
199
+ /**
200
+ * The transcript's cursor is on this block.
201
+ *
202
+ * The block takes the `selected` background and its words turn `inverted`,
203
+ * the theme's own rule for that tone: a quiet grey on the selection blue is
204
+ * a row you can find and cannot read.
205
+ */
206
+ active?: boolean;
207
+ }
208
+
209
+ /**
210
+ * What the agent was thinking, folded away.
211
+ *
212
+ * Reasoning is prose the host sends like any other, and it is not what the
213
+ * reader came for - so it is one row until it is asked for. Dropping it
214
+ * instead loses the only account of *why* a turn did what it did.
215
+ *
216
+ * Open, it ends with a rule. The thought is set in the same quiet tone as the
217
+ * answer's own gutter, and without a line under it the reader cannot tell
218
+ * where the thinking stopped and the answer began.
219
+ */
220
+ export const ReasoningBlock: (props: ReasoningBlockProps) => RenderOutput =
221
+ defineComponent<ReasoningBlockProps>('ReasoningBlock', (props) => {
222
+ const { content, expanded, streaming, summary, markdown, match, onToggle, active, ...rest } = props;
223
+ const theme = useTheme();
224
+ const chevron = expanded ? theme.glyphs.chevronDown : theme.glyphs.chevronRight;
225
+ const words = content.trim().split(/\s+/).filter(Boolean).length;
226
+ const fg = active ? 'inverted' : 'subtle';
227
+
228
+ return (
229
+ <Column {...rest} {...(active ? { bg: 'selected' as const } : {})}>
230
+ <Row
231
+ gap={1}
232
+ {...(onToggle ? { onClick: onToggle } : {})}
233
+ // The whole row lights up, as a tool row does: the row is the thing
234
+ // that opens.
235
+ style={{ hover: { bg: 'hover' } }}
236
+ >
237
+ <text content={chevron} fg={fg} />
238
+ <text content={summary ?? (streaming ? 'thinking' : `thought, ${words} words`)} fg={fg} italic />
239
+ </Row>
240
+ {expanded ? (
241
+ <Row gap={1}>
242
+ <text content=" " />
243
+ {/* `quiet` sets every run to `muted` itself, which is exactly the
244
+ grey that vanishes on the selection; on it the text inherits
245
+ `inverted` from here instead. */}
246
+ <StreamingText
247
+ content={content}
248
+ flex={1}
249
+ {...(active ? { fg: 'inverted' as const } : { quiet: true })}
250
+ {...(streaming ? { streaming: true } : {})}
251
+ {...(markdown !== undefined ? { markdown } : {})}
252
+ {...(match ? { match } : {})}
253
+ />
254
+ </Row>
255
+ ) : null}
256
+ {expanded ? (
257
+ // Under the text, not the chevron: the same one-cell lead the text
258
+ // has, so the rule closes what it opened.
259
+ <Row gap={1}>
260
+ <text content=" " />
261
+ <Divider flex={1} />
262
+ </Row>
263
+ ) : null}
264
+ </Column>
265
+ );
266
+ });
@@ -0,0 +1,302 @@
1
+ import type { BoxProps, Rect, RenderOutput } from '@textui/core';
2
+ import { defineComponent, useApp, useEffect, useSize, useState, useTheme } from '@textui/core';
3
+ import type { ListItem } from '@textui/widgets';
4
+ import { Column, Divider, List, TextArea } from '@textui/widgets';
5
+ import type { ChatCommand, ChatCompletion } from './types.js';
6
+ import { ComposerBar, composerRows } from './controls.js';
7
+ import type { ComposerOption } from './controls.js';
8
+ import { useReportMeasure } from './measure.js';
9
+
10
+ /**
11
+ * Rows the completion menu shows at once, at most.
12
+ *
13
+ * A cap on the box's height and not on the list: what does not fit is
14
+ * scrolled to. The menu sits above the composer and takes its room from it,
15
+ * so on a short terminal this is not the number that applies - see `fits`.
16
+ */
17
+ const VISIBLE = 8;
18
+
19
+ /**
20
+ * Rows the composer itself takes: two border, two divider, the field and the
21
+ * two control rows under it. Eight menu rows on a terminal twelve high left
22
+ * four for all of that and drew an empty box - a menu on top of a field with
23
+ * no room to type in it.
24
+ *
25
+ * Counted with both control rows; a bar that draws one gives the row back.
26
+ */
27
+ const COMPOSER_ROWS = 7;
28
+
29
+ /** The menu's own frame, which is height the list does not get. */
30
+ const MENU_BORDER = 2;
31
+
32
+ /**
33
+ * Rows the menu may have here, which is whatever the composer can spare.
34
+ *
35
+ * The floor is there because a terminal can always be made too short for
36
+ * both; below it the composer gives way, since a menu with nothing under it
37
+ * is the same dead end from the other side.
38
+ */
39
+ const fits = (height: number, rows: 1 | 2): number =>
40
+ Math.max(3, Math.min(VISIBLE, height - (COMPOSER_ROWS - (2 - rows)) - MENU_BORDER));
41
+
42
+ /**
43
+ * What you type, and one line saying what it will be sent as.
44
+ *
45
+ * The field itself is `TextArea` from the catalog - growing, scrolling and
46
+ * giving back the keys it does not want is not a chat problem. What is here is
47
+ * the rest of a composer: what enter means while a turn is running, the slash
48
+ * menu over what has already been typed, and the control row.
49
+ *
50
+ * The row used to be four ghost buttons naming their own keys - `send enter`,
51
+ * `newline alt+enter`, `stop ctrl+c`, `commands ctrl+p` - which spent the one
52
+ * line under the field on a keyboard legend. The keys belong in the footer,
53
+ * which already lists them and changes with where the focus is. The line under
54
+ * the field is worth more as *what is about to happen*: which harness, which
55
+ * model, what it may do without asking, where it runs.
56
+ */
57
+
58
+ export interface ChatComposerProps extends BoxProps {
59
+ value: string;
60
+ onChange(value: string): void;
61
+ onSubmit(value: string): void;
62
+ onCancel?(): void;
63
+ onHistory?(direction: -1 | 1): void;
64
+ /** Left off the front of the field: out of the composer entirely. */
65
+ onLeave?(): void;
66
+ /** A turn is running: enter queues rather than sends, and stop is offered. */
67
+ running?: boolean;
68
+ queued?: number;
69
+ /** The control row. Each is a value, and each may open a picker. */
70
+ options?: ComposerOption[];
71
+ onOption?(option: ComposerOption, anchorId: string): void;
72
+ placeholder?: string;
73
+ /** Offered when the draft starts with a slash. */
74
+ commands?: ChatCommand[];
75
+ /**
76
+ * One of `commands` was chosen from the slash menu.
77
+ *
78
+ * The whole command rather than its id, because the two kinds go different
79
+ * places and only the command knows which it is. A `client` command is
80
+ * *ours*: it opens a screen, changes a setting or picks a theme, and none of
81
+ * that is a message - sending it down the session channel would put
82
+ * "/theme" in the transcript and ask the agent to make sense of it. A
83
+ * `session` command is a skill the host contributed, and the only way to
84
+ * invoke one is to send its name as the message.
85
+ *
86
+ * A slash the menu does not match is left alone and sent, which is how a
87
+ * command the host offers but did not list still reaches it.
88
+ */
89
+ onCommand?(command: ChatCommand): void;
90
+ /**
91
+ * What the host offers to complete the word the caret is in.
92
+ *
93
+ * Fetched rather than filtered: a path is a path on the *host's*
94
+ * filesystem, so which of them match what has been typed is a question only
95
+ * it can answer, and the answer changes with every keystroke.
96
+ */
97
+ paths?: ChatCompletion[];
98
+ /** One of `paths` was chosen. The range it replaces is on the completion. */
99
+ onPath?(path: ChatCompletion): void;
100
+ autoFocus?: boolean;
101
+ focusId?: string;
102
+ /** Where the composer is on screen whenever that changes, and `null` once it is gone. */
103
+ onMeasure?(rect: Rect | null): void;
104
+ }
105
+
106
+ export const ChatComposer: (props: ChatComposerProps) => RenderOutput =
107
+ defineComponent<ChatComposerProps>('ChatComposer', (props) => {
108
+ const {
109
+ value, onChange, onSubmit, onCancel, onHistory, onLeave, running, queued = 0,
110
+ options = [], onOption, placeholder, commands = [], onCommand, paths = [], onPath, autoFocus,
111
+ focusId = 'chat.composer', onMeasure, ...rest
112
+ } = props;
113
+ const theme = useTheme();
114
+ const app = useApp();
115
+
116
+ // A slash menu is a completion over what is already typed, not a mode.
117
+ const slash = value.startsWith('/') && !value.includes(' ') ? value.slice(1).toLowerCase() : null;
118
+ const found = slash === null ? [] : commands
119
+ .filter((command) => command.id.toLowerCase().includes(slash) || command.title.toLowerCase().includes(slash))
120
+ // What the host contributed first. A person typing a slash into a chat
121
+ // is usually reaching for a skill, and the client's own commands - which
122
+ // are also in the palette, on their own key - would otherwise fill the
123
+ // rows that are visible without scrolling.
124
+ .sort((a, b) => (a.kind === b.kind ? 0 : a.kind === 'session' ? -1 : 1));
125
+ const byId = new Map(found.map((command) => [command.id, command]));
126
+ /*
127
+ * One menu, and whichever list is live fills it.
128
+ *
129
+ * The two cannot both be: a slash menu is a draft that *starts* with a
130
+ * slash, and a path menu is a word the caret is in that starts with an
131
+ * at-sign. Two menus would be two boxes above one field.
132
+ */
133
+ const offered: ListItem[] = found.length > 0
134
+ ? found.map((command) => ({
135
+ id: command.id,
136
+ label: `/${command.id}`,
137
+ ...(command.description ? { description: command.description } : {}),
138
+ // Where it came from, when something did: two plugins can contribute
139
+ // a `/review`, and the title alone does not say which this is.
140
+ meta: command.from ?? command.title,
141
+ }))
142
+ : paths.map((path) => ({
143
+ id: path.insertText,
144
+ label: path.insertText,
145
+ ...(path.description ? { description: path.description } : {}),
146
+ }));
147
+ const byInsert = new Map(paths.map((path) => [path.insertText, path]));
148
+
149
+ /*
150
+ * Escape closes the menu before it does anything else.
151
+ *
152
+ * The menu is drawn from the draft, so there is no state to close - which
153
+ * is why escape used to pass straight through it to the field and then to
154
+ * the screen, and typing `/` and pressing escape left for the session
155
+ * list. What is remembered is the draft it was dismissed at: the menu
156
+ * stays shut for that exact text and comes back the moment another
157
+ * character makes it a different question.
158
+ *
159
+ * And it is forgotten as soon as there is no menu to dismiss. Remembering
160
+ * the text alone was not enough: dismissing at `/`, deleting it and typing
161
+ * `/` again produced the same draft, so the menu stayed shut for a
162
+ * question that had been asked afresh. Clearing when nothing matches ties
163
+ * the dismissal to one continuous menu rather than to a string that can
164
+ * come back.
165
+ */
166
+ const [dismissedAt, setDismissedAt] = useState<string | null>(null);
167
+ const empty = offered.length === 0;
168
+ useEffect(() => {
169
+ if (empty && dismissedAt !== null) setDismissedAt(null);
170
+ }, [empty]);
171
+ const matches = dismissedAt === value ? [] : offered;
172
+
173
+ // Which completion is under the cursor. Clamped rather than reset, so a
174
+ // list that shrinks as more is typed keeps a valid row instead of
175
+ // snapping back to the top on every keystroke.
176
+ const [highlight, setHighlight] = useState(0);
177
+ const index = Math.max(0, Math.min(highlight, matches.length - 1));
178
+ const chosen = matches[index];
179
+
180
+ /*
181
+ * What goes after the name of the command under the cursor.
182
+ *
183
+ * A row is the name, so a command that takes an argument has nowhere in
184
+ * the list to say so, and `/autocompact` reads as complete when it is
185
+ * not. It goes on the rule under the list, where it is one line for the
186
+ * whole menu and changes as the highlight moves rather than being
187
+ * repeated down every row.
188
+ */
189
+ const usage = chosen === undefined ? undefined : byId.get(chosen.id)?.hint;
190
+ const hint = usage === undefined ? undefined : `/${chosen?.id ?? ''} ${usage}`;
191
+
192
+ /**
193
+ * Up and down, while the menu is open.
194
+ *
195
+ * They arrive as `onOverflow` - the field reports the key rather than
196
+ * handling it once there is no row above or below the caret, which for a
197
+ * `/word` draft is immediately. The same pair walks the history when there
198
+ * is no menu, and the menu is the nearer of the two things they could
199
+ * mean.
200
+ */
201
+ const step = (direction: -1 | 1): void => {
202
+ setHighlight((matches.length + index + direction) % matches.length);
203
+ };
204
+
205
+ // Where this box is. The slash menu grows it upward, so whoever wants to
206
+ // stand clear of it is told every time rather than once.
207
+ useReportMeasure(onMeasure);
208
+
209
+ // How tall the menu may be here. Read unconditionally: it is a hook, and
210
+ // the menu is drawn from a branch.
211
+ const rows = fits(useSize().height, composerRows(options));
212
+
213
+ return (
214
+ <Column {...rest} gap={0}>
215
+ {matches.length > 0 ? (
216
+ // The theme's border, never a named one. A hardcoded `single` draws
217
+ // a box-drawing frame inside an ascii one on a terminal that cannot
218
+ // do either, and an airy theme gets a line it deliberately does not
219
+ // draw anywhere else.
220
+ <Column border={theme.border} padding={[0, 1]}>
221
+ <List
222
+ items={matches}
223
+ focusable={false}
224
+ selectedId={chosen?.id}
225
+ /*
226
+ * A window over all of them, not the first six.
227
+ *
228
+ * The list scrolls to keep the selected row in view, and the
229
+ * selection here is driven from outside - so walking past the
230
+ * sixth moves the window rather than stopping. Truncating the
231
+ * items instead made up and down cycle the six that survived,
232
+ * with no way to reach a seventh: a host that answers thirty
233
+ * paths for `@src/` offered six of them and looked like it had
234
+ * no more.
235
+ */
236
+ visibleRows={hint === undefined ? rows : rows - 1}
237
+ marker
238
+ // Not focusable, so this is the click: a completion clicked is a
239
+ // completion chosen, and there is nowhere for a merely
240
+ // highlighted row to lead.
241
+ onSelect={(id: string) => {
242
+ const command = byId.get(id);
243
+ if (command) { onCommand?.(command); return; }
244
+ const path = byInsert.get(id);
245
+ if (path) onPath?.(path);
246
+ }}
247
+ emptyMessage="no command"
248
+ />
249
+ {hint === undefined ? null : <Divider label={hint} />}
250
+ </Column>
251
+ ) : null}
252
+
253
+ <Column border={theme.border}>
254
+ <Divider dim />
255
+ <TextArea
256
+ value={value}
257
+ onChange={onChange}
258
+ // A slash the menu matched runs here; anything else is a message,
259
+ // which is what lets a command the agent offers through.
260
+ onSubmit={(next: string) => {
261
+ const command = chosen ? byId.get(chosen.id) : undefined;
262
+ if (command && onCommand) { onCommand(command); return; }
263
+ // A highlighted path completes rather than sends: enter on a
264
+ // menu row means "that one", and a draft half-way through a
265
+ // path is not a message anybody meant to send.
266
+ const path = chosen ? byInsert.get(chosen.id) : undefined;
267
+ if (path && onPath) { onPath(path); return; }
268
+ onSubmit(next);
269
+ }}
270
+ onCancel={() => {
271
+ if (matches.length > 0) { setDismissedAt(value); return; }
272
+ onCancel?.();
273
+ }}
274
+ onOverflow={(direction: -1 | 1) => {
275
+ if (matches.length > 0) { step(direction); return; }
276
+ onHistory?.(direction);
277
+ }}
278
+ {...(onLeave ? { onEdge: (edge: 'start' | 'end') => { if (edge === 'start') onLeave(); } } : {})}
279
+ placeholder={placeholder
280
+ ?? (running ? 'The agent is working. Type to queue a message.' : 'Ask the agent anything…')}
281
+ focusId={focusId}
282
+ // The caret is the one thing on this screen saying where typing
283
+ // goes, and this field is the point of the screen.
284
+ caretTone="accent"
285
+ {...(autoFocus ? { autoFocus: true } : {})}
286
+ />
287
+ {/* Inside the same frame, so the field and what it will be sent as
288
+ read as one control rather than two stacked boxes. */}
289
+ <Divider dim />
290
+ <ComposerBar
291
+ options={options}
292
+ onOpen={(option, anchorId) => onOption?.(option, anchorId)}
293
+ onSend={() => onSubmit(value)}
294
+ onLeave={() => app.focus.focus(focusId)}
295
+ {...(running ? { running: true } : {})}
296
+ queued={queued}
297
+ sendDisabled={value.trim() === ''}
298
+ />
299
+ </Column>
300
+ </Column>
301
+ );
302
+ });