@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.
- package/CHANGELOG.md +292 -0
- package/README.md +558 -0
- package/dist/agent/coalesce.d.ts +14 -0
- package/dist/agent/coalesce.js +41 -0
- package/dist/agent/controller.d.ts +132 -0
- package/dist/agent/controller.js +332 -0
- package/dist/agent/headless.d.ts +22 -0
- package/dist/agent/headless.js +74 -0
- package/dist/agent/reducer.d.ts +181 -0
- package/dist/agent/reducer.js +420 -0
- package/dist/agent/system-prompt.d.ts +30 -0
- package/dist/agent/system-prompt.js +58 -0
- package/dist/agent/usage.d.ts +13 -0
- package/dist/agent/usage.js +41 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +482 -0
- package/dist/commands/builtins.d.ts +16 -0
- package/dist/commands/builtins.js +223 -0
- package/dist/commands/registry.d.ts +48 -0
- package/dist/commands/registry.js +45 -0
- package/dist/commands/skills.d.ts +22 -0
- package/dist/commands/skills.js +625 -0
- package/dist/config/app-paths.d.ts +11 -0
- package/dist/config/app-paths.js +16 -0
- package/dist/config/env.d.ts +28 -0
- package/dist/config/env.js +92 -0
- package/dist/config/load.d.ts +48 -0
- package/dist/config/load.js +158 -0
- package/dist/config/migrate-legacy-state.d.ts +78 -0
- package/dist/config/migrate-legacy-state.js +181 -0
- package/dist/config/schema.d.ts +249 -0
- package/dist/config/schema.js +247 -0
- package/dist/config/store.d.ts +44 -0
- package/dist/config/store.js +126 -0
- package/dist/input/keymap.d.ts +20 -0
- package/dist/input/keymap.js +85 -0
- package/dist/session/persist.d.ts +24 -0
- package/dist/session/persist.js +51 -0
- package/dist/skills/archive.d.ts +41 -0
- package/dist/skills/archive.js +152 -0
- package/dist/skills/cli-commands.d.ts +52 -0
- package/dist/skills/cli-commands.js +502 -0
- package/dist/skills/fetch-source.d.ts +104 -0
- package/dist/skills/fetch-source.js +448 -0
- package/dist/skills/installer.d.ts +117 -0
- package/dist/skills/installer.js +414 -0
- package/dist/skills/lock.d.ts +75 -0
- package/dist/skills/lock.js +223 -0
- package/dist/skills/manifest.d.ts +47 -0
- package/dist/skills/manifest.js +95 -0
- package/dist/skills/node-host.d.ts +23 -0
- package/dist/skills/node-host.js +59 -0
- package/dist/skills/paths.d.ts +61 -0
- package/dist/skills/paths.js +107 -0
- package/dist/skills/service.d.ts +256 -0
- package/dist/skills/service.js +730 -0
- package/dist/skills/tools.d.ts +35 -0
- package/dist/skills/tools.js +164 -0
- package/dist/skills/updater.d.ts +101 -0
- package/dist/skills/updater.js +330 -0
- package/dist/skills/usage.d.ts +72 -0
- package/dist/skills/usage.js +216 -0
- package/dist/tools/bash-tool.d.ts +12 -0
- package/dist/tools/bash-tool.js +131 -0
- package/dist/tools/diff.d.ts +15 -0
- package/dist/tools/diff.js +50 -0
- package/dist/tools/fs-tools.d.ts +16 -0
- package/dist/tools/fs-tools.js +215 -0
- package/dist/tools/index.d.ts +93 -0
- package/dist/tools/index.js +157 -0
- package/dist/tools/search-tools.d.ts +11 -0
- package/dist/tools/search-tools.js +225 -0
- package/dist/ui/App.d.ts +24 -0
- package/dist/ui/App.js +522 -0
- package/dist/ui/AutocompletePopup.d.ts +21 -0
- package/dist/ui/AutocompletePopup.js +19 -0
- package/dist/ui/Composer.d.ts +40 -0
- package/dist/ui/Composer.js +61 -0
- package/dist/ui/Header.d.ts +30 -0
- package/dist/ui/Header.js +25 -0
- package/dist/ui/Logo.d.ts +50 -0
- package/dist/ui/Logo.js +85 -0
- package/dist/ui/Markdown.d.ts +23 -0
- package/dist/ui/Markdown.js +122 -0
- package/dist/ui/PromptInput.d.ts +81 -0
- package/dist/ui/PromptInput.js +383 -0
- package/dist/ui/SessionOpener.d.ts +30 -0
- package/dist/ui/SessionOpener.js +12 -0
- package/dist/ui/StatusBar.d.ts +39 -0
- package/dist/ui/StatusBar.js +43 -0
- package/dist/ui/ToastStack.d.ts +24 -0
- package/dist/ui/ToastStack.js +38 -0
- package/dist/ui/Transcript.d.ts +62 -0
- package/dist/ui/Transcript.js +128 -0
- package/dist/ui/capabilities.d.ts +25 -0
- package/dist/ui/capabilities.js +59 -0
- package/dist/ui/console-bridge.d.ts +19 -0
- package/dist/ui/console-bridge.js +52 -0
- package/dist/ui/density.d.ts +26 -0
- package/dist/ui/density.js +34 -0
- package/dist/ui/entries/AssistantEntry.d.ts +23 -0
- package/dist/ui/entries/AssistantEntry.js +13 -0
- package/dist/ui/entries/EntryFrame.d.ts +23 -0
- package/dist/ui/entries/EntryFrame.js +6 -0
- package/dist/ui/entries/ToolCard.d.ts +38 -0
- package/dist/ui/entries/ToolCard.js +72 -0
- package/dist/ui/entries/ToolPreview.d.ts +57 -0
- package/dist/ui/entries/ToolPreview.js +144 -0
- package/dist/ui/entries/UserEntry.d.ts +14 -0
- package/dist/ui/entries/UserEntry.js +5 -0
- package/dist/ui/exit-snapshot.d.ts +27 -0
- package/dist/ui/exit-snapshot.js +26 -0
- package/dist/ui/gauge.d.ts +24 -0
- package/dist/ui/gauge.js +36 -0
- package/dist/ui/glyphs.d.ts +87 -0
- package/dist/ui/glyphs.js +127 -0
- package/dist/ui/gradient.d.ts +11 -0
- package/dist/ui/gradient.js +50 -0
- package/dist/ui/layout/AppShell.d.ts +24 -0
- package/dist/ui/layout/AppShell.js +9 -0
- package/dist/ui/layout/Gutter.d.ts +67 -0
- package/dist/ui/layout/Gutter.js +42 -0
- package/dist/ui/layout/OverlayFrame.d.ts +62 -0
- package/dist/ui/layout/OverlayFrame.js +92 -0
- package/dist/ui/layout/ScrollViewport.d.ts +33 -0
- package/dist/ui/layout/ScrollViewport.js +71 -0
- package/dist/ui/layout/budget.d.ts +42 -0
- package/dist/ui/layout/budget.js +47 -0
- package/dist/ui/layout/frame.d.ts +58 -0
- package/dist/ui/layout/frame.js +75 -0
- package/dist/ui/layout/overlay-window.d.ts +43 -0
- package/dist/ui/layout/overlay-window.js +51 -0
- package/dist/ui/layout/scroll.d.ts +28 -0
- package/dist/ui/layout/scroll.js +76 -0
- package/dist/ui/layout/useTerminalSize.d.ts +13 -0
- package/dist/ui/layout/useTerminalSize.js +50 -0
- package/dist/ui/markdown-blocks.d.ts +62 -0
- package/dist/ui/markdown-blocks.js +120 -0
- package/dist/ui/overlays/ConfirmDialog.d.ts +30 -0
- package/dist/ui/overlays/ConfirmDialog.js +25 -0
- package/dist/ui/overlays/HelpOverlay.d.ts +17 -0
- package/dist/ui/overlays/HelpOverlay.js +60 -0
- package/dist/ui/overlays/ModelPicker.d.ts +29 -0
- package/dist/ui/overlays/ModelPicker.js +29 -0
- package/dist/ui/overlays/SettingsScreen.d.ts +37 -0
- package/dist/ui/overlays/SettingsScreen.js +106 -0
- package/dist/ui/palettes.d.ts +79 -0
- package/dist/ui/palettes.js +115 -0
- package/dist/ui/screen.d.ts +27 -0
- package/dist/ui/screen.js +57 -0
- package/dist/ui/theme.d.ts +78 -0
- package/dist/ui/theme.js +127 -0
- package/dist/ui/transcript-text.d.ts +27 -0
- package/dist/ui/transcript-text.js +110 -0
- package/package.json +73 -0
- 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
|
+
}
|