@softov/ahpc 0.1.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 (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +296 -0
  3. package/dist/src/ahp/channels.d.ts +146 -0
  4. package/dist/src/ahp/channels.js +423 -0
  5. package/dist/src/ahp/connection.d.ts +656 -0
  6. package/dist/src/ahp/connection.js +1 -0
  7. package/dist/src/ahp/fake.d.ts +69 -0
  8. package/dist/src/ahp/fake.js +1996 -0
  9. package/dist/src/ahp/live.d.ts +160 -0
  10. package/dist/src/ahp/live.js +2641 -0
  11. package/dist/src/ahp/operate.d.ts +36 -0
  12. package/dist/src/ahp/operate.js +56 -0
  13. package/dist/src/ahp/publish.d.ts +39 -0
  14. package/dist/src/ahp/publish.js +370 -0
  15. package/dist/src/ahp/status.d.ts +23 -0
  16. package/dist/src/ahp/status.js +23 -0
  17. package/dist/src/ahp/types.d.ts +745 -0
  18. package/dist/src/ahp/types.js +27 -0
  19. package/dist/src/app.d.ts +54 -0
  20. package/dist/src/app.js +369 -0
  21. package/dist/src/blocks.d.ts +88 -0
  22. package/dist/src/blocks.js +102 -0
  23. package/dist/src/cli/main.d.ts +13 -0
  24. package/dist/src/cli/main.js +1537 -0
  25. package/dist/src/cli/render.d.ts +43 -0
  26. package/dist/src/cli/render.js +91 -0
  27. package/dist/src/config.d.ts +63 -0
  28. package/dist/src/config.js +45 -0
  29. package/dist/src/connect.d.ts +53 -0
  30. package/dist/src/connect.js +69 -0
  31. package/dist/src/control.d.ts +189 -0
  32. package/dist/src/control.js +2241 -0
  33. package/dist/src/diff.d.ts +44 -0
  34. package/dist/src/diff.js +111 -0
  35. package/dist/src/flags.d.ts +28 -0
  36. package/dist/src/flags.js +76 -0
  37. package/dist/src/main.d.ts +10 -0
  38. package/dist/src/main.js +54 -0
  39. package/dist/src/schedule.d.ts +46 -0
  40. package/dist/src/schedule.js +140 -0
  41. package/dist/src/screens.d.ts +79 -0
  42. package/dist/src/screens.js +1322 -0
  43. package/dist/src/state.d.ts +355 -0
  44. package/dist/src/state.js +531 -0
  45. package/dist/src/terminal.d.ts +44 -0
  46. package/dist/src/terminal.js +101 -0
  47. package/dist/src/tui.d.ts +92 -0
  48. package/dist/src/tui.js +372 -0
  49. package/dist/src/view/automations.d.ts +31 -0
  50. package/dist/src/view/automations.js +76 -0
  51. package/dist/src/view/bood/art.d.ts +90 -0
  52. package/dist/src/view/bood/art.js +152 -0
  53. package/dist/src/view/bood/beetle.d.ts +7 -0
  54. package/dist/src/view/bood/beetle.js +123 -0
  55. package/dist/src/view/bood/bunny.d.ts +8 -0
  56. package/dist/src/view/bood/bunny.js +228 -0
  57. package/dist/src/view/bood/cat.d.ts +9 -0
  58. package/dist/src/view/bood/cat.js +358 -0
  59. package/dist/src/view/bood/crab.d.ts +7 -0
  60. package/dist/src/view/bood/crab.js +121 -0
  61. package/dist/src/view/bood/index.d.ts +29 -0
  62. package/dist/src/view/bood/index.js +25 -0
  63. package/dist/src/view/bood/motion.d.ts +178 -0
  64. package/dist/src/view/bood/motion.js +298 -0
  65. package/dist/src/view/bood/owl.d.ts +9 -0
  66. package/dist/src/view/bood/owl.js +285 -0
  67. package/dist/src/view/bood/registry.d.ts +36 -0
  68. package/dist/src/view/bood/registry.js +218 -0
  69. package/dist/src/view/bood/render.d.ts +31 -0
  70. package/dist/src/view/bood/render.js +196 -0
  71. package/dist/src/view/bood/sprite.d.ts +18 -0
  72. package/dist/src/view/bood/sprite.js +130 -0
  73. package/dist/src/view/bood/sprout.d.ts +8 -0
  74. package/dist/src/view/bood/sprout.js +121 -0
  75. package/dist/src/view/bood/types.d.ts +158 -0
  76. package/dist/src/view/bood/types.js +57 -0
  77. package/dist/src/view/bubble.d.ts +75 -0
  78. package/dist/src/view/bubble.js +86 -0
  79. package/dist/src/view/changes.d.ts +35 -0
  80. package/dist/src/view/changes.js +22 -0
  81. package/dist/src/view/composer.d.ts +64 -0
  82. package/dist/src/view/composer.js +192 -0
  83. package/dist/src/view/controls.d.ts +44 -0
  84. package/dist/src/view/controls.js +49 -0
  85. package/dist/src/view/creature.d.ts +69 -0
  86. package/dist/src/view/creature.js +83 -0
  87. package/dist/src/view/customizations.d.ts +28 -0
  88. package/dist/src/view/customizations.js +53 -0
  89. package/dist/src/view/details.d.ts +65 -0
  90. package/dist/src/view/details.js +65 -0
  91. package/dist/src/view/filediff.d.ts +29 -0
  92. package/dist/src/view/filediff.js +24 -0
  93. package/dist/src/view/files.d.ts +31 -0
  94. package/dist/src/view/files.js +39 -0
  95. package/dist/src/view/hitl.d.ts +43 -0
  96. package/dist/src/view/hitl.js +171 -0
  97. package/dist/src/view/icons.d.ts +13 -0
  98. package/dist/src/view/icons.js +71 -0
  99. package/dist/src/view/picker.d.ts +42 -0
  100. package/dist/src/view/picker.js +71 -0
  101. package/dist/src/view/sessionhead.d.ts +41 -0
  102. package/dist/src/view/sessionhead.js +60 -0
  103. package/dist/src/view/sessions.d.ts +34 -0
  104. package/dist/src/view/sessions.js +61 -0
  105. package/dist/src/view/terminal.d.ts +30 -0
  106. package/dist/src/view/terminal.js +32 -0
  107. package/dist/src/view/toolcall.d.ts +27 -0
  108. package/dist/src/view/toolcall.js +48 -0
  109. package/dist/src/view/transcript.d.ts +50 -0
  110. package/dist/src/view/transcript.js +60 -0
  111. package/package.json +72 -0
@@ -0,0 +1,57 @@
1
+ /**
2
+ * What a creature is, before anyone draws one.
3
+ *
4
+ * A creature file writes art and nothing else - no measuring, no padding, no
5
+ * component. The registry does the arithmetic once, at registration, so that
6
+ * a drawing is checked the moment it joins the bood rather than the first
7
+ * time somebody renders it on a narrow terminal.
8
+ */
9
+ /** What the figure is doing. The one thing worth reading from across the room. */
10
+ export const MOODS = ['happy', 'sad', 'thinking', 'executing', 'error'];
11
+ /**
12
+ * How much room the figure gets.
13
+ *
14
+ * Three sizes rather than one scaled one, because art does not scale: a cat
15
+ * shrunk to five cells is a smudge, and a cat *redrawn* at five cells is a
16
+ * cat. Each form is drawn by hand and each one is allowed to look different.
17
+ */
18
+ export const FORMS = ['draw', 'block', 'inline'];
19
+ /**
20
+ * The ceiling each form is held to.
21
+ *
22
+ * `block` and `inline` exist to be placed next to something else - a header, a
23
+ * status row, a list item - and a caller who has budgeted seven cells cannot
24
+ * have that budget decided by whichever creature came up. So the bound is the
25
+ * contract, and `registerCreature` refuses art that breaks it.
26
+ *
27
+ * `draw` has no width bound on purpose. The figures run from seven cells wide
28
+ * to seventeen and the outline *is* the animal; a creature that fits a
29
+ * template is a template wearing a hat.
30
+ */
31
+ export const BOUNDS = {
32
+ draw: { rows: 8, cols: Infinity },
33
+ block: { rows: 3, cols: 5 },
34
+ inline: { rows: 1, cols: 7 },
35
+ };
36
+ /**
37
+ * What the body is doing, as distinct from what the mood is.
38
+ *
39
+ * Nothing sets one of these. A pose is derived from the physics every tick -
40
+ * `poseOf` in `motion.ts` - because a body that is told what it is doing and a
41
+ * body that is falling are two authorities on one fact, and the visible half
42
+ * of that disagreement is a figure walking through the air.
43
+ */
44
+ export const POSES = [
45
+ 'idle', 'sit', 'walk', 'jump', 'fall', 'fly', 'land', 'held', 'sleep', 'alarm',
46
+ ];
47
+ /**
48
+ * Which way it is pointed, which is latched rather than read.
49
+ *
50
+ * `front` is the one a creature returns to: after a couple of seconds standing
51
+ * still it turns and looks at you. It is also the fallback, so a species that
52
+ * has no left view of a pose falls through to its front one and still reads
53
+ * right rather than drawing nothing.
54
+ */
55
+ export const FACINGS = ['front', 'right', 'left'];
56
+ /** Identity, for the type inference. A creature file is data, and stays data. */
57
+ export const defineCreature = (spec) => spec;
@@ -0,0 +1,75 @@
1
+ import type { BoxProps, RenderOutput, 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
+ /**
12
+ * The rule down the left of everything one speaker said.
13
+ *
14
+ * A box that fills rather than a `text`: the text is one row tall and the
15
+ * paragraph beside it is nine, so a rule written as a character marks the
16
+ * first line of a wrapped answer and abandons the rest of it.
17
+ */
18
+ export declare const Gutter: (props: BoxProps) => RenderOutput;
19
+ export interface ChatBubbleProps extends BoxProps {
20
+ speaker: Speaker;
21
+ /** The name, when the speaker is not enough: a model, a person, a host. */
22
+ author?: string;
23
+ /** Right of the author line: a time, a duration, a model. */
24
+ meta?: string;
25
+ tone?: SemanticVariant;
26
+ /** The transcript's cursor is on this block. */
27
+ active?: boolean;
28
+ children?: unknown;
29
+ }
30
+ export declare const ChatBubble: (props: ChatBubbleProps) => RenderOutput;
31
+ export interface StreamingTextProps extends BoxProps {
32
+ content: string;
33
+ /** Still arriving. Draws a caret and keeps it on the last word. */
34
+ streaming?: boolean;
35
+ quiet?: boolean;
36
+ maxLines?: number;
37
+ /**
38
+ * Draw it as markdown, or as the characters that arrived.
39
+ *
40
+ * Unstated it follows the application's own switch, which is what the key
41
+ * that toggles it moves - so a caller has to say something here only when
42
+ * it wants one or the other regardless.
43
+ */
44
+ markdown?: boolean;
45
+ /** Text to pick out, for the find box. Coloured wherever it appears. */
46
+ match?: string;
47
+ }
48
+ /**
49
+ * Text that is still being said.
50
+ *
51
+ * The caret is part of the content rather than a node beside it, because a
52
+ * caret placed after the block sits under the last line instead of at the end
53
+ * of it - and the end of the sentence is the only place it means anything.
54
+ *
55
+ * It blinks on the theme's own ticker, so animation being off (a pipe, a test,
56
+ * a `--static` capture) leaves a steady caret rather than a missing one.
57
+ */
58
+ export declare const StreamingText: (props: StreamingTextProps) => RenderOutput;
59
+ export interface ReasoningBlockProps extends BoxProps {
60
+ /** Text to pick out, for the find box. Handed to the text inside it. */
61
+ match?: string;
62
+ content: string;
63
+ expanded?: boolean;
64
+ streaming?: boolean;
65
+ /** Shown collapsed: "thought for 12s". */
66
+ summary?: string;
67
+ }
68
+ /**
69
+ * What the agent was thinking, folded away.
70
+ *
71
+ * Reasoning is prose the host sends like any other, and it is not what the
72
+ * reader came for - so it is one row until it is asked for. Dropping it
73
+ * instead loses the only account of *why* a turn did what it did.
74
+ */
75
+ export declare const ReasoningBlock: (props: ReasoningBlockProps) => RenderOutput;
@@ -0,0 +1,86 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@textui/core/jsx-runtime";
2
+ import { defineComponent, useFrame, useStoreValue, useTheme } from '@textui/core';
3
+ import { Column, MarkdownView, Row } from '@textui/widgets';
4
+ import { MARKDOWN } from '../state.js';
5
+ /**
6
+ * The rule down the left of everything one speaker said.
7
+ *
8
+ * A box that fills rather than a `text`: the text is one row tall and the
9
+ * paragraph beside it is nine, so a rule written as a character marks the
10
+ * first line of a wrapped answer and abandons the rest of it.
11
+ */
12
+ export const Gutter = defineComponent('ChatGutter', (props) => {
13
+ const theme = useTheme();
14
+ // `alignSelf` because `Row` centres its children: a one-cell box in a
15
+ // centred row is one cell tall, wherever the rule was meant to reach.
16
+ return _jsx("box", { width: 1, alignSelf: "stretch", fill: theme.borderChars().left, fg: "borderSubtle", ...props });
17
+ });
18
+ const SPEAKER = {
19
+ user: { fg: 'primary', label: 'you' },
20
+ agent: { fg: 'accent', label: 'agent' },
21
+ system: { fg: 'muted', label: 'system' },
22
+ };
23
+ export const ChatBubble = defineComponent('ChatBubble', (props) => {
24
+ const { speaker, author, meta, tone, active, children, ...rest } = props;
25
+ const theme = useTheme();
26
+ const look = SPEAKER[speaker];
27
+ const glyph = speaker === 'user' ? theme.glyphs.chevronRight
28
+ : speaker === 'agent' ? theme.glyphs.bulletFilled
29
+ : theme.glyphs.info;
30
+ // The gutter is one column of glyph and one of rule. It is what makes a
31
+ // wrapped paragraph read as one person talking rather than as the page
32
+ // starting again, and it survives losing colour - which a tinted
33
+ // background does not.
34
+ return (_jsxs(Column, { ...rest, ...(active ? { bg: 'selected' } : {}), children: [_jsxs(Row, { gap: 1, children: [_jsx("text", { content: glyph, fg: 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, {}), _jsx(Column, { flex: 1, gap: 1, children: children })] })] }));
35
+ });
36
+ /**
37
+ * Text that is still being said.
38
+ *
39
+ * The caret is part of the content rather than a node beside it, because a
40
+ * caret placed after the block sits under the last line instead of at the end
41
+ * of it - and the end of the sentence is the only place it means anything.
42
+ *
43
+ * It blinks on the theme's own ticker, so animation being off (a pipe, a test,
44
+ * a `--static` capture) leaves a steady caret rather than a missing one.
45
+ */
46
+ export const StreamingText = defineComponent('StreamingText', (props) => {
47
+ const { content, streaming, quiet, maxLines, markdown, match, ...rest } = props;
48
+ const theme = useTheme();
49
+ // Only while something is arriving. A ticker marks its component dirty
50
+ // whether or not the frame it produces differs, so an unconditional one
51
+ // here meant every settled paragraph in the transcript asked the
52
+ // application to redraw twice a second, for ever - a conversation that
53
+ // got heavier to sit in the longer it got.
54
+ const frame = useFrame(2, { enabled: streaming === true });
55
+ const caret = streaming && frame % 2 === 0 ? theme.glyphs.caret : '';
56
+ // Read unconditionally: `??` short-circuits, and a hook that is only
57
+ // reached when a prop is absent is a hook that changes position between
58
+ // renders. The prop still wins - it is just decided after the read.
59
+ const preference = useStoreValue(MARKDOWN, true) ?? true;
60
+ const rendered = markdown ?? preference;
61
+ const shown = streaming ? `${content}${caret}` : content;
62
+ // Raw is a `text`, not a `MarkdownView` that was told not to parse: the
63
+ // point of turning it off is to see the characters that arrived, and
64
+ // anything that lays the document out has already decided some of them
65
+ // were structure. `wrap` rather than truncate, because the lines being
66
+ // read are the long ones - a fenced block and a table are exactly what is
67
+ // wider than the pane.
68
+ if (!rendered) {
69
+ return (_jsx("text", { content: shown, wrap: "word", ...(quiet ? { fg: 'muted' } : {}), ...(match ? { match } : {}), ...rest }));
70
+ }
71
+ return (_jsx(MarkdownView, { content: shown, ...(quiet ? { quiet: true } : {}), ...(maxLines !== undefined ? { maxLines } : {}), ...(match ? { match } : {}), ...rest }));
72
+ });
73
+ /**
74
+ * What the agent was thinking, folded away.
75
+ *
76
+ * Reasoning is prose the host sends like any other, and it is not what the
77
+ * reader came for - so it is one row until it is asked for. Dropping it
78
+ * instead loses the only account of *why* a turn did what it did.
79
+ */
80
+ export const ReasoningBlock = defineComponent('ReasoningBlock', (props) => {
81
+ const { content, expanded, streaming, summary, match, ...rest } = props;
82
+ const theme = useTheme();
83
+ const chevron = expanded ? theme.glyphs.chevronDown : theme.glyphs.chevronRight;
84
+ const words = content.trim().split(/\s+/).filter(Boolean).length;
85
+ return (_jsxs(Column, { ...rest, children: [_jsxs(Row, { gap: 1, children: [_jsx("text", { content: chevron, fg: "subtle" }), _jsx("text", { content: summary ?? (streaming ? 'thinking' : `thought, ${words} words`), fg: "subtle", italic: true })] }), expanded ? (_jsxs(Row, { gap: 1, children: [_jsx("text", { content: " " }), _jsx(StreamingText, { content: content, quiet: true, flex: 1, ...(streaming ? { streaming: true } : {}), ...(match ? { match } : {}) })] })) : null] }));
86
+ });
@@ -0,0 +1,35 @@
1
+ import type { BoxProps, RenderOutput } from '@textui/core';
2
+ import type { Changeset } from '../ahp/types.js';
3
+ /**
4
+ * What the session changed on disk.
5
+ *
6
+ * Not read off the tool calls: a provider decides whether to send `fileEdit`
7
+ * blocks and this one sends none, so a client that reads them reports that a
8
+ * turn which created one file and rewrote another touched nothing. The account
9
+ * is the changeset channel, and `computing` is worth showing rather than
10
+ * swallowing - a partial answer read as a complete one is the wrong kind of
11
+ * wrong.
12
+ *
13
+ * A `List` rather than a column of rows, because the rows became openable: the
14
+ * selection, the keys, the window and the highlight are the list's, and a
15
+ * hand-rolled cursor over a changeset of two hundred files is the mistake the
16
+ * catalogue already made once.
17
+ */
18
+ export interface ChangesListProps extends BoxProps {
19
+ changes: Changeset;
20
+ /** Enter on a row. Absent leaves the list a read-only account. */
21
+ onOpen?(uri: string): void;
22
+ /** The cursor moved. What a key acting on "this file" needs. */
23
+ onSelect?(uri: string): void;
24
+ /**
25
+ * Whether to draw the read column at all.
26
+ *
27
+ * Per changeset, because the flag is: a host says which of its changesets it
28
+ * will keep a tick for, and a checkbox on one it will not is a control that
29
+ * silently does nothing.
30
+ */
31
+ reviewable?: boolean;
32
+ focusId?: string;
33
+ autoFocus?: boolean;
34
+ }
35
+ export declare const ChangesList: (props: ChangesListProps) => RenderOutput;
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@textui/core/jsx-runtime";
2
+ import { defineComponent, useTheme } from '@textui/core';
3
+ import { Column, EmptyState, List, Marquee, Row } from '@textui/widgets';
4
+ export const ChangesList = defineComponent('ChangesList', (props) => {
5
+ const { changes, onOpen, onSelect, reviewable, focusId, autoFocus, ...rest } = props;
6
+ const theme = useTheme();
7
+ if (changes.files.length === 0) {
8
+ return (_jsx(EmptyState, { title: changes.status === 'computing' ? 'Working out what changed' : 'Nothing changed', message: changes.status === 'computing' ? 'The host is still computing the changeset.' : 'No file was created, edited or deleted.', ...rest }));
9
+ }
10
+ const byUri = new Map(changes.files.map((file) => [file.uri, file]));
11
+ const items = changes.files.map((file) => {
12
+ const kind = !file.before ? 'new' : !file.after ? 'deleted' : 'edited';
13
+ return {
14
+ id: file.uri,
15
+ icon: kind === 'new' ? '+' : kind === 'deleted' ? '-' : theme.glyphs.chevronRight,
16
+ label: file.uri.replace(/^file:\/\//, ''),
17
+ meta: `+${file.diff.added} -${file.diff.removed}`,
18
+ tone: (kind === 'new' ? 'success' : kind === 'deleted' ? 'danger' : 'muted'),
19
+ };
20
+ });
21
+ return (_jsxs(Column, { ...rest, children: [changes.status === 'computing' ? (_jsx("text", { content: `${theme.glyphs.ellipsis} still computing - this is not the whole list`, fg: "warning" })) : null, _jsx(List, { items: items, flex: 1, renderItem: (item, state) => (_jsxs(Row, { gap: 1, children: [reviewable ? (_jsx("text", { content: byUri.get(item.id)?.reviewed === true ? theme.glyphs.checkboxOn : theme.glyphs.checkboxOff, ...(byUri.get(item.id)?.reviewed === true ? { fg: 'success' } : { fg: 'muted' }), shrink: 0 })) : null, _jsx("text", { content: item.icon ?? '', ...(state.selected ? {} : { fg: item.tone }), shrink: 0 }), _jsx(Marquee, { content: item.label, active: state.selected && state.focused, truncate: "start", flex: 1 }), _jsx("text", { content: `+${byUri.get(item.id)?.diff.added ?? 0}`, fg: "success", shrink: 0 }), _jsx("text", { content: `-${byUri.get(item.id)?.diff.removed ?? 0}`, fg: "danger", shrink: 0 })] })), ...(onOpen ? { onActivate: (uri) => onOpen(uri) } : {}), ...(onSelect ? { onSelect: (uri) => onSelect(uri) } : {}), ...(focusId ? { focusId } : {}), ...(autoFocus ? { autoFocus: true } : {}), emptyMessage: "Nothing changed" })] }));
22
+ });
@@ -0,0 +1,64 @@
1
+ import type { BoxProps, RenderOutput } from '@textui/core';
2
+ import type { Completion, SlashCommand } from '../ahp/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?: SlashCommand[];
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: SlashCommand): 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?: Completion[];
59
+ /** One of `paths` was chosen. The range it replaces is on the completion. */
60
+ onPath?(path: Completion): void;
61
+ autoFocus?: boolean;
62
+ focusId?: string;
63
+ }
64
+ export declare const ChatComposer: (props: ChatComposerProps) => RenderOutput;
@@ -0,0 +1,192 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@textui/core/jsx-runtime";
2
+ import { defineComponent, useEffect, useMeasure, useSize, useState, useTheme } from '@textui/core';
3
+ import { Column, Divider, List, TextArea } from '@textui/widgets';
4
+ import { ComposerBar } from './controls.js';
5
+ import { useFloorTop } from './creature.js';
6
+ /**
7
+ * Rows the completion menu shows at once, at most.
8
+ *
9
+ * A cap on the box's height and not on the list: what does not fit is
10
+ * scrolled to. The menu sits above the composer and takes its room from it,
11
+ * so on a short terminal this is not the number that applies - see `fits`.
12
+ */
13
+ const VISIBLE = 8;
14
+ /**
15
+ * Rows the composer itself takes: two border, two divider, the field and the
16
+ * bar under it. Eight menu rows on a terminal twelve high left four for all
17
+ * of that and drew an empty box - a menu on top of a field with no room to
18
+ * type in it.
19
+ */
20
+ const COMPOSER_ROWS = 6;
21
+ /** The menu's own frame, which is height the list does not get. */
22
+ const MENU_BORDER = 2;
23
+ /**
24
+ * Rows the menu may have here, which is whatever the composer can spare.
25
+ *
26
+ * The floor is there because a terminal can always be made too short for
27
+ * both; below it the composer gives way, since a menu with nothing under it
28
+ * is the same dead end from the other side.
29
+ */
30
+ const fits = (height) => Math.max(3, Math.min(VISIBLE, height - COMPOSER_ROWS - MENU_BORDER));
31
+ export const ChatComposer = defineComponent('ChatComposer', (props) => {
32
+ const { value, onChange, onSubmit, onCancel, onHistory, onLeave, running, queued = 0, options = [], onOption, placeholder, commands = [], onCommand, paths = [], onPath, autoFocus, focusId = 'chat.composer', ...rest } = props;
33
+ const theme = useTheme();
34
+ // A slash menu is a completion over what is already typed, not a mode.
35
+ const slash = value.startsWith('/') && !value.includes(' ') ? value.slice(1).toLowerCase() : null;
36
+ const found = slash === null ? [] : commands
37
+ .filter((command) => command.id.toLowerCase().includes(slash) || command.title.toLowerCase().includes(slash))
38
+ // What the host contributed first. A person typing a slash into a chat
39
+ // is usually reaching for a skill, and the client's own commands - which
40
+ // are also in the palette, on their own key - would otherwise fill the
41
+ // rows that are visible without scrolling.
42
+ .sort((a, b) => (a.kind === b.kind ? 0 : a.kind === 'session' ? -1 : 1));
43
+ const byId = new Map(found.map((command) => [command.id, command]));
44
+ /*
45
+ * One menu, and whichever list is live fills it.
46
+ *
47
+ * The two cannot both be: a slash menu is a draft that *starts* with a
48
+ * slash, and a path menu is a word the caret is in that starts with an
49
+ * at-sign. Two menus would be two boxes above one field.
50
+ */
51
+ const offered = found.length > 0
52
+ ? found.map((command) => ({
53
+ id: command.id,
54
+ label: `/${command.id}`,
55
+ ...(command.description ? { description: command.description } : {}),
56
+ // Where it came from, when something did: two plugins can contribute
57
+ // a `/review`, and the title alone does not say which this is.
58
+ meta: command.from ?? command.title,
59
+ }))
60
+ : paths.map((path) => ({
61
+ id: path.insertText,
62
+ label: path.insertText,
63
+ ...(path.description ? { description: path.description } : {}),
64
+ }));
65
+ const byInsert = new Map(paths.map((path) => [path.insertText, path]));
66
+ /*
67
+ * Escape closes the menu before it does anything else.
68
+ *
69
+ * The menu is drawn from the draft, so there is no state to close - which
70
+ * is why escape used to pass straight through it to the field and then to
71
+ * the screen, and typing `/` and pressing escape left for the session
72
+ * list. What is remembered is the draft it was dismissed at: the menu
73
+ * stays shut for that exact text and comes back the moment another
74
+ * character makes it a different question.
75
+ *
76
+ * And it is forgotten as soon as there is no menu to dismiss. Remembering
77
+ * the text alone was not enough: dismissing at `/`, deleting it and typing
78
+ * `/` again produced the same draft, so the menu stayed shut for a
79
+ * question that had been asked afresh. Clearing when nothing matches ties
80
+ * the dismissal to one continuous menu rather than to a string that can
81
+ * come back.
82
+ */
83
+ const [dismissedAt, setDismissedAt] = useState(null);
84
+ const empty = offered.length === 0;
85
+ useEffect(() => {
86
+ if (empty && dismissedAt !== null)
87
+ setDismissedAt(null);
88
+ }, [empty]);
89
+ const matches = dismissedAt === value ? [] : offered;
90
+ // Which completion is under the cursor. Clamped rather than reset, so a
91
+ // list that shrinks as more is typed keeps a valid row instead of
92
+ // snapping back to the top on every keystroke.
93
+ const [highlight, setHighlight] = useState(0);
94
+ const index = Math.max(0, Math.min(highlight, matches.length - 1));
95
+ const chosen = matches[index];
96
+ /*
97
+ * What goes after the name of the command under the cursor.
98
+ *
99
+ * A row is the name, so a command that takes an argument has nowhere in
100
+ * the list to say so, and `/autocompact` reads as complete when it is
101
+ * not. It goes on the rule under the list, where it is one line for the
102
+ * whole menu and changes as the highlight moves rather than being
103
+ * repeated down every row.
104
+ */
105
+ const usage = chosen === undefined ? undefined : byId.get(chosen.id)?.hint;
106
+ const hint = usage === undefined ? undefined : `/${chosen?.id ?? ''} ${usage}`;
107
+ /**
108
+ * Up and down, while the menu is open.
109
+ *
110
+ * They arrive as `onOverflow` - the field reports the key rather than
111
+ * handling it once there is no row above or below the caret, which for a
112
+ * `/word` draft is immediately. The same pair walks the history when there
113
+ * is no menu, and the menu is the nearer of the two things they could
114
+ * mean.
115
+ */
116
+ const step = (direction) => {
117
+ setHighlight((matches.length + index + direction) % matches.length);
118
+ };
119
+ // Where this box starts, so the creature has somewhere to stand that is
120
+ // not on it. The slash menu grows this upward, so it is read every time
121
+ // rather than being a number somebody wrote down once.
122
+ useFloorTop('composer', useMeasure().y);
123
+ // How tall the menu may be here. Read unconditionally: it is a hook, and
124
+ // the menu is drawn from a branch.
125
+ const rows = fits(useSize().height);
126
+ return (_jsxs(Column, { ...rest, gap: 0, children: [matches.length > 0 ? (
127
+ // The theme's border, never a named one. A hardcoded `single` draws
128
+ // a box-drawing frame inside an ascii one on a terminal that cannot
129
+ // do either, and an airy theme gets a line it deliberately does not
130
+ // draw anywhere else.
131
+ _jsxs(Column, { border: theme.border, padding: [0, 1], children: [_jsx(List, { items: matches, focusable: false, selectedId: chosen?.id,
132
+ /*
133
+ * A window over all of them, not the first six.
134
+ *
135
+ * The list scrolls to keep the selected row in view, and the
136
+ * selection here is driven from outside - so walking past the
137
+ * sixth moves the window rather than stopping. Truncating the
138
+ * items instead made up and down cycle the six that survived,
139
+ * with no way to reach a seventh: a host that answers thirty
140
+ * paths for `@src/` offered six of them and looked like it had
141
+ * no more.
142
+ */
143
+ visibleRows: hint === undefined ? rows : rows - 1, marker: true,
144
+ // Not focusable, so this is the click: a completion clicked is a
145
+ // completion chosen, and there is nowhere for a merely
146
+ // highlighted row to lead.
147
+ onSelect: (id) => {
148
+ const command = byId.get(id);
149
+ if (command) {
150
+ onCommand?.(command);
151
+ return;
152
+ }
153
+ const path = byInsert.get(id);
154
+ if (path)
155
+ onPath?.(path);
156
+ }, emptyMessage: "no command" }), hint === undefined ? null : _jsx(Divider, { label: hint })] })) : null, _jsxs(Column, { border: theme.border, children: [_jsx(Divider, { dim: true }), _jsx(TextArea, { value: value, onChange: onChange,
157
+ // A slash the menu matched runs here; anything else is a message,
158
+ // which is what lets a command the agent offers through.
159
+ onSubmit: (next) => {
160
+ const command = chosen ? byId.get(chosen.id) : undefined;
161
+ if (command && onCommand) {
162
+ onCommand(command);
163
+ return;
164
+ }
165
+ // A highlighted path completes rather than sends: enter on a
166
+ // menu row means "that one", and a draft half-way through a
167
+ // path is not a message anybody meant to send.
168
+ const path = chosen ? byInsert.get(chosen.id) : undefined;
169
+ if (path && onPath) {
170
+ onPath(path);
171
+ return;
172
+ }
173
+ onSubmit(next);
174
+ }, onCancel: () => {
175
+ if (matches.length > 0) {
176
+ setDismissedAt(value);
177
+ return;
178
+ }
179
+ onCancel?.();
180
+ }, onOverflow: (direction) => {
181
+ if (matches.length > 0) {
182
+ step(direction);
183
+ return;
184
+ }
185
+ onHistory?.(direction);
186
+ }, ...(onLeave ? { onEdge: (edge) => { if (edge === 'start')
187
+ onLeave(); } } : {}), placeholder: placeholder
188
+ ?? (running ? 'The agent is working. Type to queue a message.' : 'Ask the agent anything…'), focusId: focusId,
189
+ // The caret is the one thing on this screen saying where typing
190
+ // goes, and this field is the point of the screen.
191
+ caretTone: "accent", ...(autoFocus ? { autoFocus: true } : {}) }), _jsx(Divider, { dim: true }), _jsx(ComposerBar, { options: options, onOpen: (option, anchorId) => onOption?.(option, anchorId), onSend: () => onSubmit(value), ...(running ? { running: true } : {}), queued: queued, sendDisabled: value.trim() === '' })] })] }));
192
+ });
@@ -0,0 +1,44 @@
1
+ import type { BoxProps, RenderOutput } from '@textui/core';
2
+ /**
3
+ * The composer's control row: what this message will be sent as.
4
+ *
5
+ * One line under the field, and everything on it is a *current value* rather
6
+ * than a label - which harness, which model, how much it may do before it
7
+ * asks, where it works. A person can read what will happen without opening
8
+ * anything, and change any of it without leaving the composer.
9
+ *
10
+ * Each chip is one command's argument, asked through the palette (see
11
+ * `picker.ts`). That is the whole design: nothing here knows what a model or a
12
+ * permission mode is, so a new chip is a new command and no change to this
13
+ * file.
14
+ */
15
+ export interface ComposerOption {
16
+ id: string;
17
+ /** The value, as a person reads it. Never the id the host stores. */
18
+ label: string;
19
+ /**
20
+ * The question, as opposed to the answer in `label`.
21
+ *
22
+ * The chip has no room for it - "Ask each time" is the whole row and the
23
+ * mark in front says which question it belongs to - but anything listing
24
+ * these somewhere with more space needs the pair, and the host's own wording
25
+ * for both is on the schema rather than in this file.
26
+ */
27
+ title?: string;
28
+ icon?: string;
29
+ /** The command whose argument this chip asks about. Absent: shown, not asked. */
30
+ commandId?: string;
31
+ }
32
+ export interface ComposerBarProps extends BoxProps {
33
+ options: ComposerOption[];
34
+ onOpen(option: ComposerOption, anchorId: string): void;
35
+ onSend(): void;
36
+ /** A turn is running, so this message joins the queue instead. */
37
+ running?: boolean;
38
+ queued?: number;
39
+ sendDisabled?: boolean;
40
+ }
41
+ /** The focus id of one chip. The picker anchors to it, so it has to be knowable. */
42
+ export declare const chipId: (id: string) => string;
43
+ export declare const SEND_ID = "chat.send";
44
+ export declare const ComposerBar: (props: ComposerBarProps) => RenderOutput;
@@ -0,0 +1,49 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@textui/core/jsx-runtime";
2
+ import { defineComponent, useFocus, useInput, useTheme } from '@textui/core';
3
+ import { Row } from '@textui/widgets';
4
+ /** The focus id of one chip. The picker anchors to it, so it has to be knowable. */
5
+ export const chipId = (id) => `chat.option.${id}`;
6
+ export const SEND_ID = 'chat.send';
7
+ export const ComposerBar = defineComponent('ComposerBar', (props) => {
8
+ const { options, onOpen, onSend, running, queued = 0, sendDisabled, ...rest } = props;
9
+ const theme = useTheme();
10
+ return (_jsxs(Row, { gap: 1, ...rest, children: [options.map((option, at) => (option.commandId
11
+ ? (_jsx(Chip, { focusId: chipId(option.id),
12
+ // Stated, not inherited. Tab order is registration order, and
13
+ // which chips exist is the *host's* answer - it arrives one
14
+ // round trip after the row is first drawn, so the ones that were
15
+ // there from the start would otherwise come first however far to
16
+ // the right they sit. Tab would run harness, workspace, and then
17
+ // back to the middle.
18
+ order: at, label: option.label, ...(option.icon ? { icon: option.icon } : {}), onOpen: () => onOpen(option, chipId(option.id)) }, option.id))
19
+ : (
20
+ // Shown, not asked: a value that is fixed for this session is
21
+ // still worth reading, and a chip that opens a panel offering one
22
+ // choice is a worse way of saying so.
23
+ _jsxs(Row, { gap: 1, children: [option.icon ? _jsx("text", { content: option.icon, fg: "subtle" }) : null, _jsx("text", { content: option.label, fg: "subtle" })] }, option.id)))), _jsx("text", { content: "", flex: 1 }), queued > 0 ? _jsx("text", { content: `${queued} queued`, fg: "warning" }) : null, _jsx(Chip, { focusId: SEND_ID, order: options.length, label: running ? 'queue' : 'send', trailing: theme.glyphs.chevronRight, tone: "accent", ...(sendDisabled ? { disabled: true } : {}), onOpen: onSend })] }));
24
+ });
25
+ /**
26
+ * One value on the control row.
27
+ *
28
+ * Not a `Button`: a button is a verb and these are nouns, and at `size="sm"`
29
+ * four of them still read as four buttons rather than as one sentence about
30
+ * what is about to be sent. What it borrows from a button is the part that
31
+ * matters - it is focusable, tab reaches it, and enter opens it.
32
+ */
33
+ const Chip = defineComponent('ComposerChip', (props) => {
34
+ const { focusId, order, label, icon, trailing, tone, disabled, onOpen } = props;
35
+ const theme = useTheme();
36
+ const focus = useFocus({ id: focusId, order, disabled: disabled === true });
37
+ useInput((event) => {
38
+ if (disabled)
39
+ return false;
40
+ // Down as well as enter: the panel comes up out of the chip, and reaching
41
+ // for it downwards is what the shape of the thing suggests.
42
+ if (event.name === 'enter' || event.name === 'space' || event.name === 'down') {
43
+ onOpen();
44
+ return true;
45
+ }
46
+ return false;
47
+ }, { focusId: focus.id, enabled: disabled !== true });
48
+ return (_jsxs(Row, { id: focus.id, gap: 1, padding: [0, 1], ...(focus.focused ? { bg: 'selected' } : {}), onClick: disabled ? undefined : onOpen, children: [icon ? _jsx("text", { content: icon, shrink: 0, fg: focus.focused ? 'inverted' : tone ?? 'muted' }) : null, _jsx("text", { content: label, truncate: "end", fg: focus.focused ? 'inverted' : disabled ? 'disabled' : tone ?? undefined, ...(tone ? { bold: true } : {}) }), _jsx("text", { content: trailing ?? theme.glyphs.chevronDown, shrink: 0, fg: focus.focused ? 'inverted' : 'subtle' })] }));
49
+ });