@aragon-agent/cli 0.6.24 → 0.6.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1835 -1786
- package/README.md +2578 -2562
- package/dist/agent/controller.d.ts +49 -3
- package/dist/agent/controller.js +78 -3
- package/dist/agent/conversation-reset.d.ts +91 -0
- package/dist/agent/conversation-reset.js +66 -0
- package/dist/agent/model-probe-limits.d.ts +39 -0
- package/dist/agent/model-probe-limits.js +25 -0
- package/dist/agent/model-probe.d.ts +104 -0
- package/dist/agent/model-probe.js +391 -0
- package/dist/agent/reducer.js +45 -13
- package/dist/commands/builtins.js +14 -34
- package/dist/commands/clear-command.d.ts +27 -0
- package/dist/commands/clear-command.js +107 -0
- package/dist/commands/registry.d.ts +20 -1
- package/dist/config/model-profiles.d.ts +8 -0
- package/dist/config/model-profiles.js +10 -0
- package/dist/session/clear-archive.d.ts +51 -0
- package/dist/session/clear-archive.js +160 -0
- package/dist/todo/types.d.ts +5 -3
- package/dist/ui/App.js +39 -7
- package/dist/ui/entries/CompactionCard.d.ts +5 -4
- package/dist/ui/entries/CompactionCard.js +5 -4
- package/dist/ui/overlays/HelpOverlay.js +8 -2
- package/dist/ui/overlays/ModelDiscoveryList.d.ts +23 -0
- package/dist/ui/overlays/ModelDiscoveryList.js +161 -0
- package/dist/ui/overlays/ModelProfileEditor.d.ts +18 -1
- package/dist/ui/overlays/ModelProfileEditor.js +117 -68
- package/dist/ui/overlays/SettingsScreen.d.ts +8 -0
- package/dist/ui/overlays/SettingsScreen.js +34 -2
- package/dist/ui/overlays/model-probe-view.d.ts +128 -0
- package/dist/ui/overlays/model-probe-view.js +225 -0
- package/dist/ui/overlays/model-profile-state.d.ts +9 -1
- package/dist/ui/overlays/model-profile-state.js +10 -0
- package/dist/ui/overlays/use-model-probe.d.ts +30 -0
- package/dist/ui/overlays/use-model-probe.js +109 -0
- package/package.json +2 -2
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { Text, useInput } from 'ink';
|
|
4
|
+
import { PROBE_LIMITS } from '../../agent/model-probe-limits.js';
|
|
5
|
+
import { pickGlyphs } from '../glyphs.js';
|
|
6
|
+
import { OverlayFrame } from '../layout/OverlayFrame.js';
|
|
7
|
+
import { stripPasteFrames } from '../paste-frames.js';
|
|
8
|
+
import { stripEnterFrames } from '../enter-frames.js';
|
|
9
|
+
import { clipProfileText, useProfileScroll } from './ModelProfilePicker.js';
|
|
10
|
+
import { rankCandidates, windowLabel, clipName, LIST_ID_CHARS } from './model-probe-view.js';
|
|
11
|
+
function matches(candidate, query) {
|
|
12
|
+
const needle = query.toLocaleLowerCase();
|
|
13
|
+
return candidate.id.toLocaleLowerCase().includes(needle)
|
|
14
|
+
|| candidate.name.toLocaleLowerCase().includes(needle);
|
|
15
|
+
}
|
|
16
|
+
/** One row: id column, name column, window right-aligned inside the frame budget. */
|
|
17
|
+
function listRowText(candidate, cols) {
|
|
18
|
+
const width = Math.max(20, cols - 8);
|
|
19
|
+
const label = windowLabel(candidate.contextWindow);
|
|
20
|
+
const idWidth = Math.max(10, Math.min(LIST_ID_CHARS, width - label.length - 14));
|
|
21
|
+
const nameWidth = Math.max(4, width - idWidth - label.length - 2);
|
|
22
|
+
const id = [...candidate.id].slice(0, idWidth).join('').padEnd(idWidth);
|
|
23
|
+
// `clipName` caps the NAME COLUMN itself, so a vendor that ships a paragraph in
|
|
24
|
+
// `name` cannot push the window label out of a wide frame either.
|
|
25
|
+
const name = [...clipName(candidate.name)].slice(0, nameWidth).join('').padEnd(nameWidth);
|
|
26
|
+
return clipProfileText(`${id} ${name} ${label}`, width);
|
|
27
|
+
}
|
|
28
|
+
/** What the page says when there is no row to select. */
|
|
29
|
+
function emptyLines(busy, provider, view) {
|
|
30
|
+
if (busy)
|
|
31
|
+
return [`Discovering ${provider} models...`];
|
|
32
|
+
if (view.kind === 'discovery' && !view.outcome.ok) {
|
|
33
|
+
return [view.outcome.detail ?? 'The model list could not be fetched.',
|
|
34
|
+
'Fix: check the base URL and key, then press r to retry.'];
|
|
35
|
+
}
|
|
36
|
+
if (view.kind === 'discovery') {
|
|
37
|
+
return ['No models returned. Press r to retry, Esc to go back.',
|
|
38
|
+
'Test the connection for a precise reason.'];
|
|
39
|
+
}
|
|
40
|
+
return ['No models discovered yet. Press r to retry, Esc to go back.'];
|
|
41
|
+
}
|
|
42
|
+
function buildRows(props, view, glyphs) {
|
|
43
|
+
const rows = [];
|
|
44
|
+
if (view.searchable) {
|
|
45
|
+
rows.push(_jsxs(Text, { wrap: "truncate", color: props.theme.primary, children: [view.searching ? `${glyphs.caret} ` : ' ', "Search: ", view.query || '(/ to search)'] }, "search"));
|
|
46
|
+
}
|
|
47
|
+
if (view.shown.length === 0) {
|
|
48
|
+
const lines = view.query.length > 0
|
|
49
|
+
? [`No model matches "${view.query}". Esc clears the search.`]
|
|
50
|
+
: emptyLines(props.busy, props.provider, props.view);
|
|
51
|
+
for (const line of lines) {
|
|
52
|
+
rows.push(_jsx(Text, { wrap: "truncate", color: props.theme.muted, children: line }, `empty-${rows.length}`));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
view.shown.forEach((candidate, at) => {
|
|
56
|
+
const focused = at === view.cursor && !view.searching;
|
|
57
|
+
rows.push(_jsxs(Text, { wrap: "truncate", color: at === view.cursor ? props.theme.accent : props.theme.muted, children: [focused ? `${glyphs.caret} ` : ' ', listRowText(candidate, props.cols)] }, candidate.id));
|
|
58
|
+
});
|
|
59
|
+
if (view.truncated) {
|
|
60
|
+
const total = props.view.kind === 'discovery'
|
|
61
|
+
? props.view.outcome.total ?? view.shown.length : view.shown.length;
|
|
62
|
+
rows.push(_jsx(Text, { wrap: "truncate", color: props.theme.muted, children: `Showing first ${PROBE_LIMITS.maxListedModels} of ${total}. Narrow with search.` }, "truncated"));
|
|
63
|
+
}
|
|
64
|
+
return rows;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The discovered-model page (model-profile-connection-test §3.5).
|
|
68
|
+
*
|
|
69
|
+
* Pure display plus input: it receives the discovery outcome and three intents and
|
|
70
|
+
* issues no request itself. No spinner - the census of animated sites is a closed
|
|
71
|
+
* list, and this overlay can be open while a run is in flight.
|
|
72
|
+
*/
|
|
73
|
+
export function ModelDiscoveryList(props) {
|
|
74
|
+
const [index, setIndex] = useState(0);
|
|
75
|
+
const [query, setQuery] = useState('');
|
|
76
|
+
const [searching, setSearching] = useState(false);
|
|
77
|
+
const glyphs = pickGlyphs(props.caps);
|
|
78
|
+
const candidates = props.view.kind === 'discovery'
|
|
79
|
+
? props.view.outcome.models : [];
|
|
80
|
+
const ranked = rankCandidates(candidates, props.currentModel);
|
|
81
|
+
const shown = query.length > 0 ? ranked.filter((item) => matches(item, query)) : ranked;
|
|
82
|
+
const searchable = ranked.length > PROBE_LIMITS.searchThreshold;
|
|
83
|
+
const cursor = shown.length > 0 ? Math.min(index, shown.length - 1) : 0;
|
|
84
|
+
// The truncation notice is one row BELOW the last candidate, and `useProfileScroll`
|
|
85
|
+
// only guarantees the FOCUSED row is visible - the same blind spot that hid the
|
|
86
|
+
// editor's result rows (RV-P1-3). Standing on the last row therefore widens the
|
|
87
|
+
// window by one, or `Showing first 400 of ...` could never be read at all.
|
|
88
|
+
const truncatedNotice = props.view.kind === 'discovery' && props.view.outcome.truncated
|
|
89
|
+
&& shown.length > 0;
|
|
90
|
+
const revealNotice = truncatedNotice && cursor >= shown.length - 1;
|
|
91
|
+
const offset = useProfileScroll(props, cursor + (searchable ? 1 : 0) + (revealNotice ? 1 : 0));
|
|
92
|
+
// Arrival, not every render: when the outcome lands, put the cursor on the model the
|
|
93
|
+
// draft already names, so "which one am I on" is answerable without scrolling.
|
|
94
|
+
const arrival = props.view.kind === 'discovery'
|
|
95
|
+
? `${props.view.outcome.connectionId}|${ranked.length}` : 'none';
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
const at = ranked.findIndex((item) => item.id === props.currentModel.trim());
|
|
98
|
+
setIndex(at > 0 ? at : 0);
|
|
99
|
+
}, [arrival]);
|
|
100
|
+
const move = (delta) => {
|
|
101
|
+
if (shown.length > 0)
|
|
102
|
+
setIndex((cursor + delta + shown.length) % shown.length);
|
|
103
|
+
};
|
|
104
|
+
useInput((input, key) => {
|
|
105
|
+
if (props.isActive === false)
|
|
106
|
+
return;
|
|
107
|
+
if (key.escape) {
|
|
108
|
+
if (searching) {
|
|
109
|
+
setSearching(false);
|
|
110
|
+
setQuery('');
|
|
111
|
+
setIndex(0);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (props.busy) {
|
|
115
|
+
props.onCancelProbe?.();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
props.onBack();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (key.upArrow || (key.tab && key.shift)) {
|
|
122
|
+
move(-1);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (key.downArrow || key.tab) {
|
|
126
|
+
move(1);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (key.return) {
|
|
130
|
+
const picked = shown[cursor];
|
|
131
|
+
if (picked)
|
|
132
|
+
props.onChoose(picked.id);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (searching) {
|
|
136
|
+
if (key.backspace || key.delete) {
|
|
137
|
+
setQuery((value) => [...value].slice(0, -1).join(''));
|
|
138
|
+
setIndex(0);
|
|
139
|
+
}
|
|
140
|
+
else if (input && !key.ctrl && !key.meta) {
|
|
141
|
+
setQuery((value) => value + stripPasteFrames(stripEnterFrames(input)));
|
|
142
|
+
setIndex(0);
|
|
143
|
+
}
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (input === '/' && searchable) {
|
|
147
|
+
setSearching(true);
|
|
148
|
+
setIndex(0);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
// `r` retries ONLY outside the search state: sharing the key with the query would
|
|
152
|
+
// turn one typo into a network request (§3.5 / D-16).
|
|
153
|
+
if (!key.ctrl && !key.meta && input === 'r')
|
|
154
|
+
props.onRetry();
|
|
155
|
+
});
|
|
156
|
+
const hint = props.busy ? 'Esc cancel'
|
|
157
|
+
: searching ? 'Type search | Enter choose | Esc back'
|
|
158
|
+
: `Enter choose${searchable ? ' | / search' : ''} | r retry | Esc back`;
|
|
159
|
+
return _jsx(OverlayFrame, { ...props, scrollOffset: offset, hint: hint, rows: buildRows(props, { query, shown, cursor, searchable, searching,
|
|
160
|
+
truncated: truncatedNotice }, glyphs), title: `Models | ${props.provider} (${shown.length})` });
|
|
161
|
+
}
|
|
@@ -1,12 +1,29 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
+
import type { ProbeTarget } from '../../agent/model-probe.js';
|
|
2
3
|
import { type ProfileFrameProps } from './ModelProfilePicker.js';
|
|
3
4
|
import { type ProfileEditorDraft, type ModelProfileAction } from './model-profile-state.js';
|
|
5
|
+
import { type ProbeView } from './model-probe-view.js';
|
|
4
6
|
interface ModelProfileEditorProps extends ProfileFrameProps {
|
|
5
7
|
editor: ProfileEditorDraft;
|
|
6
8
|
users: string;
|
|
7
9
|
error?: string;
|
|
8
10
|
dispatch: React.Dispatch<ModelProfileAction>;
|
|
11
|
+
/**
|
|
12
|
+
* The rendered probe state and its three intents. ALL OPTIONAL: a standalone or
|
|
13
|
+
* embedded mount has no probe port, and the two action rows then read
|
|
14
|
+
* `(unavailable)` instead of pretending to work (§6.1 / AC-15).
|
|
15
|
+
*/
|
|
16
|
+
probeView?: ProbeView;
|
|
17
|
+
probeBusy?: boolean;
|
|
18
|
+
probeTarget?: ProbeTarget;
|
|
19
|
+
onTest?: () => void;
|
|
20
|
+
onDiscover?: () => void;
|
|
21
|
+
onCancelProbe?: () => void;
|
|
9
22
|
}
|
|
10
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* The profile form. Row semantics come from `editorRows()` - never from index
|
|
25
|
+
* arithmetic - so `apiKey` stays at index 6 and the two probe actions sit at the end
|
|
26
|
+
* of the action block, after `retain` (P0-1 / I-4).
|
|
27
|
+
*/
|
|
11
28
|
export declare function ModelProfileEditor(props: ModelProfileEditorProps): React.ReactElement;
|
|
12
29
|
export {};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useState } from 'react';
|
|
3
3
|
import { Text, useInput } from 'ink';
|
|
4
4
|
import { ADAPTER_PROVIDERS } from '../../config/schema.js';
|
|
@@ -7,54 +7,47 @@ import { OverlayFrame } from '../layout/OverlayFrame.js';
|
|
|
7
7
|
import { stripPasteFrames } from '../paste-frames.js';
|
|
8
8
|
import { stripEnterFrames } from '../enter-frames.js';
|
|
9
9
|
import { pickGlyphs } from '../glyphs.js';
|
|
10
|
-
import {
|
|
10
|
+
import { useProfileScroll } from './ModelProfilePicker.js';
|
|
11
11
|
import { finishProfileEditor } from './model-profile-state.js';
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
// label budget `LABELS.map(...padEnd(18))` enforces below.
|
|
15
|
-
const FIELDS = ['name', 'provider', 'model', 'contextWindow', 'baseUrl', 'credentialMode',
|
|
16
|
-
'apiKey'];
|
|
17
|
-
const LABELS = ['Name', 'Provider', 'Model', 'Context window', 'Base URL', 'Credential mode',
|
|
18
|
-
'API key'];
|
|
19
|
-
function useEditorInput(props, navigation) {
|
|
12
|
+
import { EDITOR_FIELDS, editorRows, editorProbeTarget, focusableRows, resultRowCount } from './model-probe-view.js';
|
|
13
|
+
function useEditorInput(props, focus) {
|
|
20
14
|
const { editor, dispatch } = props;
|
|
21
|
-
const { index, setIndex,
|
|
15
|
+
const { index, setIndex, count, focusable } = focus;
|
|
22
16
|
function commit() {
|
|
23
17
|
const result = finishProfileEditor(editor);
|
|
24
18
|
if (result.field) {
|
|
25
|
-
|
|
19
|
+
// Field rows are the first focusables, in `EDITOR_FIELDS` order, so the jump an
|
|
20
|
+
// invalid field asks for is a lookup rather than an offset guess.
|
|
21
|
+
const at = EDITOR_FIELDS.indexOf(result.field);
|
|
26
22
|
if (at >= 0)
|
|
27
23
|
setIndex(at);
|
|
28
24
|
}
|
|
29
25
|
dispatch({ type: 'merge-editor' });
|
|
30
26
|
}
|
|
31
|
-
|
|
32
|
-
if (
|
|
33
|
-
return;
|
|
34
|
-
if (key.escape) {
|
|
35
|
-
dispatch({ type: 'cancel-editor' });
|
|
36
|
-
return;
|
|
37
|
-
}
|
|
38
|
-
if (key.upArrow || (key.tab && key.shift)) {
|
|
39
|
-
setIndex((index - 1 + count) % count);
|
|
27
|
+
function activate(row) {
|
|
28
|
+
if (!row)
|
|
40
29
|
return;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
setIndex((index + 1) % count);
|
|
30
|
+
if (row.kind === 'field') {
|
|
31
|
+
commit();
|
|
44
32
|
return;
|
|
45
33
|
}
|
|
46
|
-
if (
|
|
47
|
-
if (retain && index === FIELDS.length)
|
|
48
|
-
dispatch({ type: 'retain-key' });
|
|
49
|
-
else if (index === count - 1)
|
|
50
|
-
dispatch({ type: 'cancel-editor' });
|
|
51
|
-
else
|
|
52
|
-
commit();
|
|
34
|
+
if (row.kind !== 'action')
|
|
53
35
|
return;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (
|
|
36
|
+
if (row.action === 'retain')
|
|
37
|
+
dispatch({ type: 'retain-key' });
|
|
38
|
+
if (row.action === 'test')
|
|
39
|
+
props.onTest?.();
|
|
40
|
+
if (row.action === 'discover')
|
|
41
|
+
props.onDiscover?.();
|
|
42
|
+
if (row.action === 'apply')
|
|
43
|
+
commit();
|
|
44
|
+
if (row.action === 'cancel')
|
|
45
|
+
dispatch({ type: 'cancel-editor' });
|
|
46
|
+
}
|
|
47
|
+
function editField(row, input, key) {
|
|
48
|
+
if (row.kind !== 'field')
|
|
57
49
|
return;
|
|
50
|
+
const field = row.field;
|
|
58
51
|
if (field === 'provider' || field === 'credentialMode') {
|
|
59
52
|
if (!key.leftArrow && !key.rightArrow)
|
|
60
53
|
return;
|
|
@@ -75,45 +68,101 @@ function useEditorInput(props, navigation) {
|
|
|
75
68
|
registerSecret(value);
|
|
76
69
|
if (key.backspace || key.delete || text)
|
|
77
70
|
dispatch({ type: 'editor-field', field, value });
|
|
71
|
+
}
|
|
72
|
+
useInput((input, key) => {
|
|
73
|
+
if (props.isActive === false)
|
|
74
|
+
return;
|
|
75
|
+
// Accelerators work on EVERY row (§3.3): the action rows are the primary entry,
|
|
76
|
+
// these are only the shortcut. Both keys are unused by `App`'s global branch.
|
|
77
|
+
if (key.ctrl && input === 'r') {
|
|
78
|
+
props.onTest?.();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (key.ctrl && input === 'f') {
|
|
82
|
+
props.onDiscover?.();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (key.escape) {
|
|
86
|
+
// THREE-LEVEL Esc (§3.4): a probe in flight is cancelled first and the draft
|
|
87
|
+
// survives; only the second press discards the form.
|
|
88
|
+
if (props.probeBusy) {
|
|
89
|
+
props.onCancelProbe?.();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
dispatch({ type: 'cancel-editor' });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (key.upArrow || (key.tab && key.shift)) {
|
|
96
|
+
setIndex((index - 1 + count) % count);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (key.downArrow || key.tab) {
|
|
100
|
+
setIndex((index + 1) % count);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (key.return) {
|
|
104
|
+
activate(focusable[index]);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (key.ctrl || key.meta)
|
|
108
|
+
return;
|
|
109
|
+
const row = focusable[index];
|
|
110
|
+
if (row)
|
|
111
|
+
editField(row, input, key);
|
|
78
112
|
});
|
|
79
113
|
}
|
|
80
|
-
function
|
|
81
|
-
|
|
82
|
-
|
|
114
|
+
function rowColor(props, row) {
|
|
115
|
+
if (row.kind === 'result') {
|
|
116
|
+
return row.tone === 'ok' ? props.theme.toolDone
|
|
117
|
+
: row.tone === 'fail' ? props.theme.noticeError : props.theme.muted;
|
|
118
|
+
}
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
function buildRows(props, focus) {
|
|
83
122
|
const glyphs = pickGlyphs(props.caps);
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if (
|
|
90
|
-
return
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
? '(provider default)' : '(required)');
|
|
123
|
+
const focused = focus.focusable[focus.index];
|
|
124
|
+
return focus.rows.map((row, at) => {
|
|
125
|
+
if (row.kind === 'note') {
|
|
126
|
+
return _jsx(Text, { wrap: "truncate", color: props.theme.muted, children: row.label }, at);
|
|
127
|
+
}
|
|
128
|
+
if (row.kind === 'result') {
|
|
129
|
+
return _jsx(Text, { wrap: "truncate", color: rowColor(props, row), children: row.label }, at);
|
|
130
|
+
}
|
|
131
|
+
const line = row.value.length > 0 ? `${row.label.padEnd(18)}${row.value}` : row.label;
|
|
132
|
+
const isFocused = row === focused;
|
|
133
|
+
return _jsxs(Text, { wrap: "truncate", color: isFocused ? props.theme.accent : props.theme.muted, children: [isFocused ? `${glyphs.caret} ` : ' ', line] }, at);
|
|
96
134
|
});
|
|
97
|
-
const lines = LABELS.map((label, at) => `${label.padEnd(18)}${values[at]}`);
|
|
98
|
-
if (retain)
|
|
99
|
-
lines.push('Retain key for new endpoint');
|
|
100
|
-
lines.push('Apply to settings draft', 'Cancel editing');
|
|
101
|
-
const rows = lines.map((line, at) => _jsxs(Text, { wrap: "truncate", color: at === index ? props.theme.accent : props.theme.muted, children: [at === index ? `${glyphs.caret} ` : ' ', line] }, at));
|
|
102
|
-
rows.push(_jsxs(Text, { wrap: "truncate", color: props.theme.muted, children: ["Target: ", editor.provider, " ", profileEndpointSummary(editor.baseUrl)] }, "target"));
|
|
103
|
-
// HONESTY FOR THE FAST-ONLY ENTRY (§6.5): a window saved on a profile that
|
|
104
|
-
// only ever serves the Fast role does not take effect today, and saying so
|
|
105
|
-
// beats letting the user believe it does.
|
|
106
|
-
if (props.users === 'Fast') {
|
|
107
|
-
rows.push(_jsx(Text, { wrap: "truncate", color: props.theme.muted, children: "Window applies when this profile is bound as Main." }, "window-scope"));
|
|
108
|
-
}
|
|
109
|
-
return rows;
|
|
110
135
|
}
|
|
111
|
-
|
|
136
|
+
function editorHint(props) {
|
|
137
|
+
if (props.probeBusy)
|
|
138
|
+
return 'Esc cancel the probe | draft is kept';
|
|
139
|
+
return 'Enter apply to draft | Ctrl+R test | Ctrl+F discover | Esc cancel';
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The profile form. Row semantics come from `editorRows()` - never from index
|
|
143
|
+
* arithmetic - so `apiKey` stays at index 6 and the two probe actions sit at the end
|
|
144
|
+
* of the action block, after `retain` (P0-1 / I-4).
|
|
145
|
+
*/
|
|
112
146
|
export function ModelProfileEditor(props) {
|
|
113
147
|
const [index, setIndex] = useState(0);
|
|
114
|
-
const
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
148
|
+
const editor = props.editor;
|
|
149
|
+
const retain = editor.endpointChanged && Boolean(editor.original.apiKey);
|
|
150
|
+
const probe = props.probeView ?? { kind: 'idle', reason: 'unavailable' };
|
|
151
|
+
const target = props.probeTarget ?? editorProbeTarget(editor, {});
|
|
152
|
+
const rows = editorRows({ editor, retain, probe, target, fastOnly: props.users === 'Fast' });
|
|
153
|
+
const focusable = focusableRows(rows);
|
|
154
|
+
const count = Math.max(1, focusable.length);
|
|
155
|
+
const clamped = Math.min(index, count - 1);
|
|
156
|
+
// The result rows sit BELOW the action block, so following the focused row alone
|
|
157
|
+
// would leave the verdict off-screen right after the user asked for it (§4.9 / AC-21).
|
|
158
|
+
// How many action rows there are comes from the row model, never from a constant: a
|
|
159
|
+
// sixth action would otherwise move this window boundary onto a FIELD row.
|
|
160
|
+
const actionRows = focusable.filter((row) => row.kind === 'action').length;
|
|
161
|
+
const results = resultRowCount(rows);
|
|
162
|
+
const inActions = clamped >= count - actionRows;
|
|
163
|
+
const viewFocus = results > 0 && inActions ? Math.max(clamped, rows.length - 1) : clamped;
|
|
164
|
+
const offset = useProfileScroll(props, viewFocus);
|
|
165
|
+
useEditorInput(props, { index: clamped, setIndex, count, rows, focusable });
|
|
166
|
+
return _jsx(OverlayFrame, { ...props, rows: buildRows(props, { index: clamped, setIndex, count,
|
|
167
|
+
rows, focusable }), scrollOffset: offset, title: props.error ?? `Edit profile${props.users ? ` | Used by ${props.users}` : ''}`, hint: editorHint(props) });
|
|
119
168
|
}
|
|
@@ -14,6 +14,7 @@ import { type LogLevelName } from '../../logging/levels.js';
|
|
|
14
14
|
import type { Theme } from '../theme.js';
|
|
15
15
|
import type { TermCapabilities } from '../capabilities.js';
|
|
16
16
|
import type { ModelSettingsDraft, ModelSettingsPatch, ModelSettingsSaveResult } from '../../config/model-profile-store.js';
|
|
17
|
+
import type { ModelProbe } from '../../agent/model-probe.js';
|
|
17
18
|
export interface SettingsValues {
|
|
18
19
|
provider: string;
|
|
19
20
|
model: string;
|
|
@@ -113,6 +114,13 @@ interface SettingsScreenProps {
|
|
|
113
114
|
onProfileSave?: (draft: ModelSettingsDraft) => ModelSettingsSaveResult;
|
|
114
115
|
onClose?: () => void;
|
|
115
116
|
onReload?: () => ModelSettingsDraft;
|
|
117
|
+
/**
|
|
118
|
+
* The connection probe port (model-profile-connection-test 6.1). Absent in an
|
|
119
|
+
* embedded or test mount, where the two probe rows read `(unavailable)` - an
|
|
120
|
+
* EXPLICIT degradation this feature adds, not the silent no-op of the
|
|
121
|
+
* optional callbacks above.
|
|
122
|
+
*/
|
|
123
|
+
probe?: ModelProbe;
|
|
116
124
|
/**
|
|
117
125
|
* `API retries` READ-ONLY row (llm-api-retry-backoff §6.9), e.g. `10` or `off`.
|
|
118
126
|
*
|
|
@@ -21,6 +21,9 @@ import { emptyModelProfiles } from '../../config/model-profiles.js';
|
|
|
21
21
|
import { registerSecret } from '../../logging/secret-registry.js';
|
|
22
22
|
import { ModelProfilePicker, useProfileScroll } from './ModelProfilePicker.js';
|
|
23
23
|
import { ModelProfileEditor } from './ModelProfileEditor.js';
|
|
24
|
+
import { ModelDiscoveryList } from './ModelDiscoveryList.js';
|
|
25
|
+
import { useModelProbe } from './use-model-probe.js';
|
|
26
|
+
import { canProbe, editorProbeTarget, visibleProbeViews } from './model-probe-view.js';
|
|
24
27
|
import { createModelProfileState, createCustomProfile, modelProfileReducer, profileUsers, draftTeam, draftSubagentProfileId, subagentModelSummary, subagentSelectedId } from './model-profile-state.js';
|
|
25
28
|
/**
|
|
26
29
|
* THE ROOT PAGE'S ROW INDEXES, AS NAMES (subagent-model V-5).
|
|
@@ -35,6 +38,9 @@ const ROOT_FAST = 1;
|
|
|
35
38
|
const ROOT_SUBAGENT = 2;
|
|
36
39
|
const ROOT_MANAGE = 3;
|
|
37
40
|
const ROOT_FIRST_FIELD = 4;
|
|
41
|
+
/** Stand-in target while no editor exists; no probe row is rendered then. */
|
|
42
|
+
const NO_PROFILE_TARGET = { provider: '', model: '', baseUrl: null,
|
|
43
|
+
apiKey: null };
|
|
38
44
|
const SHOW_THINKING_OPTIONS = ['off', 'on'];
|
|
39
45
|
const LIVE_OUTPUT_OPTIONS = ['off', 'on'];
|
|
40
46
|
const FAST_ENABLED_OPTIONS = ['off', 'on'];
|
|
@@ -527,6 +533,15 @@ function useProfileSettingsState(props) {
|
|
|
527
533
|
const [confirmIndex, setConfirmIndex] = useState(0);
|
|
528
534
|
const [reloadPending, setReloadPending] = useState(false);
|
|
529
535
|
const offset = useProfileScroll(props, index);
|
|
536
|
+
// THE PROBE LIVES HERE, NOT IN THE EDITOR (RV-P0-1): this component survives a
|
|
537
|
+
// page switch, the editor does not, and a hook inside the editor would abort its
|
|
538
|
+
// own discovery the moment `Discover models` opens the list page.
|
|
539
|
+
const probe = useModelProbe(props.probe);
|
|
540
|
+
const probeTarget = state.editor
|
|
541
|
+
? editorProbeTarget(state.editor, props.apiKeys) : undefined;
|
|
542
|
+
// TWO SLOTS: a verdict and a discovered list answer different questions, and
|
|
543
|
+
// opening the list must not erase a verdict the user still wants to read.
|
|
544
|
+
const probeViews = visibleProbeViews(probe.test, probe.discovery, probeTarget ?? NO_PROFILE_TARGET, props.probe);
|
|
530
545
|
const previousPage = useRef(state.page);
|
|
531
546
|
useEffect(() => {
|
|
532
547
|
const previous = previousPage.current;
|
|
@@ -553,7 +568,7 @@ function useProfileSettingsState(props) {
|
|
|
553
568
|
return { state, dispatch, initial, setInitial, values, setValues, index, setIndex,
|
|
554
569
|
error, setError, persisted, setPersisted, confirmIndex, setConfirmIndex,
|
|
555
570
|
reloadPending, setReloadPending, offset, library, glyphs, dirty, fields, count,
|
|
556
|
-
field, readError, confirming };
|
|
571
|
+
field, readError, confirming, probe, probeTarget, probeViews };
|
|
557
572
|
}
|
|
558
573
|
function saveProfileSettings(props, view) {
|
|
559
574
|
const { persisted, readError, initial, values, fields, state, setError, setIndex, setPersisted } = view;
|
|
@@ -733,6 +748,19 @@ function renderSettingsConfirmation(props, view) {
|
|
|
733
748
|
}
|
|
734
749
|
return _jsx(OverlayFrame, { ...props, title: deleting ? `Delete ${name}?` : 'Discard unsaved changes?', hint: "Enter choose | Esc continue", rows: rows, scrollOffset: confirmIndex });
|
|
735
750
|
}
|
|
751
|
+
/** Zero-network guard: no key (or no probe port) means no request at all. */
|
|
752
|
+
function startTest(view) {
|
|
753
|
+
if (view.probeTarget && canProbe(view.probeViews.test)) {
|
|
754
|
+
view.probe.startTest(view.probeTarget);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
/** Discover from the draft, then show the list page: the state stays here. */
|
|
758
|
+
function startDiscovery(view, dispatch) {
|
|
759
|
+
if (!view.probeTarget || !canProbe(view.probeViews.test))
|
|
760
|
+
return;
|
|
761
|
+
view.probe.startDiscovery(view.probeTarget);
|
|
762
|
+
dispatch({ type: 'page', page: 'models' });
|
|
763
|
+
}
|
|
736
764
|
/**
|
|
737
765
|
* Whether the fast tier resolves FOR DISPLAY while the subagent picker is
|
|
738
766
|
* open (D-5): the built-in `Fast tier model` row then annotates itself with
|
|
@@ -753,7 +781,11 @@ function fastTierUnavailable(props, view) {
|
|
|
753
781
|
function renderProfileSubpage(props, view) {
|
|
754
782
|
const { state, library, dispatch, setConfirmIndex } = view;
|
|
755
783
|
if (state.page === 'editor' && state.editor)
|
|
756
|
-
return _jsx(ModelProfileEditor, { ...props, editor: state.editor, users: profileUsers(library, state.editor.original.id, draftSubagentProfileId(state.draft)), error: state.error, dispatch: dispatch });
|
|
784
|
+
return _jsx(ModelProfileEditor, { ...props, editor: state.editor, users: profileUsers(library, state.editor.original.id, draftSubagentProfileId(state.draft)), error: state.error, dispatch: dispatch, probeView: view.probeViews.test, probeBusy: view.probe.busy, probeTarget: view.probeTarget, onTest: () => startTest(view), onDiscover: () => startDiscovery(view, dispatch), onCancelProbe: view.probe.cancel });
|
|
785
|
+
// A DISCOVERY IS A PAGE, NOT A POPUP: the list is where the answer belongs, and
|
|
786
|
+
// the probe state it renders keeps running because it lives one level up.
|
|
787
|
+
if (state.page === 'models' && state.editor)
|
|
788
|
+
return _jsx(ModelDiscoveryList, { ...props, provider: state.editor.provider, currentModel: state.editor.model, view: view.probeViews.discovery, busy: view.probe.discovery.phase === 'discovering', onChoose: (model) => dispatch({ type: 'choose-model', model }), onRetry: () => startDiscovery(view, dispatch), onBack: () => dispatch({ type: 'page', page: 'editor' }), onCancelProbe: view.probe.cancel });
|
|
757
789
|
// A NEW PROFILE SEEDED FROM THE MAIN CONNECTION while the subagent picker is
|
|
758
790
|
// open: 'subagent' is picker-only (6.2), so the seed role falls back to
|
|
759
791
|
// 'main' while `state.role` keeps driving the select that binds it.
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The probe's VIEW MODEL (model-profile-connection-test §3.2 / §6.3).
|
|
3
|
+
*
|
|
4
|
+
* Pure functions only: the editor's row list, the result sentences, the candidate
|
|
5
|
+
* ordering and the two number formatters. Rendering and input both read
|
|
6
|
+
* `editorRows()`, so "which row am I on" has exactly one definition and inserting a
|
|
7
|
+
* row can no longer shift `apiKey` off index 6 by arithmetic.
|
|
8
|
+
*
|
|
9
|
+
* ASCII ONLY (`ui/` is inside `glyphs.test.ts::inScope`).
|
|
10
|
+
*/
|
|
11
|
+
import type { ProbeOutcome, ProbeTarget, ModelCandidate, DiscoveryOutcome, ModelProbe } from '../../agent/model-probe.js';
|
|
12
|
+
import type { ProfileEditorDraft } from './model-profile-state.js';
|
|
13
|
+
export declare const EDITOR_FIELDS: readonly ["name", "provider", "model", "contextWindow", "baseUrl", "credentialMode", "apiKey"];
|
|
14
|
+
export type EditorField = typeof EDITOR_FIELDS[number];
|
|
15
|
+
export declare const EDITOR_LABELS: string[];
|
|
16
|
+
export type EditorAction = 'retain' | 'test' | 'discover' | 'apply' | 'cancel';
|
|
17
|
+
export type EditorRow = {
|
|
18
|
+
kind: 'field';
|
|
19
|
+
field: EditorField;
|
|
20
|
+
label: string;
|
|
21
|
+
value: string;
|
|
22
|
+
} | {
|
|
23
|
+
kind: 'action';
|
|
24
|
+
action: EditorAction;
|
|
25
|
+
label: string;
|
|
26
|
+
value: string;
|
|
27
|
+
} | {
|
|
28
|
+
kind: 'note';
|
|
29
|
+
label: string;
|
|
30
|
+
muted: true;
|
|
31
|
+
} | {
|
|
32
|
+
kind: 'result';
|
|
33
|
+
label: string;
|
|
34
|
+
tone: 'ok' | 'fail' | 'muted';
|
|
35
|
+
};
|
|
36
|
+
/** What the current target's probe looks like to a renderer (editor surface). */
|
|
37
|
+
export type ProbeView = {
|
|
38
|
+
kind: 'idle';
|
|
39
|
+
reason: 'no_result' | 'no_key' | 'unavailable';
|
|
40
|
+
} | {
|
|
41
|
+
kind: 'testing';
|
|
42
|
+
} | {
|
|
43
|
+
kind: 'result';
|
|
44
|
+
outcome: ProbeOutcome;
|
|
45
|
+
};
|
|
46
|
+
/** The list page's own surface: a discovery is a page, not a result row. */
|
|
47
|
+
export type DiscoveryView = {
|
|
48
|
+
kind: 'idle';
|
|
49
|
+
} | {
|
|
50
|
+
kind: 'discovering';
|
|
51
|
+
} | {
|
|
52
|
+
kind: 'discovery';
|
|
53
|
+
outcome: DiscoveryOutcome;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* The hook's two INDEPENDENT slots, declared here so this module stays React-free.
|
|
57
|
+
*
|
|
58
|
+
* They are separate on purpose: a test outcome and a discovered list describe the
|
|
59
|
+
* same connection but answer different questions, and a single slot would make
|
|
60
|
+
* "run a test, then open the list, then come back" lose the verdict (AC-27).
|
|
61
|
+
*/
|
|
62
|
+
export type ProbeTestPhase = {
|
|
63
|
+
phase: 'idle';
|
|
64
|
+
} | {
|
|
65
|
+
phase: 'testing';
|
|
66
|
+
targetId: string;
|
|
67
|
+
} | {
|
|
68
|
+
phase: 'tested';
|
|
69
|
+
outcome: ProbeOutcome;
|
|
70
|
+
};
|
|
71
|
+
export type ProbeDiscoveryPhase = {
|
|
72
|
+
phase: 'idle';
|
|
73
|
+
} | {
|
|
74
|
+
phase: 'discovering';
|
|
75
|
+
connectionId: string;
|
|
76
|
+
} | {
|
|
77
|
+
phase: 'discovered';
|
|
78
|
+
outcome: DiscoveryOutcome;
|
|
79
|
+
};
|
|
80
|
+
export interface ProbeViews {
|
|
81
|
+
test: ProbeView;
|
|
82
|
+
discovery: DiscoveryView;
|
|
83
|
+
}
|
|
84
|
+
/** Whether pressing a probe action can produce anything at all (AC-12 / AC-15). */
|
|
85
|
+
export declare function canProbe(view: ProbeView): boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Filter both slots down to what the CURRENT target may honestly claim (§4.6).
|
|
88
|
+
*
|
|
89
|
+
* A `OK 812 ms` that describes a model the user has since edited is worse than no
|
|
90
|
+
* result at all, so a fingerprint mismatch degrades to `idle` - no effect, no
|
|
91
|
+
* reset call, no cross-page synchronisation.
|
|
92
|
+
*/
|
|
93
|
+
export declare function visibleProbeViews(test: ProbeTestPhase, discovery: ProbeDiscoveryPhase, target: ProbeTarget, probe?: ModelProbe): ProbeViews;
|
|
94
|
+
/** The exact target a draft would use - the resolved key included (§4.2.1). */
|
|
95
|
+
export declare function editorProbeTarget(editor: ProfileEditorDraft, apiKeys: Record<string, string | undefined>): ProbeTarget;
|
|
96
|
+
/** `812 ms` / `1.4 s` - one decimal only once a second has passed. */
|
|
97
|
+
export declare function latencyLabel(ms: number): string;
|
|
98
|
+
/** `128K` / `1M` / `auto` - `auto` when the vendor did not report a window. */
|
|
99
|
+
export declare function windowLabel(window?: number): string;
|
|
100
|
+
/**
|
|
101
|
+
* Clean and order a discovered list: one row per id, and the model the draft
|
|
102
|
+
* already names first, so "which one am I on" is answerable without scrolling.
|
|
103
|
+
*/
|
|
104
|
+
export declare function rankCandidates(models: readonly ModelCandidate[], currentModel: string): ModelCandidate[];
|
|
105
|
+
/** At most TWO rows: the verdict, then what to do about it (§3.1 / D-11). */
|
|
106
|
+
export declare function probeLines(view: ProbeView, target: ProbeTarget): string[];
|
|
107
|
+
/** Colour for the result rows; the status WORDS carry the meaning without colour. */
|
|
108
|
+
export declare function probeTone(view: ProbeView): 'ok' | 'fail' | 'muted';
|
|
109
|
+
export interface EditorRowsInput {
|
|
110
|
+
editor: ProfileEditorDraft;
|
|
111
|
+
retain: boolean;
|
|
112
|
+
probe: ProbeView;
|
|
113
|
+
target: ProbeTarget;
|
|
114
|
+
/** The `Window applies ...` honesty line for a Fast-only entry (§3.2 / RV-P1-7). */
|
|
115
|
+
fastOnly?: boolean;
|
|
116
|
+
}
|
|
117
|
+
/** THE single source of the editor's rows - fields, actions, notes AND results. */
|
|
118
|
+
export declare function editorRows(input: EditorRowsInput): EditorRow[];
|
|
119
|
+
/** Rows the cursor can land on; index arithmetic now derives from the row model. */
|
|
120
|
+
export declare function isFocusable(row: EditorRow): boolean;
|
|
121
|
+
/** The focusable rows, in order - the index space `useProfileScroll` follows. */
|
|
122
|
+
export declare function focusableRows(rows: readonly EditorRow[]): EditorRow[];
|
|
123
|
+
/** Result rows currently on screen, so the viewport can be widened to include them. */
|
|
124
|
+
export declare function resultRowCount(rows: readonly EditorRow[]): number;
|
|
125
|
+
/** Longest id shown in one list row, inside the 40-column budget. */
|
|
126
|
+
export declare const LIST_ID_CHARS = 28;
|
|
127
|
+
/** A model's one-line name, bounded by `maxNameChars`. */
|
|
128
|
+
export declare function clipName(name: string): string;
|