@aragon-agent/cli 0.4.3

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 (156) hide show
  1. package/CHANGELOG.md +292 -0
  2. package/README.md +558 -0
  3. package/dist/agent/coalesce.d.ts +14 -0
  4. package/dist/agent/coalesce.js +41 -0
  5. package/dist/agent/controller.d.ts +132 -0
  6. package/dist/agent/controller.js +332 -0
  7. package/dist/agent/headless.d.ts +22 -0
  8. package/dist/agent/headless.js +74 -0
  9. package/dist/agent/reducer.d.ts +181 -0
  10. package/dist/agent/reducer.js +420 -0
  11. package/dist/agent/system-prompt.d.ts +30 -0
  12. package/dist/agent/system-prompt.js +58 -0
  13. package/dist/agent/usage.d.ts +13 -0
  14. package/dist/agent/usage.js +41 -0
  15. package/dist/cli.d.ts +11 -0
  16. package/dist/cli.js +482 -0
  17. package/dist/commands/builtins.d.ts +16 -0
  18. package/dist/commands/builtins.js +223 -0
  19. package/dist/commands/registry.d.ts +48 -0
  20. package/dist/commands/registry.js +45 -0
  21. package/dist/commands/skills.d.ts +22 -0
  22. package/dist/commands/skills.js +625 -0
  23. package/dist/config/app-paths.d.ts +11 -0
  24. package/dist/config/app-paths.js +16 -0
  25. package/dist/config/env.d.ts +28 -0
  26. package/dist/config/env.js +92 -0
  27. package/dist/config/load.d.ts +48 -0
  28. package/dist/config/load.js +158 -0
  29. package/dist/config/migrate-legacy-state.d.ts +78 -0
  30. package/dist/config/migrate-legacy-state.js +181 -0
  31. package/dist/config/schema.d.ts +249 -0
  32. package/dist/config/schema.js +247 -0
  33. package/dist/config/store.d.ts +44 -0
  34. package/dist/config/store.js +126 -0
  35. package/dist/input/keymap.d.ts +20 -0
  36. package/dist/input/keymap.js +85 -0
  37. package/dist/session/persist.d.ts +24 -0
  38. package/dist/session/persist.js +51 -0
  39. package/dist/skills/archive.d.ts +41 -0
  40. package/dist/skills/archive.js +152 -0
  41. package/dist/skills/cli-commands.d.ts +52 -0
  42. package/dist/skills/cli-commands.js +502 -0
  43. package/dist/skills/fetch-source.d.ts +104 -0
  44. package/dist/skills/fetch-source.js +448 -0
  45. package/dist/skills/installer.d.ts +117 -0
  46. package/dist/skills/installer.js +414 -0
  47. package/dist/skills/lock.d.ts +75 -0
  48. package/dist/skills/lock.js +223 -0
  49. package/dist/skills/manifest.d.ts +47 -0
  50. package/dist/skills/manifest.js +95 -0
  51. package/dist/skills/node-host.d.ts +23 -0
  52. package/dist/skills/node-host.js +59 -0
  53. package/dist/skills/paths.d.ts +61 -0
  54. package/dist/skills/paths.js +107 -0
  55. package/dist/skills/service.d.ts +256 -0
  56. package/dist/skills/service.js +730 -0
  57. package/dist/skills/tools.d.ts +35 -0
  58. package/dist/skills/tools.js +164 -0
  59. package/dist/skills/updater.d.ts +101 -0
  60. package/dist/skills/updater.js +330 -0
  61. package/dist/skills/usage.d.ts +72 -0
  62. package/dist/skills/usage.js +216 -0
  63. package/dist/tools/bash-tool.d.ts +12 -0
  64. package/dist/tools/bash-tool.js +131 -0
  65. package/dist/tools/diff.d.ts +15 -0
  66. package/dist/tools/diff.js +50 -0
  67. package/dist/tools/fs-tools.d.ts +16 -0
  68. package/dist/tools/fs-tools.js +215 -0
  69. package/dist/tools/index.d.ts +93 -0
  70. package/dist/tools/index.js +157 -0
  71. package/dist/tools/search-tools.d.ts +11 -0
  72. package/dist/tools/search-tools.js +225 -0
  73. package/dist/ui/App.d.ts +24 -0
  74. package/dist/ui/App.js +522 -0
  75. package/dist/ui/AutocompletePopup.d.ts +21 -0
  76. package/dist/ui/AutocompletePopup.js +19 -0
  77. package/dist/ui/Composer.d.ts +40 -0
  78. package/dist/ui/Composer.js +61 -0
  79. package/dist/ui/Header.d.ts +30 -0
  80. package/dist/ui/Header.js +25 -0
  81. package/dist/ui/Logo.d.ts +50 -0
  82. package/dist/ui/Logo.js +85 -0
  83. package/dist/ui/Markdown.d.ts +23 -0
  84. package/dist/ui/Markdown.js +122 -0
  85. package/dist/ui/PromptInput.d.ts +81 -0
  86. package/dist/ui/PromptInput.js +383 -0
  87. package/dist/ui/SessionOpener.d.ts +30 -0
  88. package/dist/ui/SessionOpener.js +12 -0
  89. package/dist/ui/StatusBar.d.ts +39 -0
  90. package/dist/ui/StatusBar.js +43 -0
  91. package/dist/ui/ToastStack.d.ts +24 -0
  92. package/dist/ui/ToastStack.js +38 -0
  93. package/dist/ui/Transcript.d.ts +62 -0
  94. package/dist/ui/Transcript.js +128 -0
  95. package/dist/ui/capabilities.d.ts +25 -0
  96. package/dist/ui/capabilities.js +59 -0
  97. package/dist/ui/console-bridge.d.ts +19 -0
  98. package/dist/ui/console-bridge.js +52 -0
  99. package/dist/ui/density.d.ts +26 -0
  100. package/dist/ui/density.js +34 -0
  101. package/dist/ui/entries/AssistantEntry.d.ts +23 -0
  102. package/dist/ui/entries/AssistantEntry.js +13 -0
  103. package/dist/ui/entries/EntryFrame.d.ts +23 -0
  104. package/dist/ui/entries/EntryFrame.js +6 -0
  105. package/dist/ui/entries/ToolCard.d.ts +38 -0
  106. package/dist/ui/entries/ToolCard.js +72 -0
  107. package/dist/ui/entries/ToolPreview.d.ts +57 -0
  108. package/dist/ui/entries/ToolPreview.js +144 -0
  109. package/dist/ui/entries/UserEntry.d.ts +14 -0
  110. package/dist/ui/entries/UserEntry.js +5 -0
  111. package/dist/ui/exit-snapshot.d.ts +27 -0
  112. package/dist/ui/exit-snapshot.js +26 -0
  113. package/dist/ui/gauge.d.ts +24 -0
  114. package/dist/ui/gauge.js +36 -0
  115. package/dist/ui/glyphs.d.ts +87 -0
  116. package/dist/ui/glyphs.js +127 -0
  117. package/dist/ui/gradient.d.ts +11 -0
  118. package/dist/ui/gradient.js +50 -0
  119. package/dist/ui/layout/AppShell.d.ts +24 -0
  120. package/dist/ui/layout/AppShell.js +9 -0
  121. package/dist/ui/layout/Gutter.d.ts +67 -0
  122. package/dist/ui/layout/Gutter.js +42 -0
  123. package/dist/ui/layout/OverlayFrame.d.ts +62 -0
  124. package/dist/ui/layout/OverlayFrame.js +92 -0
  125. package/dist/ui/layout/ScrollViewport.d.ts +33 -0
  126. package/dist/ui/layout/ScrollViewport.js +71 -0
  127. package/dist/ui/layout/budget.d.ts +42 -0
  128. package/dist/ui/layout/budget.js +47 -0
  129. package/dist/ui/layout/frame.d.ts +58 -0
  130. package/dist/ui/layout/frame.js +75 -0
  131. package/dist/ui/layout/overlay-window.d.ts +43 -0
  132. package/dist/ui/layout/overlay-window.js +51 -0
  133. package/dist/ui/layout/scroll.d.ts +28 -0
  134. package/dist/ui/layout/scroll.js +76 -0
  135. package/dist/ui/layout/useTerminalSize.d.ts +13 -0
  136. package/dist/ui/layout/useTerminalSize.js +50 -0
  137. package/dist/ui/markdown-blocks.d.ts +62 -0
  138. package/dist/ui/markdown-blocks.js +120 -0
  139. package/dist/ui/overlays/ConfirmDialog.d.ts +30 -0
  140. package/dist/ui/overlays/ConfirmDialog.js +25 -0
  141. package/dist/ui/overlays/HelpOverlay.d.ts +17 -0
  142. package/dist/ui/overlays/HelpOverlay.js +60 -0
  143. package/dist/ui/overlays/ModelPicker.d.ts +29 -0
  144. package/dist/ui/overlays/ModelPicker.js +29 -0
  145. package/dist/ui/overlays/SettingsScreen.d.ts +37 -0
  146. package/dist/ui/overlays/SettingsScreen.js +106 -0
  147. package/dist/ui/palettes.d.ts +79 -0
  148. package/dist/ui/palettes.js +115 -0
  149. package/dist/ui/screen.d.ts +27 -0
  150. package/dist/ui/screen.js +57 -0
  151. package/dist/ui/theme.d.ts +78 -0
  152. package/dist/ui/theme.js +127 -0
  153. package/dist/ui/transcript-text.d.ts +27 -0
  154. package/dist/ui/transcript-text.js +110 -0
  155. package/package.json +73 -0
  156. package/skills/skill-creator/SKILL.md +96 -0
package/dist/ui/App.js ADDED
@@ -0,0 +1,522 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * App — the root Ink component. Owns the view reducer, subscribes to the
4
+ * controller's event stream (with a streaming coalescer), wires global
5
+ * keybindings, routes slash commands, and renders the header / transcript /
6
+ * input / toast stack / status bar / overlays.
7
+ */
8
+ import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
9
+ import process from 'node:process';
10
+ import { Box, Text, useApp, useInput, useStdout } from 'ink';
11
+ import { initialViewState, reduceEvent, viewReducer, } from '../agent/reducer.js';
12
+ import { mergeDeltas } from '../agent/coalesce.js';
13
+ import { coerceMaxTokens, DEFAULT_TRANSCRIPT_WINDOW, PROMPT_HISTORY_CAP, } from '../config/schema.js';
14
+ import { updatePersistedConfig } from '../config/store.js';
15
+ import { detectCapabilities } from './capabilities.js';
16
+ import { getTheme } from './theme.js';
17
+ import { pickGlyphs } from './glyphs.js';
18
+ import { Header } from './Header.js';
19
+ import { pickHeaderVariant, pickOpenerVariant } from './Logo.js';
20
+ import { SessionOpener } from './SessionOpener.js';
21
+ import { Transcript, TranscriptList } from './Transcript.js';
22
+ import { PromptInput } from './PromptInput.js';
23
+ import { Composer } from './Composer.js';
24
+ import { StatusBar } from './StatusBar.js';
25
+ import { ToastStack } from './ToastStack.js';
26
+ import { AppShell } from './layout/AppShell.js';
27
+ import { ScrollViewport } from './layout/ScrollViewport.js';
28
+ import { useTerminalSize } from './layout/useTerminalSize.js';
29
+ import { frameHeight, MIN_FULLSCREEN_ROWS } from './layout/frame.js';
30
+ import { HINT_MIN_ROWS, viewportRows as computeViewportRows } from './layout/budget.js';
31
+ import { installConsoleBridge } from './console-bridge.js';
32
+ import { publishExitSnapshot } from './exit-snapshot.js';
33
+ import { OverlayFrame } from './layout/OverlayFrame.js';
34
+ import { helpRows } from './overlays/HelpOverlay.js';
35
+ import { ModelPicker } from './overlays/ModelPicker.js';
36
+ import { SettingsScreen } from './overlays/SettingsScreen.js';
37
+ import { ConfirmDialog } from './overlays/ConfirmDialog.js';
38
+ import { CommandRegistry, runSlashInput } from '../commands/registry.js';
39
+ import { registerBuiltinCommands } from '../commands/builtins.js';
40
+ import { makeSkillsCommand, registerSkillCommands } from '../commands/skills.js';
41
+ /** Merge pending delta actions ~33 ms and flush the coalescer buffer. */
42
+ const COALESCE_MS = 33;
43
+ /** Rows a PgUp/PgDn moves inside a controlled overlay. */
44
+ const OVERLAY_PAGE = 8;
45
+ export function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, }) {
46
+ const { exit } = useApp();
47
+ const { stdout } = useStdout();
48
+ const fullscreen = mode === 'fullscreen';
49
+ const { rows, cols } = useTerminalSize();
50
+ const [state, dispatch] = useReducer(viewReducer, undefined, initialViewState);
51
+ const [elapsedMs, setElapsedMs] = useState(0);
52
+ const [confirmState, setConfirmState] = useState(null);
53
+ const [promptHistory, setPromptHistory] = useState(() => controller.getConfig().promptHistory ?? []);
54
+ // Scroll plumbing: intent goes down, one derived display number comes back.
55
+ const [scrollIntent, setScrollIntent] = useState();
56
+ const [pinToBottomNonce, setPinToBottomNonce] = useState(0);
57
+ const [scrolledLines, setScrolledLines] = useState(0);
58
+ // I-5 (§4.13): Ctrl+L cannot repaint by writing escapes — Ink dedupes
59
+ // identical output twice over — so it bumps this instead, and StatusBar turns
60
+ // it into a one-byte, zero-width change that both dedupe gates let through.
61
+ const [redrawNonce, setRedrawNonce] = useState(0);
62
+ // Overlay scroll offset. Held here rather than inside `OverlayFrame` so that
63
+ // closing an overlay cannot leave a stale offset behind, and reset whenever
64
+ // the active overlay changes.
65
+ const [overlayScroll, setOverlayScroll] = useState(0);
66
+ // Bumped whenever the installed skill set changes. The command registry and
67
+ // the completion list are memoized against it, so a skill installed mid-session
68
+ // gets its `/<name>` command and its autocomplete entry immediately.
69
+ const [skillsNonce, setSkillsNonce] = useState(0);
70
+ const cfg = controller.getConfig();
71
+ const caps = useMemo(() => {
72
+ const detected = detectCapabilities(process.env, stdout);
73
+ return {
74
+ colorLevel: cfg.color === false ? 0 : cfg.colorLevel ?? detected.colorLevel,
75
+ unicode: cfg.unicode ?? detected.unicode,
76
+ };
77
+ }, [cfg.color, cfg.colorLevel, cfg.unicode, stdout]);
78
+ const theme = useMemo(() => getTheme(cfg.theme, caps), [cfg.theme, caps]);
79
+ const glyphs = useMemo(() => pickGlyphs(caps), [caps]);
80
+ const reducedMotion = cfg.reducedMotion ?? false;
81
+ const stateRef = useRef(state);
82
+ stateRef.current = state;
83
+ const startedAt = useRef(Date.now());
84
+ const confirmRef = useRef(null);
85
+ confirmRef.current = confirmState;
86
+ const ctrlCArmed = useRef(false);
87
+ const ctrlCTimer = useRef(null);
88
+ const runBaselineOut = useRef(0);
89
+ const toastTimers = useRef(new Map());
90
+ const registry = useMemo(() => {
91
+ const r = new CommandRegistry();
92
+ // Order matters: built-ins first, so `registerSkillCommands` sees them when
93
+ // it probes for name conflicts and a skill can never displace `/exit` (D6).
94
+ registerBuiltinCommands(r);
95
+ const skills = controller.getSkillService();
96
+ if (cfg.skills.enabled) {
97
+ r.register(makeSkillsCommand(skills, version));
98
+ registerSkillCommands(r, skills);
99
+ }
100
+ return r;
101
+ // eslint-disable-next-line react-hooks/exhaustive-deps
102
+ }, [controller, cfg.skills.enabled, version, skillsNonce]);
103
+ const commandOptions = useMemo(() => registry.all().map((c) => ({ name: c.name, description: c.description })), [registry]);
104
+ // --- Streaming coalescer + controller subscription. --------------------
105
+ const pending = useRef([]);
106
+ const flushTimer = useRef(null);
107
+ const flushPending = useCallback(() => {
108
+ if (flushTimer.current) {
109
+ clearTimeout(flushTimer.current);
110
+ flushTimer.current = null;
111
+ }
112
+ if (pending.current.length === 0)
113
+ return;
114
+ const merged = mergeDeltas(pending.current);
115
+ pending.current = [];
116
+ for (const action of merged)
117
+ dispatch(action);
118
+ }, []);
119
+ useEffect(() => {
120
+ const unsubscribe = controller.subscribe((event) => {
121
+ const cost = controller.getModelInfo().cost;
122
+ for (const action of reduceEvent(event, cost)) {
123
+ if (action.type === 'textDelta' || action.type === 'thinkingDelta') {
124
+ pending.current.push(action);
125
+ if (!flushTimer.current) {
126
+ flushTimer.current = setTimeout(() => flushPending(), COALESCE_MS);
127
+ }
128
+ }
129
+ else {
130
+ flushPending();
131
+ dispatch(action);
132
+ }
133
+ }
134
+ });
135
+ return () => {
136
+ flushPending();
137
+ unsubscribe();
138
+ };
139
+ }, [controller, flushPending]);
140
+ // --- Elapsed timer + tokens/sec baseline while running. ----------------
141
+ useEffect(() => {
142
+ if (state.status !== 'running') {
143
+ setElapsedMs(0);
144
+ return;
145
+ }
146
+ runBaselineOut.current = stateRef.current.usageTotal.outputTokens;
147
+ const startedAt = Date.now();
148
+ setElapsedMs(0);
149
+ const id = setInterval(() => setElapsedMs(Date.now() - startedAt), 200);
150
+ return () => clearInterval(id);
151
+ }, [state.status]);
152
+ // --- Toast auto-dismiss (single effect keyed on the toast list). -------
153
+ useEffect(() => {
154
+ for (const t of state.toasts) {
155
+ if (!toastTimers.current.has(t.id)) {
156
+ const id = setTimeout(() => {
157
+ dispatch({ type: 'dismissToast', id: t.id });
158
+ toastTimers.current.delete(t.id);
159
+ }, t.ttlMs);
160
+ toastTimers.current.set(t.id, id);
161
+ }
162
+ }
163
+ for (const [tid, timer] of toastTimers.current) {
164
+ if (!state.toasts.some((t) => t.id === tid)) {
165
+ clearTimeout(timer);
166
+ toastTimers.current.delete(tid);
167
+ }
168
+ }
169
+ }, [state.toasts]);
170
+ useEffect(() => () => {
171
+ for (const timer of toastTimers.current.values())
172
+ clearTimeout(timer);
173
+ toastTimers.current.clear();
174
+ }, []);
175
+ // --- Console bridge (fullscreen only, I-4). ----------------------------
176
+ // Ink's own patchConsole writes straight to stdout, which shifts the fixed
177
+ // frame by a row and breaks its line accounting for good.
178
+ useEffect(() => {
179
+ if (!fullscreen)
180
+ return undefined;
181
+ return installConsoleBridge((level, text) => dispatch({ type: 'notice', level, text }));
182
+ }, [fullscreen]);
183
+ // --- Exit snapshot: the only way state reaches cli.tsx (§4.4). ----------
184
+ useEffect(() => {
185
+ publishExitSnapshot({
186
+ entries: state.entries,
187
+ usageTotal: state.usageTotal,
188
+ provider: cfg.provider,
189
+ model: cfg.model,
190
+ startedAt: startedAt.current,
191
+ });
192
+ }, [state.entries, state.usageTotal, cfg.provider, cfg.model]);
193
+ // --- Confirm bridge (confirmTools mode + skill approvals). -------------
194
+ useEffect(() => {
195
+ if (!confirmBridge)
196
+ return;
197
+ confirmBridge.handler = (req) => new Promise((resolve) => {
198
+ setConfirmState({ summary: req.summary, resolve });
199
+ dispatch({ type: 'setOverlay', overlay: 'confirm' });
200
+ });
201
+ return () => {
202
+ // The skill ApprovalGate reads `handler !== null` LIVE on every call, so
203
+ // this cleanup is what makes approvals fail closed again once the App
204
+ // unmounts. Caching the probe result anywhere would defeat it (Q7).
205
+ confirmBridge.handler = null;
206
+ };
207
+ }, [confirmBridge]);
208
+ // --- Let the controller drive command-list rebuilds. -------------------
209
+ useEffect(() => {
210
+ controller.setOnSkillsChanged(() => setSkillsNonce((n) => n + 1));
211
+ }, [controller]);
212
+ // --- Project skill-directory trust gate (D13 / §9.3). ------------------
213
+ // Asked once per untrusted root, after the first render so the confirm bridge
214
+ // is attached. Declining does NOT write anything: the root is skipped for this
215
+ // session and the question comes back next time, which is the right default
216
+ // for "I have not looked at this repo yet".
217
+ const trustAsked = useRef(false);
218
+ useEffect(() => {
219
+ if (trustAsked.current || !cfg.skills.enabled)
220
+ return;
221
+ const service = controller.getSkillService();
222
+ const pending = service.untrustedDirs();
223
+ if (pending.length === 0)
224
+ return;
225
+ trustAsked.current = true;
226
+ void (async () => {
227
+ for (const dir of pending) {
228
+ const approved = await new Promise((resolve) => {
229
+ setConfirmState({
230
+ summary: `Load project skills from ${dir}?\n` +
231
+ 'Skills in this directory can inject instructions into this session.',
232
+ resolve,
233
+ });
234
+ dispatch({ type: 'setOverlay', overlay: 'confirm' });
235
+ });
236
+ if (approved) {
237
+ service.trustDir(dir);
238
+ service.reload();
239
+ setSkillsNonce((n) => n + 1);
240
+ }
241
+ }
242
+ })();
243
+ // eslint-disable-next-line react-hooks/exhaustive-deps
244
+ }, [controller, cfg.skills.enabled]);
245
+ // --- Helpers -----------------------------------------------------------
246
+ const notify = (level, text) => dispatch({ type: 'notice', level, text });
247
+ const toast = (level, text) => dispatch({ type: 'pushToast', level, text });
248
+ const persistConfig = (patch) => {
249
+ try {
250
+ updatePersistedConfig(patch);
251
+ }
252
+ catch {
253
+ // Best-effort: a failed persist should not break the live session.
254
+ }
255
+ };
256
+ const doExit = () => {
257
+ controller.abort();
258
+ exit();
259
+ };
260
+ const makeCtx = (args) => ({
261
+ args,
262
+ controller,
263
+ state: stateRef.current,
264
+ dispatch,
265
+ setOverlay: (overlay) => dispatch({ type: 'setOverlay', overlay }),
266
+ notify,
267
+ toast,
268
+ persistConfig,
269
+ exit: doExit,
270
+ submit: (text) => submitMessage(text),
271
+ refreshSkills: () => setSkillsNonce((n) => n + 1),
272
+ });
273
+ const recordPrompt = (text) => {
274
+ const next = [...promptHistory.filter((p) => p !== text), text].slice(-PROMPT_HISTORY_CAP);
275
+ setPromptHistory(next);
276
+ // The submit counter rides along with the history write rather than opening
277
+ // a second I/O path (§4.6). It is read back through `controller.getConfig()`
278
+ // and never enters React state — nothing re-renders on its account.
279
+ const nextCount = (controller.getConfig().submitCount ?? 0) + 1;
280
+ controller.setSubmitCount(nextCount);
281
+ persistConfig({ promptHistory: next, submitCount: nextCount });
282
+ };
283
+ const scrollBy = useCallback((kind) => {
284
+ setScrollIntent((prev) => ({ kind, nonce: (prev?.nonce ?? 0) + 1 }));
285
+ }, []);
286
+ /**
287
+ * Send a message to the agent, bypassing slash parsing.
288
+ *
289
+ * Split out of `handleSubmit` so a dynamic skill command can submit the
290
+ * expanded skill body on the user's behalf: routing that text back through
291
+ * `handleSubmit` would re-parse it as input and, for a body that happens to
292
+ * start with `/`, recurse into command dispatch.
293
+ */
294
+ const submitMessage = (message) => {
295
+ if (stateRef.current.status === 'running') {
296
+ controller.steer(message);
297
+ toast('info', 'Steering queued.');
298
+ return;
299
+ }
300
+ recordPrompt(message);
301
+ dispatch({ type: 'submit', text: message });
302
+ const pre = controller.preflight();
303
+ if (!pre.ok) {
304
+ notify('error', pre.message ?? 'Configuration error.');
305
+ dispatch({ type: 'runEnd' }); // reset status; nothing was started
306
+ return;
307
+ }
308
+ // Fire-and-forget: events drive the UI. prompt() never rejects.
309
+ void controller.prompt(message);
310
+ };
311
+ const handleSubmit = async (raw) => {
312
+ // Submitting is an unconditional "take me to the newest output" (§4.5).
313
+ setPinToBottomNonce((n) => n + 1);
314
+ const handled = await runSlashInput(registry, raw, makeCtx);
315
+ if (handled)
316
+ return;
317
+ submitMessage(raw.startsWith('//') ? raw.slice(1) : raw);
318
+ };
319
+ const handleSettingsSave = (values) => {
320
+ const maxTokens = coerceMaxTokens(values.maxTokens);
321
+ controller.setModel(values.provider, values.model, values.baseUrl || undefined);
322
+ controller.setThinkingLevel(values.thinkingLevel);
323
+ controller.setMaxTokens(maxTokens);
324
+ const key = values.apiKey.trim();
325
+ if (key.length > 0)
326
+ controller.setApiKey(values.provider, key);
327
+ const patch = {
328
+ provider: values.provider,
329
+ model: values.model,
330
+ baseUrl: values.baseUrl.trim() ? values.baseUrl.trim() : null,
331
+ thinkingLevel: values.thinkingLevel,
332
+ maxTokens: maxTokens ?? null,
333
+ };
334
+ if (key.length > 0)
335
+ patch.apiKeys = { [values.provider]: key };
336
+ persistConfig(patch);
337
+ dispatch({ type: 'setOverlay', overlay: null });
338
+ toast('success', 'Settings saved.');
339
+ };
340
+ const handleModelSelect = (provider, model) => {
341
+ controller.setModel(provider, model);
342
+ persistConfig({ provider, model });
343
+ dispatch({ type: 'setOverlay', overlay: null });
344
+ toast('info', `Model set to ${provider}:${model}.`);
345
+ };
346
+ const closeConfirm = () => {
347
+ setConfirmState(null);
348
+ dispatch({ type: 'setOverlay', overlay: null });
349
+ };
350
+ // A fresh overlay always opens at the top; carrying the previous overlay's
351
+ // offset over would open Help mid-list for no reason the user can see.
352
+ useEffect(() => {
353
+ setOverlayScroll(0);
354
+ }, [state.overlay]);
355
+ // --- Initial overlay / prompt (after handlers are defined). ------------
356
+ useEffect(() => {
357
+ if (initialOverlay)
358
+ dispatch({ type: 'setOverlay', overlay: initialOverlay });
359
+ if (initialPrompt && initialPrompt.trim().length > 0) {
360
+ // Defer so the subscription is live before the first run.
361
+ const id = setTimeout(() => void handleSubmit(initialPrompt), 0);
362
+ return () => clearTimeout(id);
363
+ }
364
+ return undefined;
365
+ // eslint-disable-next-line react-hooks/exhaustive-deps
366
+ }, []);
367
+ // --- Global keys -------------------------------------------------------
368
+ useInput((input, key) => {
369
+ if (key.ctrl && (input === 'c' || input === 'C')) {
370
+ if (ctrlCArmed.current) {
371
+ doExit();
372
+ return;
373
+ }
374
+ ctrlCArmed.current = true;
375
+ toast('warn', 'Press Ctrl+C again to exit.');
376
+ if (ctrlCTimer.current)
377
+ clearTimeout(ctrlCTimer.current);
378
+ ctrlCTimer.current = setTimeout(() => {
379
+ ctrlCArmed.current = false;
380
+ }, 1500);
381
+ return;
382
+ }
383
+ // R-P1-7: the overlay branch MUST come before the transcript scroll branch
384
+ // and MUST return. `PgUp` used to fall through to `scrollBy()` while an
385
+ // overlay was open; `ScrollViewport` is unmounted then, but its
386
+ // `useEffect([intentNonce])` fires once on REMOUNT, so the stale intent was
387
+ // applied when the overlay closed and the transcript jumped a page on its
388
+ // own. Even overlays that ignore the key must swallow it here.
389
+ if (stateRef.current.overlay) {
390
+ const overlay = stateRef.current.overlay;
391
+ // Modes A only. `model` / `confirm` manage their own keys; registering an
392
+ // arrow handler for them would double every keypress (R-12).
393
+ const controlled = overlay === 'help' || overlay === 'settings';
394
+ if (key.pageUp) {
395
+ if (controlled)
396
+ setOverlayScroll((n) => Math.max(0, n - OVERLAY_PAGE));
397
+ return;
398
+ }
399
+ if (key.pageDown) {
400
+ if (controlled)
401
+ setOverlayScroll((n) => n + OVERLAY_PAGE);
402
+ return;
403
+ }
404
+ // `settings` keeps Up/Down for field movement — a high-frequency action
405
+ // worth more than scrolling six fields (§4.9).
406
+ if (overlay === 'help' && (key.upArrow || key.downArrow)) {
407
+ setOverlayScroll((n) => Math.max(0, n + (key.upArrow ? -1 : 1)));
408
+ return;
409
+ }
410
+ }
411
+ if (key.ctrl && (input === 'l' || input === 'L')) {
412
+ if (fullscreen) {
413
+ // Writing \x1B[2J here would erase the screen and then leave it blank:
414
+ // Ink sees no output change and skips the repaint (ink.js:132 +
415
+ // log-update.js:13), possibly forever on an idle empty session. Bump
416
+ // the nonce instead and let Ink redraw the frame normally (§4.13).
417
+ setRedrawNonce((n) => n + 1);
418
+ return;
419
+ }
420
+ try {
421
+ stdout.write('\x1B[2J\x1B[3J\x1B[H');
422
+ }
423
+ catch {
424
+ /* ignore */
425
+ }
426
+ return;
427
+ }
428
+ if (fullscreen) {
429
+ if (key.pageUp) {
430
+ scrollBy('pageUp');
431
+ return;
432
+ }
433
+ if (key.pageDown) {
434
+ scrollBy('pageDown');
435
+ return;
436
+ }
437
+ if (key.shift && key.upArrow) {
438
+ scrollBy('lineUp');
439
+ return;
440
+ }
441
+ if (key.shift && key.downArrow) {
442
+ scrollBy('lineDown');
443
+ return;
444
+ }
445
+ }
446
+ if (key.ctrl && (input === 't' || input === 'T')) {
447
+ dispatch({ type: 'toggleThinking' });
448
+ return;
449
+ }
450
+ if (key.ctrl && (input === 'o' || input === 'O')) {
451
+ const last = [...stateRef.current.entries].reverse().find((e) => e.kind === 'tool');
452
+ if (last)
453
+ dispatch({ type: 'toggleExpand', id: last.id });
454
+ else
455
+ toast('info', 'No tool output to expand.');
456
+ return;
457
+ }
458
+ if (key.escape) {
459
+ const overlay = stateRef.current.overlay;
460
+ if (overlay) {
461
+ if (overlay === 'confirm' && confirmRef.current) {
462
+ confirmRef.current.resolve(false);
463
+ setConfirmState(null);
464
+ }
465
+ dispatch({ type: 'setOverlay', overlay: null });
466
+ return;
467
+ }
468
+ if (stateRef.current.status === 'running') {
469
+ controller.abort();
470
+ dispatch({ type: 'abortMark' });
471
+ toast('info', 'Run aborted.');
472
+ }
473
+ return;
474
+ }
475
+ });
476
+ // --- Render ------------------------------------------------------------
477
+ const modelInfo = controller.getModelInfo();
478
+ const modelKnown = !!controller.getModelRegistry().getModel(cfg.provider, cfg.model);
479
+ const overlay = state.overlay;
480
+ const hasKey = controller.hasApiKey();
481
+ const empty = state.entries.length === 0;
482
+ const tokPerSec = state.status === 'running' && elapsedMs > 500
483
+ ? Math.max(0, Math.round((state.usageTotal.outputTokens - runBaselineOut.current) / (elapsedMs / 1000)))
484
+ : 0;
485
+ // Row budget for the viewport and everything sized against it (§4.2a). The
486
+ // inline path has no fixed frame, hence no height to fit into: `Infinity`
487
+ // tells `OverlayFrame` to render everything and show no position indicator.
488
+ const viewportBudget = computeViewportRows(rows);
489
+ const overlayMaxRows = fullscreen ? viewportBudget : Number.POSITIVE_INFINITY;
490
+ const overlayNode = overlay === 'help' ? (_jsx(OverlayFrame, { title: "Help", hint: `Esc close ${glyphs.midDot} PgUp/PgDn scroll`, maxRows: overlayMaxRows, cols: cols, rows: helpRows(theme, caps), scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps })) : overlay === 'model' ? (_jsx(ModelPicker, { registry: controller.getModelRegistry(), currentProvider: cfg.provider, currentModel: cfg.model, maxRows: overlayMaxRows, cols: cols, theme: theme, caps: caps, onSelect: handleModelSelect })) : overlay === 'settings' ? (_jsx(SettingsScreen, { initial: {
491
+ provider: cfg.provider,
492
+ model: cfg.model,
493
+ baseUrl: cfg.baseUrl ?? '',
494
+ thinkingLevel: cfg.thinkingLevel,
495
+ maxTokens: cfg.maxTokens ? String(cfg.maxTokens) : '',
496
+ apiKey: '',
497
+ }, apiKeys: cfg.apiKeys, maxRows: overlayMaxRows, cols: cols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, onSave: handleSettingsSave })) : overlay === 'confirm' && confirmState ? (_jsx(ConfirmDialog, { state: confirmState, maxRows: overlayMaxRows, cols: cols, theme: theme, caps: caps, onClose: closeConfirm })) : null;
498
+ // A resize can drop below the usable floor. Do NOT leave the alt-screen —
499
+ // entering and leaving it on every drag is worse than a placeholder — and take
500
+ // the height from `frameHeight()` like everything else, because this is the
501
+ // one path that reaches rows < 12 and a hard-coded height here would land on
502
+ // `outputHeight >= rows` exactly when the user is dragging the window (§4.11).
503
+ if (fullscreen && rows < MIN_FULLSCREEN_ROWS) {
504
+ return (_jsx(Box, { flexDirection: "column", height: frameHeight(rows), width: cols, overflow: "hidden", children: _jsxs(Text, { wrap: "truncate", color: theme.noticeWarn, children: [glyphs.warn, " Terminal too small - needs at least ", MIN_FULLSCREEN_ROWS, " rows."] }) }));
505
+ }
506
+ // Full-screen: a constant one-row brand bar at every size, which is what makes
507
+ // the viewport monotonic and the first submit jump-free (§4.2). Inline keeps
508
+ // its v0.3.0 ternary and never calls `pickHeaderVariant`.
509
+ const header = (_jsx(Header, { version: version, cwd: controller.getCwd(), provider: cfg.provider, model: cfg.model, hasKey: hasKey, variant: fullscreen ? pickHeaderVariant(cols) : empty ? 'banner' : 'bar', theme: theme, caps: caps }));
510
+ // The wordmark + getting-started card now live INSIDE the viewport, as its
511
+ // first block of content, so they scroll away instead of being yanked out of
512
+ // the header the moment the first message lands.
513
+ const opener = empty ? (_jsx(SessionOpener, { variant: fullscreen ? pickOpenerVariant(viewportBudget, cols, caps) : 'none', version: version, cwd: controller.getCwd(), hasKey: hasKey, viewportRows: fullscreen ? viewportBudget : Number.POSITIVE_INFINITY, theme: theme, caps: caps })) : null;
514
+ const density = cfg.density;
515
+ const viewport = fullscreen ? (overlayNode ? (_jsx(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden", children: overlayNode })) : (_jsxs(ScrollViewport, { intent: scrollIntent, pinToBottomNonce: pinToBottomNonce, onScrolledLinesChange: setScrolledLines, theme: theme, caps: caps, children: [opener, _jsx(TranscriptList, { entries: state.entries, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: reducedMotion, density: density, theme: theme, caps: caps, windowSize: cfg.transcriptWindow ?? DEFAULT_TRANSCRIPT_WINDOW })] }))) : (_jsxs(_Fragment, { children: [opener, _jsx(Transcript, { entries: state.entries, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: reducedMotion, density: density, theme: theme, caps: caps }), overlayNode] }));
516
+ // The completion popup lives inside the bottom chrome and can add up to 9
517
+ // rows; unbounded, it pushes the status bar past the frame height where
518
+ // `overflow: hidden` clips it away (R-14 / M-14). Cap it against the viewport.
519
+ const popupMaxRows = fullscreen ? Math.max(1, viewportBudget - 4) : undefined;
520
+ const composer = fullscreen ? (_jsx(Composer, { isActive: overlay === null, running: state.status === 'running', history: promptHistory, commands: commandOptions, cwd: controller.getCwd(), showHint: rows >= HINT_MIN_ROWS, submitCount: cfg.submitCount, hintsEnabled: cfg.hints, popupMaxRows: popupMaxRows, theme: theme, caps: caps, onSubmit: (text) => void handleSubmit(text), onHelp: () => dispatch({ type: 'setOverlay', overlay: 'help' }) })) : (_jsx(Box, { marginTop: 1, children: _jsx(PromptInput, { isActive: overlay === null, running: state.status === 'running', history: promptHistory, commands: commandOptions, cwd: controller.getCwd(), theme: theme, caps: caps, onSubmit: (text) => void handleSubmit(text), onHelp: () => dispatch({ type: 'setOverlay', overlay: 'help' }) }) }));
521
+ return (_jsx(AppShell, { mode: mode, rows: rows, cols: cols, header: header, viewport: viewport, toast: _jsx(ToastStack, { toasts: state.toasts, theme: theme, mode: mode }), composer: composer, status: _jsx(StatusBar, { model: cfg.model, provider: cfg.provider, usageTotal: state.usageTotal, contextTokens: state.contextTokens, contextWindow: modelInfo.contextWindow, contextWindowKnown: modelKnown, status: state.status, elapsedMs: elapsedMs, thinkingLevel: cfg.thinkingLevel, tokPerSec: tokPerSec, theme: theme, caps: caps, scrolledLines: scrolledLines, redrawNonce: redrawNonce }) }));
522
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Autocomplete popup (spec §3.6). A shared bordered suggestion menu used by both
3
+ * the slash-command palette and `@file` completion. Presentational only — the
4
+ * suggestion list, selection index, and key handling live in `PromptInput`.
5
+ */
6
+ import React from 'react';
7
+ import type { Theme } from './theme.js';
8
+ import type { TermCapabilities } from './capabilities.js';
9
+ export interface Suggestion {
10
+ label: string;
11
+ hint?: string;
12
+ }
13
+ interface AutocompletePopupProps {
14
+ items: Suggestion[];
15
+ selected: number;
16
+ theme: Theme;
17
+ caps: TermCapabilities;
18
+ maxRows?: number;
19
+ }
20
+ export declare function AutocompletePopup({ items, selected, theme, caps, maxRows, }: AutocompletePopupProps): React.ReactElement | null;
21
+ export {};
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { pickGlyphs } from './glyphs.js';
4
+ const DEFAULT_MAX_ROWS = 6;
5
+ export function AutocompletePopup({ items, selected, theme, caps, maxRows = DEFAULT_MAX_ROWS, }) {
6
+ if (items.length === 0)
7
+ return null;
8
+ const glyphs = pickGlyphs(caps);
9
+ // Keep the highlighted row within a window of `maxRows`.
10
+ const start = Math.max(0, Math.min(selected - maxRows + 1, items.length - maxRows));
11
+ const windowStart = Math.max(0, start);
12
+ const visible = items.slice(windowStart, windowStart + maxRows);
13
+ const moreBelow = items.length - (windowStart + visible.length);
14
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: glyphs.boxStyle, borderColor: theme.border, paddingX: 1, children: [visible.map((item, i) => {
15
+ const idx = windowStart + i;
16
+ const active = idx === selected;
17
+ return (_jsxs(Text, { inverse: active, children: [_jsx(Text, { color: active ? undefined : theme.primary, children: item.label }), item.hint ? _jsxs(Text, { color: theme.muted, children: [" ", item.hint] }) : null] }, item.label));
18
+ }), moreBelow > 0 && _jsxs(Text, { color: theme.muted, children: [" +", moreBelow, " more"] })] }));
19
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Composer (spec §4.7) — the thin shell around the existing `PromptInput`.
3
+ *
4
+ * Owns exactly two things: the semantic border color and the hint row. The
5
+ * editor's pure helpers (`applyEdit` / `moveVertical` / `slashSuggestions` /
6
+ * `fileTokenAt`) are untouched, so `input.test.ts` keeps passing.
7
+ *
8
+ * The draft flag is held HERE rather than in `App`: the border has to react to
9
+ * "the input is no longer empty", and routing that through App would re-render
10
+ * the transcript on the first keystroke of every message.
11
+ *
12
+ * As of v0.4.0 this is the ONLY place keybinding hints are shown — the status
13
+ * bar's duplicate cluster is gone (§4.6). Two hints for the same keys, one of
14
+ * them 31 columns wide, were both redundant and the reason the status bar had a
15
+ * `cols >= 110` breakpoint.
16
+ */
17
+ import React from 'react';
18
+ import type { Theme } from './theme.js';
19
+ import type { TermCapabilities } from './capabilities.js';
20
+ import { type CommandOption } from './PromptInput.js';
21
+ export interface ComposerProps {
22
+ /** False while an overlay owns the keyboard (the input is visually blurred). */
23
+ isActive: boolean;
24
+ running: boolean;
25
+ history: string[];
26
+ commands: CommandOption[];
27
+ cwd: string;
28
+ /** Dropped on short terminals to buy back a row (§5.4). */
29
+ showHint: boolean;
30
+ /** Lifetime submissions; drives progressive disclosure of the idle hint. */
31
+ submitCount: number;
32
+ /** User opt-out (`--no-hints` / `hints: false`). */
33
+ hintsEnabled: boolean;
34
+ popupMaxRows?: number;
35
+ theme: Theme;
36
+ caps: TermCapabilities;
37
+ onSubmit: (text: string) => void;
38
+ onHelp?: () => void;
39
+ }
40
+ export declare function Composer({ isActive, running, history, commands, cwd, showHint, submitCount, hintsEnabled, popupMaxRows, theme, caps, onSubmit, onHelp, }: ComposerProps): React.ReactElement;
@@ -0,0 +1,61 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Composer (spec §4.7) — the thin shell around the existing `PromptInput`.
4
+ *
5
+ * Owns exactly two things: the semantic border color and the hint row. The
6
+ * editor's pure helpers (`applyEdit` / `moveVertical` / `slashSuggestions` /
7
+ * `fileTokenAt`) are untouched, so `input.test.ts` keeps passing.
8
+ *
9
+ * The draft flag is held HERE rather than in `App`: the border has to react to
10
+ * "the input is no longer empty", and routing that through App would re-render
11
+ * the transcript on the first keystroke of every message.
12
+ *
13
+ * As of v0.4.0 this is the ONLY place keybinding hints are shown — the status
14
+ * bar's duplicate cluster is gone (§4.6). Two hints for the same keys, one of
15
+ * them 31 columns wide, were both redundant and the reason the status bar had a
16
+ * `cols >= 110` breakpoint.
17
+ */
18
+ import { useCallback, useState } from 'react';
19
+ import { Box, Text } from 'ink';
20
+ import { pickGlyphs } from './glyphs.js';
21
+ import { HINT_FADE_AFTER } from '../config/schema.js';
22
+ import { PromptInput } from './PromptInput.js';
23
+ /**
24
+ * Build the hint row.
25
+ *
26
+ * The `running` branch is NEVER abbreviated, no matter how experienced the user
27
+ * is. Once the status bar's hint cluster was removed, the composer became the
28
+ * only place that names the abort key — and fading that out after eight
29
+ * submissions would leave a long-running task with no visible way to stop it.
30
+ * Progressive disclosure is for teaching discoverable things, not for retiring
31
+ * an emergency exit (§4.6 / A-14 / M-10b).
32
+ */
33
+ function hintText(running, submitCount, glyphs) {
34
+ const dot = ` ${glyphs.midDot} `;
35
+ if (running) {
36
+ return [`${glyphs.enterKey} steer`, 'esc abort', `ctrl+c${glyphs.times}2 exit`].join(dot);
37
+ }
38
+ if (submitCount >= HINT_FADE_AFTER)
39
+ return '? help';
40
+ return [
41
+ `${glyphs.enterKey} send`,
42
+ `${glyphs.shiftEnter} newline`,
43
+ '/ commands',
44
+ '@ files',
45
+ '? help',
46
+ ].join(dot);
47
+ }
48
+ export function Composer({ isActive, running, history, commands, cwd, showHint, submitCount, hintsEnabled, popupMaxRows, theme, caps, onSubmit, onHelp, }) {
49
+ const [hasDraft, setHasDraft] = useState(false);
50
+ const onDraftChange = useCallback((next) => setHasDraft(next), []);
51
+ const borderColor = !isActive
52
+ ? theme.toolPending
53
+ : running
54
+ ? theme.toolRunning
55
+ : hasDraft
56
+ ? theme.focusBorder ?? theme.primary
57
+ : theme.idleBorder ?? theme.border;
58
+ const glyphs = pickGlyphs(caps);
59
+ const visible = showHint && hintsEnabled;
60
+ return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsx(PromptInput, { isActive: isActive, running: running, history: history, commands: commands, cwd: cwd, theme: theme, caps: caps, popupMaxRows: popupMaxRows, onSubmit: onSubmit, onHelp: onHelp, bordered: { color: borderColor }, onDraftChange: onDraftChange }), visible && (_jsxs(Text, { wrap: "truncate", color: theme.hintFg ?? theme.muted, children: [' ', hintText(running, submitCount, glyphs)] }))] }));
61
+ }