@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
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { BoxProps, RenderOutput } from '@textui/core';
|
|
2
|
+
import type { DiffResult } from './diff.js';
|
|
3
|
+
/**
|
|
4
|
+
* One file out of a changeset, both sides of it.
|
|
5
|
+
*
|
|
6
|
+
* Unified rather than side by side, and not for want of a splitter: a terminal
|
|
7
|
+
* that a changeset list already shares with a session pane has sixty columns
|
|
8
|
+
* left, and eighty characters of source in thirty is two columns of nothing
|
|
9
|
+
* legible. Unified spends the width on the line instead.
|
|
10
|
+
*
|
|
11
|
+
* Every row is exactly one row tall. The scroll position is a row count, so a
|
|
12
|
+
* line that wrapped would put the gutter numbers out of step with what is on
|
|
13
|
+
* screen - which is the same invariant the markdown layout keeps, for the same
|
|
14
|
+
* reason. Long lines are clipped, and the file is there to be read rather than
|
|
15
|
+
* edited.
|
|
16
|
+
*/
|
|
17
|
+
export interface FileDiffProps extends BoxProps {
|
|
18
|
+
/** The file, as the changeset names it. */
|
|
19
|
+
path: string;
|
|
20
|
+
diff: DiffResult;
|
|
21
|
+
/** Absent `before` is a creation, absent `after` a deletion. */
|
|
22
|
+
kind: 'new' | 'edited' | 'deleted';
|
|
23
|
+
/** Set instead of a diff when the bytes are not text. */
|
|
24
|
+
binary?: {
|
|
25
|
+
bytes: number;
|
|
26
|
+
contentType?: string;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export declare const FileDiff: (props: FileDiffProps) => RenderOutput;
|
|
30
|
+
//# sourceMappingURL=filediff.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"filediff.d.ts","sourceRoot":"","sources":["../src/filediff.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAG3D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAE5C;;;;;;;;;;;;;GAaG;AAEH,MAAM,WAAW,aAAc,SAAQ,QAAQ;IAC7C,2CAA2C;IAC3C,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,UAAU,CAAC;IACjB,gEAAgE;IAChE,IAAI,EAAE,KAAK,GAAG,QAAQ,GAAG,SAAS,CAAC;IACnC,yDAAyD;IACzD,MAAM,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CAClD;AAED,eAAO,MAAM,QAAQ,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,YAuF7C,CAAC"}
|
package/dist/filediff.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "@textui/core/jsx-runtime";
|
|
2
|
+
import { defineComponent, useTheme } from '@textui/core';
|
|
3
|
+
import { Column, EmptyState, Row, ScrollView } from '@textui/widgets';
|
|
4
|
+
export const FileDiff = defineComponent('FileDiff', (props) => {
|
|
5
|
+
const { path, diff, kind, binary, ...rest } = props;
|
|
6
|
+
const theme = useTheme();
|
|
7
|
+
if (binary) {
|
|
8
|
+
return (_jsx(EmptyState, { title: "Not text", message: `${binary.contentType ?? 'Binary'}, ${binary.bytes} bytes. There is nothing to show a line at a time.`, ...rest }));
|
|
9
|
+
}
|
|
10
|
+
if (diff.tooLarge) {
|
|
11
|
+
return (_jsx(EmptyState, { title: "Too big to line up", message: `${diff.tooLarge.lines} lines between the two sides, over the ${diff.tooLarge.limit} this will compare. Open it in an editor.`, ...rest }));
|
|
12
|
+
}
|
|
13
|
+
if (diff.rows.length === 0) {
|
|
14
|
+
return _jsx(EmptyState, { title: "Nothing between the two", message: "Both sides of this file are the same.", ...rest });
|
|
15
|
+
}
|
|
16
|
+
// The widest line number either side will need, so the gutter does not
|
|
17
|
+
// change width partway down the file.
|
|
18
|
+
const width = String(Math.max(...diff.rows.map((row) => Math.max(row.before ?? 0, row.after ?? 0)))).length;
|
|
19
|
+
return (_jsxs(Column, { ...rest, children: [_jsxs(Row, { gap: 1, children: [_jsx("text", { content: kind === 'new' ? '+' : kind === 'deleted' ? '-' : theme.glyphs.chevronRight, fg: kind === 'new' ? 'success' : kind === 'deleted' ? 'danger' : 'muted', shrink: 0 }), _jsx("text", { content: path, flex: 1, truncate: "start" }), _jsx("text", { content: `+${diff.added}`, fg: "success", shrink: 0 }), _jsx("text", { content: `-${diff.removed}`, fg: "danger", shrink: 0 })] }), _jsx(ScrollView, { flex: 1, focusId: "changes.file", children: _jsx(Column, { children: diff.rows.map((row, index) => (_jsxs(Row, { gap: 1, children: [_jsx("text", { content: String(row.before ?? '').padStart(width), fg: "subtle", shrink: 0 }), _jsx("text", { content: String(row.after ?? '').padStart(width), fg: "subtle", shrink: 0 }), _jsx("text", { content: row.kind === 'added' ? '+' : row.kind === 'removed' ? '-' : ' ', fg: row.kind === 'added' ? 'success' : row.kind === 'removed' ? 'danger' : 'muted', shrink: 0 }), _jsx("text", { content: row.text, wrap: "none", truncate: "end", flex: 1, ...(row.kind === 'added'
|
|
20
|
+
? { fg: 'success' }
|
|
21
|
+
: row.kind === 'removed'
|
|
22
|
+
? { fg: 'danger' }
|
|
23
|
+
: {}) })] }, index))) }) })] }));
|
|
24
|
+
});
|
package/dist/hitl.d.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { BoxProps, Rect, RenderOutput } from '@textui/core';
|
|
2
|
+
import type { ChatAnswer, ChatPendingInput, ChatSendStatus } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* The block that means the agent is stopped, waiting on a person.
|
|
5
|
+
*
|
|
6
|
+
* It is the only thing on the screen that is waiting on the reader, so it
|
|
7
|
+
* takes the focus, traps it, and is answerable without leaving the keyboard.
|
|
8
|
+
* Rendered as one more bubble in the transcript it scrolls away, and a blocked
|
|
9
|
+
* agent that has scrolled off is a session that looks merely slow.
|
|
10
|
+
*
|
|
11
|
+
* Two kinds, and they are nothing alike:
|
|
12
|
+
*
|
|
13
|
+
* a **tool confirmation** is a yes or a no about a command, with named
|
|
14
|
+
* options where the host offers them;
|
|
15
|
+
*
|
|
16
|
+
* a **question** carries no tool call at all. Its prose is the request's own
|
|
17
|
+
* message and what is being asked is its questions. Rendering it as a
|
|
18
|
+
* confirmation loses the entire request - the choices vanish, the question
|
|
19
|
+
* is never shown, and what is left is a heading and an Approve button.
|
|
20
|
+
*/
|
|
21
|
+
export interface ChatHitlProps extends BoxProps {
|
|
22
|
+
input: ChatPendingInput;
|
|
23
|
+
/**
|
|
24
|
+
* The answers so far, keyed by question id.
|
|
25
|
+
*
|
|
26
|
+
* Owned by whoever mounts this rather than by the block: a host may have an
|
|
27
|
+
* action for a draft answer precisely because another client can be looking
|
|
28
|
+
* at the same question, and a value only this box knows is one nobody else
|
|
29
|
+
* can see. Unused by a tool confirmation.
|
|
30
|
+
*/
|
|
31
|
+
draft?: Record<string, ChatAnswer>;
|
|
32
|
+
onDraft?(next: Record<string, ChatAnswer>): void;
|
|
33
|
+
onApprove(optionId?: string): void;
|
|
34
|
+
onDeny(): void;
|
|
35
|
+
onAnswer(answers: Record<string, ChatAnswer>, accepted: boolean): void;
|
|
36
|
+
/** Leave it up, but give the keyboard back. */
|
|
37
|
+
onEscape?(): void;
|
|
38
|
+
/** Where the block is on screen whenever that changes, and `null` once it is gone. */
|
|
39
|
+
onMeasure?(rect: Rect | null): void;
|
|
40
|
+
}
|
|
41
|
+
export declare const ChatHitl: (props: ChatHitlProps) => RenderOutput;
|
|
42
|
+
/** A yes or a no about a command, and the named options the host offered. */
|
|
43
|
+
export declare const ConfirmRequest: (props: {
|
|
44
|
+
input: Extract<ChatPendingInput, {
|
|
45
|
+
kind: "toolConfirmation";
|
|
46
|
+
}>;
|
|
47
|
+
onApprove(optionId?: string): void;
|
|
48
|
+
onDeny(): void;
|
|
49
|
+
onEscape?(): void;
|
|
50
|
+
}) => RenderOutput;
|
|
51
|
+
/**
|
|
52
|
+
* A question, with its questions.
|
|
53
|
+
*
|
|
54
|
+
* Every answer names its own kind, keyed by question id, and a required one
|
|
55
|
+
* left empty is not sendable - accepting with no answers resumes the agent on
|
|
56
|
+
* the answers it already had, which for a question it has just asked is none.
|
|
57
|
+
*/
|
|
58
|
+
export declare const QuestionForm: (props: {
|
|
59
|
+
input: Extract<ChatPendingInput, {
|
|
60
|
+
kind: "chatInput";
|
|
61
|
+
}>;
|
|
62
|
+
/** The answers so far. Controlled: every change goes out through `onDraft`. */
|
|
63
|
+
draft: Record<string, ChatAnswer>;
|
|
64
|
+
onDraft(next: Record<string, ChatAnswer>): void;
|
|
65
|
+
onAnswer(answers: Record<string, ChatAnswer>, accepted: boolean): void;
|
|
66
|
+
onEscape?(): void;
|
|
67
|
+
}) => RenderOutput;
|
|
68
|
+
/**
|
|
69
|
+
* What came of the answer, on the row between the block and the composer.
|
|
70
|
+
*
|
|
71
|
+
* That row was a blank one, and it is where a person is already looking after
|
|
72
|
+
* pressing a button on the block above it. A press that reaches a host which
|
|
73
|
+
* then says nothing is indistinguishable from a key that was never read - so
|
|
74
|
+
* this says the answer has gone, and says so in red when it did not.
|
|
75
|
+
*
|
|
76
|
+
* Nothing to say is no row rather than an empty one: a status line that is
|
|
77
|
+
* always there is a row of chrome, and the composer is a row further from the
|
|
78
|
+
* conversation for the whole of every session in which nothing goes wrong.
|
|
79
|
+
*/
|
|
80
|
+
export interface ChatInputStatusProps extends BoxProps {
|
|
81
|
+
/** Nothing to say is `null`, and no row. */
|
|
82
|
+
status: ChatSendStatus | null;
|
|
83
|
+
}
|
|
84
|
+
export declare const ChatInputStatus: (props: ChatInputStatusProps) => RenderOutput;
|
|
85
|
+
//# sourceMappingURL=hitl.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hitl.d.ts","sourceRoot":"","sources":["../src/hitl.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAejE,OAAO,KAAK,EAAE,UAAU,EAAE,gBAAgB,EAAgB,cAAc,EAAE,MAAM,YAAY,CAAC;AAG7F;;;;;;;;;;;;;;;;;GAiBG;AAEH,MAAM,WAAW,aAAc,SAAQ,QAAQ;IAC7C,KAAK,EAAE,gBAAgB,CAAC;IACxB;;;;;;;OAOG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;IACnC,OAAO,CAAC,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,GAAG,IAAI,CAAC;IACjD,SAAS,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACnC,MAAM,IAAI,IAAI,CAAC;IACf,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,EAAE,QAAQ,EAAE,OAAO,GAAG,IAAI,CAAC;IACvE,+CAA+C;IAC/C,QAAQ,CAAC,IAAI,IAAI,CAAC;IAClB,sFAAsF;IACtF,SAAS,CAAC,CAAC,IAAI,EAAE,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;CACrC;AAED,eAAO,MAAM,QAAQ,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,YA4C7C,CAAC;AAEL,6EAA6E;AAC7E,eAAO,MAAM,cAAc;WAClB,OAAO,CAAC,gBAAgB,EAAE;QAAE,IAAI,EAAE,kBAAkB,CAAA;KAAE,CAAC;yBACzC,MAAM,GAAG,IAAI;cACxB,IAAI;iBACD,IAAI;kBAyCjB,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,YAAY;WAChB,OAAO,CAAC,gBAAgB,EAAE;QAAE,IAAI,EAAE,WAAW,CAAA;KAAE,CAAC;IACvD,+EAA+E;WACxE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC;kBACnB,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,GAAG,IAAI;sBAC7B,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,YAAY,OAAO,GAAG,IAAI;iBACzD,IAAI;kBAwEjB,CAAC;AA0HH;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,oBAAqB,SAAQ,QAAQ;IACpD,4CAA4C;IAC5C,MAAM,EAAE,cAAc,GAAG,IAAI,CAAC;CAC/B;AAED,eAAO,MAAM,eAAe,EAAE,CAAC,KAAK,EAAE,oBAAoB,KAAK,YAe3D,CAAC"}
|
package/dist/hitl.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "@textui/core/jsx-runtime";
|
|
2
|
+
import { defineComponent, useApp, useFocusScope, useInput, useTheme, } from '@textui/core';
|
|
3
|
+
import { Button, Checkbox, Column, MarkdownView, Panel, RadioGroup, Row, TextArea, TextInput, } from '@textui/widgets';
|
|
4
|
+
import { useReportMeasure } from './measure.js';
|
|
5
|
+
export const ChatHitl = defineComponent('ChatHitl', (props) => {
|
|
6
|
+
// It sits above the composer rather than inside it, so it says where it
|
|
7
|
+
// starts: whoever keeps clear of the composer has to keep clear of this.
|
|
8
|
+
const { input, draft, onDraft, onApprove, onDeny, onAnswer, onEscape, onMeasure, ...rest } = props;
|
|
9
|
+
useReportMeasure(onMeasure);
|
|
10
|
+
const theme = useTheme();
|
|
11
|
+
// Focused on arrival, and not trapped.
|
|
12
|
+
//
|
|
13
|
+
// Trapping read well - the answer is the only thing to do - and it was
|
|
14
|
+
// wrong. A trap routes every key inside the scope, so while a session was
|
|
15
|
+
// blocked the transcript could not be scrolled, escape could not leave the
|
|
16
|
+
// screen, and the block that said `esc read` was the one thing making that
|
|
17
|
+
// impossible. You approve a command on the strength of what is written
|
|
18
|
+
// above it, so reading has to stay available while it is up.
|
|
19
|
+
//
|
|
20
|
+
// The keys that answer it are global (see `ConfirmRequest`), so they still
|
|
21
|
+
// work from wherever the reader has gone.
|
|
22
|
+
useFocusScope({ id: 'chat.hitl', autoFocus: true, restore: true });
|
|
23
|
+
// Wrapped, so that what `onMeasure` reports is the box this occupies
|
|
24
|
+
// rather than the room inside its border, which is one row lower.
|
|
25
|
+
return (_jsx(Column, { ...rest, children: _jsx(Panel, { title: input.kind === 'toolConfirmation' ? (input.call.confirmationTitle ?? 'The agent asks') : 'The agent asks', tone: "warning", border: theme.border, meta: `${theme.glyphs.warning} waiting on you`, children: input.kind === 'toolConfirmation'
|
|
26
|
+
? _jsx(ConfirmRequest, { input: input, onApprove: onApprove, onDeny: onDeny, ...(onEscape ? { onEscape } : {}) })
|
|
27
|
+
: (_jsx(QuestionForm, { input: input, draft: draft ?? {}, onDraft: onDraft ?? (() => { }), onAnswer: onAnswer, ...(onEscape ? { onEscape } : {}) })) }) }));
|
|
28
|
+
});
|
|
29
|
+
/** A yes or a no about a command, and the named options the host offered. */
|
|
30
|
+
export const ConfirmRequest = defineComponent('ConfirmRequest', ({ input, onApprove, onDeny, onEscape }) => {
|
|
31
|
+
const theme = useTheme();
|
|
32
|
+
const options = input.call.options ?? [];
|
|
33
|
+
// The letters are on the block, not on the application: they only exist
|
|
34
|
+
// while it is up, and while it is up nothing else wants them.
|
|
35
|
+
useInput((event) => {
|
|
36
|
+
if (event.name === 'a') {
|
|
37
|
+
onApprove();
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
if (event.name === 'd') {
|
|
41
|
+
onDeny();
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
if (event.name === 'escape') {
|
|
45
|
+
onEscape?.();
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
const digit = Number(event.name);
|
|
49
|
+
if (Number.isInteger(digit) && digit >= 1 && digit <= options.length) {
|
|
50
|
+
onApprove(options[digit - 1]?.id);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}, { global: true });
|
|
55
|
+
return (_jsxs(Column, { gap: 1, children: [input.call.intention ? _jsx(MarkdownView, { content: input.call.intention, quiet: true }) : null, input.call.input ? (_jsx(Column, { bg: "surfaceAlt", padding: [0, 1], children: _jsx("text", { content: input.call.input, wrap: "word" }) })) : null, _jsxs(Row, { gap: 2, children: [_jsx(Button, { label: "Approve", tone: "success", variant: "solid", icon: theme.glyphs.check, hint: "a", autoFocus: true, onPress: () => onApprove() }), _jsx(Button, { label: "Deny", tone: "danger", icon: theme.glyphs.cross, hint: "d", onPress: onDeny }), options.map((option, i) => (_jsx(Button, { label: option.label, variant: "ghost", hint: String(i + 1), onPress: () => onApprove(option.id) }, option.id)))] })] }));
|
|
56
|
+
});
|
|
57
|
+
/**
|
|
58
|
+
* A question, with its questions.
|
|
59
|
+
*
|
|
60
|
+
* Every answer names its own kind, keyed by question id, and a required one
|
|
61
|
+
* left empty is not sendable - accepting with no answers resumes the agent on
|
|
62
|
+
* the answers it already had, which for a question it has just asked is none.
|
|
63
|
+
*/
|
|
64
|
+
export const QuestionForm = defineComponent('QuestionForm', ({ input, draft, onDraft, onAnswer, onEscape }) => {
|
|
65
|
+
const theme = useTheme();
|
|
66
|
+
const app = useApp();
|
|
67
|
+
const answers = draft;
|
|
68
|
+
const set = (id, answer) => {
|
|
69
|
+
const next = { ...answers };
|
|
70
|
+
if (answer === null)
|
|
71
|
+
delete next[id];
|
|
72
|
+
else
|
|
73
|
+
next[id] = answer;
|
|
74
|
+
onDraft(next);
|
|
75
|
+
};
|
|
76
|
+
const missing = input.questions.filter((question) => question.required && !answers[question.id]);
|
|
77
|
+
const send = () => { if (missing.length === 0)
|
|
78
|
+
onAnswer(answers, true); };
|
|
79
|
+
useInput((event) => {
|
|
80
|
+
if (event.name === 'escape') {
|
|
81
|
+
onEscape?.();
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
// Enter sends, which is what the hint row under this says while a question
|
|
85
|
+
// is up - but only from inside the block. A reader who has deliberately
|
|
86
|
+
// tabbed down to the composer to queue a message is pressing enter at the
|
|
87
|
+
// composer, and answering the question with it would be the same mistake
|
|
88
|
+
// in the other direction. The fields that consume enter themselves - a
|
|
89
|
+
// text input, a checkbox, the Send button - never reach this.
|
|
90
|
+
if (event.name === 'enter') {
|
|
91
|
+
const at = app.focus.focused();
|
|
92
|
+
if (at === null || app.focus.scopeOf(at) !== 'chat.hitl')
|
|
93
|
+
return false;
|
|
94
|
+
send();
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}, { global: true });
|
|
99
|
+
return (_jsxs(Column, { gap: 1, children: [_jsx(MarkdownView, { content: input.message }), input.questions.map((question, i) => (_jsx(QuestionField, { question: question, answer: answers[question.id] ?? null,
|
|
100
|
+
// The first field takes the keyboard, and the scope asking for it is
|
|
101
|
+
// not enough: a scope's `autoFocus` only claims focus when nothing
|
|
102
|
+
// holds any, and while a question is up the composer holds it. So a
|
|
103
|
+
// question rendered its field, put `required` beside it, disabled
|
|
104
|
+
// Send until it was answered - and every key typed at it went into
|
|
105
|
+
// the composer behind. A control that says it wants focus is the one
|
|
106
|
+
// thing that takes it off another scope, which is why the
|
|
107
|
+
// confirmation's Approve button works and this did not.
|
|
108
|
+
autoFocus: i === 0, onChange: (answer) => set(question.id, answer), onSubmit: send }, question.id))), _jsxs(Row, { gap: 2, children: [_jsx(Button, { label: "Send", tone: "success", variant: "solid", icon: theme.glyphs.check, hint: "enter", disabled: missing.length > 0, onPress: () => onAnswer(answers, true) }), _jsx(Button, { label: "Decline", variant: "ghost", icon: theme.glyphs.cross, onPress: () => onAnswer({}, false) }), missing.length > 0 ? (_jsx("text", { content: `${theme.glyphs.warning} ${missing.length} still to answer`, fg: "warning" })) : null] })] }));
|
|
109
|
+
});
|
|
110
|
+
/** One question, rendered by its kind. The kind decides the answer's shape. */
|
|
111
|
+
const QuestionField = defineComponent('QuestionField', ({ question, answer, autoFocus, onChange, onSubmit }) => {
|
|
112
|
+
const theme = useTheme();
|
|
113
|
+
const options = question.options ?? [];
|
|
114
|
+
const selectedMany = answer?.kind === 'selected-many' ? answer.value : [];
|
|
115
|
+
return (_jsxs(Column, { gap: 0, children: [_jsxs(Row, { gap: 1, children: [_jsx("text", { content: question.message, bold: true, wrap: "word", flex: 1 }), question.required ? _jsx("text", { content: "required", fg: "warning" }) : null] }), question.kind === 'boolean' ? (_jsx(Checkbox, { label: "yes", autoFocus: autoFocus === true, checked: answer?.kind === 'boolean' ? answer.value : false, onChange: (checked) => onChange({ kind: 'boolean', value: checked }) })) : null, question.kind === 'single-select' ? (_jsx(RadioGroup, { autoFocus: autoFocus === true, options: options.map((option, i) => ({ value: option.id, label: `${i + 1}. ${option.label}` })), ...(answer?.kind === 'selected' ? { value: answer.value } : {}), onChange: (value) => onChange({ kind: 'selected', value }) })) : null, question.kind === 'multi-select' ? (_jsx(Column, { children: options.map((option, i) => (_jsx(Checkbox, { label: `${i + 1}. ${option.label}`, checked: selectedMany.includes(option.id), onChange: (checked) => onChange({
|
|
116
|
+
kind: 'selected-many',
|
|
117
|
+
value: checked
|
|
118
|
+
? [...selectedMany, option.id]
|
|
119
|
+
: selectedMany.filter((id) => id !== option.id),
|
|
120
|
+
}) }, option.id))) })) : null, question.kind === 'text' ? (_jsx(TextArea, { value: answer?.kind === 'text' ? answer.value : '', autoFocus: autoFocus === true, maxRows: 6, border: theme.border, placeholder: "Type your answer \u00B7 enter sends \u00B7 alt+enter for a new line", caretTone: "accent", onSubmit: onSubmit, onChange: (value) => onChange(value === '' ? null : { kind: 'text', value }) })) : null, question.kind === 'number' || question.kind === 'integer' ? (_jsx(TextInput, { value: answer && 'value' in answer ? String(answer.value) : '', autoFocus: autoFocus === true, label: "number", hideLabel: true, placeholder: "A number", onSubmit: onSubmit, onChange: (value) => onChange(value === '' ? null : { kind: 'number', value: Number(value) }) })) : null, question.allowFreeformInput && question.kind !== 'text' ? (
|
|
121
|
+
// Freeform answers *instead of* choosing, never as one more choice: a
|
|
122
|
+
// host reading only the selection must not be handed typed words in
|
|
123
|
+
// the field where it expects an option id.
|
|
124
|
+
_jsxs(Row, { gap: 1, children: [_jsx("text", { content: theme.glyphs.chevronRight, fg: "subtle" }), _jsx(TextInput, { value: answer?.kind === 'text' ? answer.value : '', label: "or say it in words", placeholder: "or say it in words", hideLabel: true, flex: 1, onSubmit: onSubmit, onChange: (value) => onChange(value === '' ? null : { kind: 'text', value }) })] })) : null] }));
|
|
125
|
+
});
|
|
126
|
+
export const ChatInputStatus = defineComponent('ChatInputStatus', (props) => {
|
|
127
|
+
const theme = useTheme();
|
|
128
|
+
const { status, ...rest } = props;
|
|
129
|
+
if (!status)
|
|
130
|
+
return null;
|
|
131
|
+
const failed = status.state === 'failed';
|
|
132
|
+
const tone = failed ? 'danger' : 'muted';
|
|
133
|
+
return (_jsxs(Row, { gap: 1, ...rest, children: [_jsx("text", { content: failed ? theme.glyphs.warning : theme.glyphs.bulletHollow, fg: tone }), _jsx("text", { content: status.text, fg: tone, flex: 1, truncate: "end" })] }));
|
|
134
|
+
});
|
package/dist/icons.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { UnicodeLevel } from '@textui/core';
|
|
2
|
+
/** The mark for one of the host's questions. Never absent. */
|
|
3
|
+
export declare function settingIcon(unicode: UnicodeLevel, key: string, title?: string): string;
|
|
4
|
+
/**
|
|
5
|
+
* The mark for one of its answers.
|
|
6
|
+
*
|
|
7
|
+
* Absent rather than neutral when nothing matches *and* the setting is not one
|
|
8
|
+
* whose values are worth marking - a branch name is not a mode, and a column
|
|
9
|
+
* of identical dots beside a list of branches is noise with a shape.
|
|
10
|
+
*/
|
|
11
|
+
export declare function valueIcon(unicode: UnicodeLevel, value: string, label?: string, options?: {
|
|
12
|
+
fallback?: boolean;
|
|
13
|
+
}): string | undefined;
|
|
14
|
+
//# sourceMappingURL=icons.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"icons.d.ts","sourceRoot":"","sources":["../src/icons.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAuFjD,8DAA8D;AAC9D,wBAAgB,WAAW,CAAC,OAAO,EAAE,YAAY,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAEtF;AAED;;;;;;GAMG;AACH,wBAAgB,SAAS,CACvB,OAAO,EAAE,YAAY,EACrB,KAAK,EAAE,MAAM,EACb,KAAK,CAAC,EAAE,MAAM,EACd,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE,OAAO,CAAA;CAAO,GACnC,MAAM,GAAG,SAAS,CAIpB"}
|
package/dist/icons.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const pick = (mark, unicode) => (unicode === 'ascii' ? mark.ascii : mark.full);
|
|
2
|
+
/** Which question. Ordered: the first that matches wins. */
|
|
3
|
+
const SETTINGS = [
|
|
4
|
+
{ when: /harness|provider|agent(?!\s*mode)/, mark: { full: '◆', ascii: '@' } },
|
|
5
|
+
{ when: /model/, mark: { full: '◇', ascii: '#' } },
|
|
6
|
+
{ when: /branch/, mark: { full: '⋔', ascii: 'Y' } },
|
|
7
|
+
{ when: /workspace|director|folder|cwd|path/, mark: { full: '⌂', ascii: '~' } },
|
|
8
|
+
{ when: /isolat|worktree|target/, mark: { full: '▣', ascii: '=' } },
|
|
9
|
+
{ when: /approv|permission|mode/, mark: { full: '◉', ascii: '%' } },
|
|
10
|
+
];
|
|
11
|
+
const OTHER_SETTING = { full: '▪', ascii: '-' };
|
|
12
|
+
/**
|
|
13
|
+
* Which answer.
|
|
14
|
+
*
|
|
15
|
+
* The approval modes are the reason this exists: five of them, all named in
|
|
16
|
+
* the same two words - "Auto Mode", "Plan Mode", "Ask Before Edits" - and a
|
|
17
|
+
* list of five look-alike labels is a list you read twice. The marks put them
|
|
18
|
+
* in an order you can see, from "asks about everything" to "asks about
|
|
19
|
+
* nothing".
|
|
20
|
+
*
|
|
21
|
+
* Ordered, and the value is tested before the label: `bypassPermissions` and
|
|
22
|
+
* `autoApprove` both contain "auto", and only one of them means "decide for
|
|
23
|
+
* yourself".
|
|
24
|
+
*/
|
|
25
|
+
const VALUES = [
|
|
26
|
+
{ when: /bypass|approveall|autoapprove|allow all|yolo/, mark: { full: '⚠', ascii: '!' } },
|
|
27
|
+
{ when: /plan/, mark: { full: '≡', ascii: '=' } },
|
|
28
|
+
{ when: /auto|assist/, mark: { full: '⊙', ascii: 'o' } },
|
|
29
|
+
{ when: /accept|edit|write/, mark: { full: '✓', ascii: 'v' } },
|
|
30
|
+
{ when: /ask|default|manual|interactive|confirm/, mark: { full: '?', ascii: '?' } },
|
|
31
|
+
{ when: /worktree/, mark: { full: '⋔', ascii: 'Y' } },
|
|
32
|
+
// Not the house, which is the *workspace* chip's mark. Working in place and
|
|
33
|
+
// the directory being worked in are two chips side by side, and giving them
|
|
34
|
+
// the same mark is the one thing a mark is supposed to prevent.
|
|
35
|
+
{ when: /folder|workspace|inplace|in place/, mark: { full: '▣', ascii: '=' } },
|
|
36
|
+
{ when: /observe|readonly|read only/, mark: { full: '◌', ascii: '.' } },
|
|
37
|
+
];
|
|
38
|
+
const OTHER_VALUE = { full: '·', ascii: '-' };
|
|
39
|
+
const normalise = (text) => text.toLowerCase().replace(/[\s_-]+/g, '');
|
|
40
|
+
function match(table, ...against) {
|
|
41
|
+
for (const text of against) {
|
|
42
|
+
if (!text)
|
|
43
|
+
continue;
|
|
44
|
+
const flat = normalise(text);
|
|
45
|
+
// Both spellings: `when` clauses like /allow all/ want the words apart and
|
|
46
|
+
// /autoapprove/ wants them together, and which one a host used is not
|
|
47
|
+
// something to have an opinion about.
|
|
48
|
+
const spaced = text.toLowerCase();
|
|
49
|
+
const found = table.find((entry) => entry.when.test(flat) || entry.when.test(spaced));
|
|
50
|
+
if (found)
|
|
51
|
+
return found.mark;
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
/** The mark for one of the host's questions. Never absent. */
|
|
56
|
+
export function settingIcon(unicode, key, title) {
|
|
57
|
+
return pick(match(SETTINGS, key, title) ?? OTHER_SETTING, unicode);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The mark for one of its answers.
|
|
61
|
+
*
|
|
62
|
+
* Absent rather than neutral when nothing matches *and* the setting is not one
|
|
63
|
+
* whose values are worth marking - a branch name is not a mode, and a column
|
|
64
|
+
* of identical dots beside a list of branches is noise with a shape.
|
|
65
|
+
*/
|
|
66
|
+
export function valueIcon(unicode, value, label, options = {}) {
|
|
67
|
+
const found = match(VALUES, value, label);
|
|
68
|
+
if (found)
|
|
69
|
+
return pick(found, unicode);
|
|
70
|
+
return options.fallback === true ? pick(OTHER_VALUE, unicode) : undefined;
|
|
71
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export * from './types.js';
|
|
2
|
+
export * from './blocks.js';
|
|
3
|
+
export * from './diff.js';
|
|
4
|
+
export * from './icons.js';
|
|
5
|
+
export * from './picker.js';
|
|
6
|
+
export * from './measure.js';
|
|
7
|
+
export * from './bubble.js';
|
|
8
|
+
export * from './transcript.js';
|
|
9
|
+
export * from './toolcall.js';
|
|
10
|
+
export * from './controls.js';
|
|
11
|
+
export * from './composer.js';
|
|
12
|
+
export * from './hitl.js';
|
|
13
|
+
export * from './details.js';
|
|
14
|
+
export * from './sessions.js';
|
|
15
|
+
export * from './sessionhead.js';
|
|
16
|
+
export * from './filediff.js';
|
|
17
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,aAAa,CAAC;AAC5B,cAAc,WAAW,CAAC;AAC1B,cAAc,YAAY,CAAC;AAC3B,cAAc,aAAa,CAAC;AAC5B,cAAc,cAAc,CAAC;AAC7B,cAAc,aAAa,CAAC;AAC5B,cAAc,iBAAiB,CAAC;AAChC,cAAc,eAAe,CAAC;AAC9B,cAAc,eAAe,CAAC;AAC9B,cAAc,eAAe,CAAC;AAC9B,cAAc,WAAW,CAAC;AAC1B,cAAc,cAAc,CAAC;AAC7B,cAAc,eAAe,CAAC;AAC9B,cAAc,kBAAkB,CAAC;AACjC,cAAc,eAAe,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export * from './types.js';
|
|
2
|
+
export * from './blocks.js';
|
|
3
|
+
export * from './diff.js';
|
|
4
|
+
export * from './icons.js';
|
|
5
|
+
export * from './picker.js';
|
|
6
|
+
export * from './measure.js';
|
|
7
|
+
export * from './bubble.js';
|
|
8
|
+
export * from './transcript.js';
|
|
9
|
+
export * from './toolcall.js';
|
|
10
|
+
export * from './controls.js';
|
|
11
|
+
export * from './composer.js';
|
|
12
|
+
export * from './hitl.js';
|
|
13
|
+
export * from './details.js';
|
|
14
|
+
export * from './sessions.js';
|
|
15
|
+
export * from './sessionhead.js';
|
|
16
|
+
export * from './filediff.js';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Rect } from '@textui/core';
|
|
2
|
+
/**
|
|
3
|
+
* Tell whoever asked where this component is, and when it is gone.
|
|
4
|
+
*
|
|
5
|
+
* Reported on every change rather than once: a composer grows a slash menu
|
|
6
|
+
* upward and grows again with a wrapped draft, and the block that asks about
|
|
7
|
+
* a tool is only there while something is waiting. `null` on the way out, or
|
|
8
|
+
* a screen that had one would keep making room for it after it had gone.
|
|
9
|
+
*
|
|
10
|
+
* The callback is read through a ref so an inline arrow - which is a new
|
|
11
|
+
* function every render - does not re-run the unmount report every frame.
|
|
12
|
+
*/
|
|
13
|
+
export declare function useReportMeasure(onMeasure: ((rect: Rect | null) => void) | undefined): void;
|
|
14
|
+
//# sourceMappingURL=measure.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"measure.d.ts","sourceRoot":"","sources":["../src/measure.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,cAAc,CAAC;AAGzC;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,CAAC,CAAC,IAAI,EAAE,IAAI,GAAG,IAAI,KAAK,IAAI,CAAC,GAAG,SAAS,GAAG,IAAI,CAM3F"}
|
package/dist/measure.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { useEffect, useMeasure, useRef } from '@textui/core';
|
|
2
|
+
/**
|
|
3
|
+
* Tell whoever asked where this component is, and when it is gone.
|
|
4
|
+
*
|
|
5
|
+
* Reported on every change rather than once: a composer grows a slash menu
|
|
6
|
+
* upward and grows again with a wrapped draft, and the block that asks about
|
|
7
|
+
* a tool is only there while something is waiting. `null` on the way out, or
|
|
8
|
+
* a screen that had one would keep making room for it after it had gone.
|
|
9
|
+
*
|
|
10
|
+
* The callback is read through a ref so an inline arrow - which is a new
|
|
11
|
+
* function every render - does not re-run the unmount report every frame.
|
|
12
|
+
*/
|
|
13
|
+
export function useReportMeasure(onMeasure) {
|
|
14
|
+
const rect = useMeasure();
|
|
15
|
+
const latest = useRef(onMeasure);
|
|
16
|
+
latest.current = onMeasure;
|
|
17
|
+
useEffect(() => { latest.current?.(rect); }, [rect.x, rect.y, rect.width, rect.height]);
|
|
18
|
+
useEffect(() => () => { latest.current?.(null); }, []);
|
|
19
|
+
}
|
package/dist/picker.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { TextUIApp } from '@textui/core';
|
|
2
|
+
/**
|
|
3
|
+
* The palette, moved.
|
|
4
|
+
*
|
|
5
|
+
* A chip on the composer's control row asks one question - which harness,
|
|
6
|
+
* which model, how much it may do without asking, which workspace - and every
|
|
7
|
+
* one of those is a command with an argument. The command palette already
|
|
8
|
+
* knows how to ask about exactly that: `openAt` drills straight into an
|
|
9
|
+
* argument, `choices` may be a list, a function or a promise, an argument
|
|
10
|
+
* *without* choices is answered by typing, and `preview` shows what a
|
|
11
|
+
* highlighted value would do.
|
|
12
|
+
*
|
|
13
|
+
* So this is not a second overlay. It is the same one, anchored above the
|
|
14
|
+
* control that asked, and shown one command's question rather than the whole
|
|
15
|
+
* registry. Anything registered later - a workspace list, a thinking level, a
|
|
16
|
+
* subagent - is a command with an argument and needs nothing here.
|
|
17
|
+
*/
|
|
18
|
+
export declare const PICKER = "chat.picker";
|
|
19
|
+
export interface PickerOptions {
|
|
20
|
+
/** The command whose argument is being asked about. */
|
|
21
|
+
commandId: string;
|
|
22
|
+
/** The node the panel sits above: the chip's own focus id. */
|
|
23
|
+
anchorId: string;
|
|
24
|
+
/**
|
|
25
|
+
* A fixed width. Left off, the panel is as wide as its widest answer, up to
|
|
26
|
+
* `maxWidth` - which is what a chip wants: "Worktree" and "Step-by-step
|
|
27
|
+
* collaboration" are the same question asked at two very different widths.
|
|
28
|
+
*/
|
|
29
|
+
width?: number;
|
|
30
|
+
maxWidth?: number;
|
|
31
|
+
visibleRows?: number;
|
|
32
|
+
/**
|
|
33
|
+
* Put each value's sentence on its own line.
|
|
34
|
+
*
|
|
35
|
+
* For the questions whose answers are two words apart - "Accept edits" and
|
|
36
|
+
* "Plan only" - and told apart entirely by the line under them. Inline,
|
|
37
|
+
* that line shares the width with the label and shows the same truncated
|
|
38
|
+
* half of every answer.
|
|
39
|
+
*/
|
|
40
|
+
descriptions?: 'inline' | 'below';
|
|
41
|
+
}
|
|
42
|
+
export declare function openPicker(app: TextUIApp, options: PickerOptions): void;
|
|
43
|
+
//# sourceMappingURL=picker.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"picker.d.ts","sourceRoot":"","sources":["../src/picker.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAG9C;;;;;;;;;;;;;;;GAeG;AAEH,eAAO,MAAM,MAAM,gBAAgB,CAAC;AAEpC,MAAM,WAAW,aAAa;IAC5B,uDAAuD;IACvD,SAAS,EAAE,MAAM,CAAC;IAClB,8DAA8D;IAC9D,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;;OAOG;IACH,YAAY,CAAC,EAAE,QAAQ,GAAG,OAAO,CAAC;CACnC;AAED,wBAAgB,UAAU,CAAC,GAAG,EAAE,SAAS,EAAE,OAAO,EAAE,aAAa,GAAG,IAAI,CA0DvE"}
|
package/dist/picker.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { argumentOf } from '@textui/widgets';
|
|
2
|
+
/**
|
|
3
|
+
* The palette, moved.
|
|
4
|
+
*
|
|
5
|
+
* A chip on the composer's control row asks one question - which harness,
|
|
6
|
+
* which model, how much it may do without asking, which workspace - and every
|
|
7
|
+
* one of those is a command with an argument. The command palette already
|
|
8
|
+
* knows how to ask about exactly that: `openAt` drills straight into an
|
|
9
|
+
* argument, `choices` may be a list, a function or a promise, an argument
|
|
10
|
+
* *without* choices is answered by typing, and `preview` shows what a
|
|
11
|
+
* highlighted value would do.
|
|
12
|
+
*
|
|
13
|
+
* So this is not a second overlay. It is the same one, anchored above the
|
|
14
|
+
* control that asked, and shown one command's question rather than the whole
|
|
15
|
+
* registry. Anything registered later - a workspace list, a thinking level, a
|
|
16
|
+
* subagent - is a command with an argument and needs nothing here.
|
|
17
|
+
*/
|
|
18
|
+
export const PICKER = 'chat.picker';
|
|
19
|
+
export function openPicker(app, options) {
|
|
20
|
+
const command = app.commands.get(options.commandId);
|
|
21
|
+
if (!command)
|
|
22
|
+
return;
|
|
23
|
+
// A chip whose command has no question to ask runs it: the workspace chip
|
|
24
|
+
// opens a dialog of its own, and a palette drilled into nothing is a list
|
|
25
|
+
// of one row that runs it anyway.
|
|
26
|
+
if (!argumentOf(command)) {
|
|
27
|
+
void app.execute(options.commandId, undefined, 'menu');
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
// The chip that opened this one closes it. A control whose panel is already
|
|
31
|
+
// showing is a toggle - clicking it again to make it go away is the first
|
|
32
|
+
// thing anybody tries, and reopening it looks like the click did nothing.
|
|
33
|
+
const showing = app.layers.entries().find((entry) => entry.id === PICKER);
|
|
34
|
+
const already = showing?.node;
|
|
35
|
+
if (already?.openAt === options.commandId) {
|
|
36
|
+
app.layers.close(PICKER);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
// Closed first, so opening a second chip's panel replaces the first rather
|
|
40
|
+
// than stacking two of them with the same id.
|
|
41
|
+
app.layers.close(PICKER);
|
|
42
|
+
app.layers.open({
|
|
43
|
+
id: PICKER,
|
|
44
|
+
// Floating, not modal: there is no scrim, because the row it is asking
|
|
45
|
+
// about has to stay readable underneath it. Which value is in force is
|
|
46
|
+
// half of what makes the question answerable.
|
|
47
|
+
layer: 'floating',
|
|
48
|
+
trapFocus: true,
|
|
49
|
+
dismissOnEscape: true,
|
|
50
|
+
dismissOnOutsideClick: true,
|
|
51
|
+
// Above the chip, aligned to its left edge - so it rises out of the
|
|
52
|
+
// control rather than appearing somewhere else on the screen. A composer
|
|
53
|
+
// sits at the bottom, so `top` is the side with room.
|
|
54
|
+
position: { kind: 'anchor', targetId: options.anchorId, side: 'top', align: 'start' },
|
|
55
|
+
node: {
|
|
56
|
+
component: 'CommandPalette',
|
|
57
|
+
...(options.width !== undefined ? { width: options.width } : {}),
|
|
58
|
+
maxWidth: options.maxWidth ?? 52,
|
|
59
|
+
visibleRows: options.visibleRows ?? 6,
|
|
60
|
+
// One command, so the panel opens on its values instead of on a search
|
|
61
|
+
// box with everything in it.
|
|
62
|
+
commands: [command],
|
|
63
|
+
openAt: options.commandId,
|
|
64
|
+
...(options.descriptions ? { descriptions: options.descriptions } : {}),
|
|
65
|
+
onClose: { handler: () => app.layers.close(PICKER) },
|
|
66
|
+
},
|
|
67
|
+
// Back to the chip that asked, however it was closed.
|
|
68
|
+
//
|
|
69
|
+
// The layer's own scope restores focus when a choice is made, and does not
|
|
70
|
+
// when the panel is dismissed with escape - which strands the keyboard on
|
|
71
|
+
// a node inside a layer that is no longer there, and the next tab starts
|
|
72
|
+
// from nowhere. Saying where focus goes is cheaper than depending on which
|
|
73
|
+
// of the two paths ran.
|
|
74
|
+
// Back to the chip that asked, whichever way it was closed. The layer's
|
|
75
|
+
// own scope restores focus after a choice; a dismissal would otherwise
|
|
76
|
+
// leave the keyboard on a node that is no longer mounted.
|
|
77
|
+
onClose: () => app.focus.focus(options.anchorId),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { BoxProps, RenderOutput } from '@textui/core';
|
|
2
|
+
import type { ChatSession } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* What this conversation *is*, at the top of it.
|
|
5
|
+
*
|
|
6
|
+
* The first thing in the transcript rather than a band above it, and that is
|
|
7
|
+
* the whole design: a caption pinned outside the scrolling region costs a row
|
|
8
|
+
* of the conversation on every screen for ever, so it has to earn each one -
|
|
9
|
+
* which meant one line, which meant dropping most of what it is for. Scrolled
|
|
10
|
+
* with the conversation it costs nothing after the first screen and can say
|
|
11
|
+
* everything, the way the top of a printed letter does.
|
|
12
|
+
*
|
|
13
|
+
* The identifiers are the point. They are what gets pasted into a shell or a
|
|
14
|
+
* bug report, they are exactly what does not fit anywhere else, and the
|
|
15
|
+
* catalogue's detail pane - the only other place they appear - is a screen
|
|
16
|
+
* away from the conversation they belong to.
|
|
17
|
+
*/
|
|
18
|
+
export interface ChatSessionHeadProps extends BoxProps {
|
|
19
|
+
session: ChatSession;
|
|
20
|
+
/** What the last turn ran on. A session has no model; each message has one. */
|
|
21
|
+
model?: string;
|
|
22
|
+
/** The chat uri, when the host has said which one this dispatches to. */
|
|
23
|
+
chat?: string | null;
|
|
24
|
+
/** The settings in force, by the host's own labels. */
|
|
25
|
+
settings?: {
|
|
26
|
+
label: string;
|
|
27
|
+
value: string;
|
|
28
|
+
}[];
|
|
29
|
+
/**
|
|
30
|
+
* Who else the host says is in this session.
|
|
31
|
+
*
|
|
32
|
+
* This client is in the list too - it adds itself on opening the view - so
|
|
33
|
+
* a session with nobody else in it has one entry and says nothing, which is
|
|
34
|
+
* the ordinary case.
|
|
35
|
+
*/
|
|
36
|
+
present?: {
|
|
37
|
+
clientId: string;
|
|
38
|
+
displayName?: string;
|
|
39
|
+
}[];
|
|
40
|
+
}
|
|
41
|
+
export declare const ChatSessionHead: (props: ChatSessionHeadProps) => RenderOutput;
|
|
42
|
+
//# sourceMappingURL=sessionhead.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sessionhead.d.ts","sourceRoot":"","sources":["../src/sessionhead.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,YAAY,EAAmB,MAAM,cAAc,CAAC;AAG5E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C;;;;;;;;;;;;;;GAcG;AAEH,MAAM,WAAW,oBAAqB,SAAQ,QAAQ;IACpD,OAAO,EAAE,WAAW,CAAC;IACrB,+EAA+E;IAC/E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yEAAyE;IACzE,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,uDAAuD;IACvD,QAAQ,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IAC9C;;;;;;OAMG;IACH,OAAO,CAAC,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CACxD;AAkBD,eAAO,MAAM,eAAe,EAAE,CAAC,KAAK,EAAE,oBAAoB,KAAK,YAiD3D,CAAC"}
|