@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,222 @@
1
+ import type { BoxProps, RenderOutput } from '@textui/core';
2
+ import { defineComponent, useFocus, useInput, useTheme } from '@textui/core';
3
+ import { Column, Row } from '@textui/widgets';
4
+
5
+ /**
6
+ * The composer's control rows: what this message will be sent as.
7
+ *
8
+ * Under the field, and everything on it is a *current value* rather than a
9
+ * label. What will run - which harness, which model, how much it may do
10
+ * before it asks - and, when the host asks it, where: the directory, whether
11
+ * in place or in a worktree, and from which branch. A person can read what
12
+ * will happen without opening anything, and change any of it without leaving
13
+ * the composer.
14
+ *
15
+ * Where gets a row of its own, and only when there is a where. One line held
16
+ * all of it until a host that answers every question the reference host asks
17
+ * put eight chips on it, and the row truncated each to its mark; a host that
18
+ * asks nothing about where - a chat that runs where it was opened - keeps the
19
+ * one line, with send at the end of it, rather than a second row holding
20
+ * nothing but send.
21
+ *
22
+ * Each chip is one command's argument, asked through the palette (see
23
+ * `picker.ts`). That is the whole design: nothing here knows what a model or a
24
+ * permission mode is, so a new chip is a new command and no change to this
25
+ * file.
26
+ */
27
+
28
+ export interface ComposerOption {
29
+ id: string;
30
+ /** The value, as a person reads it. Never the id the host stores. */
31
+ label: string;
32
+ /**
33
+ * The question, as opposed to the answer in `label`.
34
+ *
35
+ * The chip has no room for it - "Ask each time" is the whole row and the
36
+ * mark in front says which question it belongs to - but anything listing
37
+ * these somewhere with more space needs the pair, and the host's own wording
38
+ * for both is on the schema rather than in this file.
39
+ */
40
+ title?: string;
41
+ icon?: string;
42
+ /** The command whose argument this chip asks about. Absent: shown, not asked. */
43
+ commandId?: string;
44
+ /** On the second row, with the workspace: where the session runs rather than what runs it. */
45
+ where?: boolean;
46
+ }
47
+
48
+ export interface ComposerBarProps extends BoxProps {
49
+ options: ComposerOption[];
50
+ onOpen(option: ComposerOption, anchorId: string): void;
51
+ onSend(): void;
52
+ /** Escape on a chip: back to the field. */
53
+ onLeave?(): void;
54
+ /** A turn is running, so this message joins the queue instead. */
55
+ running?: boolean;
56
+ queued?: number;
57
+ sendDisabled?: boolean;
58
+ }
59
+
60
+ /** The focus id of one chip. The picker anchors to it, so it has to be knowable. */
61
+ export const chipId = (id: string): string => `chat.option.${id}`;
62
+
63
+ export const SEND_ID = 'chat.send';
64
+
65
+ /**
66
+ * How many rows the bar takes for these options.
67
+ *
68
+ * Two when any of them is a `where`, and one otherwise - which is the number
69
+ * the composer needs before the bar is drawn, to know how much of a short
70
+ * terminal is left for the menu above it.
71
+ */
72
+ export function composerRows(options: ComposerOption[]): 1 | 2 {
73
+ return options.some((option) => option.where) ? 2 : 1;
74
+ }
75
+
76
+ export const ComposerBar: (props: ComposerBarProps) => RenderOutput =
77
+ defineComponent<ComposerBarProps>('ComposerBar', (props) => {
78
+ const { options, onOpen, onSend, onLeave, running, queued = 0, sendDisabled, ...rest } = props;
79
+ const theme = useTheme();
80
+
81
+ // The first row and then the second, which is also the tab order. Stated
82
+ // rather than inherited: tab order is registration order, and which chips
83
+ // exist is the *host's* answer - it arrives one round trip after the rows
84
+ // are first drawn, so the ones that were there from the start would
85
+ // otherwise come first however far to the right they sit.
86
+ const what = options.filter((option) => !option.where);
87
+ const where = options.filter((option) => option.where);
88
+ const ordered = [...what, ...where];
89
+ const chip = (option: ComposerOption): RenderOutput => (option.commandId
90
+ ? (
91
+ <Chip
92
+ key={option.id}
93
+ focusId={chipId(option.id)}
94
+ order={ordered.indexOf(option)}
95
+ label={option.label}
96
+ {...(option.icon ? { icon: option.icon } : {})}
97
+ onOpen={() => onOpen(option, chipId(option.id))}
98
+ {...(onLeave ? { onLeave } : {})}
99
+ />
100
+ )
101
+ : (
102
+ // Shown, not asked: a value that is fixed for this session is
103
+ // still worth reading, and a chip that opens a panel offering one
104
+ // choice is a worse way of saying so.
105
+ <Row key={option.id} gap={1}>
106
+ {option.icon ? <text content={option.icon} fg="subtle" /> : null}
107
+ <text content={option.label} fg="subtle" />
108
+ </Row>
109
+ ));
110
+
111
+ // The end of the last row, whichever row that is: what is waiting, and
112
+ // the one verb on the bar.
113
+ const tail = [
114
+ <text key="gap" content="" flex={1} />,
115
+ queued > 0 ? <text key="queued" content={`${queued} queued`} fg="warning" /> : null,
116
+ <Chip
117
+ key="send"
118
+ focusId={SEND_ID}
119
+ order={ordered.length}
120
+ label={running ? 'queue' : 'send'}
121
+ trailing={theme.glyphs.chevronRight}
122
+ tone="accent"
123
+ {...(sendDisabled ? { disabled: true } : {})}
124
+ onOpen={onSend}
125
+ {...(onLeave ? { onLeave } : {})}
126
+ />,
127
+ ];
128
+
129
+ if (where.length === 0) {
130
+ return (
131
+ <Row gap={1} {...rest}>
132
+ {what.map(chip)}
133
+ {tail}
134
+ </Row>
135
+ );
136
+ }
137
+
138
+ return (
139
+ <Column gap={0} {...rest}>
140
+ <Row gap={1}>
141
+ {what.map(chip)}
142
+ </Row>
143
+ <Row gap={1}>
144
+ {where.map(chip)}
145
+ {tail}
146
+ </Row>
147
+ </Column>
148
+ );
149
+ });
150
+
151
+ interface ChipProps {
152
+ focusId: string;
153
+ /** Where tab reaches it. The row's own order, not the order it mounted in. */
154
+ order: number;
155
+ label: string;
156
+ icon?: string;
157
+ /** After the label. A chevron for something that opens, an arrow for send. */
158
+ trailing?: string;
159
+ tone?: 'accent';
160
+ disabled?: boolean;
161
+ onOpen(): void;
162
+ /** Escape. Absent, the key goes on to whatever the screen does with it. */
163
+ onLeave?(): void;
164
+ }
165
+
166
+ /**
167
+ * One value on the control row.
168
+ *
169
+ * Not a `Button`: a button is a verb and these are nouns, and at `size="sm"`
170
+ * four of them still read as four buttons rather than as one sentence about
171
+ * what is about to be sent. What it borrows from a button is the part that
172
+ * matters - it is focusable, tab reaches it, and enter opens it.
173
+ */
174
+ const Chip = defineComponent<ChipProps>('ComposerChip', (props) => {
175
+ const { focusId, order, label, icon, trailing, tone, disabled, onOpen, onLeave } = props;
176
+ const theme = useTheme();
177
+ const focus = useFocus({ id: focusId, order, disabled: disabled === true });
178
+
179
+ useInput((event) => {
180
+ if (disabled) return false;
181
+ // Out of the row and back to the field, the way escape leaves the field
182
+ // for the transcript: one step out, not out of the screen.
183
+ if (event.name === 'escape' && onLeave) {
184
+ onLeave();
185
+ return true;
186
+ }
187
+ // Down as well as enter: the panel comes up out of the chip, and reaching
188
+ // for it downwards is what the shape of the thing suggests.
189
+ if (event.name === 'enter' || event.name === 'space' || event.name === 'down') {
190
+ onOpen();
191
+ return true;
192
+ }
193
+ return false;
194
+ }, { focusId: focus.id, enabled: disabled !== true });
195
+
196
+ return (
197
+ <Row
198
+ id={focus.id}
199
+ gap={1}
200
+ padding={[0, 1]}
201
+ {...(focus.focused ? { bg: 'selected' as const } : {})}
202
+ onClick={disabled ? undefined : onOpen}
203
+ >
204
+ {/* The mark and the chevron never give up room. As the terminal
205
+ narrows the labels truncate from the right, and a chip that has
206
+ given up its mark as well is four cells of ellipsis that could be
207
+ any of six questions. */}
208
+ {icon ? <text content={icon} shrink={0} fg={focus.focused ? 'inverted' : tone ?? 'muted'} /> : null}
209
+ <text
210
+ content={label}
211
+ truncate="end"
212
+ fg={focus.focused ? 'inverted' : disabled ? 'disabled' : tone ?? undefined}
213
+ {...(tone ? { bold: true } : {})}
214
+ />
215
+ <text
216
+ content={trailing ?? theme.glyphs.chevronDown}
217
+ shrink={0}
218
+ fg={focus.focused ? 'inverted' : 'subtle'}
219
+ />
220
+ </Row>
221
+ );
222
+ });
@@ -0,0 +1,162 @@
1
+ import type { BoxProps, RenderOutput, SemanticVariant } from '@textui/core';
2
+ import {
3
+ defineComponent,
4
+ useClipboard,
5
+ useEffect,
6
+ useFocus,
7
+ useInput,
8
+ useState,
9
+ useTheme,
10
+ } from '@textui/core';
11
+ import { Column, Row } from '@textui/widgets';
12
+
13
+ /**
14
+ * A property list you can walk, and take a value out of.
15
+ *
16
+ * The catalogue's detail pane is where the identifiers live - a session URI, a
17
+ * chat URI - and an identifier you cannot read in full or paste anywhere is
18
+ * decoration. `KeyValue` draws the same pairs and is static: nothing selects a
19
+ * row, so nothing can be copied and nothing can be shown untruncated.
20
+ *
21
+ * So the selected row is the one that gets the room. Every other row is one
22
+ * line with its value truncated, and the selected row wraps its value across
23
+ * as many lines as it needs - which costs nothing when the value is short and
24
+ * is the whole answer when it is a URI in a 36-column pane. `enter` puts it on
25
+ * the clipboard.
26
+ *
27
+ * This is a finding, not a flourish: it is the third component this example
28
+ * wanted that the catalog does not have.
29
+ */
30
+
31
+ export interface DetailField {
32
+ id: string;
33
+ label: string;
34
+ value: string;
35
+ tone?: SemanticVariant;
36
+ /** Shown instead of the value when there is none, in the subtle tone. */
37
+ absent?: string;
38
+ }
39
+
40
+ export interface SessionDetailsProps extends BoxProps {
41
+ fields: DetailField[];
42
+ focusId?: string;
43
+ /**
44
+ * Width of the label column, where a caller wants to fix it.
45
+ *
46
+ * Left off, it is the widest label there is. It used to be a constant on
47
+ * the grounds that the labels were ours, and they are not: a session's
48
+ * settings and a model's options are named by whichever host is answering,
49
+ * in words this client does not choose, and the constant was one character
50
+ * wider than the longest label anybody had thought of.
51
+ */
52
+ labelWidth?: number;
53
+ /**
54
+ * Take the keyboard on the frame this mounts, out of whatever holds it.
55
+ *
56
+ * Not `autoFocus`, which claims focus rather than taking it: a pane that
57
+ * appears *because* a key asked for it has to end up with the cursor, and
58
+ * the thing it is taking the cursor from - the session list - is in the
59
+ * same focus scope and already has it.
60
+ *
61
+ * It has to be done here rather than by the screen that mounts this,
62
+ * because a focusable registers in its own effect: on the render that opens
63
+ * the pane, the id does not exist yet and the screen's `focus()` is a call
64
+ * that quietly returns false.
65
+ */
66
+ claim?: boolean;
67
+ /**
68
+ * Which rows show their value whole.
69
+ *
70
+ * `selected` is the default and the one that keeps the pane a list: every
71
+ * row is one line, and the row you have stopped on wraps to as many as its
72
+ * value needs. `all` wraps every row, which is what you want when the
73
+ * values *are* the point - a pane of URIs where the answer is on the third
74
+ * one down and reading it should not mean walking there first.
75
+ */
76
+ values?: 'selected' | 'all';
77
+ }
78
+
79
+ export const SessionDetails: (props: SessionDetailsProps) => RenderOutput =
80
+ defineComponent<SessionDetailsProps>('SessionDetails', (props) => {
81
+ const { fields, focusId, labelWidth, claim, values = 'selected', ...rest } = props;
82
+ // Floored at the eleven this used to be, so nothing that already fitted
83
+ // moves, and capped so one verbose title cannot take the pane from the
84
+ // values it is there to label.
85
+ const column = labelWidth ?? Math.min(20, Math.max(11, ...fields.map((field) => field.label.length)));
86
+ const theme = useTheme();
87
+ const clipboard = useClipboard();
88
+ const focus = useFocus({ ...(focusId ? { id: focusId } : {}) });
89
+ const [index, setIndex] = useState(0);
90
+ const [copied, setCopied] = useState<string | null>(null);
91
+
92
+ // The selection is an index into a list that changes with the selected
93
+ // session, so it is clamped on the way out rather than reset on the way in.
94
+ const at = Math.max(0, Math.min(index, fields.length - 1));
95
+
96
+ // After the registration above, which is the whole point of it being here.
97
+ useEffect(() => { if (claim) focus.focus(); }, [claim]);
98
+
99
+ useInput((event) => {
100
+ if (fields.length === 0) return false;
101
+ switch (event.name) {
102
+ case 'up': setIndex(Math.max(0, at - 1)); setCopied(null); return true;
103
+ case 'down': setIndex(Math.min(fields.length - 1, at + 1)); setCopied(null); return true;
104
+ case 'home': setIndex(0); setCopied(null); return true;
105
+ case 'end': setIndex(fields.length - 1); setCopied(null); return true;
106
+ case 'enter': {
107
+ const field = fields[at];
108
+ if (!field || !field.value) return true;
109
+ // OSC 52 where the terminal takes it, and the store either way - so
110
+ // a test can assert what was copied without a terminal at all.
111
+ clipboard.write(field.value);
112
+ setCopied(field.id);
113
+ return true;
114
+ }
115
+ default: return false;
116
+ }
117
+ }, { focusId: focus.id });
118
+
119
+ return (
120
+ <Column {...rest} id={focus.id}>
121
+ {fields.map((field, i) => {
122
+ const active = i === at;
123
+ const selected = active && focus.focused;
124
+ const whole = values === 'all' || active;
125
+ return (
126
+ // `align="start"` because a wrapped value makes the row two lines
127
+ // tall and the label is one: centred, it drifted down to sit
128
+ // beside the *second* line of a URI, with nothing at all beside
129
+ // the first. A label names the row it starts.
130
+ <Row key={field.id} gap={1} align="start">
131
+ <text
132
+ content={active ? theme.glyphs.chevronRight : ' '}
133
+ fg={selected ? 'accent' : 'subtle'}
134
+ shrink={0}
135
+ />
136
+ {/* Neither of these gives up a cell. `width` is a starting size,
137
+ not a floor - shrink is 1 unless it is said - so a value with
138
+ no space in it to break at was squeezing the label column,
139
+ and `Chat` ended up four columns left of `Harness`. A column
140
+ that moves per row is not a column. */}
141
+ <text
142
+ content={field.label}
143
+ width={column}
144
+ shrink={0}
145
+ fg="muted"
146
+ truncate="end"
147
+ />
148
+ <text
149
+ content={field.value || field.absent || '-'}
150
+ flex={1}
151
+ {...(whole ? { wrap: 'word' as const } : { truncate: 'end' as const })}
152
+ {...(field.value ? {} : { fg: 'subtle' as const })}
153
+ {...(field.tone && field.value ? { fg: field.tone } : {})}
154
+ {...(selected ? { bold: true } : {})}
155
+ />
156
+ {copied === field.id ? <text content="copied" fg="success" shrink={0} /> : null}
157
+ </Row>
158
+ );
159
+ })}
160
+ </Column>
161
+ );
162
+ });
package/src/diff.ts ADDED
@@ -0,0 +1,132 @@
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
+
17
+ export type DiffKind = 'same' | 'added' | 'removed';
18
+
19
+ export interface DiffRow {
20
+ kind: DiffKind;
21
+ /** 1-based, on the side this row exists on. Absent on the side it does not. */
22
+ before?: number;
23
+ after?: number;
24
+ text: string;
25
+ }
26
+
27
+ export interface DiffResult {
28
+ rows: DiffRow[];
29
+ added: number;
30
+ removed: number;
31
+ /** Set instead of a diff when the pair was over `limit`. */
32
+ tooLarge?: { lines: number; limit: number };
33
+ }
34
+
35
+ /** Lines of a file, with the trailing newline not counted as an empty last line. */
36
+ export function toLines(text: string): string[] {
37
+ if (text === '') return [];
38
+ const lines = text.split('\n');
39
+ if (lines[lines.length - 1] === '') lines.pop();
40
+ return lines;
41
+ }
42
+
43
+ /**
44
+ * The two sides, lined up.
45
+ *
46
+ * A creation has no `before` and a deletion no `after`; both are passed as an
47
+ * empty string rather than as a special case, because "every line is an
48
+ * addition" is exactly the right diff for a new file and needs no branch of
49
+ * its own.
50
+ */
51
+ export function diffLines(before: string, after: string, limit = 4000): DiffResult {
52
+ const a = toLines(before);
53
+ const b = toLines(after);
54
+
55
+ if (a.length + b.length > limit) {
56
+ return { rows: [], added: 0, removed: 0, tooLarge: { lines: a.length + b.length, limit } };
57
+ }
58
+
59
+ // The common head and tail first. Two files that differ in one line share
60
+ // everything either side of it, and taking those off shrinks the table the
61
+ // quadratic part has to fill to the part that actually differs.
62
+ let head = 0;
63
+ while (head < a.length && head < b.length && a[head] === b[head]) head++;
64
+ let tail = 0;
65
+ while (
66
+ tail < a.length - head
67
+ && tail < b.length - head
68
+ && a[a.length - 1 - tail] === b[b.length - 1 - tail]
69
+ ) tail++;
70
+
71
+ const midA = a.slice(head, a.length - tail);
72
+ const midB = b.slice(head, b.length - tail);
73
+ const table = lcs(midA, midB);
74
+
75
+ const rows: DiffRow[] = [];
76
+ let added = 0;
77
+ let removed = 0;
78
+ const push = (kind: DiffKind, text: string, ai: number, bi: number): void => {
79
+ rows.push({
80
+ kind,
81
+ ...(kind !== 'added' ? { before: ai + 1 } : {}),
82
+ ...(kind !== 'removed' ? { after: bi + 1 } : {}),
83
+ text,
84
+ });
85
+ if (kind === 'added') added++;
86
+ if (kind === 'removed') removed++;
87
+ };
88
+
89
+ for (let i = 0; i < head; i++) push('same', a[i] as string, i, i);
90
+
91
+ // Walking the table forwards, so the rows come out in file order.
92
+ let i = 0;
93
+ let j = 0;
94
+ while (i < midA.length || j < midB.length) {
95
+ if (i < midA.length && j < midB.length && midA[i] === midB[j]) {
96
+ push('same', midA[i] as string, head + i, head + j);
97
+ i++; j++;
98
+ // A tie goes to the removal, so a replaced line reads `-old` then `+new`
99
+ // the way every other diff on the machine prints it. With `>=` here the
100
+ // pair comes out the other way round, which is not wrong so much as
101
+ // unreadable next to `git diff`.
102
+ } else if (j < midB.length && (i === midA.length || (table[i]?.[j + 1] ?? 0) > (table[i + 1]?.[j] ?? 0))) {
103
+ push('added', midB[j] as string, head + i, head + j);
104
+ j++;
105
+ } else {
106
+ push('removed', midA[i] as string, head + i, head + j);
107
+ i++;
108
+ }
109
+ }
110
+
111
+ for (let k = 0; k < tail; k++) {
112
+ push('same', a[a.length - tail + k] as string, a.length - tail + k, b.length - tail + k);
113
+ }
114
+
115
+ return { rows, added, removed };
116
+ }
117
+
118
+ /**
119
+ * `table[i][j]` is the length of the longest common subsequence of `a[i..]`
120
+ * and `b[j..]`, filled from the end so the walk above can go forwards.
121
+ */
122
+ function lcs(a: string[], b: string[]): number[][] {
123
+ const table: number[][] = Array.from({ length: a.length + 1 }, () => new Array<number>(b.length + 1).fill(0));
124
+ for (let i = a.length - 1; i >= 0; i--) {
125
+ for (let j = b.length - 1; j >= 0; j--) {
126
+ (table[i] as number[])[j] = a[i] === b[j]
127
+ ? (table[i + 1]?.[j + 1] ?? 0) + 1
128
+ : Math.max(table[i + 1]?.[j] ?? 0, table[i]?.[j + 1] ?? 0);
129
+ }
130
+ }
131
+ return table;
132
+ }
@@ -0,0 +1,118 @@
1
+ import type { BoxProps, RenderOutput } from '@textui/core';
2
+ import { defineComponent, useTheme } from '@textui/core';
3
+ import { Column, EmptyState, Row, ScrollView } from '@textui/widgets';
4
+ import type { DiffResult } from './diff.js';
5
+
6
+ /**
7
+ * One file out of a changeset, both sides of it.
8
+ *
9
+ * Unified rather than side by side, and not for want of a splitter: a terminal
10
+ * that a changeset list already shares with a session pane has sixty columns
11
+ * left, and eighty characters of source in thirty is two columns of nothing
12
+ * legible. Unified spends the width on the line instead.
13
+ *
14
+ * Every row is exactly one row tall. The scroll position is a row count, so a
15
+ * line that wrapped would put the gutter numbers out of step with what is on
16
+ * screen - which is the same invariant the markdown layout keeps, for the same
17
+ * reason. Long lines are clipped, and the file is there to be read rather than
18
+ * edited.
19
+ */
20
+
21
+ export interface FileDiffProps extends BoxProps {
22
+ /** The file, as the changeset names it. */
23
+ path: string;
24
+ diff: DiffResult;
25
+ /** Absent `before` is a creation, absent `after` a deletion. */
26
+ kind: 'new' | 'edited' | 'deleted';
27
+ /** Set instead of a diff when the bytes are not text. */
28
+ binary?: { bytes: number; contentType?: string };
29
+ }
30
+
31
+ export const FileDiff: (props: FileDiffProps) => RenderOutput =
32
+ defineComponent<FileDiffProps>('FileDiff', (props) => {
33
+ const { path, diff, kind, binary, ...rest } = props;
34
+ const theme = useTheme();
35
+
36
+ if (binary) {
37
+ return (
38
+ <EmptyState
39
+ title="Not text"
40
+ message={`${binary.contentType ?? 'Binary'}, ${binary.bytes} bytes. There is nothing to show a line at a time.`}
41
+ {...rest}
42
+ />
43
+ );
44
+ }
45
+
46
+ if (diff.tooLarge) {
47
+ return (
48
+ <EmptyState
49
+ title="Too big to line up"
50
+ message={`${diff.tooLarge.lines} lines between the two sides, over the ${diff.tooLarge.limit} this will compare. Open it in an editor.`}
51
+ {...rest}
52
+ />
53
+ );
54
+ }
55
+
56
+ if (diff.rows.length === 0) {
57
+ return <EmptyState title="Nothing between the two" message="Both sides of this file are the same." {...rest} />;
58
+ }
59
+
60
+ // The widest line number either side will need, so the gutter does not
61
+ // change width partway down the file.
62
+ const width = String(Math.max(
63
+ ...diff.rows.map((row) => Math.max(row.before ?? 0, row.after ?? 0)),
64
+ )).length;
65
+
66
+ return (
67
+ <Column {...rest}>
68
+ <Row gap={1}>
69
+ <text
70
+ content={kind === 'new' ? '+' : kind === 'deleted' ? '-' : theme.glyphs.chevronRight}
71
+ fg={kind === 'new' ? 'success' : kind === 'deleted' ? 'danger' : 'muted'}
72
+ shrink={0}
73
+ />
74
+ <text content={path} flex={1} truncate="start" />
75
+ <text content={`+${diff.added}`} fg="success" shrink={0} />
76
+ <text content={`-${diff.removed}`} fg="danger" shrink={0} />
77
+ </Row>
78
+
79
+ <ScrollView flex={1} focusId="changes.file">
80
+ <Column>
81
+ {diff.rows.map((row, index) => (
82
+ <Row key={index} gap={1}>
83
+ {/* Both gutters, always. A single number that means the left
84
+ file on one row and the right on the next is a number
85
+ nobody can use to find anything. */}
86
+ <text
87
+ content={String(row.before ?? '').padStart(width)}
88
+ fg="subtle"
89
+ shrink={0}
90
+ />
91
+ <text
92
+ content={String(row.after ?? '').padStart(width)}
93
+ fg="subtle"
94
+ shrink={0}
95
+ />
96
+ <text
97
+ content={row.kind === 'added' ? '+' : row.kind === 'removed' ? '-' : ' '}
98
+ fg={row.kind === 'added' ? 'success' : row.kind === 'removed' ? 'danger' : 'muted'}
99
+ shrink={0}
100
+ />
101
+ <text
102
+ content={row.text}
103
+ wrap="none"
104
+ truncate="end"
105
+ flex={1}
106
+ {...(row.kind === 'added'
107
+ ? { fg: 'success' as const }
108
+ : row.kind === 'removed'
109
+ ? { fg: 'danger' as const }
110
+ : {})}
111
+ />
112
+ </Row>
113
+ ))}
114
+ </Column>
115
+ </ScrollView>
116
+ </Column>
117
+ );
118
+ });