@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.
- package/LICENSE +21 -0
- package/README.md +76 -0
- package/dist/blocks.d.ts +75 -0
- package/dist/blocks.d.ts.map +1 -0
- package/dist/blocks.js +50 -0
- package/dist/bubble.d.ts +122 -0
- package/dist/bubble.d.ts.map +1 -0
- package/dist/bubble.js +108 -0
- package/dist/composer.d.ts +67 -0
- package/dist/composer.d.ts.map +1 -0
- package/dist/composer.js +194 -0
- package/dist/controls.d.ts +66 -0
- package/dist/controls.d.ts.map +1 -0
- package/dist/controls.js +77 -0
- package/dist/details.d.ts +66 -0
- package/dist/details.d.ts.map +1 -0
- package/dist/details.js +65 -0
- package/dist/diff.d.ts +45 -0
- package/dist/diff.d.ts.map +1 -0
- package/dist/diff.js +111 -0
- package/dist/filediff.d.ts +30 -0
- package/dist/filediff.d.ts.map +1 -0
- package/dist/filediff.js +24 -0
- package/dist/hitl.d.ts +85 -0
- package/dist/hitl.d.ts.map +1 -0
- package/dist/hitl.js +134 -0
- package/dist/icons.d.ts +14 -0
- package/dist/icons.d.ts.map +1 -0
- package/dist/icons.js +71 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/measure.d.ts +14 -0
- package/dist/measure.d.ts.map +1 -0
- package/dist/measure.js +19 -0
- package/dist/picker.d.ts +43 -0
- package/dist/picker.d.ts.map +1 -0
- package/dist/picker.js +79 -0
- package/dist/sessionhead.d.ts +42 -0
- package/dist/sessionhead.d.ts.map +1 -0
- package/dist/sessionhead.js +58 -0
- package/dist/sessions.d.ts +35 -0
- package/dist/sessions.d.ts.map +1 -0
- package/dist/sessions.js +58 -0
- package/dist/toolcall.d.ts +28 -0
- package/dist/toolcall.d.ts.map +1 -0
- package/dist/toolcall.js +54 -0
- package/dist/transcript.d.ts +53 -0
- package/dist/transcript.d.ts.map +1 -0
- package/dist/transcript.js +67 -0
- package/dist/types.d.ts +176 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +11 -0
- package/package.json +58 -0
- package/src/blocks.ts +73 -0
- package/src/bubble.tsx +266 -0
- package/src/composer.tsx +302 -0
- package/src/controls.tsx +222 -0
- package/src/details.tsx +162 -0
- package/src/diff.ts +132 -0
- package/src/filediff.tsx +118 -0
- package/src/hitl.tsx +392 -0
- package/src/icons.ts +109 -0
- package/src/index.ts +16 -0
- package/src/measure.ts +21 -0
- package/src/picker.ts +105 -0
- package/src/sessionhead.tsx +105 -0
- package/src/sessions.tsx +146 -0
- package/src/toolcall.tsx +136 -0
- package/src/transcript.tsx +221 -0
- package/src/types.ts +171 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Softov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# @textui/chat
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@textui/chat) [](https://github.com/softov/textui/blob/main/LICENSE)
|
|
4
|
+
|
|
5
|
+
The components of an agent chat: the transcript, what is said in it, what the agent did, the composer, the block that waits on a person, the sessions and the diffs.
|
|
6
|
+
Built on [`@textui/core`](https://www.npmjs.com/package/@textui/core) and [`@textui/widgets`](https://www.npmjs.com/package/@textui/widgets), and on nothing that talks to a host.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install @textui/chat
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
import { useState } from '@textui/core';
|
|
14
|
+
import { Column } from '@textui/widgets';
|
|
15
|
+
import { ChatComposer, ChatTranscript } from '@textui/chat';
|
|
16
|
+
import type { Block } from '@textui/chat';
|
|
17
|
+
|
|
18
|
+
const blocks: Block[] = [
|
|
19
|
+
{ kind: 'said', id: 's1', turnId: 't1', text: 'Rename the package.' },
|
|
20
|
+
{ kind: 'header', id: 'h2', turnId: 't2', model: 'claude', meta: '4.1s', state: 'complete' },
|
|
21
|
+
{ kind: 'prose', id: 'p2', turnId: 't2', content: 'Done. Three files changed.', streaming: false },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
function Chat() {
|
|
25
|
+
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
|
26
|
+
const [draft, setDraft] = useState('');
|
|
27
|
+
return (
|
|
28
|
+
<Column flex={1}>
|
|
29
|
+
<ChatTranscript
|
|
30
|
+
blocks={blocks}
|
|
31
|
+
expanded={expanded}
|
|
32
|
+
onToggle={(id) => setExpanded({ ...expanded, [id]: !expanded[id] })}
|
|
33
|
+
flex={1}
|
|
34
|
+
/>
|
|
35
|
+
<ChatComposer value={draft} onChange={setDraft} onSubmit={() => setDraft('')} />
|
|
36
|
+
</Column>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Its own shapes
|
|
42
|
+
|
|
43
|
+
Every component takes a shape of this package's own - a `ChatToolCall`, a `ChatSession` whose status is already a word, a tone and a glyph, a `ChatPendingInput` with its questions - and nothing else.
|
|
44
|
+
There is no protocol here.
|
|
45
|
+
A client that speaks the Agent Host Protocol, or anything else, maps its records onto these and the components never learn where a session came from.
|
|
46
|
+
The field names follow AHP's where one exists, so that mapping is a pick rather than a rename.
|
|
47
|
+
|
|
48
|
+
Nothing is read from the store.
|
|
49
|
+
The markdown switch is `StreamingText`'s `markdown` prop, the draft answers of a question are `ChatHitl`'s `draft` and `onDraft`, the row under the composer is `ChatInputStatus`'s `status`, and where the composer and the waiting block sit is reported through `onMeasure`.
|
|
50
|
+
Whoever mounts them holds all of that, wherever it likes.
|
|
51
|
+
|
|
52
|
+
## What is here
|
|
53
|
+
|
|
54
|
+
| | |
|
|
55
|
+
|---|---|
|
|
56
|
+
| `ChatTranscript`, `Block` | The conversation as blocks in a `Feed`: said, header, prose, reasoning, notice, failure, tool, queued. `match` colours a found term through every block and `pinCursor` keeps the cursor in view while a search moves it |
|
|
57
|
+
| `blockText`, `findBlocks` | The words of a block, and the indexes of the blocks a query is found in, which a find walks the cursor through |
|
|
58
|
+
| `ChatBubble`, `Gutter`, `StreamingText`, `ReasoningBlock` | One thing said, and the two ways it is still being said |
|
|
59
|
+
| `ToolCallRow` | What the agent did, one row per call, opening onto its input and output. A `ChatToolCall` names its tool (`toolName`, where the id differs from the display name) and, while it runs, says what it is doing (`progress`) on the row |
|
|
60
|
+
| `ChatComposer`, `ComposerBar`, `ComposerOption` | The field, the slash and path menus sized to the terminal, and the control rows of chips. A `ChatCommand.hint` is drawn under the menu for the command under the cursor; a `ComposerOption.where` chip puts where the session runs on a second row, and escape on any chip is `onLeave`, back to the field |
|
|
61
|
+
| `ChatHitl`, `ConfirmRequest`, `QuestionForm`, `ChatInputStatus` | The block that means the agent is stopped, waiting on a person |
|
|
62
|
+
| `SessionList`, `ConnectionBadge` | The catalogue, and which host it came from. A `ChatSession` row says its project, branch and `pullRequest` (a label the host formats, `#412 merged`) |
|
|
63
|
+
| `ChatSessionHead`, `SessionDetails` | The head over a conversation, and the pane where long values are read whole |
|
|
64
|
+
| `FileDiff`, `diffLines`, `toLines` | One file, both sides lined up |
|
|
65
|
+
| `openPicker` | The command palette, anchored above the chip that asked; a command with nothing to ask is run instead |
|
|
66
|
+
| `settingIcon`, `valueIcon` | The marks beside a setting and beside its values, down to ascii |
|
|
67
|
+
|
|
68
|
+
## What is not here
|
|
69
|
+
|
|
70
|
+
A host connection, a controller, terminals, changesets, automations, the fake host.
|
|
71
|
+
Those are an application's, and the application these came from is [softov/ahpc](https://github.com/softov/ahpc).
|
|
72
|
+
|
|
73
|
+
## Runtime
|
|
74
|
+
|
|
75
|
+
No dependencies beyond `@textui/core` and `@textui/widgets`, and no `node:` imports.
|
|
76
|
+
Node 22+ and Bun.
|
package/dist/blocks.d.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { ChatToolCall } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* What the transcript draws, one entry per row group.
|
|
4
|
+
*
|
|
5
|
+
* A turn is not a block: an agent turn is a header, some prose, a reasoning
|
|
6
|
+
* fold and a row per tool call, and each of those scrolls, folds and selects
|
|
7
|
+
* on its own. The client that owns the turns turns them into these.
|
|
8
|
+
*/
|
|
9
|
+
export type Block = {
|
|
10
|
+
kind: 'said';
|
|
11
|
+
id: string;
|
|
12
|
+
turnId: string;
|
|
13
|
+
text: string;
|
|
14
|
+
} | {
|
|
15
|
+
kind: 'header';
|
|
16
|
+
id: string;
|
|
17
|
+
turnId: string;
|
|
18
|
+
model?: string;
|
|
19
|
+
settings?: string;
|
|
20
|
+
meta: string;
|
|
21
|
+
state: 'running' | 'complete' | 'cancelled' | 'failed';
|
|
22
|
+
} | {
|
|
23
|
+
kind: 'prose';
|
|
24
|
+
id: string;
|
|
25
|
+
turnId: string;
|
|
26
|
+
content: string;
|
|
27
|
+
streaming: boolean;
|
|
28
|
+
} | {
|
|
29
|
+
kind: 'reasoning';
|
|
30
|
+
id: string;
|
|
31
|
+
turnId: string;
|
|
32
|
+
content: string;
|
|
33
|
+
streaming: boolean;
|
|
34
|
+
} | {
|
|
35
|
+
kind: 'notice';
|
|
36
|
+
id: string;
|
|
37
|
+
turnId: string;
|
|
38
|
+
content: string;
|
|
39
|
+
} | {
|
|
40
|
+
kind: 'failure';
|
|
41
|
+
id: string;
|
|
42
|
+
turnId: string;
|
|
43
|
+
content: string;
|
|
44
|
+
resumable: boolean;
|
|
45
|
+
} | {
|
|
46
|
+
kind: 'tool';
|
|
47
|
+
id: string;
|
|
48
|
+
turnId: string;
|
|
49
|
+
call: ChatToolCall;
|
|
50
|
+
} | {
|
|
51
|
+
kind: 'queued';
|
|
52
|
+
id: string;
|
|
53
|
+
messageId: string;
|
|
54
|
+
text: string;
|
|
55
|
+
};
|
|
56
|
+
/** The blocks a cursor can land on: the ones that open, or can be withdrawn. */
|
|
57
|
+
export declare function selectable(block: Block): boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Everything in a block that a person could be looking for.
|
|
60
|
+
*
|
|
61
|
+
* A tool call is its name, its command and what came back, because all three
|
|
62
|
+
* are things somebody searches a transcript for - the file a command touched
|
|
63
|
+
* is in the output and nowhere else. A header is the model and the settings,
|
|
64
|
+
* which is how "where did I switch to opus" is answered.
|
|
65
|
+
*/
|
|
66
|
+
export declare function blockText(block: Block): string;
|
|
67
|
+
/**
|
|
68
|
+
* Where in the conversation a query appears, as block indices in order.
|
|
69
|
+
*
|
|
70
|
+
* Case-insensitive, and a blank query matches nothing rather than everything:
|
|
71
|
+
* a find with no term is a find that has not been typed yet, and lighting up
|
|
72
|
+
* every block for it is the opposite of what the box is for.
|
|
73
|
+
*/
|
|
74
|
+
export declare function findBlocks(blocks: Block[], query: string): number[];
|
|
75
|
+
//# sourceMappingURL=blocks.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"blocks.d.ts","sourceRoot":"","sources":["../src/blocks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAE/C;;;;;;GAMG;AACH,MAAM,MAAM,KAAK,GACb;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAC1D;IACA,IAAI,EAAE,QAAQ,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAC3D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,SAAS,GAAG,UAAU,GAAG,WAAW,GAAG,QAAQ,CAAC;CACtE,GACC;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAE,GAClF;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAE,GACtF;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAC/D;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAE,GACpF;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,YAAY,CAAA;CAAE,GAChE;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC;AAEpE,gFAAgF;AAChF,wBAAgB,UAAU,CAAC,KAAK,EAAE,KAAK,GAAG,OAAO,CAEhD;AAED;;;;;;;GAOG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,KAAK,GAAG,MAAM,CAmB9C;AAED;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,KAAK,EAAE,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAQnE"}
|
package/dist/blocks.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** The blocks a cursor can land on: the ones that open, or can be withdrawn. */
|
|
2
|
+
export function selectable(block) {
|
|
3
|
+
return block.kind === 'tool' || block.kind === 'reasoning' || block.kind === 'queued';
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Everything in a block that a person could be looking for.
|
|
7
|
+
*
|
|
8
|
+
* A tool call is its name, its command and what came back, because all three
|
|
9
|
+
* are things somebody searches a transcript for - the file a command touched
|
|
10
|
+
* is in the output and nowhere else. A header is the model and the settings,
|
|
11
|
+
* which is how "where did I switch to opus" is answered.
|
|
12
|
+
*/
|
|
13
|
+
export function blockText(block) {
|
|
14
|
+
switch (block.kind) {
|
|
15
|
+
case 'said':
|
|
16
|
+
case 'queued':
|
|
17
|
+
return block.text;
|
|
18
|
+
case 'prose':
|
|
19
|
+
case 'reasoning':
|
|
20
|
+
case 'notice':
|
|
21
|
+
case 'failure':
|
|
22
|
+
return block.content;
|
|
23
|
+
case 'header':
|
|
24
|
+
return [block.model, block.settings, block.meta].filter(Boolean).join(' ');
|
|
25
|
+
case 'tool':
|
|
26
|
+
return [
|
|
27
|
+
block.call.name, block.call.toolName, block.call.input,
|
|
28
|
+
block.call.intention, block.call.outcome, block.call.output,
|
|
29
|
+
...(block.call.files ?? []),
|
|
30
|
+
].filter(Boolean).join(' ');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Where in the conversation a query appears, as block indices in order.
|
|
35
|
+
*
|
|
36
|
+
* Case-insensitive, and a blank query matches nothing rather than everything:
|
|
37
|
+
* a find with no term is a find that has not been typed yet, and lighting up
|
|
38
|
+
* every block for it is the opposite of what the box is for.
|
|
39
|
+
*/
|
|
40
|
+
export function findBlocks(blocks, query) {
|
|
41
|
+
const needle = query.trim().toLowerCase();
|
|
42
|
+
if (needle === '')
|
|
43
|
+
return [];
|
|
44
|
+
const found = [];
|
|
45
|
+
blocks.forEach((block, index) => {
|
|
46
|
+
if (blockText(block).toLowerCase().includes(needle))
|
|
47
|
+
found.push(index);
|
|
48
|
+
});
|
|
49
|
+
return found;
|
|
50
|
+
}
|
package/dist/bubble.d.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { BoxProps, RenderOutput, ResolvedTheme, SemanticVariant } from '@textui/core';
|
|
2
|
+
/**
|
|
3
|
+
* One thing said, and the two ways it is still being said.
|
|
4
|
+
*
|
|
5
|
+
* A bubble in a terminal is not a rounded rectangle. It is a gutter that says
|
|
6
|
+
* who is speaking and a body that owns the rest of the width - because the
|
|
7
|
+
* width is 80 cells and half of it spent on alignment is half the conversation
|
|
8
|
+
* gone.
|
|
9
|
+
*/
|
|
10
|
+
export type Speaker = 'user' | 'agent' | 'system';
|
|
11
|
+
export interface GutterProps extends BoxProps {
|
|
12
|
+
/**
|
|
13
|
+
* The transcript's cursor is on this block.
|
|
14
|
+
*
|
|
15
|
+
* A heavy bar in the accent colour, down the whole block. A different glyph
|
|
16
|
+
* rather than only a different colour, so it survives a session without
|
|
17
|
+
* colour - which a background does not.
|
|
18
|
+
*/
|
|
19
|
+
active?: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* No rule at rest. For the blocks that are not something said - a tool
|
|
22
|
+
* row, a turn header - and still need the column, so the bar has a place
|
|
23
|
+
* to be drawn and their text starts where the prose does.
|
|
24
|
+
*/
|
|
25
|
+
blank?: boolean;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The glyph the transcript's cursor is drawn with, down the left of the block
|
|
29
|
+
* it is on.
|
|
30
|
+
*
|
|
31
|
+
* The `bold` border's left rule: the heavy line of the same family as the
|
|
32
|
+
* rule at rest, from the theme, so an ascii terminal gets the glyph it can
|
|
33
|
+
* draw rather than a question mark. One place, because the gutter draws it
|
|
34
|
+
* and so does whatever glyph already holds a block's left column - the
|
|
35
|
+
* header's bullet, the user line's chevron - while the cursor is there.
|
|
36
|
+
*/
|
|
37
|
+
export declare function cursorBar(theme: ResolvedTheme): string;
|
|
38
|
+
/**
|
|
39
|
+
* The rule down the left of everything one speaker said.
|
|
40
|
+
*
|
|
41
|
+
* A box that fills rather than a `text`: the text is one row tall and the
|
|
42
|
+
* paragraph beside it is nine, so a rule written as a character marks the
|
|
43
|
+
* first line of a wrapped answer and abandons the rest of it.
|
|
44
|
+
*/
|
|
45
|
+
export declare const Gutter: (props: GutterProps) => RenderOutput;
|
|
46
|
+
export interface ChatBubbleProps extends BoxProps {
|
|
47
|
+
speaker: Speaker;
|
|
48
|
+
/** The name, when the speaker is not enough: a model, a person, a host. */
|
|
49
|
+
author?: string;
|
|
50
|
+
/** Right of the author line: a time, a duration, a model. */
|
|
51
|
+
meta?: string;
|
|
52
|
+
tone?: SemanticVariant;
|
|
53
|
+
/**
|
|
54
|
+
* The transcript's cursor is on this block: the bar runs down its left
|
|
55
|
+
* column, in place of the speaker's glyph on the first row and in the
|
|
56
|
+
* gutter under it.
|
|
57
|
+
*/
|
|
58
|
+
active?: boolean;
|
|
59
|
+
children?: unknown;
|
|
60
|
+
}
|
|
61
|
+
export declare const ChatBubble: (props: ChatBubbleProps) => RenderOutput;
|
|
62
|
+
export interface StreamingTextProps extends BoxProps {
|
|
63
|
+
content: string;
|
|
64
|
+
/** Still arriving. Draws a caret and keeps it on the last word. */
|
|
65
|
+
streaming?: boolean;
|
|
66
|
+
quiet?: boolean;
|
|
67
|
+
maxLines?: number;
|
|
68
|
+
/**
|
|
69
|
+
* Draw it as markdown, or as the characters that arrived.
|
|
70
|
+
*
|
|
71
|
+
* Markdown unless told otherwise. An application with a switch for this
|
|
72
|
+
* passes it here; nothing is read from anywhere else.
|
|
73
|
+
*/
|
|
74
|
+
markdown?: boolean;
|
|
75
|
+
/** Text to pick out, for the find box. Coloured wherever it appears. */
|
|
76
|
+
match?: string;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Text that is still being said.
|
|
80
|
+
*
|
|
81
|
+
* The caret is part of the content rather than a node beside it, because a
|
|
82
|
+
* caret placed after the block sits under the last line instead of at the end
|
|
83
|
+
* of it - and the end of the sentence is the only place it means anything.
|
|
84
|
+
*
|
|
85
|
+
* It blinks on the theme's own ticker, so animation being off (a pipe, a test,
|
|
86
|
+
* a `--static` capture) leaves a steady caret rather than a missing one.
|
|
87
|
+
*/
|
|
88
|
+
export declare const StreamingText: (props: StreamingTextProps) => RenderOutput;
|
|
89
|
+
export interface ReasoningBlockProps extends BoxProps {
|
|
90
|
+
content: string;
|
|
91
|
+
expanded?: boolean;
|
|
92
|
+
streaming?: boolean;
|
|
93
|
+
/** Shown collapsed: "thought for 12s". */
|
|
94
|
+
summary?: string;
|
|
95
|
+
/** Passed to the text once opened. */
|
|
96
|
+
markdown?: boolean;
|
|
97
|
+
/** Text to pick out, for the find box. Handed to the text inside it. */
|
|
98
|
+
match?: string;
|
|
99
|
+
/** Clicking the summary row opens it, and closes it again. */
|
|
100
|
+
onToggle?(): void;
|
|
101
|
+
/**
|
|
102
|
+
* The transcript's cursor is on this block.
|
|
103
|
+
*
|
|
104
|
+
* The block takes the `selected` background and its words turn `inverted`,
|
|
105
|
+
* the theme's own rule for that tone: a quiet grey on the selection blue is
|
|
106
|
+
* a row you can find and cannot read.
|
|
107
|
+
*/
|
|
108
|
+
active?: boolean;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* What the agent was thinking, folded away.
|
|
112
|
+
*
|
|
113
|
+
* Reasoning is prose the host sends like any other, and it is not what the
|
|
114
|
+
* reader came for - so it is one row until it is asked for. Dropping it
|
|
115
|
+
* instead loses the only account of *why* a turn did what it did.
|
|
116
|
+
*
|
|
117
|
+
* Open, it ends with a rule. The thought is set in the same quiet tone as the
|
|
118
|
+
* answer's own gutter, and without a line under it the reader cannot tell
|
|
119
|
+
* where the thinking stopped and the answer began.
|
|
120
|
+
*/
|
|
121
|
+
export declare const ReasoningBlock: (props: ReasoningBlockProps) => RenderOutput;
|
|
122
|
+
//# sourceMappingURL=bubble.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bubble.d.ts","sourceRoot":"","sources":["../src/bubble.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,YAAY,EAAE,aAAa,EAAE,eAAe,EAAc,MAAM,cAAc,CAAC;AAIvG;;;;;;;GAOG;AAEH,MAAM,MAAM,OAAO,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,CAAC;AAElD,MAAM,WAAW,WAAY,SAAQ,QAAQ;IAC3C;;;;;;OAMG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;OAIG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED;;;;;;;;;GASG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,aAAa,GAAG,MAAM,CAEtD;AAED;;;;;;GAMG;AACH,eAAO,MAAM,MAAM,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,YAO3C,CAAC;AAEH,MAAM,WAAW,eAAgB,SAAQ,QAAQ;IAC/C,OAAO,EAAE,OAAO,CAAC;IACjB,2EAA2E;IAC3E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,6DAA6D;IAC7D,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,eAAe,CAAC;IACvB;;;;OAIG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAQD,eAAO,MAAM,UAAU,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,YA8BjD,CAAC;AAEL,MAAM,WAAW,kBAAmB,SAAQ,QAAQ;IAClD,OAAO,EAAE,MAAM,CAAC;IAChB,mEAAmE;IACnE,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,wEAAwE;IACxE,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,aAAa,EAAE,CAAC,KAAK,EAAE,kBAAkB,KAAK,YAyCvD,CAAC;AAEL,MAAM,WAAW,mBAAoB,SAAQ,QAAQ;IACnD,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,0CAA0C;IAC1C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,sCAAsC;IACtC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,wEAAwE;IACxE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,8DAA8D;IAC9D,QAAQ,CAAC,IAAI,IAAI,CAAC;IAClB;;;;;;OAMG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,cAAc,EAAE,CAAC,KAAK,EAAE,mBAAmB,KAAK,YA8CzD,CAAC"}
|
package/dist/bubble.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "@textui/core/jsx-runtime";
|
|
2
|
+
import { defineComponent, useFrame, useTheme } from '@textui/core';
|
|
3
|
+
import { Column, Divider, MarkdownView, Row } from '@textui/widgets';
|
|
4
|
+
/**
|
|
5
|
+
* The glyph the transcript's cursor is drawn with, down the left of the block
|
|
6
|
+
* it is on.
|
|
7
|
+
*
|
|
8
|
+
* The `bold` border's left rule: the heavy line of the same family as the
|
|
9
|
+
* rule at rest, from the theme, so an ascii terminal gets the glyph it can
|
|
10
|
+
* draw rather than a question mark. One place, because the gutter draws it
|
|
11
|
+
* and so does whatever glyph already holds a block's left column - the
|
|
12
|
+
* header's bullet, the user line's chevron - while the cursor is there.
|
|
13
|
+
*/
|
|
14
|
+
export function cursorBar(theme) {
|
|
15
|
+
return theme.borderChars('bold').left;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The rule down the left of everything one speaker said.
|
|
19
|
+
*
|
|
20
|
+
* A box that fills rather than a `text`: the text is one row tall and the
|
|
21
|
+
* paragraph beside it is nine, so a rule written as a character marks the
|
|
22
|
+
* first line of a wrapped answer and abandons the rest of it.
|
|
23
|
+
*/
|
|
24
|
+
export const Gutter = defineComponent('ChatGutter', (props) => {
|
|
25
|
+
const { active, blank, ...rest } = props;
|
|
26
|
+
const theme = useTheme();
|
|
27
|
+
const fill = active ? cursorBar(theme) : blank ? ' ' : theme.borderChars().left;
|
|
28
|
+
// `alignSelf` because `Row` centres its children: a one-cell box in a
|
|
29
|
+
// centred row is one cell tall, wherever the rule was meant to reach.
|
|
30
|
+
return _jsx("box", { width: 1, alignSelf: "stretch", fill: fill, fg: active ? 'accent' : 'borderSubtle', ...rest });
|
|
31
|
+
});
|
|
32
|
+
const SPEAKER = {
|
|
33
|
+
user: { fg: 'primary', label: 'you' },
|
|
34
|
+
agent: { fg: 'accent', label: 'agent' },
|
|
35
|
+
system: { fg: 'muted', label: 'system' },
|
|
36
|
+
};
|
|
37
|
+
export const ChatBubble = defineComponent('ChatBubble', (props) => {
|
|
38
|
+
const { speaker, author, meta, tone, active, children, ...rest } = props;
|
|
39
|
+
const theme = useTheme();
|
|
40
|
+
const look = SPEAKER[speaker];
|
|
41
|
+
const glyph = speaker === 'user' ? theme.glyphs.chevronRight
|
|
42
|
+
: speaker === 'agent' ? theme.glyphs.bulletFilled
|
|
43
|
+
: theme.glyphs.info;
|
|
44
|
+
// The gutter is one column of glyph and one of rule. It is what makes a
|
|
45
|
+
// wrapped paragraph read as one person talking rather than as the page
|
|
46
|
+
// starting again, and it survives losing colour - which a tinted
|
|
47
|
+
// background does not. The cursor is drawn in it for the same reason,
|
|
48
|
+
// rather than as a background over what was said.
|
|
49
|
+
return (_jsxs(Column, { ...rest, children: [_jsxs(Row, { gap: 1, children: [_jsx("text", { content: active ? cursorBar(theme) : glyph, fg: active ? 'accent' : tone ?? look.fg }), _jsx("text", { content: author ?? look.label, bold: true, fg: tone ?? look.fg }), meta ? _jsx("text", { content: meta, fg: "subtle", flex: 1, truncate: "end" }) : _jsx("text", { content: "", flex: 1 })] }), _jsxs(Row, { gap: 1, flex: 1, children: [_jsx(Gutter, { ...(active ? { active: true } : {}) }), _jsx(Column, { flex: 1, gap: 1, children: children })] })] }));
|
|
50
|
+
});
|
|
51
|
+
/**
|
|
52
|
+
* Text that is still being said.
|
|
53
|
+
*
|
|
54
|
+
* The caret is part of the content rather than a node beside it, because a
|
|
55
|
+
* caret placed after the block sits under the last line instead of at the end
|
|
56
|
+
* of it - and the end of the sentence is the only place it means anything.
|
|
57
|
+
*
|
|
58
|
+
* It blinks on the theme's own ticker, so animation being off (a pipe, a test,
|
|
59
|
+
* a `--static` capture) leaves a steady caret rather than a missing one.
|
|
60
|
+
*/
|
|
61
|
+
export const StreamingText = defineComponent('StreamingText', (props) => {
|
|
62
|
+
const { content, streaming, quiet, maxLines, markdown, match, ...rest } = props;
|
|
63
|
+
const theme = useTheme();
|
|
64
|
+
// Only while something is arriving. A ticker marks its component dirty
|
|
65
|
+
// whether or not the frame it produces differs, so an unconditional one
|
|
66
|
+
// here meant every settled paragraph in the transcript asked the
|
|
67
|
+
// application to redraw twice a second, for ever - a conversation that
|
|
68
|
+
// got heavier to sit in the longer it got.
|
|
69
|
+
const frame = useFrame(2, { enabled: streaming === true });
|
|
70
|
+
const caret = streaming && frame % 2 === 0 ? theme.glyphs.caret : '';
|
|
71
|
+
const rendered = markdown ?? true;
|
|
72
|
+
const shown = streaming ? `${content}${caret}` : content;
|
|
73
|
+
// Raw is a `text`, not a `MarkdownView` that was told not to parse: the
|
|
74
|
+
// point of turning it off is to see the characters that arrived, and
|
|
75
|
+
// anything that lays the document out has already decided some of them
|
|
76
|
+
// were structure. `wrap` rather than truncate, because the lines being
|
|
77
|
+
// read are the long ones - a fenced block and a table are exactly what is
|
|
78
|
+
// wider than the pane.
|
|
79
|
+
if (!rendered) {
|
|
80
|
+
return (_jsx("text", { content: shown, wrap: "word", ...(quiet ? { fg: 'muted' } : {}), ...(match ? { match } : {}), ...rest }));
|
|
81
|
+
}
|
|
82
|
+
return (_jsx(MarkdownView, { content: shown, ...(quiet ? { quiet: true } : {}), ...(maxLines !== undefined ? { maxLines } : {}), ...(match ? { match } : {}), ...rest }));
|
|
83
|
+
});
|
|
84
|
+
/**
|
|
85
|
+
* What the agent was thinking, folded away.
|
|
86
|
+
*
|
|
87
|
+
* Reasoning is prose the host sends like any other, and it is not what the
|
|
88
|
+
* reader came for - so it is one row until it is asked for. Dropping it
|
|
89
|
+
* instead loses the only account of *why* a turn did what it did.
|
|
90
|
+
*
|
|
91
|
+
* Open, it ends with a rule. The thought is set in the same quiet tone as the
|
|
92
|
+
* answer's own gutter, and without a line under it the reader cannot tell
|
|
93
|
+
* where the thinking stopped and the answer began.
|
|
94
|
+
*/
|
|
95
|
+
export const ReasoningBlock = defineComponent('ReasoningBlock', (props) => {
|
|
96
|
+
const { content, expanded, streaming, summary, markdown, match, onToggle, active, ...rest } = props;
|
|
97
|
+
const theme = useTheme();
|
|
98
|
+
const chevron = expanded ? theme.glyphs.chevronDown : theme.glyphs.chevronRight;
|
|
99
|
+
const words = content.trim().split(/\s+/).filter(Boolean).length;
|
|
100
|
+
const fg = active ? 'inverted' : 'subtle';
|
|
101
|
+
return (_jsxs(Column, { ...rest, ...(active ? { bg: 'selected' } : {}), children: [_jsxs(Row, { gap: 1, ...(onToggle ? { onClick: onToggle } : {}),
|
|
102
|
+
// The whole row lights up, as a tool row does: the row is the thing
|
|
103
|
+
// that opens.
|
|
104
|
+
style: { hover: { bg: 'hover' } }, children: [_jsx("text", { content: chevron, fg: fg }), _jsx("text", { content: summary ?? (streaming ? 'thinking' : `thought, ${words} words`), fg: fg, italic: true })] }), expanded ? (_jsxs(Row, { gap: 1, children: [_jsx("text", { content: " " }), _jsx(StreamingText, { content: content, flex: 1, ...(active ? { fg: 'inverted' } : { quiet: true }), ...(streaming ? { streaming: true } : {}), ...(markdown !== undefined ? { markdown } : {}), ...(match ? { match } : {}) })] })) : null, expanded ? (
|
|
105
|
+
// Under the text, not the chevron: the same one-cell lead the text
|
|
106
|
+
// has, so the rule closes what it opened.
|
|
107
|
+
_jsxs(Row, { gap: 1, children: [_jsx("text", { content: " " }), _jsx(Divider, { flex: 1 })] })) : null] }));
|
|
108
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { BoxProps, Rect, RenderOutput } from '@textui/core';
|
|
2
|
+
import type { ChatCommand, ChatCompletion } from './types.js';
|
|
3
|
+
import type { ComposerOption } from './controls.js';
|
|
4
|
+
/**
|
|
5
|
+
* What you type, and one line saying what it will be sent as.
|
|
6
|
+
*
|
|
7
|
+
* The field itself is `TextArea` from the catalog - growing, scrolling and
|
|
8
|
+
* giving back the keys it does not want is not a chat problem. What is here is
|
|
9
|
+
* the rest of a composer: what enter means while a turn is running, the slash
|
|
10
|
+
* menu over what has already been typed, and the control row.
|
|
11
|
+
*
|
|
12
|
+
* The row used to be four ghost buttons naming their own keys - `send enter`,
|
|
13
|
+
* `newline alt+enter`, `stop ctrl+c`, `commands ctrl+p` - which spent the one
|
|
14
|
+
* line under the field on a keyboard legend. The keys belong in the footer,
|
|
15
|
+
* which already lists them and changes with where the focus is. The line under
|
|
16
|
+
* the field is worth more as *what is about to happen*: which harness, which
|
|
17
|
+
* model, what it may do without asking, where it runs.
|
|
18
|
+
*/
|
|
19
|
+
export interface ChatComposerProps extends BoxProps {
|
|
20
|
+
value: string;
|
|
21
|
+
onChange(value: string): void;
|
|
22
|
+
onSubmit(value: string): void;
|
|
23
|
+
onCancel?(): void;
|
|
24
|
+
onHistory?(direction: -1 | 1): void;
|
|
25
|
+
/** Left off the front of the field: out of the composer entirely. */
|
|
26
|
+
onLeave?(): void;
|
|
27
|
+
/** A turn is running: enter queues rather than sends, and stop is offered. */
|
|
28
|
+
running?: boolean;
|
|
29
|
+
queued?: number;
|
|
30
|
+
/** The control row. Each is a value, and each may open a picker. */
|
|
31
|
+
options?: ComposerOption[];
|
|
32
|
+
onOption?(option: ComposerOption, anchorId: string): void;
|
|
33
|
+
placeholder?: string;
|
|
34
|
+
/** Offered when the draft starts with a slash. */
|
|
35
|
+
commands?: ChatCommand[];
|
|
36
|
+
/**
|
|
37
|
+
* One of `commands` was chosen from the slash menu.
|
|
38
|
+
*
|
|
39
|
+
* The whole command rather than its id, because the two kinds go different
|
|
40
|
+
* places and only the command knows which it is. A `client` command is
|
|
41
|
+
* *ours*: it opens a screen, changes a setting or picks a theme, and none of
|
|
42
|
+
* that is a message - sending it down the session channel would put
|
|
43
|
+
* "/theme" in the transcript and ask the agent to make sense of it. A
|
|
44
|
+
* `session` command is a skill the host contributed, and the only way to
|
|
45
|
+
* invoke one is to send its name as the message.
|
|
46
|
+
*
|
|
47
|
+
* A slash the menu does not match is left alone and sent, which is how a
|
|
48
|
+
* command the host offers but did not list still reaches it.
|
|
49
|
+
*/
|
|
50
|
+
onCommand?(command: ChatCommand): void;
|
|
51
|
+
/**
|
|
52
|
+
* What the host offers to complete the word the caret is in.
|
|
53
|
+
*
|
|
54
|
+
* Fetched rather than filtered: a path is a path on the *host's*
|
|
55
|
+
* filesystem, so which of them match what has been typed is a question only
|
|
56
|
+
* it can answer, and the answer changes with every keystroke.
|
|
57
|
+
*/
|
|
58
|
+
paths?: ChatCompletion[];
|
|
59
|
+
/** One of `paths` was chosen. The range it replaces is on the completion. */
|
|
60
|
+
onPath?(path: ChatCompletion): void;
|
|
61
|
+
autoFocus?: boolean;
|
|
62
|
+
focusId?: string;
|
|
63
|
+
/** Where the composer is on screen whenever that changes, and `null` once it is gone. */
|
|
64
|
+
onMeasure?(rect: Rect | null): void;
|
|
65
|
+
}
|
|
66
|
+
export declare const ChatComposer: (props: ChatComposerProps) => RenderOutput;
|
|
67
|
+
//# sourceMappingURL=composer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"composer.d.ts","sourceRoot":"","sources":["../src/composer.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAIjE,OAAO,KAAK,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAE9D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAmCpD;;;;;;;;;;;;;;GAcG;AAEH,MAAM,WAAW,iBAAkB,SAAQ,QAAQ;IACjD,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,QAAQ,CAAC,IAAI,IAAI,CAAC;IAClB,SAAS,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC;IACpC,qEAAqE;IACrE,OAAO,CAAC,IAAI,IAAI,CAAC;IACjB,8EAA8E;IAC9E,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,oEAAoE;IACpE,OAAO,CAAC,EAAE,cAAc,EAAE,CAAC;IAC3B,QAAQ,CAAC,CAAC,MAAM,EAAE,cAAc,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1D,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,kDAAkD;IAClD,QAAQ,CAAC,EAAE,WAAW,EAAE,CAAC;IACzB;;;;;;;;;;;;;OAaG;IACH,SAAS,CAAC,CAAC,OAAO,EAAE,WAAW,GAAG,IAAI,CAAC;IACvC;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,cAAc,EAAE,CAAC;IACzB,6EAA6E;IAC7E,MAAM,CAAC,CAAC,IAAI,EAAE,cAAc,GAAG,IAAI,CAAC;IACpC,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,yFAAyF;IACzF,SAAS,CAAC,CAAC,IAAI,EAAE,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;CACrC;AAED,eAAO,MAAM,YAAY,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,YAoMrD,CAAC"}
|