@robota-sdk/agent-ui-terminal 3.0.0-beta.82 → 3.0.0-beta.83

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 (67) hide show
  1. package/CHANGELOG.md +858 -0
  2. package/README.md +5 -1
  3. package/dist/node/index.d.ts +189 -54
  4. package/dist/node/index.d.ts.map +1 -1
  5. package/dist/node/index.js +21 -21
  6. package/dist/node/index.js.map +1 -1
  7. package/package.json +16 -21
  8. package/src/App.tsx +14 -3
  9. package/src/AppPresentation.tsx +1 -1
  10. package/src/PermissionPrompt.tsx +50 -17
  11. package/src/SessionPicker.tsx +27 -7
  12. package/src/SessionStatusBar.tsx +6 -2
  13. package/src/StatusBar.tsx +24 -0
  14. package/src/SupervisedSessionView.tsx +479 -157
  15. package/src/TuiInteractionChannel.ts +8 -53
  16. package/src/__tests__/SupervisedSessionView-render.test.tsx +28 -0
  17. package/src/__tests__/SupervisedSessionView.test.tsx +453 -82
  18. package/src/__tests__/TuiInteractionChannel.display-contract.test.ts +32 -22
  19. package/src/__tests__/TuiInteractionChannel.lifecycle.test.ts +10 -2
  20. package/src/__tests__/attached-app-session-switch.test.tsx +317 -0
  21. package/src/__tests__/checkpoint-store-per-channel.test.ts +81 -0
  22. package/src/__tests__/command-handoff-pty-e2e.test.ts +2 -2
  23. package/src/__tests__/default-tui-cli-adapter-host-settings.test.ts +3 -1
  24. package/src/__tests__/fixtures/command-handoff-driver.tsx +8 -5
  25. package/src/__tests__/grant-history-reaches-the-session.test.ts +31 -0
  26. package/src/__tests__/numbered-selection-chunk.test.tsx +41 -0
  27. package/src/__tests__/palette-consistency.test.ts +0 -1
  28. package/src/__tests__/permission-prompt-arming.test.tsx +153 -0
  29. package/src/__tests__/pty/screen-005-prompt-footers.ptytest.ts +2 -1
  30. package/src/__tests__/pty/screen-006-no-color.ptytest.ts +2 -0
  31. package/src/__tests__/pty/session-attach.ptytest.ts +144 -0
  32. package/src/__tests__/render-channel-options.test.ts +6 -6
  33. package/src/__tests__/sandbox-reaches-the-session.test.ts +37 -0
  34. package/src/__tests__/screen-reader-menus.test.tsx +1 -1
  35. package/src/__tests__/session-picker.test.tsx +93 -0
  36. package/src/__tests__/status-bar.test.tsx +8 -0
  37. package/src/__tests__/tui-channel-init-failure.test.ts +10 -2
  38. package/src/__tests__/wire-tui-channel.test.ts +1445 -0
  39. package/src/__tests__/wire-tui-client-commands.test.ts +243 -0
  40. package/src/app-view-model.ts +6 -2
  41. package/src/attached-session-connection.ts +14 -0
  42. package/src/hooks/__tests__/use-app-detach-key.test.tsx +72 -0
  43. package/src/hooks/__tests__/use-app-loop-escape.test.tsx +13 -0
  44. package/src/hooks/useAppController.ts +6 -2
  45. package/src/hooks/useAppInputBindings.ts +20 -2
  46. package/src/hooks/useAppInteractionState.ts +3 -0
  47. package/src/hooks/useAppOverlays.ts +20 -4
  48. package/src/hooks/useNumberedSelection.ts +19 -2
  49. package/src/hooks/useTuiChannel.ts +13 -1
  50. package/src/index.ts +17 -3
  51. package/src/render.tsx +218 -27
  52. package/src/short-session-id.ts +9 -0
  53. package/src/slash-command-input.ts +11 -0
  54. package/src/tui-app-channel-port.ts +29 -1
  55. package/src/tui-channel-options.ts +11 -2
  56. package/src/tui-session-event-projector.ts +0 -2
  57. package/src/tui-session-events.ts +6 -0
  58. package/src/tui-session-options.ts +9 -0
  59. package/src/tui-state-manager.ts +17 -3
  60. package/src/waiting-loop-stop-notice.ts +23 -0
  61. package/src/wire-history-sync.ts +159 -0
  62. package/src/wire-tui-channel.ts +962 -0
  63. package/src/wire-tui-client-commands.ts +142 -0
  64. package/src/wire-tui-projection.ts +136 -0
  65. package/dist/node/index.cjs +0 -32
  66. package/dist/node/index.d.cts +0 -1066
  67. package/dist/node/index.d.cts.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robota-sdk/agent-ui-terminal",
3
- "version": "3.0.0-beta.82",
3
+ "version": "3.0.0-beta.83",
4
4
  "description": "Terminal UI package (React + Ink) for the Robota SDK",
5
5
  "type": "module",
6
6
  "main": "dist/node/index.js",
@@ -12,23 +12,16 @@
12
12
  "import": {
13
13
  "types": "./dist/node/index.d.ts",
14
14
  "default": "./dist/node/index.js"
15
- },
16
- "require": {
17
- "types": "./dist/node/index.d.cts",
18
- "default": "./dist/node/index.cjs"
19
15
  }
20
16
  },
21
17
  "default": {
22
18
  "import": {
23
19
  "types": "./dist/node/index.d.ts",
24
20
  "default": "./dist/node/index.js"
25
- },
26
- "require": {
27
- "types": "./dist/node/index.d.cts",
28
- "default": "./dist/node/index.cjs"
29
21
  }
30
22
  }
31
- }
23
+ },
24
+ "./package.json": "./package.json"
32
25
  },
33
26
  "repository": {
34
27
  "type": "git",
@@ -39,11 +32,12 @@
39
32
  "url": "https://github.com/woojubb/robota/issues"
40
33
  },
41
34
  "engines": {
42
- "node": ">=22.0.0"
35
+ "node": ">=22.12.0"
43
36
  },
44
37
  "files": [
45
38
  "dist",
46
- "src"
39
+ "src",
40
+ "CHANGELOG.md"
47
41
  ],
48
42
  "dependencies": {
49
43
  "chalk": "^5.6.2",
@@ -52,14 +46,15 @@
52
46
  "marked-terminal": "^7.3.0",
53
47
  "react": "19.2.8",
54
48
  "string-width": "^8.2.2",
55
- "@robota-sdk/agent-core": "3.0.0-beta.82",
56
- "@robota-sdk/agent-framework": "3.0.0-beta.82",
57
- "@robota-sdk/agent-interface-analytics": "3.0.0-beta.82",
58
- "@robota-sdk/agent-interface-command": "3.0.0-beta.82",
59
- "@robota-sdk/agent-interface-execution": "3.0.0-beta.82",
60
- "@robota-sdk/agent-interface-session": "3.0.0-beta.82",
61
- "@robota-sdk/agent-interface-transport": "3.0.0-beta.82",
62
- "@robota-sdk/agent-interface-tui": "3.0.0-beta.82"
49
+ "@robota-sdk/agent-core": "3.0.0-beta.83",
50
+ "@robota-sdk/agent-framework": "3.0.0-beta.83",
51
+ "@robota-sdk/agent-interface-analytics": "3.0.0-beta.83",
52
+ "@robota-sdk/agent-interface-command": "3.0.0-beta.83",
53
+ "@robota-sdk/agent-interface-execution": "3.0.0-beta.83",
54
+ "@robota-sdk/agent-interface-session": "3.0.0-beta.83",
55
+ "@robota-sdk/agent-interface-transport": "3.0.0-beta.83",
56
+ "@robota-sdk/agent-transport": "3.0.0-beta.83",
57
+ "@robota-sdk/agent-interface-tui": "3.0.0-beta.83"
63
58
  },
64
59
  "devDependencies": {
65
60
  "@homebridge/node-pty-prebuilt-multiarch": "^0.14.1",
@@ -70,7 +65,7 @@
70
65
  "tsx": "^4.23.1",
71
66
  "typescript": "^6.0.3",
72
67
  "vitest": "^3.2.6",
73
- "@robota-sdk/agent-command": "3.0.0-beta.82"
68
+ "@robota-sdk/agent-command": "3.0.0-beta.83"
74
69
  },
75
70
  "license": "AGPL-3.0-only OR LicenseRef-Commercial",
76
71
  "publishConfig": {
package/src/App.tsx CHANGED
@@ -17,6 +17,12 @@ interface IProps {
17
17
  cwd: string;
18
18
  /** The composition root narrows every concrete channel to this port before React receives it. */
19
19
  createChannel: (resumeSessionId?: string) => ITuiAppChannelPort;
20
+ /**
21
+ * The session lives on a host this terminal is attached to: a switch is asked of the host, and the
22
+ * channel follows the host's `session_switched`, so the App keeps its channel. Absent ⇒ the App
23
+ * switches by building a channel for the chosen session.
24
+ */
25
+ requestSessionSwitch?: (sessionId: string) => Promise<void>;
20
26
  providerOverride?: string | undefined;
21
27
  providerType?: string | undefined;
22
28
  modelId?: string;
@@ -76,9 +82,14 @@ function useActiveChannel(props: IProps): IActiveChannel {
76
82
  setIsSwitching(true);
77
83
  try {
78
84
  setShowPicker(false);
79
- await state.channel.stop();
85
+ if (props.requestSessionSwitch) {
86
+ await props.requestSessionSwitch(sessionId);
87
+ } else {
88
+ await state.channel.stop();
89
+ if (!mounted.current) return;
90
+ setState({ channel: props.createChannel(sessionId), sessionId });
91
+ }
80
92
  if (!mounted.current) return;
81
- setState({ channel: props.createChannel(sessionId), sessionId });
82
93
  setFailedTarget(undefined);
83
94
  setError(undefined);
84
95
  } catch (cause) {
@@ -90,7 +101,7 @@ function useActiveChannel(props: IProps): IActiveChannel {
90
101
  setIsSwitching(false);
91
102
  }
92
103
  },
93
- [props.createChannel, state.channel],
104
+ [props.createChannel, props.requestSessionSwitch, state.channel],
94
105
  );
95
106
  const retrySwitch = useCallback((): void => {
96
107
  if (failedTarget !== undefined) void switchSession(failedTarget);
@@ -26,7 +26,7 @@ import type { IAppViewModel } from './app-view-model.js';
26
26
 
27
27
  function Transcript({ model }: { model: IAppViewModel }): React.ReactElement {
28
28
  return (
29
- <Static items={model.staticItems}>
29
+ <Static key={model.transcriptKey} items={model.staticItems}>
30
30
  {(item) =>
31
31
  item.kind === 'banner' ? (
32
32
  <AppBanner key="logo" version={item.version} />
@@ -27,8 +27,18 @@ import type { TToolArgs } from '@robota-sdk/agent-core';
27
27
 
28
28
  interface IProps {
29
29
  request: IPendingPermissionRequest;
30
+ /** How long a new request ignores its answer keys; tests pass their own. */
31
+ armDelayMs?: number;
30
32
  }
31
33
 
34
+ /**
35
+ * The prompt replaces the composer while the user may still be typing, and its keys are ordinary
36
+ * letters and digits (`a` allows for the session). For this long after a request appears its keys
37
+ * answer nothing, so a keystroke meant for the composer cannot grant a permission. The prompt shows
38
+ * no selection cursor until it is armed.
39
+ */
40
+ export const PERMISSION_PROMPT_ARM_DELAY_MS = 450;
41
+
32
42
  /**
33
43
  * Footer for the permission prompt — identical to the confirm prompt's (same horizontal row, same
34
44
  * reducer). Esc is deliberately suppressed by the flow (`escape: false` — a permission ask must
@@ -47,7 +57,10 @@ function formatArgs(args: TToolArgs): string {
47
57
  .join(', ');
48
58
  }
49
59
 
50
- export default function PermissionPrompt({ request }: IProps): React.ReactElement {
60
+ export default function PermissionPrompt({
61
+ request,
62
+ armDelayMs = PERMISSION_PROMPT_ARM_DELAY_MS,
63
+ }: IProps): React.ReactElement {
51
64
  const palette = usePalette();
52
65
  const canPersistProjectPermission = request.canPersistProjectPermission !== false;
53
66
  const [state, setState] = React.useState<ISelectionFlowState>(() => createSelectionFlowState());
@@ -61,6 +74,14 @@ export default function PermissionPrompt({ request }: IProps): React.ReactElemen
61
74
  setState(nextState);
62
75
  }
63
76
 
77
+ const [armedRequest, setArmedRequest] = React.useState<IPendingPermissionRequest | undefined>();
78
+ React.useEffect(() => {
79
+ if (armDelayMs <= 0) return undefined;
80
+ const timer = setTimeout(() => setArmedRequest(request), armDelayMs);
81
+ return () => clearTimeout(timer);
82
+ }, [request, armDelayMs]);
83
+ const armed = armDelayMs <= 0 || armedRequest === request;
84
+
64
85
  const applyAction = React.useCallback(
65
86
  (action: TPermissionPromptInputAction): void => {
66
87
  const result = applyPermissionPromptInput(
@@ -83,7 +104,8 @@ export default function PermissionPrompt({ request }: IProps): React.ReactElemen
83
104
  canPersistProjectPermission,
84
105
  );
85
106
  const numbered = useNumberedSelection({
86
- enabled: screenReader,
107
+ // Off while arming, so a digit typed then is not buffered for a later Enter to commit.
108
+ enabled: screenReader && armed,
87
109
  itemCount: options.length,
88
110
  onSelect: (index) => {
89
111
  const result = applyPermissionPromptInput(
@@ -99,10 +121,14 @@ export default function PermissionPrompt({ request }: IProps): React.ReactElemen
99
121
  if (result.effect.type === 'resolve') request.resolve(result.effect.decision);
100
122
  },
101
123
  });
124
+ // Digits typed for one request are not an answer to the next.
125
+ const clearNumbered = numbered.clear;
126
+ React.useEffect(() => clearNumbered(), [request, clearNumbered]);
102
127
 
103
128
  useKeybindingActions(
104
129
  'permission-prompt',
105
130
  (actions) => {
131
+ if (!armed) return;
106
132
  const shortcutIndex = {
107
133
  'allow-once': 0,
108
134
  'allow-session': 1,
@@ -147,12 +173,15 @@ export default function PermissionPrompt({ request }: IProps): React.ReactElemen
147
173
  <Box
148
174
  flexDirection="column"
149
175
  borderStyle="round"
150
- borderColor={palette.border.attention}
176
+ borderColor={armed ? palette.border.attention : palette.border.muted}
151
177
  paddingX={1}
152
178
  >
153
- <Text color={palette.text.warning} bold>
154
- [Permission Required]
155
- </Text>
179
+ <Box>
180
+ <Text color={palette.text.warning} bold>
181
+ [Permission Required]
182
+ </Text>
183
+ {armed ? null : <Text dimColor> keys answer in a moment</Text>}
184
+ </Box>
156
185
  <Text>
157
186
  Tool:{' '}
158
187
  <Text color={palette.text.accent} bold>
@@ -168,17 +197,21 @@ export default function PermissionPrompt({ request }: IProps): React.ReactElemen
168
197
  {/* Issue #2351: the "always" options carry the consent scope, so they no longer fit one row
169
198
  of the prompt box — a row wrapped `Allow [y]` across two lines. One option per line. */}
170
199
  <Box marginTop={1} flexDirection="column">
171
- {options.map((opt, i) => (
172
- <Box key={opt}>
173
- <Text
174
- color={i === state.selectedIndex ? palette.text.accent : undefined}
175
- bold={i === state.selectedIndex}
176
- >
177
- {i === state.selectedIndex ? SELECTION_INDICATOR : SELECTION_INDICATOR_NONE}
178
- {opt}
179
- </Text>
180
- </Box>
181
- ))}
200
+ {options.map((opt, i) => {
201
+ const selected = armed && i === state.selectedIndex;
202
+ return (
203
+ <Box key={opt}>
204
+ <Text
205
+ color={selected ? palette.text.accent : undefined}
206
+ bold={selected}
207
+ dimColor={!armed}
208
+ >
209
+ {selected ? SELECTION_INDICATOR : SELECTION_INDICATOR_NONE}
210
+ {opt}
211
+ </Text>
212
+ </Box>
213
+ );
214
+ })}
182
215
  </Box>
183
216
  <KeyHintFooter hints={footerHints} />
184
217
  </Box>
@@ -9,15 +9,28 @@ import React from 'react';
9
9
  import { SELECTION_INDICATOR, SELECTION_INDICATOR_NONE } from './key-hint-footer.js';
10
10
  import ListPicker from './ListPicker.js';
11
11
  import { Text } from './SafeText.js';
12
+ import { shortSessionId } from './short-session-id.js';
12
13
  import { usePalette } from './theme/index.js';
13
14
 
14
- import type { IResumableSessionSummary } from '@robota-sdk/agent-interface-session';
15
+ import type { ISessionListingEntry } from '@robota-sdk/agent-interface-session';
15
16
 
16
- const SESSION_ID_DISPLAY_LENGTH = 8;
17
17
  const SESSION_PREVIEW_DISPLAY_LENGTH = 60;
18
18
 
19
+ /**
20
+ * `● live · 2 clients` on a host that keeps sessions live: the session runs there now, and that many
21
+ * clients are on it. Null for a stored session, and for a host that says neither.
22
+ */
23
+ export function sessionLiveBadge(session: ISessionListingEntry): string | null {
24
+ const parts: string[] = [];
25
+ if (session.live === true) parts.push('● live');
26
+ if (session.clients !== undefined && session.clients > 0) {
27
+ parts.push(`${session.clients} ${session.clients === 1 ? 'client' : 'clients'}`);
28
+ }
29
+ return parts.length > 0 ? parts.join(' · ') : null;
30
+ }
31
+
19
32
  interface IProps {
20
- sessions: readonly IResumableSessionSummary[];
33
+ sessions: readonly ISessionListingEntry[];
21
34
  onSelect: (sessionId: string) => void;
22
35
  onCancel: () => void;
23
36
  }
@@ -33,9 +46,10 @@ export default function SessionPicker({
33
46
  <Text bold color={palette.text.accent}>
34
47
  Select a session to resume:
35
48
  </Text>
36
- <ListPicker<IResumableSessionSummary>
49
+ <ListPicker<ISessionListingEntry>
37
50
  items={[...sessions]}
38
- renderItem={(session: IResumableSessionSummary, isSelected: boolean) => {
51
+ renderItem={(session: ISessionListingEntry, isSelected: boolean) => {
52
+ const badge = sessionLiveBadge(session);
39
53
  const preview = session.preview
40
54
  ? session.preview.slice(0, SESSION_PREVIEW_DISPLAY_LENGTH) +
41
55
  (session.preview.length > SESSION_PREVIEW_DISPLAY_LENGTH ? '...' : '')
@@ -43,7 +57,7 @@ export default function SessionPicker({
43
57
  return (
44
58
  <Text>
45
59
  {isSelected ? SELECTION_INDICATOR : SELECTION_INDICATOR_NONE}
46
- <Text bold>{session.name ?? session.id.slice(0, SESSION_ID_DISPLAY_LENGTH)}</Text>
60
+ <Text bold>{session.name ?? shortSessionId(session.id)}</Text>
47
61
  {' '}
48
62
  <Text dimColor>
49
63
  {new Date(session.updatedAt).toLocaleString(undefined, {
@@ -55,6 +69,12 @@ export default function SessionPicker({
55
69
  </Text>
56
70
  {' '}
57
71
  <Text dimColor>msgs: {session.messageCount}</Text>
72
+ {badge !== null ? (
73
+ <>
74
+ {' '}
75
+ <Text color={palette.text.success}>{badge}</Text>
76
+ </>
77
+ ) : null}
58
78
  {preview ? (
59
79
  <>
60
80
  {'\n '}
@@ -64,7 +84,7 @@ export default function SessionPicker({
64
84
  </Text>
65
85
  );
66
86
  }}
67
- onSelect={(session: IResumableSessionSummary) => onSelect(session.id)}
87
+ onSelect={(session: ISessionListingEntry) => onSelect(session.id)}
68
88
  onCancel={onCancel}
69
89
  />
70
90
  </Box>
@@ -1,6 +1,6 @@
1
1
  import React, { useMemo } from 'react';
2
2
 
3
- import StatusBar from './StatusBar.js';
3
+ import StatusBar, { ReadOnlyText } from './StatusBar.js';
4
4
  import { useTuiCliAdapter } from './tui-cli-adapter-context.js';
5
5
 
6
6
  import type { TModelEffortSelection, TPermissionMode } from '@robota-sdk/agent-core';
@@ -23,6 +23,7 @@ interface IProps {
23
23
  activePresetId?: string;
24
24
  effort?: TModelEffortSelection;
25
25
  gitRefreshToken?: number;
26
+ readOnly?: boolean;
26
27
  }
27
28
 
28
29
  export default function SessionStatusBar({
@@ -42,6 +43,7 @@ export default function SessionStatusBar({
42
43
  activePresetId,
43
44
  effort,
44
45
  gitRefreshToken,
46
+ readOnly = false,
45
47
  }: IProps): React.ReactElement | null {
46
48
  const cliAdapter = useTuiCliAdapter();
47
49
  const gitBranch = useMemo(() => cliAdapter.getGitBranch(cwd), [cliAdapter, cwd, gitRefreshToken]);
@@ -50,7 +52,8 @@ export default function SessionStatusBar({
50
52
  providerType !== undefined ? cliAdapter.getProviderDisplayName(providerType) : undefined,
51
53
  [cliAdapter, providerType],
52
54
  );
53
- if (!settings.enabled) return null;
55
+ // With the status line off, an observer is still told it cannot change the session.
56
+ if (!settings.enabled) return readOnly ? <ReadOnlyText /> : null;
54
57
 
55
58
  return (
56
59
  <StatusBar
@@ -71,6 +74,7 @@ export default function SessionStatusBar({
71
74
  activeAgentLabel={activeAgentLabel}
72
75
  activePresetId={activePresetId}
73
76
  effort={effort}
77
+ readOnly={readOnly}
74
78
  />
75
79
  );
76
80
  }
package/src/StatusBar.tsx CHANGED
@@ -35,6 +35,8 @@ interface IProps {
35
35
  activeAgentLabel?: string;
36
36
  activePresetId?: string;
37
37
  effort?: TModelEffortSelection;
38
+ /** This terminal only observes the session. */
39
+ readOnly?: boolean;
38
40
  }
39
41
 
40
42
  interface IStatusLeftProps {
@@ -53,8 +55,12 @@ interface IStatusLeftProps {
53
55
  showGitBranch: boolean;
54
56
  activePresetId?: string;
55
57
  effort?: TModelEffortSelection;
58
+ readOnly: boolean;
56
59
  }
57
60
 
61
+ /** What an observing terminal shows first on its status line. */
62
+ export const READ_ONLY_STATUS = 'Observing — read only';
63
+
58
64
  /** Return the color for the context percentage indicator */
59
65
  function getContextColor(percentage: number, palette: IThemeColors): string {
60
66
  if (percentage >= CONTEXT_RED_THRESHOLD) return palette.text.error;
@@ -104,6 +110,15 @@ function ContextText({
104
110
  );
105
111
  }
106
112
 
113
+ export function ReadOnlyText(): React.ReactElement {
114
+ const palette = usePalette();
115
+ return (
116
+ <Text color={palette.text.warning} bold>
117
+ {READ_ONLY_STATUS}
118
+ </Text>
119
+ );
120
+ }
121
+
107
122
  function ModeText({ permissionMode }: { permissionMode: TPermissionMode }): React.ReactElement {
108
123
  const palette = usePalette();
109
124
  return (
@@ -183,6 +198,13 @@ function StatusLeft(props: IStatusLeftProps): React.ReactElement {
183
198
  const showActivePreset = shouldShowActivePreset(activePresetId);
184
199
  return (
185
200
  <Text>
201
+ {props.readOnly && (
202
+ <>
203
+ <ReadOnlyText />
204
+ {/* In screen-reader mode the next field brings its own separator. */}
205
+ {!screenReader && SEP}
206
+ </>
207
+ )}
186
208
  {!screenReader && (
187
209
  <StatusActivityText
188
210
  isThinking={props.isThinking}
@@ -253,6 +275,7 @@ export default function StatusBar({
253
275
  activeAgentLabel,
254
276
  activePresetId,
255
277
  effort,
278
+ readOnly = false,
256
279
  }: IProps): React.ReactElement {
257
280
  const palette = usePalette();
258
281
  return (
@@ -273,6 +296,7 @@ export default function StatusBar({
273
296
  showGitBranch={showGitBranch}
274
297
  activePresetId={activePresetId}
275
298
  effort={effort}
299
+ readOnly={readOnly}
276
300
  />
277
301
  {activeAgentLabel !== undefined && (
278
302
  <Text color={palette.text.warning} bold>