@softov/ahpc 0.3.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 (60) hide show
  1. package/README.md +6 -4
  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 +93 -37
  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 +75 -2
  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 +1 -1
  17. package/dist/src/links.d.ts +54 -0
  18. package/dist/src/links.js +120 -0
  19. package/dist/src/resources.d.ts +13 -0
  20. package/dist/src/resources.js +46 -0
  21. package/dist/src/screens.js +95 -50
  22. package/dist/src/state.d.ts +42 -0
  23. package/dist/src/state.js +80 -1
  24. package/dist/src/tui.d.ts +3 -1
  25. package/dist/src/tui.js +28 -4
  26. package/dist/src/view/creature.d.ts +0 -12
  27. package/dist/src/view/creature.js +0 -20
  28. package/dist/src/view/wire.d.ts +36 -0
  29. package/dist/src/view/wire.js +196 -0
  30. package/dist/src/wire.d.ts +70 -0
  31. package/dist/src/wire.js +194 -0
  32. package/dist/src/wiretui.d.ts +22 -0
  33. package/dist/src/wiretui.js +69 -0
  34. package/package.json +6 -5
  35. package/dist/src/diff.d.ts +0 -44
  36. package/dist/src/diff.js +0 -111
  37. package/dist/src/view/bubble.d.ts +0 -75
  38. package/dist/src/view/bubble.js +0 -86
  39. package/dist/src/view/composer.d.ts +0 -64
  40. package/dist/src/view/composer.js +0 -192
  41. package/dist/src/view/controls.d.ts +0 -44
  42. package/dist/src/view/controls.js +0 -49
  43. package/dist/src/view/details.d.ts +0 -65
  44. package/dist/src/view/details.js +0 -65
  45. package/dist/src/view/filediff.d.ts +0 -29
  46. package/dist/src/view/filediff.js +0 -24
  47. package/dist/src/view/hitl.d.ts +0 -43
  48. package/dist/src/view/hitl.js +0 -171
  49. package/dist/src/view/icons.d.ts +0 -13
  50. package/dist/src/view/icons.js +0 -71
  51. package/dist/src/view/picker.d.ts +0 -42
  52. package/dist/src/view/picker.js +0 -71
  53. package/dist/src/view/sessionhead.d.ts +0 -41
  54. package/dist/src/view/sessionhead.js +0 -60
  55. package/dist/src/view/sessions.d.ts +0 -34
  56. package/dist/src/view/sessions.js +0 -61
  57. package/dist/src/view/toolcall.d.ts +0 -27
  58. package/dist/src/view/toolcall.js +0 -48
  59. package/dist/src/view/transcript.d.ts +0 -50
  60. package/dist/src/view/transcript.js +0 -60
@@ -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;
@@ -1,71 +0,0 @@
1
- /**
2
- * The palette, moved.
3
- *
4
- * A chip on the composer's control row asks one question - which harness,
5
- * which model, how much it may do without asking, which workspace - and every
6
- * one of those is a command with an argument. The command palette already
7
- * knows how to ask about exactly that: `openAt` drills straight into an
8
- * argument, `choices` may be a list, a function or a promise, an argument
9
- * *without* choices is answered by typing, and `preview` shows what a
10
- * highlighted value would do.
11
- *
12
- * So this is not a second overlay. It is the same one, anchored above the
13
- * control that asked, and shown one command's question rather than the whole
14
- * registry. Anything registered later - a workspace list, a thinking level, a
15
- * subagent - is a command with an argument and needs nothing here.
16
- */
17
- export const PICKER = 'chat.picker';
18
- export function openPicker(app, options) {
19
- const command = app.commands.get(options.commandId);
20
- if (!command)
21
- return;
22
- // The chip that opened this one closes it. A control whose panel is already
23
- // showing is a toggle - clicking it again to make it go away is the first
24
- // thing anybody tries, and reopening it looks like the click did nothing.
25
- const showing = app.layers.entries().find((entry) => entry.id === PICKER);
26
- const already = showing?.node;
27
- if (already?.openAt === options.commandId) {
28
- app.layers.close(PICKER);
29
- return;
30
- }
31
- // Closed first, so opening a second chip's panel replaces the first rather
32
- // than stacking two of them with the same id.
33
- app.layers.close(PICKER);
34
- app.layers.open({
35
- id: PICKER,
36
- // Floating, not modal: there is no scrim, because the row it is asking
37
- // about has to stay readable underneath it. Which value is in force is
38
- // half of what makes the question answerable.
39
- layer: 'floating',
40
- trapFocus: true,
41
- dismissOnEscape: true,
42
- dismissOnOutsideClick: true,
43
- // Above the chip, aligned to its left edge - so it rises out of the
44
- // control rather than appearing somewhere else on the screen. A composer
45
- // sits at the bottom, so `top` is the side with room.
46
- position: { kind: 'anchor', targetId: options.anchorId, side: 'top', align: 'start' },
47
- node: {
48
- component: 'CommandPalette',
49
- ...(options.width !== undefined ? { width: options.width } : {}),
50
- maxWidth: options.maxWidth ?? 52,
51
- visibleRows: options.visibleRows ?? 6,
52
- // One command, so the panel opens on its values instead of on a search
53
- // box with everything in it.
54
- commands: [command],
55
- openAt: options.commandId,
56
- ...(options.descriptions ? { descriptions: options.descriptions } : {}),
57
- onClose: { handler: () => app.layers.close(PICKER) },
58
- },
59
- // Back to the chip that asked, however it was closed.
60
- //
61
- // The layer's own scope restores focus when a choice is made, and does not
62
- // when the panel is dismissed with escape - which strands the keyboard on
63
- // a node inside a layer that is no longer there, and the next tab starts
64
- // from nowhere. Saying where focus goes is cheaper than depending on which
65
- // of the two paths ran.
66
- // Back to the chip that asked, whichever way it was closed. The layer's
67
- // own scope restores focus after a choice; a dismissal would otherwise
68
- // leave the keyboard on a node that is no longer mounted.
69
- onClose: () => app.focus.focus(options.anchorId),
70
- });
71
- }
@@ -1,41 +0,0 @@
1
- import type { BoxProps, RenderOutput } from '@textui/core';
2
- import type { SessionSummary } from '../ahp/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: SessionSummary;
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;
@@ -1,60 +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, KeyValue, Row } from '@textui/widgets';
4
- import { decodeStatus } from '../ahp/status.js';
5
- import { branchName } from '../state.js';
6
- /**
7
- * A timestamp a person can read, in whatever this machine calls a date.
8
- *
9
- * The host sends ISO, which is unambiguous and not what anybody wants to read
10
- * off a caption. An unparseable one is passed through rather than shown as
11
- * "Invalid Date": what the host said is more useful than what we made of it.
12
- */
13
- function when(iso) {
14
- if (!iso)
15
- return '';
16
- const at = new Date(iso);
17
- if (Number.isNaN(at.getTime()))
18
- return iso;
19
- return at.toLocaleString(undefined, {
20
- year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
21
- });
22
- }
23
- export const ChatSessionHead = defineComponent('ChatSessionHead', (props) => {
24
- const { session, model, chat, settings = [], present = [], ...rest } = props;
25
- const theme = useTheme();
26
- const status = decodeStatus(session.status);
27
- const started = when(session.createdAt);
28
- const updated = when(session.modifiedAt);
29
- // Only what is known. A row of empty values is what building this from a
30
- // fixed list produces, and it reads as a session the host would not talk
31
- // about rather than as one nobody has asked yet.
32
- const rows = [
33
- { label: 'Harness', value: [session.provider, model].filter(Boolean).join(` ${theme.glyphs.separator} `) },
34
- ...settings.filter((setting) => setting.value).map((setting) => ({ ...setting })),
35
- { label: 'Workspace', value: session.workingDirectories.map((dir) => dir.replace(/^file:\/\//, '')).join(', ') },
36
- // Only when the host says one. A blank branch row reads as a detached
37
- // head rather than as a host that does not report branches.
38
- ...(branchName(session) ? [{ label: 'Branch', value: branchName(session) }] : []),
39
- {
40
- label: 'Started',
41
- value: started && updated && updated !== started
42
- ? `${started} ${theme.glyphs.separator} updated ${updated}`
43
- : started,
44
- },
45
- // Last, and in full. A uri you can read half of is worse than one you
46
- // cannot see at all: it looks like the whole thing.
47
- { label: 'Session', value: session.resource },
48
- ...(chat ? [{ label: 'Chat', value: chat }] : []),
49
- // Only when somebody else is here. One entry is this client, and a row
50
- // saying you are the person reading it is a row that tells nobody
51
- // anything.
52
- ...(present.length > 1
53
- ? [{
54
- label: 'Here',
55
- value: present.map((one) => one.displayName ?? one.clientId).join(` ${theme.glyphs.separator} `),
56
- }]
57
- : []),
58
- ].filter((row) => row.value !== '');
59
- return (_jsxs(Column, { ...rest, gap: 0, children: [_jsxs(Row, { gap: 1, children: [_jsx("text", { content: theme.glyphs[status.glyph], fg: status.tone, shrink: 0 }), _jsx("text", { content: session.title, bold: true, wrap: "word", flex: 1 }), _jsx("text", { content: status.label, fg: status.tone, shrink: 0 })] }), _jsx(KeyValue, { items: rows })] }));
60
- });
@@ -1,34 +0,0 @@
1
- import type { BoxProps, RenderOutput } from '@textui/core';
2
- import type { SessionSummary } from '../ahp/types.js';
3
- /**
4
- * The catalogue.
5
- *
6
- * Still a `List` - the selection, the keys, the window and the highlight are
7
- * all the list's, and reimplementing them here is what the transcript already
8
- * proved is a mistake. What is ours is the row, because a session does not fit
9
- * the one-line shape a list gives you for free.
10
- *
11
- * It takes two lines, and the first one is why. A title, a harness, a
12
- * workspace and a status sharing a pane that is also sharing the terminal
13
- * with the detail panel leaves every one of them truncated:
14
- * `Draft replies for desk-produ…` beside `1b444e78-d050-4fb5-a5…` names
15
- * neither the conversation nor the directory it is in. So the title gets the
16
- * width, and everything that qualifies it goes underneath.
17
- */
18
- export interface SessionListProps extends BoxProps {
19
- sessions: SessionSummary[];
20
- selectedId?: string | null;
21
- onSelect?(uri: string): void;
22
- onOpen?(uri: string): void;
23
- emptyMessage?: string;
24
- focusId?: string;
25
- autoFocus?: boolean;
26
- }
27
- export declare const SessionList: (props: SessionListProps) => RenderOutput;
28
- export interface ConnectionBadgeProps extends BoxProps {
29
- url: string;
30
- state: 'connecting' | 'connected' | 'offline';
31
- sessions?: number;
32
- }
33
- /** Which host, and whether it is answering. */
34
- export declare const ConnectionBadge: (props: ConnectionBadgeProps) => RenderOutput;
@@ -1,61 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "@textui/core/jsx-runtime";
2
- import { defineComponent, stringWidth, useTheme } from '@textui/core';
3
- import { Badge, Column, List, Marquee, Row } from '@textui/widgets';
4
- import { decodeStatus } from '../ahp/status.js';
5
- import { branchName, projectName } from '../state.js';
6
- export const SessionList = defineComponent('SessionList', (props) => {
7
- const { sessions, selectedId, onSelect, onOpen, emptyMessage, focusId, autoFocus, ...rest } = props;
8
- const theme = useTheme();
9
- const dot = ` ${theme.glyphs.separator} `;
10
- const items = sessions.map((session) => {
11
- const status = decodeStatus(session.status);
12
- const changes = session.changes;
13
- return {
14
- id: session.resource,
15
- // Glyph first, so the one that wants a person is findable in a piped
16
- // log, a 16-colour session and by a reader who cannot see the colour.
17
- icon: theme.glyphs[status.glyph],
18
- label: session.title,
19
- // The second line, in the order it gets read: which harness, then
20
- // where, then what it has to show for it.
21
- //
22
- // The harness stands where a model would. The catalogue does not carry
23
- // one: `listSessions` answers with a `SessionSummary`, and the model
24
- // AHP knows is the one on the *last message*, which lives on the
25
- // session channel. A model per row would be a subscription per row.
26
- description: [
27
- session.provider,
28
- // The project, then the branch it is on - a catalogue spanning
29
- // several repositories is read by which one each row is in, and a
30
- // list of them all on `main` is a list that needs opening to tell
31
- // apart.
32
- [projectName(session), branchName(session)].filter(Boolean).join(' '),
33
- changes?.files
34
- ? `${changes.files} files +${changes.additions ?? 0} -${changes.deletions ?? 0}`
35
- : '',
36
- // What the host says it is doing, in its own words. Last, because it
37
- // is the one that is usually not there.
38
- session.activity ?? '',
39
- // Why it is here at all, when nobody started it. Without this a
40
- // session that appeared at nine in the morning is a row with no
41
- // account of itself, sitting among rows somebody typed.
42
- session.origin?.kind === 'automation' ? 'by an automation' : '',
43
- status.archived ? 'archived' : '',
44
- ].filter(Boolean).join(dot),
45
- meta: status.label,
46
- tone: status.tone,
47
- };
48
- });
49
- return (_jsx(List, { items: items, itemHeight: 2, renderItem: (item, state) => (_jsxs(Column, { children: [_jsxs(Row, { gap: 1, children: [_jsx("text", { content: item.icon ?? '', ...(state.selected ? {} : { fg: item.tone }), shrink: 0 }), _jsx(Marquee, { content: item.label, active: state.selected && state.focused, flex: 1 }), _jsx("text", { content: item.meta ?? '', ...(state.selected ? {} : { fg: 'muted' }), shrink: 0 })] }), _jsxs(Row, { children: [_jsx("text", { content: ' '.repeat(stringWidth(item.icon ?? '') + 1), shrink: 0 }), _jsx(Marquee, { content: item.description ?? '', active: state.selected && state.focused, ...(state.selected ? {} : { fg: 'muted' }), flex: 1 })] })] })), ...(selectedId ? { selectedId } : {}), emptyMessage: emptyMessage ?? 'No sessions on this host', onSelect: (id) => onSelect?.(id), onActivate: (id) => onOpen?.(id), ...(focusId ? { focusId } : {}), ...(autoFocus ? { autoFocus: true } : {}), ...rest }));
50
- });
51
- /** Which host, and whether it is answering. */
52
- export const ConnectionBadge = defineComponent('ConnectionBadge', (props) => {
53
- const { url, state, sessions, ...rest } = props;
54
- const theme = useTheme();
55
- const look = {
56
- connected: { tone: 'success', glyph: theme.glyphs.bulletFilled },
57
- connecting: { tone: 'warning', glyph: theme.glyphs.bulletHalf },
58
- offline: { tone: 'danger', glyph: theme.glyphs.cross },
59
- }[state];
60
- return (_jsxs(Row, { gap: 1, ...rest, children: [_jsx("text", { content: look.glyph, fg: look.tone }), _jsx("text", { content: url, fg: "muted", truncate: "start" }), sessions !== undefined ? _jsx(Badge, { label: `${sessions} sessions`, tone: "muted" }) : null] }));
61
- });
@@ -1,27 +0,0 @@
1
- import type { BoxProps, RenderOutput } from '@textui/core';
2
- import type { ToolCall } from '../ahp/types.js';
3
- /**
4
- * A tool call, as a row.
5
- *
6
- * Twenty of these in a turn look identical unless the *command* is on the row,
7
- * so the input is the row and the display name is a prefix. What it meant to
8
- * do is markdown, like everything else a host writes for a person; what came
9
- * back is not, and is shown as it arrived.
10
- *
11
- * The status is a glyph and a colour together. A 16-colour session, a piped
12
- * log and a colourblind reader all lose the colour and keep the glyph.
13
- *
14
- * It sits at the turn's own left edge rather than inside the gutter, with its
15
- * status glyph where the header's bullet is. A tool call is something the
16
- * agent *did*; indenting it inside the rule filed it under what the agent was
17
- * saying, which is the one thing it is not.
18
- */
19
- export interface ToolCallRowProps extends BoxProps {
20
- call: ToolCall;
21
- expanded?: boolean;
22
- /** The transcript's cursor is on this row. */
23
- active?: boolean;
24
- /** Clicking the row opens it. */
25
- onToggle?(): void;
26
- }
27
- export declare const ToolCallRow: (props: ToolCallRowProps) => RenderOutput;