@softov/ahpc 0.2.0 → 0.4.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 (73) hide show
  1. package/README.md +37 -6
  2. package/dist/src/ahp/fake.js +61 -14
  3. package/dist/src/ahp/live.d.ts +7 -0
  4. package/dist/src/ahp/live.js +156 -43
  5. package/dist/src/ahp/publish.js +13 -0
  6. package/dist/src/ahp/types.d.ts +20 -0
  7. package/dist/src/app.js +34 -7
  8. package/dist/src/blocks.d.ts +4 -74
  9. package/dist/src/blocks.js +10 -48
  10. package/dist/src/cli/main.d.ts +1 -1
  11. package/dist/src/cli/main.js +109 -6
  12. package/dist/src/connect.d.ts +2 -0
  13. package/dist/src/connect.js +1 -0
  14. package/dist/src/control.d.ts +6 -0
  15. package/dist/src/control.js +140 -10
  16. package/dist/src/flags.js +4 -1
  17. package/dist/src/links.d.ts +54 -0
  18. package/dist/src/links.js +120 -0
  19. package/dist/src/main.js +14 -2
  20. package/dist/src/mcp/http.d.ts +10 -0
  21. package/dist/src/mcp/http.js +110 -3
  22. package/dist/src/mcp/serve.d.ts +47 -9
  23. package/dist/src/mcp/serve.js +65 -14
  24. package/dist/src/mcp/stdio.d.ts +1 -0
  25. package/dist/src/mcp/stdio.js +7 -1
  26. package/dist/src/mcp/tools.d.ts +37 -2
  27. package/dist/src/mcp/tools.js +439 -2
  28. package/dist/src/resources.d.ts +13 -0
  29. package/dist/src/resources.js +46 -0
  30. package/dist/src/screens.js +95 -50
  31. package/dist/src/state.d.ts +42 -0
  32. package/dist/src/state.js +80 -1
  33. package/dist/src/tui.d.ts +3 -1
  34. package/dist/src/tui.js +29 -4
  35. package/dist/src/version.d.ts +2 -0
  36. package/dist/src/version.js +38 -0
  37. package/dist/src/view/creature.d.ts +0 -12
  38. package/dist/src/view/creature.js +0 -20
  39. package/dist/src/view/wire.d.ts +36 -0
  40. package/dist/src/view/wire.js +196 -0
  41. package/dist/src/wait.d.ts +11 -0
  42. package/dist/src/wait.js +15 -2
  43. package/dist/src/wire.d.ts +70 -0
  44. package/dist/src/wire.js +194 -0
  45. package/dist/src/wiretui.d.ts +22 -0
  46. package/dist/src/wiretui.js +69 -0
  47. package/package.json +6 -5
  48. package/dist/src/diff.d.ts +0 -44
  49. package/dist/src/diff.js +0 -111
  50. package/dist/src/view/bubble.d.ts +0 -75
  51. package/dist/src/view/bubble.js +0 -86
  52. package/dist/src/view/composer.d.ts +0 -64
  53. package/dist/src/view/composer.js +0 -192
  54. package/dist/src/view/controls.d.ts +0 -44
  55. package/dist/src/view/controls.js +0 -49
  56. package/dist/src/view/details.d.ts +0 -65
  57. package/dist/src/view/details.js +0 -65
  58. package/dist/src/view/filediff.d.ts +0 -29
  59. package/dist/src/view/filediff.js +0 -24
  60. package/dist/src/view/hitl.d.ts +0 -43
  61. package/dist/src/view/hitl.js +0 -171
  62. package/dist/src/view/icons.d.ts +0 -13
  63. package/dist/src/view/icons.js +0 -71
  64. package/dist/src/view/picker.d.ts +0 -42
  65. package/dist/src/view/picker.js +0 -71
  66. package/dist/src/view/sessionhead.d.ts +0 -41
  67. package/dist/src/view/sessionhead.js +0 -60
  68. package/dist/src/view/sessions.d.ts +0 -34
  69. package/dist/src/view/sessions.js +0 -61
  70. package/dist/src/view/toolcall.d.ts +0 -27
  71. package/dist/src/view/toolcall.js +0 -48
  72. package/dist/src/view/transcript.d.ts +0 -50
  73. package/dist/src/view/transcript.js +0 -60
@@ -1,49 +0,0 @@
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
- });
@@ -1,65 +0,0 @@
1
- import type { BoxProps, RenderOutput, SemanticVariant } from '@textui/core';
2
- /**
3
- * A property list you can walk, and take a value out of.
4
- *
5
- * The catalogue's detail pane is where the identifiers live - a session URI, a
6
- * chat URI - and an identifier you cannot read in full or paste anywhere is
7
- * decoration. `KeyValue` draws the same pairs and is static: nothing selects a
8
- * row, so nothing can be copied and nothing can be shown untruncated.
9
- *
10
- * So the selected row is the one that gets the room. Every other row is one
11
- * line with its value truncated, and the selected row wraps its value across
12
- * as many lines as it needs - which costs nothing when the value is short and
13
- * is the whole answer when it is a URI in a 36-column pane. `enter` puts it on
14
- * the clipboard.
15
- *
16
- * This is a finding, not a flourish: it is the third component this example
17
- * wanted that the catalog does not have.
18
- */
19
- export interface DetailField {
20
- id: string;
21
- label: string;
22
- value: string;
23
- tone?: SemanticVariant;
24
- /** Shown instead of the value when there is none, in the subtle tone. */
25
- absent?: string;
26
- }
27
- export interface SessionDetailsProps extends BoxProps {
28
- fields: DetailField[];
29
- focusId?: string;
30
- /**
31
- * Width of the label column, where a caller wants to fix it.
32
- *
33
- * Left off, it is the widest label there is. It used to be a constant on
34
- * the grounds that the labels were ours, and they are not: a session's
35
- * settings and a model's options are named by whichever host is answering,
36
- * in words this client does not choose, and the constant was one character
37
- * wider than the longest label anybody had thought of.
38
- */
39
- labelWidth?: number;
40
- /**
41
- * Take the keyboard on the frame this mounts, out of whatever holds it.
42
- *
43
- * Not `autoFocus`, which claims focus rather than taking it: a pane that
44
- * appears *because* a key asked for it has to end up with the cursor, and
45
- * the thing it is taking the cursor from - the session list - is in the
46
- * same focus scope and already has it.
47
- *
48
- * It has to be done here rather than by the screen that mounts this,
49
- * because a focusable registers in its own effect: on the render that opens
50
- * the pane, the id does not exist yet and the screen's `focus()` is a call
51
- * that quietly returns false.
52
- */
53
- claim?: boolean;
54
- /**
55
- * Which rows show their value whole.
56
- *
57
- * `selected` is the default and the one that keeps the pane a list: every
58
- * row is one line, and the row you have stopped on wraps to as many as its
59
- * value needs. `all` wraps every row, which is what you want when the
60
- * values *are* the point - a pane of URIs where the answer is on the third
61
- * one down and reading it should not mean walking there first.
62
- */
63
- values?: 'selected' | 'all';
64
- }
65
- export declare const SessionDetails: (props: SessionDetailsProps) => RenderOutput;
@@ -1,65 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "@textui/core/jsx-runtime";
2
- import { defineComponent, useClipboard, useEffect, useFocus, useInput, useState, useTheme, } from '@textui/core';
3
- import { Column, Row } from '@textui/widgets';
4
- export const SessionDetails = defineComponent('SessionDetails', (props) => {
5
- const { fields, focusId, labelWidth, claim, values = 'selected', ...rest } = props;
6
- // Floored at the eleven this used to be, so nothing that already fitted
7
- // moves, and capped so one verbose title cannot take the pane from the
8
- // values it is there to label.
9
- const column = labelWidth ?? Math.min(20, Math.max(11, ...fields.map((field) => field.label.length)));
10
- const theme = useTheme();
11
- const clipboard = useClipboard();
12
- const focus = useFocus({ ...(focusId ? { id: focusId } : {}) });
13
- const [index, setIndex] = useState(0);
14
- const [copied, setCopied] = useState(null);
15
- // The selection is an index into a list that changes with the selected
16
- // session, so it is clamped on the way out rather than reset on the way in.
17
- const at = Math.max(0, Math.min(index, fields.length - 1));
18
- // After the registration above, which is the whole point of it being here.
19
- useEffect(() => { if (claim)
20
- focus.focus(); }, [claim]);
21
- useInput((event) => {
22
- if (fields.length === 0)
23
- return false;
24
- switch (event.name) {
25
- case 'up':
26
- setIndex(Math.max(0, at - 1));
27
- setCopied(null);
28
- return true;
29
- case 'down':
30
- setIndex(Math.min(fields.length - 1, at + 1));
31
- setCopied(null);
32
- return true;
33
- case 'home':
34
- setIndex(0);
35
- setCopied(null);
36
- return true;
37
- case 'end':
38
- setIndex(fields.length - 1);
39
- setCopied(null);
40
- return true;
41
- case 'enter': {
42
- const field = fields[at];
43
- if (!field || !field.value)
44
- return true;
45
- // OSC 52 where the terminal takes it, and the store either way - so
46
- // a test can assert what was copied without a terminal at all.
47
- clipboard.write(field.value);
48
- setCopied(field.id);
49
- return true;
50
- }
51
- default: return false;
52
- }
53
- }, { focusId: focus.id });
54
- return (_jsx(Column, { ...rest, id: focus.id, children: fields.map((field, i) => {
55
- const active = i === at;
56
- const selected = active && focus.focused;
57
- const whole = values === 'all' || active;
58
- return (
59
- // `align="start"` because a wrapped value makes the row two lines
60
- // tall and the label is one: centred, it drifted down to sit
61
- // beside the *second* line of a URI, with nothing at all beside
62
- // the first. A label names the row it starts.
63
- _jsxs(Row, { gap: 1, align: "start", children: [_jsx("text", { content: active ? theme.glyphs.chevronRight : ' ', fg: selected ? 'accent' : 'subtle', shrink: 0 }), _jsx("text", { content: field.label, width: column, shrink: 0, fg: "muted", truncate: "end" }), _jsx("text", { content: field.value || field.absent || '-', flex: 1, ...(whole ? { wrap: 'word' } : { truncate: 'end' }), ...(field.value ? {} : { fg: 'subtle' }), ...(field.tone && field.value ? { fg: field.tone } : {}), ...(selected ? { bold: true } : {}) }), copied === field.id ? _jsx("text", { content: "copied", fg: "success", shrink: 0 }) : null] }, field.id));
64
- }) }));
65
- });
@@ -1,29 +0,0 @@
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;
@@ -1,24 +0,0 @@
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
- });
@@ -1,43 +0,0 @@
1
- import type { BindingPath, BoxProps, RenderOutput } from '@textui/core';
2
- import type { Answer, PendingInput } from '../ahp/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 declare const ANSWERS: BindingPath;
22
- export interface ChatHitlProps extends BoxProps {
23
- input: PendingInput;
24
- onApprove(optionId?: string): void;
25
- onDeny(): void;
26
- onAnswer(answers: Record<string, Answer>, accepted: boolean): void;
27
- /** Leave it up, but give the keyboard back. */
28
- onEscape?(): void;
29
- }
30
- export declare const ChatHitl: (props: ChatHitlProps) => RenderOutput;
31
- /**
32
- * What came of the answer, on the row between the block and the composer.
33
- *
34
- * That row was a blank one, and it is where a person is already looking after
35
- * pressing a button on the block above it. A press that reaches a host which
36
- * then says nothing is indistinguishable from a key that was never read - so
37
- * this says the answer has gone, and says so in red when it did not.
38
- *
39
- * Nothing to say is no row rather than an empty one: a status line that is
40
- * always there is a row of chrome, and the composer is a row further from the
41
- * conversation for the whole of every session in which nothing goes wrong.
42
- */
43
- export declare const ChatInputStatus: (props: BoxProps) => RenderOutput;
@@ -1,171 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "@textui/core/jsx-runtime";
2
- import { defineComponent, useApp, useFocusScope, useInput, useMeasure, useStore, useStoreValue, useTheme, } from '@textui/core';
3
- import { useFloorTop } from './creature.js';
4
- import { Button, Checkbox, Column, MarkdownView, Panel, RadioGroup, Row, TextArea, TextInput, } from '@textui/widgets';
5
- import { INPUT_STATUS } from '../state.js';
6
- /**
7
- * The block that means the agent is stopped, waiting on a person.
8
- *
9
- * It is the only thing on the screen that is waiting on the reader, so it
10
- * takes the focus, traps it, and is answerable without leaving the keyboard.
11
- * Rendered as one more bubble in the transcript it scrolls away, and a blocked
12
- * agent that has scrolled off is a session that looks merely slow.
13
- *
14
- * Two kinds, and they are nothing alike:
15
- *
16
- * a **tool confirmation** is a yes or a no about a command, with named
17
- * options where the host offers them;
18
- *
19
- * a **question** carries no tool call at all. Its prose is the request's own
20
- * message and what is being asked is its questions. Rendering it as a
21
- * confirmation loses the entire request - the choices vanish, the question
22
- * is never shown, and what is left is a heading and an Approve button.
23
- */
24
- export const ANSWERS = '$/chat/ui/answers';
25
- export const ChatHitl = defineComponent('ChatHitl', (props) => {
26
- // It sits above the composer rather than inside it, so it says where it
27
- // starts: the composer alone left the creature standing in the question.
28
- useFloorTop('ask', useMeasure().y);
29
- const { input, onApprove, onDeny, onAnswer, onEscape, ...rest } = props;
30
- const theme = useTheme();
31
- // Focused on arrival, and not trapped.
32
- //
33
- // Trapping read well - the answer is the only thing to do - and it was
34
- // wrong. A trap routes every key inside the scope, so while a session was
35
- // blocked the transcript could not be scrolled, escape could not leave the
36
- // screen, and the block that said `esc read` was the one thing making that
37
- // impossible. You approve a command on the strength of what is written
38
- // above it, so reading has to stay available while it is up.
39
- //
40
- // The keys that answer it are global (see `ConfirmRequest`), so they still
41
- // work from wherever the reader has gone.
42
- useFocusScope({ id: 'chat.hitl', autoFocus: true, restore: true });
43
- // Wrapped, so that what `useFloorTop` measures is the box this occupies
44
- // rather than the room inside its border - which is one row lower, and put
45
- // the creature's feet through the title.
46
- 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'
47
- ? _jsx(ConfirmRequest, { input: input, onApprove: onApprove, onDeny: onDeny, ...(onEscape ? { onEscape } : {}) })
48
- : _jsx(QuestionForm, { input: input, onAnswer: onAnswer, ...(onEscape ? { onEscape } : {}) }) }) }));
49
- });
50
- /** A yes or a no about a command, and the named options the host offered. */
51
- const ConfirmRequest = defineComponent('ConfirmRequest', ({ input, onApprove, onDeny, onEscape }) => {
52
- const theme = useTheme();
53
- const options = input.call.options ?? [];
54
- // The letters are on the block, not on the application: they only exist
55
- // while it is up, and while it is up nothing else wants them.
56
- useInput((event) => {
57
- if (event.name === 'a') {
58
- onApprove();
59
- return true;
60
- }
61
- if (event.name === 'd') {
62
- onDeny();
63
- return true;
64
- }
65
- if (event.name === 'escape') {
66
- onEscape?.();
67
- return true;
68
- }
69
- const digit = Number(event.name);
70
- if (Number.isInteger(digit) && digit >= 1 && digit <= options.length) {
71
- onApprove(options[digit - 1]?.id);
72
- return true;
73
- }
74
- return false;
75
- }, { global: true });
76
- 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)))] })] }));
77
- });
78
- /**
79
- * A question, with its questions.
80
- *
81
- * Every answer names its own kind, keyed by question id, and a required one
82
- * left empty is not sendable - accepting with no answers resumes the agent on
83
- * the answers it already had, which for a question it has just asked is none.
84
- */
85
- const QuestionForm = defineComponent('QuestionForm', ({ input, onAnswer, onEscape }) => {
86
- const theme = useTheme();
87
- const app = useApp();
88
- // Draft answers live in the store, not in this component: AHP has an action
89
- // for a draft answer precisely because another client may be looking at the
90
- // same question, and a value only this box knows is one nobody else can see.
91
- const [draft, setDraft] = useStore(`${ANSWERS}/${input.id}`, {});
92
- const answers = draft ?? {};
93
- const set = (id, answer) => {
94
- const next = { ...answers };
95
- if (answer === null)
96
- delete next[id];
97
- else
98
- next[id] = answer;
99
- setDraft(next);
100
- };
101
- const missing = input.questions.filter((question) => question.required && !answers[question.id]);
102
- const send = () => { if (missing.length === 0)
103
- onAnswer(answers, true); };
104
- useInput((event) => {
105
- if (event.name === 'escape') {
106
- onEscape?.();
107
- return true;
108
- }
109
- // Enter sends, which is what the hint row under this says while a question
110
- // is up - but only from inside the block. A reader who has deliberately
111
- // tabbed down to the composer to queue a message is pressing enter at the
112
- // composer, and answering the question with it would be the same mistake
113
- // in the other direction. The fields that consume enter themselves - a
114
- // text input, a checkbox, the Send button - never reach this.
115
- if (event.name === 'enter') {
116
- const at = app.focus.focused();
117
- if (at === null || app.focus.scopeOf(at) !== 'chat.hitl')
118
- return false;
119
- send();
120
- return true;
121
- }
122
- return false;
123
- }, { global: true });
124
- 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,
125
- // The first field takes the keyboard, and the scope asking for it is
126
- // not enough: a scope's `autoFocus` only claims focus when nothing
127
- // holds any, and while a question is up the composer holds it. So a
128
- // question rendered its field, put `required` beside it, disabled
129
- // Send until it was answered - and every key typed at it went into
130
- // the composer behind. A control that says it wants focus is the one
131
- // thing that takes it off another scope, which is why the
132
- // confirmation's Approve button works and this did not.
133
- 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] })] }));
134
- });
135
- /** One question, rendered by its kind. The kind decides the answer's shape. */
136
- const QuestionField = defineComponent('QuestionField', ({ question, answer, autoFocus, onChange, onSubmit }) => {
137
- const theme = useTheme();
138
- const options = question.options ?? [];
139
- const selectedMany = answer?.kind === 'selected-many' ? answer.value : [];
140
- 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({
141
- kind: 'selected-many',
142
- value: checked
143
- ? [...selectedMany, option.id]
144
- : selectedMany.filter((id) => id !== option.id),
145
- }) }, 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' ? (
146
- // Freeform answers *instead of* choosing, never as one more choice: a
147
- // host reading only the selection must not be handed typed words in
148
- // the field where it expects an option id.
149
- _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] }));
150
- });
151
- /**
152
- * What came of the answer, on the row between the block and the composer.
153
- *
154
- * That row was a blank one, and it is where a person is already looking after
155
- * pressing a button on the block above it. A press that reaches a host which
156
- * then says nothing is indistinguishable from a key that was never read - so
157
- * this says the answer has gone, and says so in red when it did not.
158
- *
159
- * Nothing to say is no row rather than an empty one: a status line that is
160
- * always there is a row of chrome, and the composer is a row further from the
161
- * conversation for the whole of every session in which nothing goes wrong.
162
- */
163
- export const ChatInputStatus = defineComponent('ChatInputStatus', (props) => {
164
- const theme = useTheme();
165
- const status = useStoreValue(INPUT_STATUS, null) ?? null;
166
- if (!status)
167
- return null;
168
- const failed = status.state === 'failed';
169
- const tone = failed ? 'danger' : 'muted';
170
- return (_jsxs(Row, { gap: 1, ...props, children: [_jsx("text", { content: failed ? theme.glyphs.warning : theme.glyphs.bulletHollow, fg: tone }), _jsx("text", { content: status.text, fg: tone, flex: 1, truncate: "end" })] }));
171
- });
@@ -1,13 +0,0 @@
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;
@@ -1,71 +0,0 @@
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
- }
@@ -1,42 +0,0 @@
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;