@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,105 @@
1
+ import type { BoxProps, RenderOutput, SemanticVariant } from '@textui/core';
2
+ import { defineComponent, useTheme } from '@textui/core';
3
+ import { Column, KeyValue, Row } from '@textui/widgets';
4
+ import type { ChatSession } from './types.js';
5
+
6
+ /**
7
+ * What this conversation *is*, at the top of it.
8
+ *
9
+ * The first thing in the transcript rather than a band above it, and that is
10
+ * the whole design: a caption pinned outside the scrolling region costs a row
11
+ * of the conversation on every screen for ever, so it has to earn each one -
12
+ * which meant one line, which meant dropping most of what it is for. Scrolled
13
+ * with the conversation it costs nothing after the first screen and can say
14
+ * everything, the way the top of a printed letter does.
15
+ *
16
+ * The identifiers are the point. They are what gets pasted into a shell or a
17
+ * bug report, they are exactly what does not fit anywhere else, and the
18
+ * catalogue's detail pane - the only other place they appear - is a screen
19
+ * away from the conversation they belong to.
20
+ */
21
+
22
+ export interface ChatSessionHeadProps extends BoxProps {
23
+ session: ChatSession;
24
+ /** What the last turn ran on. A session has no model; each message has one. */
25
+ model?: string;
26
+ /** The chat uri, when the host has said which one this dispatches to. */
27
+ chat?: string | null;
28
+ /** The settings in force, by the host's own labels. */
29
+ settings?: { label: string; value: string }[];
30
+ /**
31
+ * Who else the host says is in this session.
32
+ *
33
+ * This client is in the list too - it adds itself on opening the view - so
34
+ * a session with nobody else in it has one entry and says nothing, which is
35
+ * the ordinary case.
36
+ */
37
+ present?: { clientId: string; displayName?: string }[];
38
+ }
39
+
40
+ /**
41
+ * A timestamp a person can read, in whatever this machine calls a date.
42
+ *
43
+ * The host sends ISO, which is unambiguous and not what anybody wants to read
44
+ * off a caption. An unparseable one is passed through rather than shown as
45
+ * "Invalid Date": what the host said is more useful than what we made of it.
46
+ */
47
+ function when(iso: string | undefined): string {
48
+ if (!iso) return '';
49
+ const at = new Date(iso);
50
+ if (Number.isNaN(at.getTime())) return iso;
51
+ return at.toLocaleString(undefined, {
52
+ year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
53
+ });
54
+ }
55
+
56
+ export const ChatSessionHead: (props: ChatSessionHeadProps) => RenderOutput =
57
+ defineComponent<ChatSessionHeadProps>('ChatSessionHead', (props) => {
58
+ const { session, model, chat, settings = [], present = [], ...rest } = props;
59
+ const theme = useTheme();
60
+ const status = session.status;
61
+ const started = when(session.createdAt);
62
+ const updated = when(session.modifiedAt);
63
+
64
+ // Only what is known. A row of empty values is what building this from a
65
+ // fixed list produces, and it reads as a session the host would not talk
66
+ // about rather than as one nobody has asked yet.
67
+ const rows: { label: string; value: string; tone?: SemanticVariant }[] = [
68
+ { label: 'Harness', value: [session.provider, model].filter(Boolean).join(` ${theme.glyphs.separator} `) },
69
+ ...settings.filter((setting) => setting.value).map((setting) => ({ ...setting })),
70
+ { label: 'Workspace', value: session.workingDirectories.map((dir) => dir.replace(/^file:\/\//, '')).join(', ') },
71
+ // Only when the host says one. A blank branch row reads as a detached
72
+ // head rather than as a host that does not report branches.
73
+ ...(session.branch ? [{ label: 'Branch', value: session.branch }] : []),
74
+ {
75
+ label: 'Started',
76
+ value: started && updated && updated !== started
77
+ ? `${started} ${theme.glyphs.separator} updated ${updated}`
78
+ : started,
79
+ },
80
+ // Last, and in full. A uri you can read half of is worse than one you
81
+ // cannot see at all: it looks like the whole thing.
82
+ { label: 'Session', value: session.id },
83
+ ...(chat ? [{ label: 'Chat', value: chat }] : []),
84
+ // Only when somebody else is here. One entry is this client, and a row
85
+ // saying you are the person reading it is a row that tells nobody
86
+ // anything.
87
+ ...(present.length > 1
88
+ ? [{
89
+ label: 'Here',
90
+ value: present.map((one) => one.displayName ?? one.clientId).join(` ${theme.glyphs.separator} `),
91
+ }]
92
+ : []),
93
+ ].filter((row) => row.value !== '');
94
+
95
+ return (
96
+ <Column {...rest} gap={0}>
97
+ <Row gap={1}>
98
+ <text content={theme.glyphs[status.glyph]} fg={status.tone as SemanticVariant} shrink={0} />
99
+ <text content={session.title} bold wrap="word" flex={1} />
100
+ <text content={status.label} fg={status.tone as SemanticVariant} shrink={0} />
101
+ </Row>
102
+ <KeyValue items={rows} />
103
+ </Column>
104
+ );
105
+ });
@@ -0,0 +1,146 @@
1
+ import type { BoxProps, RenderOutput, SemanticVariant } from '@textui/core';
2
+ import { defineComponent, stringWidth, useTheme } from '@textui/core';
3
+ import type { ListItem, ListItemState } from '@textui/widgets';
4
+ import { Badge, Column, List, Marquee, Row } from '@textui/widgets';
5
+ import type { ChatSession } from './types.js';
6
+
7
+ /**
8
+ * The catalogue.
9
+ *
10
+ * Still a `List` - the selection, the keys, the window and the highlight are
11
+ * all the list's, and reimplementing them here is what the transcript already
12
+ * proved is a mistake. What is ours is the row, because a session does not fit
13
+ * the one-line shape a list gives you for free.
14
+ *
15
+ * It takes two lines, and the first one is why. A title, a harness, a
16
+ * workspace and a status sharing a pane that is also sharing the terminal
17
+ * with the detail panel leaves every one of them truncated:
18
+ * `Draft replies for desk-produ…` beside `1b444e78-d050-4fb5-a5…` names
19
+ * neither the conversation nor the directory it is in. So the title gets the
20
+ * width, and everything that qualifies it goes underneath.
21
+ */
22
+
23
+ export interface SessionListProps extends BoxProps {
24
+ sessions: ChatSession[];
25
+ selectedId?: string | null;
26
+ onSelect?(id: string): void;
27
+ onOpen?(id: string): void;
28
+ emptyMessage?: string;
29
+ focusId?: string;
30
+ autoFocus?: boolean;
31
+ }
32
+
33
+ export const SessionList: (props: SessionListProps) => RenderOutput =
34
+ defineComponent<SessionListProps>('SessionList', (props) => {
35
+ const { sessions, selectedId, onSelect, onOpen, emptyMessage, focusId, autoFocus, ...rest } = props;
36
+ const theme = useTheme();
37
+
38
+ const dot = ` ${theme.glyphs.separator} `;
39
+
40
+ const items: ListItem[] = sessions.map((session) => {
41
+ const status = session.status;
42
+ const changes = session.changes;
43
+ return {
44
+ id: session.id,
45
+ // Glyph first, so the one that wants a person is findable in a piped
46
+ // log, a 16-colour session and by a reader who cannot see the colour.
47
+ icon: theme.glyphs[status.glyph],
48
+ label: session.title,
49
+ // The second line, in the order it gets read: which harness, then
50
+ // where, then what it has to show for it.
51
+ //
52
+ // The harness stands where a model would: a catalogue row does not
53
+ // carry one, and a model per row would be a subscription per row.
54
+ description: [
55
+ session.provider,
56
+ // The project, then the branch it is on - a catalogue spanning
57
+ // several repositories is read by which one each row is in, and a
58
+ // list of them all on `main` is a list that needs opening to tell
59
+ // apart. Then the pull request the branch became, where the host
60
+ // found one: a merged branch is a finished row.
61
+ [session.project, session.branch, session.pullRequest].filter(Boolean).join(' '),
62
+ changes?.files
63
+ ? `${changes.files} files +${changes.additions ?? 0} -${changes.deletions ?? 0}`
64
+ : '',
65
+ // What the host says it is doing, in its own words. Last, because it
66
+ // is the one that is usually not there.
67
+ session.activity ?? '',
68
+ // Why it is here at all, when nobody started it. Without this a
69
+ // session that appeared at nine in the morning is a row with no
70
+ // account of itself, sitting among rows somebody typed.
71
+ session.origin ?? '',
72
+ status.archived ? 'archived' : '',
73
+ ].filter(Boolean).join(dot),
74
+ meta: status.label,
75
+ tone: status.tone as SemanticVariant,
76
+ };
77
+ });
78
+
79
+ return (
80
+ <List
81
+ items={items}
82
+ itemHeight={2}
83
+ renderItem={(item: ListItem, state: ListItemState) => (
84
+ <Column>
85
+ <Row gap={1}>
86
+ <text
87
+ content={item.icon ?? ''}
88
+ {...(state.selected ? {} : { fg: item.tone })}
89
+ shrink={0}
90
+ />
91
+ {/* The row under the cursor reads itself out; the rest are
92
+ truncated and still. A title is the one thing on this screen
93
+ that is arbitrarily long and the one thing you are looking
94
+ for, so the row you have stopped on says all of it. */}
95
+ <Marquee content={item.label} active={state.selected && state.focused} flex={1} />
96
+ <text content={item.meta ?? ''} {...(state.selected ? {} : { fg: 'muted' })} shrink={0} />
97
+ </Row>
98
+ <Row>
99
+ {/* Under the title, not under the glyph: the second line
100
+ qualifies the thing the first one names. */}
101
+ <text content={' '.repeat(stringWidth(item.icon ?? '') + 1)} shrink={0} />
102
+ <Marquee
103
+ content={item.description ?? ''}
104
+ active={state.selected && state.focused}
105
+ {...(state.selected ? {} : { fg: 'muted' as const })}
106
+ flex={1}
107
+ />
108
+ </Row>
109
+ </Column>
110
+ )}
111
+ {...(selectedId ? { selectedId } : {})}
112
+ emptyMessage={emptyMessage ?? 'No sessions on this host'}
113
+ onSelect={(id: string) => onSelect?.(id)}
114
+ onActivate={(id: string) => onOpen?.(id)}
115
+ {...(focusId ? { focusId } : {})}
116
+ {...(autoFocus ? { autoFocus: true } : {})}
117
+ {...rest}
118
+ />
119
+ );
120
+ });
121
+
122
+ export interface ConnectionBadgeProps extends BoxProps {
123
+ url: string;
124
+ state: 'connecting' | 'connected' | 'offline';
125
+ sessions?: number;
126
+ }
127
+
128
+ /** Which host, and whether it is answering. */
129
+ export const ConnectionBadge: (props: ConnectionBadgeProps) => RenderOutput =
130
+ defineComponent<ConnectionBadgeProps>('ConnectionBadge', (props) => {
131
+ const { url, state, sessions, ...rest } = props;
132
+ const theme = useTheme();
133
+ const look = {
134
+ connected: { tone: 'success' as SemanticVariant, glyph: theme.glyphs.bulletFilled },
135
+ connecting: { tone: 'warning' as SemanticVariant, glyph: theme.glyphs.bulletHalf },
136
+ offline: { tone: 'danger' as SemanticVariant, glyph: theme.glyphs.cross },
137
+ }[state];
138
+
139
+ return (
140
+ <Row gap={1} {...rest}>
141
+ <text content={look.glyph} fg={look.tone} />
142
+ <text content={url} fg="muted" truncate="start" />
143
+ {sessions !== undefined ? <Badge label={`${sessions} sessions`} tone="muted" /> : null}
144
+ </Row>
145
+ );
146
+ });
@@ -0,0 +1,136 @@
1
+ import type { BoxProps, RenderOutput, SemanticVariant } from '@textui/core';
2
+ import { defineComponent, useTheme } from '@textui/core';
3
+ import { Badge, Column, MarkdownView, Row } from '@textui/widgets';
4
+ import type { ChatToolCall } from './types.js';
5
+
6
+ /**
7
+ * A tool call, as a row.
8
+ *
9
+ * Twenty of these in a turn look identical unless the *command* is on the row,
10
+ * so the input is the row and the display name is a prefix. What it meant to
11
+ * do is markdown, like everything else a host writes for a person; what came
12
+ * back is not, and is shown as it arrived.
13
+ *
14
+ * The status is a glyph and a colour together. A 16-colour session, a piped
15
+ * log and a colourblind reader all lose the colour and keep the glyph.
16
+ *
17
+ * In the transcript it sits beside a blank gutter rather than inside the rule,
18
+ * with its status glyph where the header's bullet is. A tool call is something
19
+ * the agent *did*; indenting it inside the rule filed it under what the agent
20
+ * was saying, which is the one thing it is not.
21
+ */
22
+
23
+ export interface ToolCallRowProps extends BoxProps {
24
+ call: ChatToolCall;
25
+ expanded?: boolean;
26
+ /** The transcript's cursor is on this row. */
27
+ active?: boolean;
28
+ /** Clicking the row opens it. */
29
+ onToggle?(): void;
30
+ }
31
+
32
+ type StatusGlyph = 'bulletHollow' | 'bulletHalf' | 'bulletFilled' | 'check' | 'cross';
33
+
34
+ const LOOK: Record<string, { tone: SemanticVariant; glyph: StatusGlyph }> = {
35
+ pending: { tone: 'muted', glyph: 'bulletHollow' },
36
+ 'pending-confirmation': { tone: 'warning', glyph: 'bulletHalf' },
37
+ running: { tone: 'accent', glyph: 'bulletFilled' },
38
+ completed: { tone: 'success', glyph: 'check' },
39
+ failed: { tone: 'danger', glyph: 'cross' },
40
+ cancelled: { tone: 'muted', glyph: 'cross' },
41
+ };
42
+
43
+ /**
44
+ * The input, as one line.
45
+ *
46
+ * A tool's arguments are often JSON, and JSON arrives with newlines in it. Put
47
+ * straight on the row, a three-line object makes the row three lines tall and
48
+ * every other cell in it vertically centred - so the name floats beside the
49
+ * middle line of a brace-delimited block. The whole thing is on its own lines
50
+ * once the row is opened; this is the part that fits beside a name.
51
+ */
52
+ function oneLine(text: string): string {
53
+ return text.replace(/\s+/g, ' ').trim();
54
+ }
55
+
56
+ export const ToolCallRow: (props: ToolCallRowProps) => RenderOutput =
57
+ defineComponent<ToolCallRowProps>('ToolCallRow', (props) => {
58
+ const { call, expanded, active, onToggle, ...rest } = props;
59
+ const theme = useTheme();
60
+ const look = LOOK[call.status] ?? { tone: 'muted' as SemanticVariant, glyph: 'bulletHollow' as StatusGlyph };
61
+ const glyph = theme.glyphs[look.glyph];
62
+ const chevron = expanded ? theme.glyphs.chevronDown : theme.glyphs.chevronRight;
63
+ const failed = call.status === 'failed' || (call.exitCode !== undefined && call.exitCode !== 0);
64
+ // What it is doing beats what it was asked, for as long as it is doing
65
+ // it: a subagent's row that says "look for the bug" for a minute is a
66
+ // row that says nothing, and the host has a line for what it is up to.
67
+ const summary = oneLine(call.progress ?? call.input ?? call.intention ?? '');
68
+ // Only when there is something under it. A chevron on a row that opens on
69
+ // to nothing is a promise the row cannot keep.
70
+ const opens = Boolean(call.intention ?? call.input ?? call.output ?? call.outcome
71
+ ?? (call.files && call.files.length > 0));
72
+
73
+ // On the selection the name, the summary and the chevron take `inverted`,
74
+ // the theme's own rule for that tone; the status glyph keeps its own, since
75
+ // a check that turned white would stop saying "completed".
76
+ return (
77
+ <Column {...rest} {...(active ? { bg: 'selected' as const } : {})}>
78
+ <Row
79
+ gap={1}
80
+ {...(opens && onToggle ? { onClick: onToggle } : {})}
81
+ // The whole row lights up, not the glyph the pointer happens to be
82
+ // over: the row is the thing that opens.
83
+ style={{ hover: { bg: 'hover' } }}
84
+ >
85
+ <text content={glyph} fg={look.tone} />
86
+ <text content={call.name} bold {...(active ? { fg: 'inverted' as const } : {})} />
87
+ <text content={summary} fg={active ? 'inverted' : 'muted'} flex={1} truncate="middle" />
88
+ {call.status === 'pending-confirmation' ? <Badge label="asks" tone="warning" icon={theme.glyphs.warning} /> : null}
89
+ {failed ? <Badge label={`exit ${call.exitCode ?? 1}`} tone="danger" /> : null}
90
+ {/* Trailing, like a disclosure triangle - the row says what it is
91
+ first and how to see more of it last. */}
92
+ {opens ? <text content={chevron} fg={active ? 'inverted' : 'subtle'} /> : null}
93
+ </Row>
94
+
95
+ {expanded ? (
96
+ // Indented to the row's own text, which starts one glyph and one gap
97
+ // in - so what opened out of a row lines up under it.
98
+ <Column padding={[0, 0, 0, 2]} gap={0}>
99
+ {/* Only when it says more than the row already does. A host
100
+ whose intention is the tool's own name, or the input verbatim,
101
+ is repeating the header and the block below it. */}
102
+ {call.intention && call.intention !== call.name && call.intention !== call.input
103
+ ? <MarkdownView content={call.intention} quiet />
104
+ : null}
105
+ {call.input ? (
106
+ // On its own lines, wrapped as written. This is where the JSON
107
+ // goes: whole, and not sharing a row with the name.
108
+ <Column bg="surfaceAlt" padding={[0, 1]}>
109
+ {call.input.split('\n').map((line, i) => (
110
+ <text key={i} content={line} fg="text" wrap="word" />
111
+ ))}
112
+ </Column>
113
+ ) : null}
114
+ {call.output ? (
115
+ <Column>
116
+ {call.output.split('\n').slice(0, 12).map((line, i) => (
117
+ <text key={i} content={line} fg="muted" truncate="end" />
118
+ ))}
119
+ {call.output.split('\n').length > 12 ? (
120
+ <text content={`${theme.glyphs.ellipsis} ${call.output.split('\n').length - 12} more lines`} fg="subtle" />
121
+ ) : null}
122
+ </Column>
123
+ ) : null}
124
+ {call.files && call.files.length > 0 ? (
125
+ <Column>
126
+ {call.files.map((file) => (
127
+ <text key={file} content={`${theme.glyphs.chevronRight} ${file}`} fg="info" />
128
+ ))}
129
+ </Column>
130
+ ) : null}
131
+ {call.outcome ? <text content={call.outcome} fg="subtle" /> : null}
132
+ </Column>
133
+ ) : null}
134
+ </Column>
135
+ );
136
+ });
@@ -0,0 +1,221 @@
1
+ import type { BoxProps, RenderOutput } from '@textui/core';
2
+ import { defineComponent, useTheme } from '@textui/core';
3
+ import { Feed, Row } from '@textui/widgets';
4
+ import type { Block } from './blocks.js';
5
+ import { ChatBubble, Gutter, ReasoningBlock, StreamingText, cursorBar } from './bubble.js';
6
+ import { ToolCallRow } from './toolcall.js';
7
+
8
+ /**
9
+ * The conversation, as blocks in a feed.
10
+ *
11
+ * There is no scrolling in this file. `Feed` owns the viewport, the cursor and
12
+ * the tail it follows, because none of that is about chat: a transcript, an
13
+ * activity stream and a list of search results with snippets are the same
14
+ * problem, which is "entries that are not one line tall". What is left here is
15
+ * the only part that *is* about chat - which block draws as what.
16
+ */
17
+
18
+ export interface ChatTranscriptProps extends BoxProps {
19
+ blocks: Block[];
20
+ expanded: Record<string, boolean>;
21
+ onToggle(id: string): void;
22
+ /** Which block the cursor is on. Held by the screen, like every other state. */
23
+ cursor?: number;
24
+ onCursor?(index: number): void;
25
+ /**
26
+ * What the find box is looking for.
27
+ *
28
+ * Passed down to be coloured where it appears, not to decide what is drawn:
29
+ * every block stays where it was and the ones holding the term light up, so
30
+ * a reader keeps the conversation around a hit instead of a filtered list
31
+ * of the lines that matched.
32
+ */
33
+ match?: string;
34
+ /**
35
+ * Keep the cursor in view rather than only when it moves.
36
+ *
37
+ * For the find box, which drives the cursor: its first hit is often the
38
+ * block the cursor is already on, and a feed that only scrolls on a change
39
+ * would leave that one off screen while the box counted it.
40
+ */
41
+ pinCursor?: boolean;
42
+ /**
43
+ * What this conversation is, as the first thing in it.
44
+ *
45
+ * Inside the scrolling region rather than pinned above it: a caption outside
46
+ * costs a row of the conversation on every screen for ever, so it has to
47
+ * earn each one - which is what forces it down to a line and then down to
48
+ * less than it was for. Here it costs nothing after the first screen.
49
+ *
50
+ * It is not a block. The cursor walks the conversation and there is nothing
51
+ * to do to a caption, so it sits ahead of the indices rather than in them.
52
+ */
53
+ head?: RenderOutput;
54
+ focusId?: string;
55
+ /** Prose and reasoning as markdown (the default), or as the characters that arrived. */
56
+ markdown?: boolean;
57
+ }
58
+
59
+ export const ChatTranscript: (props: ChatTranscriptProps) => RenderOutput =
60
+ defineComponent<ChatTranscriptProps>('ChatTranscript', (props) => {
61
+ const {
62
+ blocks, expanded, onToggle, cursor, onCursor, head, markdown, match, pinCursor,
63
+ focusId = 'chat.transcript', ...rest
64
+ } = props;
65
+
66
+ // The caption is an entry the feed scrolls and the cursor does not visit,
67
+ // so every index the feed reports is one further along than the block it
68
+ // stands for. Converted here, once, rather than at each of the three
69
+ // places that would otherwise each have to remember.
70
+ const lead = head ? 1 : 0;
71
+
72
+ return (
73
+ <Feed
74
+ focusId={focusId}
75
+ // Page up from the composer means the conversation above it. There is
76
+ // nothing else on this screen those keys could be for, and taking the
77
+ // keyboard off the field to use them is what a reader is avoiding.
78
+ pageKeys="always"
79
+ {...(cursor !== undefined ? { selectedIndex: cursor + lead } : {})}
80
+ {...(pinCursor ? { pinSelection: true } : {})}
81
+ {...(onCursor ? { onSelect: (index: number) => onCursor(Math.max(0, index - lead)) } : {})}
82
+ onActivate={(index: number) => {
83
+ const block = blocks[index - lead];
84
+ if (block) onToggle(block.id);
85
+ }}
86
+ {...rest}
87
+ >
88
+ {head ?? null}
89
+ {blocks.map((block) => (
90
+ <BlockView
91
+ key={block.id}
92
+ block={block}
93
+ expanded={expanded[block.id] ?? false}
94
+ active={cursor !== undefined && blocks[cursor]?.id === block.id}
95
+ onToggle={() => onToggle(block.id)}
96
+ {...(markdown !== undefined ? { markdown } : {})}
97
+ {...(match ? { match } : {})}
98
+ />
99
+ ))}
100
+ </Feed>
101
+ );
102
+ });
103
+
104
+ const BlockView = defineComponent<{
105
+ block: Block;
106
+ expanded: boolean;
107
+ active: boolean;
108
+ onToggle(): void;
109
+ markdown?: boolean;
110
+ match?: string;
111
+ }>('ChatBlockView', ({ block, expanded, active, onToggle, markdown, match }) => {
112
+ const asMarkdown = markdown !== undefined ? { markdown } : {};
113
+ // Spread rather than passed, so a block with no search over it carries no
114
+ // extra prop and its text node is compared unchanged.
115
+ const hit = match ? { match } : {};
116
+ const theme = useTheme();
117
+
118
+ // Every block has a one-cell left column the cursor is drawn in. The blocks
119
+ // that are something said keep the rule they draw there; a block whose
120
+ // first row already carries a glyph in that column - the header's bullet,
121
+ // the user line's chevron - has that glyph as its gutter cell, and the bar
122
+ // takes its place while the cursor is on it; the rest lead with a blank
123
+ // gutter, so their text starts where the prose does.
124
+ const mark = active ? { active: true } : {};
125
+ switch (block.kind) {
126
+ case 'said':
127
+ // The blank row is the turn boundary. A uniform gap between every block
128
+ // would space a paragraph from the sentence it belongs to just as much
129
+ // as it spaces one speaker from the next.
130
+ return (
131
+ <ChatBubble speaker="user" padding={[1, 0, 0, 0]} {...mark}>
132
+ <text content={block.text} wrap="word" {...hit} />
133
+ </ChatBubble>
134
+ );
135
+ case 'header':
136
+ return (
137
+ <Row gap={1} padding={[1, 0, 0, 0]}>
138
+ <text
139
+ content={active ? cursorBar(theme) : theme.glyphs.bulletFilled}
140
+ fg={active || block.state === 'running' ? 'accent' : 'muted'}
141
+ />
142
+ <text content={block.model ?? 'agent'} bold fg="accent" />
143
+ {/* What this turn was asked for, where the host said. A thinking
144
+ level is chosen per turn and holds from that turn onwards, so
145
+ two answers from one model are two different questions. */}
146
+ {block.settings ? <text content={block.settings} fg="subtle" /> : null}
147
+ <text content={block.meta} fg="subtle" flex={1} />
148
+ {block.state === 'cancelled' ? <text content="stopped" fg="warning" /> : null}
149
+ {block.state === 'failed' ? <text content="failed" fg="danger" /> : null}
150
+ </Row>
151
+ );
152
+ case 'prose':
153
+ return (
154
+ <Row gap={1}>
155
+ <Gutter {...mark} />
156
+ <StreamingText content={block.content} streaming={block.streaming} flex={1} {...asMarkdown} {...hit} />
157
+ </Row>
158
+ );
159
+ case 'reasoning':
160
+ return (
161
+ <Row gap={1}>
162
+ <Gutter {...mark} />
163
+ <ReasoningBlock
164
+ content={block.content}
165
+ expanded={expanded}
166
+ streaming={block.streaming}
167
+ onToggle={onToggle}
168
+ flex={1}
169
+ {...mark}
170
+ {...asMarkdown}
171
+ {...hit}
172
+ />
173
+ </Row>
174
+ );
175
+ case 'notice':
176
+ return (
177
+ <Row gap={1}>
178
+ <Gutter blank {...mark} />
179
+ <text content={theme.glyphs.info} fg="info" />
180
+ <text content={block.content} fg="muted" wrap="word" flex={1} {...hit} />
181
+ </Row>
182
+ );
183
+ // Not a notice. A notice is the harness saying something in passing, and
184
+ // this is the turn stopping - so it takes the danger tone and says whether
185
+ // there is anything to carry on from.
186
+ case 'failure':
187
+ return (
188
+ <Row gap={1}>
189
+ <Gutter blank {...mark} />
190
+ <text content={theme.glyphs.cross} fg="danger" />
191
+ <text content={block.content} fg="danger" wrap="word" flex={1} {...hit} />
192
+ {block.resumable ? <text content="resumable" fg="subtle" /> : null}
193
+ </Row>
194
+ );
195
+ case 'tool':
196
+ // No rule. A tool call is something the agent *did*, not something it
197
+ // said, so its status glyph sits where the header's bullet is - rather
198
+ // than inside the rule as though it were a paragraph of the answer.
199
+ return (
200
+ <Row gap={1}>
201
+ <Gutter blank {...mark} />
202
+ <ToolCallRow call={block.call} expanded={expanded} active={active} onToggle={onToggle} flex={1} />
203
+ </Row>
204
+ );
205
+ case 'queued':
206
+ // Not sent. It reads as a message unless it says so, and "I typed that
207
+ // and nothing happened" is the complaint that follows.
208
+ return (
209
+ <Row gap={1}>
210
+ <Gutter blank {...mark} />
211
+ <text content={theme.glyphs.chevronRight} fg={active ? 'accent' : 'subtle'} />
212
+ <text content={block.text} fg="subtle" italic wrap="word" flex={1} {...hit} />
213
+ {/* What the cursor being here is *for*. A queue you cannot take
214
+ anything out of is a list of messages you have to let happen. */}
215
+ <text content={active ? 'enter drops it' : 'queued'} fg="warning" />
216
+ </Row>
217
+ );
218
+ default:
219
+ return null;
220
+ }
221
+ });