@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
@@ -0,0 +1,194 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@textui/core/jsx-runtime";
2
+ import { defineComponent, useApp, useEffect, useSize, useState, useTheme } from '@textui/core';
3
+ import { Column, Divider, List, TextArea } from '@textui/widgets';
4
+ import { ComposerBar, composerRows } from './controls.js';
5
+ import { useReportMeasure } from './measure.js';
6
+ /**
7
+ * Rows the completion menu shows at once, at most.
8
+ *
9
+ * A cap on the box's height and not on the list: what does not fit is
10
+ * scrolled to. The menu sits above the composer and takes its room from it,
11
+ * so on a short terminal this is not the number that applies - see `fits`.
12
+ */
13
+ const VISIBLE = 8;
14
+ /**
15
+ * Rows the composer itself takes: two border, two divider, the field and the
16
+ * two control rows under it. Eight menu rows on a terminal twelve high left
17
+ * four for all of that and drew an empty box - a menu on top of a field with
18
+ * no room to type in it.
19
+ *
20
+ * Counted with both control rows; a bar that draws one gives the row back.
21
+ */
22
+ const COMPOSER_ROWS = 7;
23
+ /** The menu's own frame, which is height the list does not get. */
24
+ const MENU_BORDER = 2;
25
+ /**
26
+ * Rows the menu may have here, which is whatever the composer can spare.
27
+ *
28
+ * The floor is there because a terminal can always be made too short for
29
+ * both; below it the composer gives way, since a menu with nothing under it
30
+ * is the same dead end from the other side.
31
+ */
32
+ const fits = (height, rows) => Math.max(3, Math.min(VISIBLE, height - (COMPOSER_ROWS - (2 - rows)) - MENU_BORDER));
33
+ export const ChatComposer = defineComponent('ChatComposer', (props) => {
34
+ const { value, onChange, onSubmit, onCancel, onHistory, onLeave, running, queued = 0, options = [], onOption, placeholder, commands = [], onCommand, paths = [], onPath, autoFocus, focusId = 'chat.composer', onMeasure, ...rest } = props;
35
+ const theme = useTheme();
36
+ const app = useApp();
37
+ // A slash menu is a completion over what is already typed, not a mode.
38
+ const slash = value.startsWith('/') && !value.includes(' ') ? value.slice(1).toLowerCase() : null;
39
+ const found = slash === null ? [] : commands
40
+ .filter((command) => command.id.toLowerCase().includes(slash) || command.title.toLowerCase().includes(slash))
41
+ // What the host contributed first. A person typing a slash into a chat
42
+ // is usually reaching for a skill, and the client's own commands - which
43
+ // are also in the palette, on their own key - would otherwise fill the
44
+ // rows that are visible without scrolling.
45
+ .sort((a, b) => (a.kind === b.kind ? 0 : a.kind === 'session' ? -1 : 1));
46
+ const byId = new Map(found.map((command) => [command.id, command]));
47
+ /*
48
+ * One menu, and whichever list is live fills it.
49
+ *
50
+ * The two cannot both be: a slash menu is a draft that *starts* with a
51
+ * slash, and a path menu is a word the caret is in that starts with an
52
+ * at-sign. Two menus would be two boxes above one field.
53
+ */
54
+ const offered = found.length > 0
55
+ ? found.map((command) => ({
56
+ id: command.id,
57
+ label: `/${command.id}`,
58
+ ...(command.description ? { description: command.description } : {}),
59
+ // Where it came from, when something did: two plugins can contribute
60
+ // a `/review`, and the title alone does not say which this is.
61
+ meta: command.from ?? command.title,
62
+ }))
63
+ : paths.map((path) => ({
64
+ id: path.insertText,
65
+ label: path.insertText,
66
+ ...(path.description ? { description: path.description } : {}),
67
+ }));
68
+ const byInsert = new Map(paths.map((path) => [path.insertText, path]));
69
+ /*
70
+ * Escape closes the menu before it does anything else.
71
+ *
72
+ * The menu is drawn from the draft, so there is no state to close - which
73
+ * is why escape used to pass straight through it to the field and then to
74
+ * the screen, and typing `/` and pressing escape left for the session
75
+ * list. What is remembered is the draft it was dismissed at: the menu
76
+ * stays shut for that exact text and comes back the moment another
77
+ * character makes it a different question.
78
+ *
79
+ * And it is forgotten as soon as there is no menu to dismiss. Remembering
80
+ * the text alone was not enough: dismissing at `/`, deleting it and typing
81
+ * `/` again produced the same draft, so the menu stayed shut for a
82
+ * question that had been asked afresh. Clearing when nothing matches ties
83
+ * the dismissal to one continuous menu rather than to a string that can
84
+ * come back.
85
+ */
86
+ const [dismissedAt, setDismissedAt] = useState(null);
87
+ const empty = offered.length === 0;
88
+ useEffect(() => {
89
+ if (empty && dismissedAt !== null)
90
+ setDismissedAt(null);
91
+ }, [empty]);
92
+ const matches = dismissedAt === value ? [] : offered;
93
+ // Which completion is under the cursor. Clamped rather than reset, so a
94
+ // list that shrinks as more is typed keeps a valid row instead of
95
+ // snapping back to the top on every keystroke.
96
+ const [highlight, setHighlight] = useState(0);
97
+ const index = Math.max(0, Math.min(highlight, matches.length - 1));
98
+ const chosen = matches[index];
99
+ /*
100
+ * What goes after the name of the command under the cursor.
101
+ *
102
+ * A row is the name, so a command that takes an argument has nowhere in
103
+ * the list to say so, and `/autocompact` reads as complete when it is
104
+ * not. It goes on the rule under the list, where it is one line for the
105
+ * whole menu and changes as the highlight moves rather than being
106
+ * repeated down every row.
107
+ */
108
+ const usage = chosen === undefined ? undefined : byId.get(chosen.id)?.hint;
109
+ const hint = usage === undefined ? undefined : `/${chosen?.id ?? ''} ${usage}`;
110
+ /**
111
+ * Up and down, while the menu is open.
112
+ *
113
+ * They arrive as `onOverflow` - the field reports the key rather than
114
+ * handling it once there is no row above or below the caret, which for a
115
+ * `/word` draft is immediately. The same pair walks the history when there
116
+ * is no menu, and the menu is the nearer of the two things they could
117
+ * mean.
118
+ */
119
+ const step = (direction) => {
120
+ setHighlight((matches.length + index + direction) % matches.length);
121
+ };
122
+ // Where this box is. The slash menu grows it upward, so whoever wants to
123
+ // stand clear of it is told every time rather than once.
124
+ useReportMeasure(onMeasure);
125
+ // How tall the menu may be here. Read unconditionally: it is a hook, and
126
+ // the menu is drawn from a branch.
127
+ const rows = fits(useSize().height, composerRows(options));
128
+ return (_jsxs(Column, { ...rest, gap: 0, children: [matches.length > 0 ? (
129
+ // The theme's border, never a named one. A hardcoded `single` draws
130
+ // a box-drawing frame inside an ascii one on a terminal that cannot
131
+ // do either, and an airy theme gets a line it deliberately does not
132
+ // draw anywhere else.
133
+ _jsxs(Column, { border: theme.border, padding: [0, 1], children: [_jsx(List, { items: matches, focusable: false, selectedId: chosen?.id,
134
+ /*
135
+ * A window over all of them, not the first six.
136
+ *
137
+ * The list scrolls to keep the selected row in view, and the
138
+ * selection here is driven from outside - so walking past the
139
+ * sixth moves the window rather than stopping. Truncating the
140
+ * items instead made up and down cycle the six that survived,
141
+ * with no way to reach a seventh: a host that answers thirty
142
+ * paths for `@src/` offered six of them and looked like it had
143
+ * no more.
144
+ */
145
+ visibleRows: hint === undefined ? rows : rows - 1, marker: true,
146
+ // Not focusable, so this is the click: a completion clicked is a
147
+ // completion chosen, and there is nowhere for a merely
148
+ // highlighted row to lead.
149
+ onSelect: (id) => {
150
+ const command = byId.get(id);
151
+ if (command) {
152
+ onCommand?.(command);
153
+ return;
154
+ }
155
+ const path = byInsert.get(id);
156
+ if (path)
157
+ onPath?.(path);
158
+ }, emptyMessage: "no command" }), hint === undefined ? null : _jsx(Divider, { label: hint })] })) : null, _jsxs(Column, { border: theme.border, children: [_jsx(Divider, { dim: true }), _jsx(TextArea, { value: value, onChange: onChange,
159
+ // A slash the menu matched runs here; anything else is a message,
160
+ // which is what lets a command the agent offers through.
161
+ onSubmit: (next) => {
162
+ const command = chosen ? byId.get(chosen.id) : undefined;
163
+ if (command && onCommand) {
164
+ onCommand(command);
165
+ return;
166
+ }
167
+ // A highlighted path completes rather than sends: enter on a
168
+ // menu row means "that one", and a draft half-way through a
169
+ // path is not a message anybody meant to send.
170
+ const path = chosen ? byInsert.get(chosen.id) : undefined;
171
+ if (path && onPath) {
172
+ onPath(path);
173
+ return;
174
+ }
175
+ onSubmit(next);
176
+ }, onCancel: () => {
177
+ if (matches.length > 0) {
178
+ setDismissedAt(value);
179
+ return;
180
+ }
181
+ onCancel?.();
182
+ }, onOverflow: (direction) => {
183
+ if (matches.length > 0) {
184
+ step(direction);
185
+ return;
186
+ }
187
+ onHistory?.(direction);
188
+ }, ...(onLeave ? { onEdge: (edge) => { if (edge === 'start')
189
+ onLeave(); } } : {}), placeholder: placeholder
190
+ ?? (running ? 'The agent is working. Type to queue a message.' : 'Ask the agent anything…'), focusId: focusId,
191
+ // The caret is the one thing on this screen saying where typing
192
+ // goes, and this field is the point of the screen.
193
+ caretTone: "accent", ...(autoFocus ? { autoFocus: true } : {}) }), _jsx(Divider, { dim: true }), _jsx(ComposerBar, { options: options, onOpen: (option, anchorId) => onOption?.(option, anchorId), onSend: () => onSubmit(value), onLeave: () => app.focus.focus(focusId), ...(running ? { running: true } : {}), queued: queued, sendDisabled: value.trim() === '' })] })] }));
194
+ });
@@ -0,0 +1,66 @@
1
+ import type { BoxProps, RenderOutput } from '@textui/core';
2
+ /**
3
+ * The composer's control rows: what this message will be sent as.
4
+ *
5
+ * Under the field, and everything on it is a *current value* rather than a
6
+ * label. What will run - which harness, which model, how much it may do
7
+ * before it asks - and, when the host asks it, where: the directory, whether
8
+ * in place or in a worktree, and from which branch. A person can read what
9
+ * will happen without opening anything, and change any of it without leaving
10
+ * the composer.
11
+ *
12
+ * Where gets a row of its own, and only when there is a where. One line held
13
+ * all of it until a host that answers every question the reference host asks
14
+ * put eight chips on it, and the row truncated each to its mark; a host that
15
+ * asks nothing about where - a chat that runs where it was opened - keeps the
16
+ * one line, with send at the end of it, rather than a second row holding
17
+ * nothing but send.
18
+ *
19
+ * Each chip is one command's argument, asked through the palette (see
20
+ * `picker.ts`). That is the whole design: nothing here knows what a model or a
21
+ * permission mode is, so a new chip is a new command and no change to this
22
+ * file.
23
+ */
24
+ export interface ComposerOption {
25
+ id: string;
26
+ /** The value, as a person reads it. Never the id the host stores. */
27
+ label: string;
28
+ /**
29
+ * The question, as opposed to the answer in `label`.
30
+ *
31
+ * The chip has no room for it - "Ask each time" is the whole row and the
32
+ * mark in front says which question it belongs to - but anything listing
33
+ * these somewhere with more space needs the pair, and the host's own wording
34
+ * for both is on the schema rather than in this file.
35
+ */
36
+ title?: string;
37
+ icon?: string;
38
+ /** The command whose argument this chip asks about. Absent: shown, not asked. */
39
+ commandId?: string;
40
+ /** On the second row, with the workspace: where the session runs rather than what runs it. */
41
+ where?: boolean;
42
+ }
43
+ export interface ComposerBarProps extends BoxProps {
44
+ options: ComposerOption[];
45
+ onOpen(option: ComposerOption, anchorId: string): void;
46
+ onSend(): void;
47
+ /** Escape on a chip: back to the field. */
48
+ onLeave?(): void;
49
+ /** A turn is running, so this message joins the queue instead. */
50
+ running?: boolean;
51
+ queued?: number;
52
+ sendDisabled?: boolean;
53
+ }
54
+ /** The focus id of one chip. The picker anchors to it, so it has to be knowable. */
55
+ export declare const chipId: (id: string) => string;
56
+ export declare const SEND_ID = "chat.send";
57
+ /**
58
+ * How many rows the bar takes for these options.
59
+ *
60
+ * Two when any of them is a `where`, and one otherwise - which is the number
61
+ * the composer needs before the bar is drawn, to know how much of a short
62
+ * terminal is left for the menu above it.
63
+ */
64
+ export declare function composerRows(options: ComposerOption[]): 1 | 2;
65
+ export declare const ComposerBar: (props: ComposerBarProps) => RenderOutput;
66
+ //# sourceMappingURL=controls.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"controls.d.ts","sourceRoot":"","sources":["../src/controls.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAI3D;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX,qEAAqE;IACrE,KAAK,EAAE,MAAM,CAAC;IACd;;;;;;;OAOG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,iFAAiF;IACjF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,8FAA8F;IAC9F,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,gBAAiB,SAAQ,QAAQ;IAChD,OAAO,EAAE,cAAc,EAAE,CAAC;IAC1B,MAAM,CAAC,MAAM,EAAE,cAAc,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACvD,MAAM,IAAI,IAAI,CAAC;IACf,2CAA2C;IAC3C,OAAO,CAAC,IAAI,IAAI,CAAC;IACjB,kEAAkE;IAClE,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AAED,oFAAoF;AACpF,eAAO,MAAM,MAAM,GAAI,IAAI,MAAM,KAAG,MAA6B,CAAC;AAElE,eAAO,MAAM,OAAO,cAAc,CAAC;AAEnC;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,cAAc,EAAE,GAAG,CAAC,GAAG,CAAC,CAE7D;AAED,eAAO,MAAM,WAAW,EAAE,CAAC,KAAK,EAAE,gBAAgB,KAAK,YAyEnD,CAAC"}
@@ -0,0 +1,77 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@textui/core/jsx-runtime";
2
+ import { defineComponent, useFocus, useInput, useTheme } from '@textui/core';
3
+ import { Column, Row } from '@textui/widgets';
4
+ /** The focus id of one chip. The picker anchors to it, so it has to be knowable. */
5
+ export const chipId = (id) => `chat.option.${id}`;
6
+ export const SEND_ID = 'chat.send';
7
+ /**
8
+ * How many rows the bar takes for these options.
9
+ *
10
+ * Two when any of them is a `where`, and one otherwise - which is the number
11
+ * the composer needs before the bar is drawn, to know how much of a short
12
+ * terminal is left for the menu above it.
13
+ */
14
+ export function composerRows(options) {
15
+ return options.some((option) => option.where) ? 2 : 1;
16
+ }
17
+ export const ComposerBar = defineComponent('ComposerBar', (props) => {
18
+ const { options, onOpen, onSend, onLeave, running, queued = 0, sendDisabled, ...rest } = props;
19
+ const theme = useTheme();
20
+ // The first row and then the second, which is also the tab order. Stated
21
+ // rather than inherited: tab order is registration order, and which chips
22
+ // exist is the *host's* answer - it arrives one round trip after the rows
23
+ // are first drawn, so the ones that were there from the start would
24
+ // otherwise come first however far to the right they sit.
25
+ const what = options.filter((option) => !option.where);
26
+ const where = options.filter((option) => option.where);
27
+ const ordered = [...what, ...where];
28
+ const chip = (option) => (option.commandId
29
+ ? (_jsx(Chip, { focusId: chipId(option.id), order: ordered.indexOf(option), label: option.label, ...(option.icon ? { icon: option.icon } : {}), onOpen: () => onOpen(option, chipId(option.id)), ...(onLeave ? { onLeave } : {}) }, option.id))
30
+ : (
31
+ // Shown, not asked: a value that is fixed for this session is
32
+ // still worth reading, and a chip that opens a panel offering one
33
+ // choice is a worse way of saying so.
34
+ _jsxs(Row, { gap: 1, children: [option.icon ? _jsx("text", { content: option.icon, fg: "subtle" }) : null, _jsx("text", { content: option.label, fg: "subtle" })] }, option.id)));
35
+ // The end of the last row, whichever row that is: what is waiting, and
36
+ // the one verb on the bar.
37
+ const tail = [
38
+ _jsx("text", { content: "", flex: 1 }, "gap"),
39
+ queued > 0 ? _jsx("text", { content: `${queued} queued`, fg: "warning" }, "queued") : null,
40
+ _jsx(Chip, { focusId: SEND_ID, order: ordered.length, label: running ? 'queue' : 'send', trailing: theme.glyphs.chevronRight, tone: "accent", ...(sendDisabled ? { disabled: true } : {}), onOpen: onSend, ...(onLeave ? { onLeave } : {}) }, "send"),
41
+ ];
42
+ if (where.length === 0) {
43
+ return (_jsxs(Row, { gap: 1, ...rest, children: [what.map(chip), tail] }));
44
+ }
45
+ return (_jsxs(Column, { gap: 0, ...rest, children: [_jsx(Row, { gap: 1, children: what.map(chip) }), _jsxs(Row, { gap: 1, children: [where.map(chip), tail] })] }));
46
+ });
47
+ /**
48
+ * One value on the control row.
49
+ *
50
+ * Not a `Button`: a button is a verb and these are nouns, and at `size="sm"`
51
+ * four of them still read as four buttons rather than as one sentence about
52
+ * what is about to be sent. What it borrows from a button is the part that
53
+ * matters - it is focusable, tab reaches it, and enter opens it.
54
+ */
55
+ const Chip = defineComponent('ComposerChip', (props) => {
56
+ const { focusId, order, label, icon, trailing, tone, disabled, onOpen, onLeave } = props;
57
+ const theme = useTheme();
58
+ const focus = useFocus({ id: focusId, order, disabled: disabled === true });
59
+ useInput((event) => {
60
+ if (disabled)
61
+ return false;
62
+ // Out of the row and back to the field, the way escape leaves the field
63
+ // for the transcript: one step out, not out of the screen.
64
+ if (event.name === 'escape' && onLeave) {
65
+ onLeave();
66
+ return true;
67
+ }
68
+ // Down as well as enter: the panel comes up out of the chip, and reaching
69
+ // for it downwards is what the shape of the thing suggests.
70
+ if (event.name === 'enter' || event.name === 'space' || event.name === 'down') {
71
+ onOpen();
72
+ return true;
73
+ }
74
+ return false;
75
+ }, { focusId: focus.id, enabled: disabled !== true });
76
+ return (_jsxs(Row, { id: focus.id, gap: 1, padding: [0, 1], ...(focus.focused ? { bg: 'selected' } : {}), onClick: disabled ? undefined : onOpen, children: [icon ? _jsx("text", { content: icon, shrink: 0, fg: focus.focused ? 'inverted' : tone ?? 'muted' }) : null, _jsx("text", { content: label, truncate: "end", fg: focus.focused ? 'inverted' : disabled ? 'disabled' : tone ?? undefined, ...(tone ? { bold: true } : {}) }), _jsx("text", { content: trailing ?? theme.glyphs.chevronDown, shrink: 0, fg: focus.focused ? 'inverted' : 'subtle' })] }));
77
+ });
@@ -0,0 +1,66 @@
1
+ import type { BoxProps, RenderOutput, SemanticVariant } from '@textui/core';
2
+ /**
3
+ * A property list you can walk, and take a value out of.
4
+ *
5
+ * The catalogue's detail pane is where the identifiers live - a session URI, a
6
+ * chat URI - and an identifier you cannot read in full or paste anywhere is
7
+ * decoration. `KeyValue` draws the same pairs and is static: nothing selects a
8
+ * row, so nothing can be copied and nothing can be shown untruncated.
9
+ *
10
+ * So the selected row is the one that gets the room. Every other row is one
11
+ * line with its value truncated, and the selected row wraps its value across
12
+ * as many lines as it needs - which costs nothing when the value is short and
13
+ * is the whole answer when it is a URI in a 36-column pane. `enter` puts it on
14
+ * the clipboard.
15
+ *
16
+ * This is a finding, not a flourish: it is the third component this example
17
+ * wanted that the catalog does not have.
18
+ */
19
+ export interface DetailField {
20
+ id: string;
21
+ label: string;
22
+ value: string;
23
+ tone?: SemanticVariant;
24
+ /** Shown instead of the value when there is none, in the subtle tone. */
25
+ absent?: string;
26
+ }
27
+ export interface SessionDetailsProps extends BoxProps {
28
+ fields: DetailField[];
29
+ focusId?: string;
30
+ /**
31
+ * Width of the label column, where a caller wants to fix it.
32
+ *
33
+ * Left off, it is the widest label there is. It used to be a constant on
34
+ * the grounds that the labels were ours, and they are not: a session's
35
+ * settings and a model's options are named by whichever host is answering,
36
+ * in words this client does not choose, and the constant was one character
37
+ * wider than the longest label anybody had thought of.
38
+ */
39
+ labelWidth?: number;
40
+ /**
41
+ * Take the keyboard on the frame this mounts, out of whatever holds it.
42
+ *
43
+ * Not `autoFocus`, which claims focus rather than taking it: a pane that
44
+ * appears *because* a key asked for it has to end up with the cursor, and
45
+ * the thing it is taking the cursor from - the session list - is in the
46
+ * same focus scope and already has it.
47
+ *
48
+ * It has to be done here rather than by the screen that mounts this,
49
+ * because a focusable registers in its own effect: on the render that opens
50
+ * the pane, the id does not exist yet and the screen's `focus()` is a call
51
+ * that quietly returns false.
52
+ */
53
+ claim?: boolean;
54
+ /**
55
+ * Which rows show their value whole.
56
+ *
57
+ * `selected` is the default and the one that keeps the pane a list: every
58
+ * row is one line, and the row you have stopped on wraps to as many as its
59
+ * value needs. `all` wraps every row, which is what you want when the
60
+ * values *are* the point - a pane of URIs where the answer is on the third
61
+ * one down and reading it should not mean walking there first.
62
+ */
63
+ values?: 'selected' | 'all';
64
+ }
65
+ export declare const SessionDetails: (props: SessionDetailsProps) => RenderOutput;
66
+ //# sourceMappingURL=details.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"details.d.ts","sourceRoot":"","sources":["../src/details.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAY5E;;;;;;;;;;;;;;;;GAgBG;AAEH,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,eAAe,CAAC;IACvB,yEAAyE;IACzE,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,mBAAoB,SAAQ,QAAQ;IACnD,MAAM,EAAE,WAAW,EAAE,CAAC;IACtB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;;;;;OAQG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;;;;;;;;OAYG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;;;;;;OAQG;IACH,MAAM,CAAC,EAAE,UAAU,GAAG,KAAK,CAAC;CAC7B;AAED,eAAO,MAAM,cAAc,EAAE,CAAC,KAAK,EAAE,mBAAmB,KAAK,YAmFzD,CAAC"}
@@ -0,0 +1,65 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@textui/core/jsx-runtime";
2
+ import { defineComponent, useClipboard, useEffect, useFocus, useInput, useState, useTheme, } from '@textui/core';
3
+ import { Column, Row } from '@textui/widgets';
4
+ export const SessionDetails = defineComponent('SessionDetails', (props) => {
5
+ const { fields, focusId, labelWidth, claim, values = 'selected', ...rest } = props;
6
+ // Floored at the eleven this used to be, so nothing that already fitted
7
+ // moves, and capped so one verbose title cannot take the pane from the
8
+ // values it is there to label.
9
+ const column = labelWidth ?? Math.min(20, Math.max(11, ...fields.map((field) => field.label.length)));
10
+ const theme = useTheme();
11
+ const clipboard = useClipboard();
12
+ const focus = useFocus({ ...(focusId ? { id: focusId } : {}) });
13
+ const [index, setIndex] = useState(0);
14
+ const [copied, setCopied] = useState(null);
15
+ // The selection is an index into a list that changes with the selected
16
+ // session, so it is clamped on the way out rather than reset on the way in.
17
+ const at = Math.max(0, Math.min(index, fields.length - 1));
18
+ // After the registration above, which is the whole point of it being here.
19
+ useEffect(() => { if (claim)
20
+ focus.focus(); }, [claim]);
21
+ useInput((event) => {
22
+ if (fields.length === 0)
23
+ return false;
24
+ switch (event.name) {
25
+ case 'up':
26
+ setIndex(Math.max(0, at - 1));
27
+ setCopied(null);
28
+ return true;
29
+ case 'down':
30
+ setIndex(Math.min(fields.length - 1, at + 1));
31
+ setCopied(null);
32
+ return true;
33
+ case 'home':
34
+ setIndex(0);
35
+ setCopied(null);
36
+ return true;
37
+ case 'end':
38
+ setIndex(fields.length - 1);
39
+ setCopied(null);
40
+ return true;
41
+ case 'enter': {
42
+ const field = fields[at];
43
+ if (!field || !field.value)
44
+ return true;
45
+ // OSC 52 where the terminal takes it, and the store either way - so
46
+ // a test can assert what was copied without a terminal at all.
47
+ clipboard.write(field.value);
48
+ setCopied(field.id);
49
+ return true;
50
+ }
51
+ default: return false;
52
+ }
53
+ }, { focusId: focus.id });
54
+ return (_jsx(Column, { ...rest, id: focus.id, children: fields.map((field, i) => {
55
+ const active = i === at;
56
+ const selected = active && focus.focused;
57
+ const whole = values === 'all' || active;
58
+ return (
59
+ // `align="start"` because a wrapped value makes the row two lines
60
+ // tall and the label is one: centred, it drifted down to sit
61
+ // beside the *second* line of a URI, with nothing at all beside
62
+ // the first. A label names the row it starts.
63
+ _jsxs(Row, { gap: 1, align: "start", children: [_jsx("text", { content: active ? theme.glyphs.chevronRight : ' ', fg: selected ? 'accent' : 'subtle', shrink: 0 }), _jsx("text", { content: field.label, width: column, shrink: 0, fg: "muted", truncate: "end" }), _jsx("text", { content: field.value || field.absent || '-', flex: 1, ...(whole ? { wrap: 'word' } : { truncate: 'end' }), ...(field.value ? {} : { fg: 'subtle' }), ...(field.tone && field.value ? { fg: field.tone } : {}), ...(selected ? { bold: true } : {}) }), copied === field.id ? _jsx("text", { content: "copied", fg: "success", shrink: 0 }) : null] }, field.id));
64
+ }) }));
65
+ });
package/dist/diff.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * A line diff, small enough to read.
3
+ *
4
+ * The host sends two whole files and a count of what changed between them; it
5
+ * does not send the diff itself, so somebody has to work out which lines those
6
+ * were. This is that, and it is deliberately the textbook algorithm rather
7
+ * than anything clever: the longest common subsequence of the two line arrays,
8
+ * with everything not in it marked as removed on the left or added on the
9
+ * right.
10
+ *
11
+ * The cost is quadratic in the number of lines, which is why `diffLines` takes
12
+ * a ceiling. Two files of ten thousand lines each is a hundred million cells
13
+ * and a terminal that stops answering, and the honest answer at that size is
14
+ * to say the files are too big rather than to spend a minute proving it.
15
+ */
16
+ export type DiffKind = 'same' | 'added' | 'removed';
17
+ export interface DiffRow {
18
+ kind: DiffKind;
19
+ /** 1-based, on the side this row exists on. Absent on the side it does not. */
20
+ before?: number;
21
+ after?: number;
22
+ text: string;
23
+ }
24
+ export interface DiffResult {
25
+ rows: DiffRow[];
26
+ added: number;
27
+ removed: number;
28
+ /** Set instead of a diff when the pair was over `limit`. */
29
+ tooLarge?: {
30
+ lines: number;
31
+ limit: number;
32
+ };
33
+ }
34
+ /** Lines of a file, with the trailing newline not counted as an empty last line. */
35
+ export declare function toLines(text: string): string[];
36
+ /**
37
+ * The two sides, lined up.
38
+ *
39
+ * A creation has no `before` and a deletion no `after`; both are passed as an
40
+ * empty string rather than as a special case, because "every line is an
41
+ * addition" is exactly the right diff for a new file and needs no branch of
42
+ * its own.
43
+ */
44
+ export declare function diffLines(before: string, after: string, limit?: number): DiffResult;
45
+ //# sourceMappingURL=diff.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"diff.d.ts","sourceRoot":"","sources":["../src/diff.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,OAAO,GAAG,SAAS,CAAC;AAEpD,MAAM,WAAW,OAAO;IACtB,IAAI,EAAE,QAAQ,CAAC;IACf,+EAA+E;IAC/E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,OAAO,EAAE,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,4DAA4D;IAC5D,QAAQ,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;CAC7C;AAED,oFAAoF;AACpF,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAK9C;AAED;;;;;;;GAOG;AACH,wBAAgB,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,SAAO,GAAG,UAAU,CAiEjF"}
package/dist/diff.js ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * A line diff, small enough to read.
3
+ *
4
+ * The host sends two whole files and a count of what changed between them; it
5
+ * does not send the diff itself, so somebody has to work out which lines those
6
+ * were. This is that, and it is deliberately the textbook algorithm rather
7
+ * than anything clever: the longest common subsequence of the two line arrays,
8
+ * with everything not in it marked as removed on the left or added on the
9
+ * right.
10
+ *
11
+ * The cost is quadratic in the number of lines, which is why `diffLines` takes
12
+ * a ceiling. Two files of ten thousand lines each is a hundred million cells
13
+ * and a terminal that stops answering, and the honest answer at that size is
14
+ * to say the files are too big rather than to spend a minute proving it.
15
+ */
16
+ /** Lines of a file, with the trailing newline not counted as an empty last line. */
17
+ export function toLines(text) {
18
+ if (text === '')
19
+ return [];
20
+ const lines = text.split('\n');
21
+ if (lines[lines.length - 1] === '')
22
+ lines.pop();
23
+ return lines;
24
+ }
25
+ /**
26
+ * The two sides, lined up.
27
+ *
28
+ * A creation has no `before` and a deletion no `after`; both are passed as an
29
+ * empty string rather than as a special case, because "every line is an
30
+ * addition" is exactly the right diff for a new file and needs no branch of
31
+ * its own.
32
+ */
33
+ export function diffLines(before, after, limit = 4000) {
34
+ const a = toLines(before);
35
+ const b = toLines(after);
36
+ if (a.length + b.length > limit) {
37
+ return { rows: [], added: 0, removed: 0, tooLarge: { lines: a.length + b.length, limit } };
38
+ }
39
+ // The common head and tail first. Two files that differ in one line share
40
+ // everything either side of it, and taking those off shrinks the table the
41
+ // quadratic part has to fill to the part that actually differs.
42
+ let head = 0;
43
+ while (head < a.length && head < b.length && a[head] === b[head])
44
+ head++;
45
+ let tail = 0;
46
+ while (tail < a.length - head
47
+ && tail < b.length - head
48
+ && a[a.length - 1 - tail] === b[b.length - 1 - tail])
49
+ tail++;
50
+ const midA = a.slice(head, a.length - tail);
51
+ const midB = b.slice(head, b.length - tail);
52
+ const table = lcs(midA, midB);
53
+ const rows = [];
54
+ let added = 0;
55
+ let removed = 0;
56
+ const push = (kind, text, ai, bi) => {
57
+ rows.push({
58
+ kind,
59
+ ...(kind !== 'added' ? { before: ai + 1 } : {}),
60
+ ...(kind !== 'removed' ? { after: bi + 1 } : {}),
61
+ text,
62
+ });
63
+ if (kind === 'added')
64
+ added++;
65
+ if (kind === 'removed')
66
+ removed++;
67
+ };
68
+ for (let i = 0; i < head; i++)
69
+ push('same', a[i], i, i);
70
+ // Walking the table forwards, so the rows come out in file order.
71
+ let i = 0;
72
+ let j = 0;
73
+ while (i < midA.length || j < midB.length) {
74
+ if (i < midA.length && j < midB.length && midA[i] === midB[j]) {
75
+ push('same', midA[i], head + i, head + j);
76
+ i++;
77
+ j++;
78
+ // A tie goes to the removal, so a replaced line reads `-old` then `+new`
79
+ // the way every other diff on the machine prints it. With `>=` here the
80
+ // pair comes out the other way round, which is not wrong so much as
81
+ // unreadable next to `git diff`.
82
+ }
83
+ else if (j < midB.length && (i === midA.length || (table[i]?.[j + 1] ?? 0) > (table[i + 1]?.[j] ?? 0))) {
84
+ push('added', midB[j], head + i, head + j);
85
+ j++;
86
+ }
87
+ else {
88
+ push('removed', midA[i], head + i, head + j);
89
+ i++;
90
+ }
91
+ }
92
+ for (let k = 0; k < tail; k++) {
93
+ push('same', a[a.length - tail + k], a.length - tail + k, b.length - tail + k);
94
+ }
95
+ return { rows, added, removed };
96
+ }
97
+ /**
98
+ * `table[i][j]` is the length of the longest common subsequence of `a[i..]`
99
+ * and `b[j..]`, filled from the end so the walk above can go forwards.
100
+ */
101
+ function lcs(a, b) {
102
+ const table = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
103
+ for (let i = a.length - 1; i >= 0; i--) {
104
+ for (let j = b.length - 1; j >= 0; j--) {
105
+ table[i][j] = a[i] === b[j]
106
+ ? (table[i + 1]?.[j + 1] ?? 0) + 1
107
+ : Math.max(table[i + 1]?.[j] ?? 0, table[i]?.[j + 1] ?? 0);
108
+ }
109
+ }
110
+ return table;
111
+ }