@marifold/tui 0.60.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +674 -0
- package/dist/core/appState.d.ts +158 -0
- package/dist/core/appState.d.ts.map +1 -0
- package/dist/core/appState.js +167 -0
- package/dist/core/appState.js.map +1 -0
- package/dist/core/commands.d.ts +70 -0
- package/dist/core/commands.d.ts.map +1 -0
- package/dist/core/commands.js +227 -0
- package/dist/core/commands.js.map +1 -0
- package/dist/core/displayPaths.d.ts +5 -0
- package/dist/core/displayPaths.d.ts.map +1 -0
- package/dist/core/displayPaths.js +14 -0
- package/dist/core/displayPaths.js.map +1 -0
- package/dist/core/eventView.d.ts +10 -0
- package/dist/core/eventView.d.ts.map +1 -0
- package/dist/core/eventView.js +77 -0
- package/dist/core/eventView.js.map +1 -0
- package/dist/core/inputGrammar.d.ts +29 -0
- package/dist/core/inputGrammar.d.ts.map +1 -0
- package/dist/core/inputGrammar.js +30 -0
- package/dist/core/inputGrammar.js.map +1 -0
- package/dist/core/skills.d.ts +2 -0
- package/dist/core/skills.d.ts.map +1 -0
- package/dist/core/skills.js +2 -0
- package/dist/core/skills.js.map +1 -0
- package/dist/core/transcriptWindow.d.ts.map +1 -0
- package/dist/core/transcriptWindow.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/runTui.d.ts +18 -0
- package/dist/runTui.d.ts.map +1 -0
- package/dist/runTui.js +146 -0
- package/dist/runTui.js.map +1 -0
- package/dist/ui/App.d.ts +23 -0
- package/dist/ui/App.d.ts.map +1 -0
- package/dist/ui/App.js +991 -0
- package/dist/ui/App.js.map +1 -0
- package/dist/ui/ApprovalModal.d.ts +12 -0
- package/dist/ui/ApprovalModal.d.ts.map +1 -0
- package/dist/ui/ApprovalModal.js +62 -0
- package/dist/ui/ApprovalModal.js.map +1 -0
- package/dist/ui/Header.d.ts +12 -0
- package/dist/ui/Header.d.ts.map +1 -0
- package/dist/ui/Header.js +15 -0
- package/dist/ui/Header.js.map +1 -0
- package/dist/ui/InfoPanel.d.ts +11 -0
- package/dist/ui/InfoPanel.d.ts.map +1 -0
- package/dist/ui/InfoPanel.js +16 -0
- package/dist/ui/InfoPanel.js.map +1 -0
- package/dist/ui/InputBox.d.ts +31 -0
- package/dist/ui/InputBox.d.ts.map +1 -0
- package/dist/ui/InputBox.js +410 -0
- package/dist/ui/InputBox.js.map +1 -0
- package/dist/ui/Markdown.d.ts +12 -0
- package/dist/ui/Markdown.d.ts.map +1 -0
- package/dist/ui/Markdown.js +111 -0
- package/dist/ui/Markdown.js.map +1 -0
- package/dist/ui/QuestionModal.d.ts +11 -0
- package/dist/ui/QuestionModal.d.ts.map +1 -0
- package/dist/ui/QuestionModal.js +211 -0
- package/dist/ui/QuestionModal.js.map +1 -0
- package/dist/ui/RunStatus.d.ts +17 -0
- package/dist/ui/RunStatus.d.ts.map +1 -0
- package/dist/ui/RunStatus.js +46 -0
- package/dist/ui/RunStatus.js.map +1 -0
- package/dist/ui/SelectList.d.ts +18 -0
- package/dist/ui/SelectList.d.ts.map +1 -0
- package/dist/ui/SelectList.js +54 -0
- package/dist/ui/SelectList.js.map +1 -0
- package/dist/ui/Spinner.d.ts +6 -0
- package/dist/ui/Spinner.d.ts.map +1 -0
- package/dist/ui/Spinner.js +14 -0
- package/dist/ui/Spinner.js.map +1 -0
- package/dist/ui/StatusLine.d.ts +6 -0
- package/dist/ui/StatusLine.d.ts.map +1 -0
- package/dist/ui/StatusLine.js +32 -0
- package/dist/ui/StatusLine.js.map +1 -0
- package/dist/ui/Transcript.d.ts +17 -0
- package/dist/ui/Transcript.d.ts.map +1 -0
- package/dist/ui/Transcript.js +58 -0
- package/dist/ui/Transcript.js.map +1 -0
- package/dist/ui/appHelpers.d.ts +16 -0
- package/dist/ui/appHelpers.d.ts.map +1 -0
- package/dist/ui/appHelpers.js +59 -0
- package/dist/ui/appHelpers.js.map +1 -0
- package/dist/ui/text.d.ts +7 -0
- package/dist/ui/text.d.ts.map +1 -0
- package/dist/ui/text.js +16 -0
- package/dist/ui/text.js.map +1 -0
- package/dist/ui/theme.d.ts +12 -0
- package/dist/ui/theme.d.ts.map +1 -0
- package/dist/ui/theme.js +12 -0
- package/dist/ui/theme.js.map +1 -0
- package/dist/ui/useResizing.d.ts +14 -0
- package/dist/ui/useResizing.d.ts.map +1 -0
- package/dist/ui/useResizing.js +37 -0
- package/dist/ui/useResizing.js.map +1 -0
- package/dist/ui/useTerminalSize.d.ts +8 -0
- package/dist/ui/useTerminalSize.d.ts.map +1 -0
- package/dist/ui/useTerminalSize.js +23 -0
- package/dist/ui/useTerminalSize.js.map +1 -0
- package/package.json +32 -0
package/dist/ui/App.js
ADDED
|
@@ -0,0 +1,991 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
|
|
3
|
+
import { Box, Static, useApp, useInput, useStdout } from 'ink';
|
|
4
|
+
import { randomUUID } from 'crypto';
|
|
5
|
+
import * as fs from 'fs';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
import { expandHome, isInsideAny, renderSkillPrompt, } from '@marifold/core';
|
|
8
|
+
import { appReducer, createInitialState } from '../core/appState.js';
|
|
9
|
+
import { parseInput } from '../core/inputGrammar.js';
|
|
10
|
+
import { listCommandCompletions, listCommands, runCommand } from '../core/commands.js';
|
|
11
|
+
import { bindSkillArgs, skillUsage } from '../core/skills.js';
|
|
12
|
+
import { Header } from './Header.js';
|
|
13
|
+
import { TranscriptRow, topGap } from './Transcript.js';
|
|
14
|
+
import { InputBox } from './InputBox.js';
|
|
15
|
+
import { StatusLine } from './StatusLine.js';
|
|
16
|
+
import { RunStatus } from './RunStatus.js';
|
|
17
|
+
import { ApprovalModal, trustTargetFolder } from './ApprovalModal.js';
|
|
18
|
+
import { QuestionModal } from './QuestionModal.js';
|
|
19
|
+
import { SelectList } from './SelectList.js';
|
|
20
|
+
import { useTerminalSize } from './useTerminalSize.js';
|
|
21
|
+
import { useResizing } from './useResizing.js';
|
|
22
|
+
import { copyToClipboard, errorText, runSummary, skillInvocation, unwrapPath } from './appHelpers.js';
|
|
23
|
+
const READ_FILE_CHAR_LIMIT = 100000;
|
|
24
|
+
export function App({ runtime, loadedConfig, initial }) {
|
|
25
|
+
const { exit } = useApp();
|
|
26
|
+
const [state, dispatch] = useReducer(appReducer, createInitialState({
|
|
27
|
+
profile: initial.profile,
|
|
28
|
+
...(initial.displayName ? { displayName: initial.displayName } : {}),
|
|
29
|
+
provider: initial.provider,
|
|
30
|
+
model: initial.model,
|
|
31
|
+
cwd: initial.cwd,
|
|
32
|
+
version: initial.version,
|
|
33
|
+
...(initial.mode ? { mode: initial.mode } : {}),
|
|
34
|
+
...(initial.latestVersion ? { latestVersion: initial.latestVersion } : {}),
|
|
35
|
+
...(initial.sessionId ? { sessionId: initial.sessionId } : {}),
|
|
36
|
+
...(initial.maxContextTokens != null ? { maxContextTokens: initial.maxContextTokens } : {}),
|
|
37
|
+
...(initial.transcript ? { transcript: initial.transcript } : {}),
|
|
38
|
+
}));
|
|
39
|
+
const [overlay, setOverlay] = useState(null);
|
|
40
|
+
const [think, setThink] = useState(initial.think);
|
|
41
|
+
// `/steps` arms a one-shot forced plan for the next model turn (then auto-disarms).
|
|
42
|
+
const [planNext, setPlanNext] = useState(false);
|
|
43
|
+
const [steeringCount, setSteeringCount] = useState(0);
|
|
44
|
+
const [pendingSkill, setPendingSkill] = useState(null);
|
|
45
|
+
const [history, setHistory] = useState([]);
|
|
46
|
+
const [skillItems, setSkillItems] = useState(() => {
|
|
47
|
+
try {
|
|
48
|
+
return runtime.listSkills(initial.profile).map(skill => ({ name: skill.name, hint: skill.description }));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
const commandItems = useMemo(() => listCommandCompletions(), []);
|
|
55
|
+
// Used to cap overlay (picker) height between the latest content and the input.
|
|
56
|
+
const { rows } = useTerminalSize();
|
|
57
|
+
// While the terminal is actively resizing, the live region collapses to a
|
|
58
|
+
// single line so Ink's erase math can't desync and duplicate the input.
|
|
59
|
+
const resizing = useResizing();
|
|
60
|
+
// A full clean repaint — bound to Ctrl+L, and fired once when a resize
|
|
61
|
+
// settles — to clear lines the terminal stranded while reflowing scrollback
|
|
62
|
+
// mid-resize. We clear the screen, then bump the <Static> key so Ink re-emits
|
|
63
|
+
// the whole committed transcript from the top (its `handleStaticChange` resets
|
|
64
|
+
// the cached static output). Nothing is lost: the banner and every turn are
|
|
65
|
+
// reprinted clean.
|
|
66
|
+
const { stdout } = useStdout();
|
|
67
|
+
const [staticEpoch, setStaticEpoch] = useState(0);
|
|
68
|
+
const wasResizing = useRef(false);
|
|
69
|
+
const repaint = useCallback(() => {
|
|
70
|
+
(stdout ?? process.stdout).write('\x1b[2J\x1b[3J\x1b[H');
|
|
71
|
+
setStaticEpoch(epoch => epoch + 1);
|
|
72
|
+
}, [stdout]);
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (wasResizing.current && !resizing)
|
|
75
|
+
repaint();
|
|
76
|
+
wasResizing.current = resizing;
|
|
77
|
+
}, [resizing, repaint]);
|
|
78
|
+
// Anchor the run clock here so it survives RunStatus unmounting during a
|
|
79
|
+
// resize burst (`!resizing && state.running` below). If RunStatus owned the
|
|
80
|
+
// start time, remounting after the resize settled would restart it at 0.
|
|
81
|
+
const runStartedAt = useRef(0);
|
|
82
|
+
if (state.running) {
|
|
83
|
+
if (runStartedAt.current === 0)
|
|
84
|
+
runStartedAt.current = Date.now();
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
runStartedAt.current = 0;
|
|
88
|
+
}
|
|
89
|
+
// Ctrl+L forces a clean repaint. Inactive while an overlay/modal owns input,
|
|
90
|
+
// so it never competes with the picker's or approval modal's key handling.
|
|
91
|
+
useInput((input, key) => {
|
|
92
|
+
if (key.ctrl && input === 'l')
|
|
93
|
+
repaint();
|
|
94
|
+
}, { isActive: !overlay && !state.approval && !state.userInput });
|
|
95
|
+
// The committed transcript lives in Ink's <Static>, which is append-only and
|
|
96
|
+
// cannot un-render. So whenever items are removed/reset (a /new, /clear,
|
|
97
|
+
// profile switch, or session resume) rather than just appended, force a clean
|
|
98
|
+
// repaint so the screen reflects the new transcript instead of stale rows.
|
|
99
|
+
const prevItemIds = useRef([]);
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
const ids = state.transcript.map(item => item.id);
|
|
102
|
+
const appendedOnly = prevItemIds.current.every((id, index) => ids[index] === id);
|
|
103
|
+
prevItemIds.current = ids;
|
|
104
|
+
if (!appendedOnly)
|
|
105
|
+
repaint();
|
|
106
|
+
}, [state.transcript, repaint]);
|
|
107
|
+
// Mutable run plumbing (does not drive rendering directly).
|
|
108
|
+
const abortRef = useRef(null);
|
|
109
|
+
const approvalResolverRef = useRef(null);
|
|
110
|
+
const userInputResolverRef = useRef(null);
|
|
111
|
+
const sessionGrantsRef = useRef(new Set());
|
|
112
|
+
const sessionTrustedFoldersRef = useRef(new Set());
|
|
113
|
+
const steeringRef = useRef([]);
|
|
114
|
+
const pendingContextRef = useRef([]);
|
|
115
|
+
const pendingImagesRef = useRef([]);
|
|
116
|
+
const lastCtrlCRef = useRef(0);
|
|
117
|
+
// Latest state for callbacks that must read current values without re-binding.
|
|
118
|
+
const stateRef = useRef(state);
|
|
119
|
+
stateRef.current = state;
|
|
120
|
+
const thinkRef = useRef(think);
|
|
121
|
+
thinkRef.current = think;
|
|
122
|
+
const planNextRef = useRef(planNext);
|
|
123
|
+
planNextRef.current = planNext;
|
|
124
|
+
// Last plain-text prompt, for `/retry`.
|
|
125
|
+
const lastPromptRef = useRef(null);
|
|
126
|
+
// Exit: the conversation already lives in the terminal's native scrollback
|
|
127
|
+
// (inline layout), so there's nothing to reprint — just unmount.
|
|
128
|
+
const quit = useCallback(() => {
|
|
129
|
+
dispatch({ type: 'exit' });
|
|
130
|
+
exit();
|
|
131
|
+
}, [exit]);
|
|
132
|
+
const notify = useCallback((text, tone = 'info') => {
|
|
133
|
+
dispatch({ type: 'notice', tone, text });
|
|
134
|
+
}, []);
|
|
135
|
+
// Confirm a `--resume` launch with a notice below the replayed turns, so the
|
|
136
|
+
// boundary between prior history and the current session is clear.
|
|
137
|
+
const resumeNoticeShown = useRef(false);
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
if (resumeNoticeShown.current)
|
|
140
|
+
return;
|
|
141
|
+
resumeNoticeShown.current = true;
|
|
142
|
+
if (initial.sessionId) {
|
|
143
|
+
notify(`Resumed session ${initial.sessionId.slice(0, 8)} — your next message continues it.`, 'info');
|
|
144
|
+
}
|
|
145
|
+
}, [notify, initial.sessionId]);
|
|
146
|
+
const refreshSkills = useCallback((profile = stateRef.current.profile) => {
|
|
147
|
+
try {
|
|
148
|
+
setSkillItems(runtime.listSkills(profile).map(skill => ({ name: skill.name, hint: skill.description })));
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
setSkillItems([]);
|
|
152
|
+
}
|
|
153
|
+
}, [runtime]);
|
|
154
|
+
// --- Approvals -----------------------------------------------------------
|
|
155
|
+
const approvalHandler = useCallback((request) => {
|
|
156
|
+
// Auto-approve from this session's "Always" grants — the run's baked config
|
|
157
|
+
// can't see them, so the App layer applies them: a kind grant for ordinary
|
|
158
|
+
// calls, a trusted folder for escalated writes inside it.
|
|
159
|
+
if (!request.escalated && sessionGrantsRef.current.has(request.kind)) {
|
|
160
|
+
return Promise.resolve({ approved: true });
|
|
161
|
+
}
|
|
162
|
+
if (request.persistable !== false && request.escalated && request.escalatedPath
|
|
163
|
+
&& isInsideAny(request.escalatedPath, [...sessionTrustedFoldersRef.current])) {
|
|
164
|
+
return Promise.resolve({ approved: true });
|
|
165
|
+
}
|
|
166
|
+
dispatch({ type: 'set_approval', request });
|
|
167
|
+
return new Promise(resolve => {
|
|
168
|
+
approvalResolverRef.current = resolve;
|
|
169
|
+
});
|
|
170
|
+
}, []);
|
|
171
|
+
const resolveApproval = useCallback((choice) => {
|
|
172
|
+
const request = stateRef.current.approval;
|
|
173
|
+
const resolve = approvalResolverRef.current;
|
|
174
|
+
approvalResolverRef.current = null;
|
|
175
|
+
dispatch({ type: 'set_approval', request: undefined });
|
|
176
|
+
if (!request || !resolve)
|
|
177
|
+
return;
|
|
178
|
+
if (choice === 'no') {
|
|
179
|
+
resolve({ approved: false, reason: 'denied by user' });
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (choice === 'always' && request.persistable !== false) {
|
|
183
|
+
const folder = trustTargetFolder(request);
|
|
184
|
+
if (folder)
|
|
185
|
+
trustFolderForProfile(folder); // escalated write → trust the folder
|
|
186
|
+
else
|
|
187
|
+
persistApprovalKind(request.kind); // ordinary call → allow this kind
|
|
188
|
+
}
|
|
189
|
+
resolve({ approved: true });
|
|
190
|
+
}, []);
|
|
191
|
+
// --- Clarification questions --------------------------------------------
|
|
192
|
+
const userInputHandler = useCallback(request => {
|
|
193
|
+
dispatch({ type: 'set_user_input', request });
|
|
194
|
+
return new Promise(resolve => {
|
|
195
|
+
userInputResolverRef.current = resolve;
|
|
196
|
+
});
|
|
197
|
+
}, []);
|
|
198
|
+
const resolveUserInput = useCallback((submission) => {
|
|
199
|
+
const resolve = userInputResolverRef.current;
|
|
200
|
+
userInputResolverRef.current = null;
|
|
201
|
+
dispatch({ type: 'set_user_input', request: undefined });
|
|
202
|
+
resolve?.(submission);
|
|
203
|
+
}, []);
|
|
204
|
+
// "Always (allow <kind>)": persist to the active profile + grant for this session.
|
|
205
|
+
const persistApprovalKind = useCallback((kind) => {
|
|
206
|
+
const profile = stateRef.current.profile;
|
|
207
|
+
sessionGrantsRef.current.add(kind);
|
|
208
|
+
try {
|
|
209
|
+
runtime.setProfileAgentApproval(profile, kind, 'allow');
|
|
210
|
+
notify(`Persisted approval: ${kind} = allow for ${profile}`, 'info');
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
notify(`Could not persist approval: ${errorText(error)}`, 'error');
|
|
214
|
+
}
|
|
215
|
+
}, [runtime, notify]);
|
|
216
|
+
// "Always (trust <folder>)" / `/trust-folder`: persist to the active profile +
|
|
217
|
+
// trust for this session (the running run can't re-read profile.toml).
|
|
218
|
+
const trustFolderForProfile = useCallback((folder) => {
|
|
219
|
+
const profile = stateRef.current.profile;
|
|
220
|
+
try {
|
|
221
|
+
const resolved = runtime.addProfileTrustedFolder(profile, folder);
|
|
222
|
+
sessionTrustedFoldersRef.current.add(resolved);
|
|
223
|
+
notify(`Trusting ${resolved} for ${profile} (writes here won't ask).`, 'info');
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
notify(`Could not trust folder: ${errorText(error)}`, 'error');
|
|
227
|
+
}
|
|
228
|
+
}, [runtime, notify]);
|
|
229
|
+
// --- Runs ----------------------------------------------------------------
|
|
230
|
+
const runAgent = useCallback(async (objective, options = {}) => {
|
|
231
|
+
const controller = new AbortController();
|
|
232
|
+
abortRef.current = controller;
|
|
233
|
+
steeringRef.current = [];
|
|
234
|
+
setSteeringCount(0);
|
|
235
|
+
const images = pendingImagesRef.current;
|
|
236
|
+
pendingImagesRef.current = [];
|
|
237
|
+
const current = stateRef.current;
|
|
238
|
+
// One conversation session shared with chat mode, so the agent remembers
|
|
239
|
+
// earlier turns. Skills pass their body via `instructions` (authoritative,
|
|
240
|
+
// not persisted) rather than isolating, so context-aware skills still see
|
|
241
|
+
// the conversation.
|
|
242
|
+
const sessionId = current.sessionId ?? randomUUID();
|
|
243
|
+
if (!current.sessionId)
|
|
244
|
+
dispatch({ type: 'set_session', sessionId });
|
|
245
|
+
dispatch({ type: 'set_running', running: true });
|
|
246
|
+
const startedAt = Date.now();
|
|
247
|
+
let usage;
|
|
248
|
+
let doneStatus;
|
|
249
|
+
try {
|
|
250
|
+
const runner = runtime.createAgentRunner(current.profile);
|
|
251
|
+
for await (const event of runner.run({
|
|
252
|
+
objective,
|
|
253
|
+
profile: current.profile,
|
|
254
|
+
provider: current.provider,
|
|
255
|
+
model: current.model,
|
|
256
|
+
sessionId,
|
|
257
|
+
...(options.instructions ? { instructions: options.instructions } : {}),
|
|
258
|
+
...(options.userTurn ? { userTurn: options.userTurn } : {}),
|
|
259
|
+
...(options.lean ? { lean: true } : {}),
|
|
260
|
+
...(options.forcePlan ? { forcePlan: true } : {}),
|
|
261
|
+
...(options.originalImages ? { originalImages: true } : {}),
|
|
262
|
+
...(images.length > 0 ? { images } : {}),
|
|
263
|
+
signal: controller.signal,
|
|
264
|
+
approvalHandler,
|
|
265
|
+
userInputHandler,
|
|
266
|
+
steering: () => {
|
|
267
|
+
const queued = steeringRef.current;
|
|
268
|
+
steeringRef.current = [];
|
|
269
|
+
setSteeringCount(0);
|
|
270
|
+
return queued;
|
|
271
|
+
},
|
|
272
|
+
})) {
|
|
273
|
+
if (event.type === 'done') {
|
|
274
|
+
usage = event.usage;
|
|
275
|
+
doneStatus = event.status;
|
|
276
|
+
}
|
|
277
|
+
dispatch({ type: 'agent_event', event });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
if (!controller.signal.aborted)
|
|
282
|
+
notify(errorText(error), 'error');
|
|
283
|
+
}
|
|
284
|
+
finally {
|
|
285
|
+
dispatch({ type: 'set_running', running: false });
|
|
286
|
+
abortRef.current = null;
|
|
287
|
+
if (usage?.inputTokens != null)
|
|
288
|
+
dispatch({ type: 'set_context_usage', tokens: usage.inputTokens });
|
|
289
|
+
if (controller.signal.aborted) {
|
|
290
|
+
notify('Cancelled.', 'warn');
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
const status = doneStatus ?? 'ended';
|
|
294
|
+
notify(`Task ${status}. ${runSummary(Date.now() - startedAt, usage)}`, status === 'completed' ? 'info' : 'warn');
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}, [runtime, approvalHandler, userInputHandler, notify]);
|
|
298
|
+
const runChat = useCallback(async (prompt, extraContext = [], options = {}) => {
|
|
299
|
+
const controller = new AbortController();
|
|
300
|
+
abortRef.current = controller;
|
|
301
|
+
const current = stateRef.current;
|
|
302
|
+
const sessionId = current.sessionId ?? randomUUID();
|
|
303
|
+
if (!current.sessionId)
|
|
304
|
+
dispatch({ type: 'set_session', sessionId });
|
|
305
|
+
const userContext = [...extraContext, ...pendingContextRef.current];
|
|
306
|
+
pendingContextRef.current = [];
|
|
307
|
+
const images = pendingImagesRef.current;
|
|
308
|
+
pendingImagesRef.current = [];
|
|
309
|
+
dispatch({ type: 'set_running', running: true });
|
|
310
|
+
dispatch({ type: 'set_activity', activity: 'thinking' });
|
|
311
|
+
const startedAt = Date.now();
|
|
312
|
+
let usage;
|
|
313
|
+
try {
|
|
314
|
+
for await (const chunk of runtime.stream({
|
|
315
|
+
prompt,
|
|
316
|
+
profile: current.profile,
|
|
317
|
+
provider: current.provider,
|
|
318
|
+
model: current.model,
|
|
319
|
+
sessionId,
|
|
320
|
+
think: thinkRef.current,
|
|
321
|
+
...(current.maxContextTokens != null ? { maxContextTokens: current.maxContextTokens } : {}),
|
|
322
|
+
userContext: userContext.length > 0 ? userContext : undefined,
|
|
323
|
+
...(options.instructions ? { instructions: options.instructions } : {}),
|
|
324
|
+
...(options.userTurn ? { userTurn: options.userTurn } : {}),
|
|
325
|
+
...(options.isolated ? { isolated: true } : {}),
|
|
326
|
+
...(options.originalImages ? { originalImages: true } : {}),
|
|
327
|
+
images: images.length > 0 ? images : undefined,
|
|
328
|
+
signal: controller.signal,
|
|
329
|
+
}, summary => { usage = summary.usage; }, text => { dispatch({ type: 'reasoning_delta', text }); })) {
|
|
330
|
+
if (controller.signal.aborted)
|
|
331
|
+
break;
|
|
332
|
+
dispatch({ type: 'assistant_delta', text: chunk });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
if (!controller.signal.aborted)
|
|
337
|
+
notify(errorText(error), 'error');
|
|
338
|
+
}
|
|
339
|
+
finally {
|
|
340
|
+
dispatch({ type: 'end_assistant' });
|
|
341
|
+
dispatch({ type: 'set_running', running: false });
|
|
342
|
+
abortRef.current = null;
|
|
343
|
+
if (usage?.inputTokens != null)
|
|
344
|
+
dispatch({ type: 'set_context_usage', tokens: usage.inputTokens });
|
|
345
|
+
if (controller.signal.aborted)
|
|
346
|
+
notify('Cancelled.', 'warn');
|
|
347
|
+
else
|
|
348
|
+
notify(runSummary(Date.now() - startedAt, usage), 'info');
|
|
349
|
+
}
|
|
350
|
+
}, [runtime, notify]);
|
|
351
|
+
const startTextRun = useCallback((text, options = {}) => {
|
|
352
|
+
// Remember the last plain-text prompt so `/retry` can re-run it. Captured
|
|
353
|
+
// here (the sole text-run entry) rather than read from the transcript, which
|
|
354
|
+
// also records `/command` and `$skill` echoes as user items.
|
|
355
|
+
lastPromptRef.current = text;
|
|
356
|
+
dispatch({ type: 'add_user', text });
|
|
357
|
+
// `/steps` armed: run this turn as a planned agent turn (planning is an agent
|
|
358
|
+
// concept), then auto-disarm.
|
|
359
|
+
if (planNextRef.current) {
|
|
360
|
+
setPlanNext(false);
|
|
361
|
+
void runAgent(text, { ...options, forcePlan: true });
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (stateRef.current.mode === 'chat')
|
|
365
|
+
void runChat(text, [], options);
|
|
366
|
+
else
|
|
367
|
+
void runAgent(text, options);
|
|
368
|
+
}, [runAgent, runChat]);
|
|
369
|
+
// Re-run the last plain-text message through the current profile/model/mode —
|
|
370
|
+
// handy for A/B-ing models (switch with /model, then /retry). Appends a new
|
|
371
|
+
// turn; does not re-invoke a `$skill` or re-attach prior images/context.
|
|
372
|
+
const retryLast = useCallback(() => {
|
|
373
|
+
if (stateRef.current.running) {
|
|
374
|
+
notify('A task is running. Use /btw to steer or /stop to cancel.', 'warn');
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
const last = lastPromptRef.current;
|
|
378
|
+
if (!last) {
|
|
379
|
+
notify('Nothing to retry yet — send a message first.', 'warn');
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
startTextRun(last);
|
|
383
|
+
}, [notify, startTextRun]);
|
|
384
|
+
const stop = useCallback(() => {
|
|
385
|
+
abortRef.current?.abort();
|
|
386
|
+
const resolve = approvalResolverRef.current;
|
|
387
|
+
if (resolve) {
|
|
388
|
+
approvalResolverRef.current = null;
|
|
389
|
+
dispatch({ type: 'set_approval', request: undefined });
|
|
390
|
+
resolve({ approved: false, reason: 'cancelled' });
|
|
391
|
+
}
|
|
392
|
+
if (userInputResolverRef.current)
|
|
393
|
+
resolveUserInput(undefined);
|
|
394
|
+
if (stateRef.current.running)
|
|
395
|
+
notify('Cancelling…', 'warn');
|
|
396
|
+
}, [notify, resolveUserInput]);
|
|
397
|
+
// --- Skills --------------------------------------------------------------
|
|
398
|
+
const startSkillRun = useCallback((skill, body, userInput, displayText) => {
|
|
399
|
+
dispatch({ type: 'add_user', text: displayText });
|
|
400
|
+
// Codex/Claude-style: the skill body is authoritative instructions (sent via
|
|
401
|
+
// `instructions`, top of the system prompt), and the user's typed input is
|
|
402
|
+
// the turn the model acts on. Direct skills do not receive prior session
|
|
403
|
+
// turns, but their typed invocation and final output still persist there.
|
|
404
|
+
const prompt = userInput.trim() || 'Follow the skill instructions above and produce the output.';
|
|
405
|
+
// `/steps` armed: force a planned agent run for this skill (then disarm).
|
|
406
|
+
const forcePlan = planNextRef.current;
|
|
407
|
+
if (forcePlan)
|
|
408
|
+
setPlanNext(false);
|
|
409
|
+
// An undeclared mode follows the session: a skill invoked in an agent session
|
|
410
|
+
// runs agentically (with tools), so it can read its own bundled files. A
|
|
411
|
+
// forced plan always runs as an agent (planning needs the agent loop).
|
|
412
|
+
const mode = forcePlan ? 'agent' : (skill.mode ?? stateRef.current.mode);
|
|
413
|
+
if (mode === 'chat') {
|
|
414
|
+
void runChat(prompt, [], {
|
|
415
|
+
instructions: [body],
|
|
416
|
+
userTurn: displayText,
|
|
417
|
+
isolated: true,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
else {
|
|
421
|
+
// Tell the agent where the skill's bundled files live so it can read them
|
|
422
|
+
// (e.g. a vars.toml of `#name` fragments) with read_file, as the skill
|
|
423
|
+
// instructions direct — the agentic-tool model, like Codex/Claude.
|
|
424
|
+
const dir = skill.source?.replace(/\/SKILL\.md$/, '');
|
|
425
|
+
const instructions = dir
|
|
426
|
+
? [body, `This skill's bundled files are in ${dir}. When the instructions reference files such as vars.toml, read them from there with read_file.`]
|
|
427
|
+
: [body];
|
|
428
|
+
// Persist the invocation the user typed (e.g. `$make-… #photo1 …`) as the
|
|
429
|
+
// resumable user turn, not the agent's internal objective. `lean` skips the
|
|
430
|
+
// optional planning and verbose framing — a skill is a single transform,
|
|
431
|
+
// so that's pure token overhead.
|
|
432
|
+
void runAgent(prompt, { instructions, userTurn: displayText, lean: true, ...(forcePlan ? { forcePlan: true } : {}) });
|
|
433
|
+
}
|
|
434
|
+
}, [runAgent, runChat]);
|
|
435
|
+
const runSkill = useCallback((name, argv) => {
|
|
436
|
+
let skill;
|
|
437
|
+
try {
|
|
438
|
+
skill = runtime.getSkill(name, stateRef.current.profile);
|
|
439
|
+
}
|
|
440
|
+
catch (error) {
|
|
441
|
+
notify(errorText(error), 'error');
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
if (!skill) {
|
|
445
|
+
notify(`Unknown skill: $${name}. Use /skills to list installed skills.`, 'warn');
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
const supplied = bindSkillArgs(skill, argv);
|
|
449
|
+
const { prompt, missing } = renderSkillPrompt(skill, supplied);
|
|
450
|
+
if (missing.length > 0) {
|
|
451
|
+
setPendingSkill({ skill, supplied, missing, index: 0 });
|
|
452
|
+
notify(`${skillUsage(skill)} — enter ${missing[0]}:`, 'info');
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
startSkillRun(skill, prompt, argv.join(' '), skillInvocation(name, argv));
|
|
456
|
+
}, [runtime, notify, startSkillRun]);
|
|
457
|
+
const fillSkillVariable = useCallback((value) => {
|
|
458
|
+
setPendingSkill(current => {
|
|
459
|
+
if (!current)
|
|
460
|
+
return null;
|
|
461
|
+
const supplied = { ...current.supplied, [current.missing[current.index]]: value };
|
|
462
|
+
const nextIndex = current.index + 1;
|
|
463
|
+
if (nextIndex < current.missing.length) {
|
|
464
|
+
notify(`Enter ${current.missing[nextIndex]}:`, 'info');
|
|
465
|
+
return { ...current, supplied, index: nextIndex };
|
|
466
|
+
}
|
|
467
|
+
const { prompt, missing } = renderSkillPrompt(current.skill, supplied);
|
|
468
|
+
if (missing.length > 0) {
|
|
469
|
+
notify(`Missing values for: ${missing.join(', ')}`, 'warn');
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
const args = current.skill.variables
|
|
473
|
+
.map(variable => supplied[variable.name])
|
|
474
|
+
.filter((value) => typeof value === 'string' && value.length > 0);
|
|
475
|
+
startSkillRun(current.skill, prompt, args.join(' '), skillInvocation(current.skill.name, args));
|
|
476
|
+
return null;
|
|
477
|
+
});
|
|
478
|
+
}, [notify, startSkillRun]);
|
|
479
|
+
// --- Overlays ------------------------------------------------------------
|
|
480
|
+
const openModelPicker = useCallback(() => {
|
|
481
|
+
const items = loadedConfig.config.models.options.map(option => ({ label: option, value: option }));
|
|
482
|
+
setOverlay({ type: 'model', items });
|
|
483
|
+
}, [loadedConfig]);
|
|
484
|
+
const openProfilePicker = useCallback(() => {
|
|
485
|
+
const items = runtime.listProfiles().map(profile => ({ label: profile.name, value: profile.name }));
|
|
486
|
+
setOverlay({ type: 'profile', items });
|
|
487
|
+
}, [runtime]);
|
|
488
|
+
const openSkills = useCallback((scope = 'profile') => {
|
|
489
|
+
const profile = stateRef.current.profile;
|
|
490
|
+
const items = runtime.listSkills(profile, scope).map(skill => ({
|
|
491
|
+
label: skill.name,
|
|
492
|
+
value: skill.name,
|
|
493
|
+
hint: skill.description,
|
|
494
|
+
}));
|
|
495
|
+
// Cross-reference the other layer so global skills stay discoverable from
|
|
496
|
+
// the profile view (and vice versa).
|
|
497
|
+
const other = scope === 'global' ? 'profile' : 'global';
|
|
498
|
+
const otherCount = runtime.listSkills(profile, other).length;
|
|
499
|
+
const otherCmd = other === 'global' ? '/skills --global' : '/skills';
|
|
500
|
+
const base = scope === 'global' ? 'Global skills' : 'Profile skills';
|
|
501
|
+
const title = `${base} — Enter runs, Del removes${otherCount ? ` (+${otherCount} ${other}: ${otherCmd})` : ''}`;
|
|
502
|
+
const emptyHint = scope === 'global'
|
|
503
|
+
? ['/install-skill --global <path> installs a global skill (all profiles)']
|
|
504
|
+
: ['/install-skill <path> installs a skill into this profile'];
|
|
505
|
+
if (otherCount)
|
|
506
|
+
emptyHint.push(`${otherCount} ${other} skill(s) — ${otherCmd}`);
|
|
507
|
+
setOverlay({ type: 'skills', scope, items, title, emptyHint });
|
|
508
|
+
}, [runtime]);
|
|
509
|
+
const showSessions = useCallback(() => {
|
|
510
|
+
if (stateRef.current.running) {
|
|
511
|
+
notify('Stop the running task before switching sessions.', 'warn');
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
const currentSessionId = stateRef.current.sessionId;
|
|
515
|
+
const items = runtime
|
|
516
|
+
.listSessions(20, stateRef.current.profile, { order: 'recent' })
|
|
517
|
+
.map(sessionItem.bind(null, currentSessionId));
|
|
518
|
+
setOverlay({ type: 'sessions', items });
|
|
519
|
+
}, [runtime, notify]);
|
|
520
|
+
const showHelp = useCallback(() => {
|
|
521
|
+
const lines = [
|
|
522
|
+
'Plain text → talk to the agent (or chat in /chat mode).',
|
|
523
|
+
'$<skill> [args] → run a model-backed skill.',
|
|
524
|
+
'',
|
|
525
|
+
...listCommands().map(spec => `/${spec.name}${spec.aliases ? ` (/${spec.aliases.join(', /')})` : ''} — ${spec.summary}`),
|
|
526
|
+
];
|
|
527
|
+
dispatch({ type: 'add_item', item: { kind: 'info', title: 'Help', lines } });
|
|
528
|
+
}, []);
|
|
529
|
+
const showStatus = useCallback(() => {
|
|
530
|
+
const s = stateRef.current;
|
|
531
|
+
const userTurns = s.transcript.filter(item => item.kind === 'user').length;
|
|
532
|
+
const session = s.sessionId ?? '(starts on your first message)';
|
|
533
|
+
const lines = [
|
|
534
|
+
`Profile: ${s.profile}`,
|
|
535
|
+
`Mode: ${s.mode}`,
|
|
536
|
+
`Provider: ${s.provider}`,
|
|
537
|
+
`Model: ${s.model}`,
|
|
538
|
+
`Thinking: ${thinkRef.current ? 'on' : 'off'}`,
|
|
539
|
+
`Session: ${session}`,
|
|
540
|
+
`Turns: ${userTurns}`,
|
|
541
|
+
`Directory: ${s.cwd}`,
|
|
542
|
+
`Version: ${s.version}`,
|
|
543
|
+
];
|
|
544
|
+
dispatch({ type: 'add_item', item: { kind: 'info', title: 'Status', lines } });
|
|
545
|
+
}, []);
|
|
546
|
+
const copyLast = useCallback(() => {
|
|
547
|
+
const last = [...stateRef.current.transcript].reverse().find(item => item.kind === 'assistant');
|
|
548
|
+
if (!last || last.kind !== 'assistant') {
|
|
549
|
+
notify('No response to copy yet.', 'warn');
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
copyToClipboard(last.text)
|
|
553
|
+
.then(() => notify(`Copied the last response (${last.text.length} chars) to the clipboard.`, 'info'))
|
|
554
|
+
.catch(error => notify(`Copy failed: ${errorText(error)}`, 'error'));
|
|
555
|
+
}, [notify]);
|
|
556
|
+
const showPermissions = useCallback(() => {
|
|
557
|
+
const profile = stateRef.current.profile;
|
|
558
|
+
const agent = runtime.resolveAgentConfigForProfile(profile);
|
|
559
|
+
const grants = [...sessionGrantsRef.current];
|
|
560
|
+
const sessionTrusted = [...sessionTrustedFoldersRef.current];
|
|
561
|
+
const lines = [
|
|
562
|
+
`Approval modes (profile ${profile}):`,
|
|
563
|
+
...Object.entries(agent.approval).map(([kind, mode]) => ` ${kind}: ${mode}`),
|
|
564
|
+
'',
|
|
565
|
+
`Trusted folders: ${agent.trustedFolders.length ? agent.trustedFolders.join(', ') : '(none)'}`,
|
|
566
|
+
`Session grants: ${grants.length ? grants.join(', ') : '(none)'}`,
|
|
567
|
+
...(sessionTrusted.length ? [`Trusted this session: ${sessionTrusted.join(', ')}`] : []),
|
|
568
|
+
'',
|
|
569
|
+
'Writes outside the working dir and in-home trusted folders prompt; external paths always ask once.',
|
|
570
|
+
];
|
|
571
|
+
dispatch({ type: 'add_item', item: { kind: 'info', title: 'Permissions', lines } });
|
|
572
|
+
}, [runtime]);
|
|
573
|
+
const runDoctor = useCallback(() => {
|
|
574
|
+
const current = stateRef.current;
|
|
575
|
+
const provider = loadedConfig.config.providers[current.provider];
|
|
576
|
+
const lines = [
|
|
577
|
+
`Provider: ${current.provider}`,
|
|
578
|
+
`Model: ${current.model}`,
|
|
579
|
+
`Type: ${provider?.type ?? 'unknown'}`,
|
|
580
|
+
`Base URL: ${provider?.baseUrl ?? '(default)'}`,
|
|
581
|
+
`API key env: ${provider?.apiKeyEnv ?? '(none)'}${provider?.apiKeyEnv ? (process.env[provider.apiKeyEnv] ? ' ✓ set' : ' ✗ unset') : ''}`,
|
|
582
|
+
`Stored key: ${provider?.apiKey ? 'present' : 'none'}`,
|
|
583
|
+
'',
|
|
584
|
+
'Run `marifold model` for full provider checks.',
|
|
585
|
+
];
|
|
586
|
+
dispatch({ type: 'add_item', item: { kind: 'info', title: 'Doctor', lines } });
|
|
587
|
+
}, [loadedConfig]);
|
|
588
|
+
const installSkill = useCallback((arg) => {
|
|
589
|
+
// `--global` installs for every profile; otherwise the skill lands in the
|
|
590
|
+
// current profile (profile skills shadow global ones).
|
|
591
|
+
const tokens = arg.trim().split(/\s+/).filter(Boolean);
|
|
592
|
+
const global = tokens.includes('--global');
|
|
593
|
+
const target = unwrapPath(tokens.filter(token => token !== '--global').join(' '));
|
|
594
|
+
const scope = global ? 'global' : 'profile';
|
|
595
|
+
const profile = stateRef.current.profile;
|
|
596
|
+
const run = async () => {
|
|
597
|
+
try {
|
|
598
|
+
if (!target) {
|
|
599
|
+
notify('Usage: /install-skill [--global] <path|url>', 'warn');
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
let installed;
|
|
603
|
+
if (/^https?:\/\//i.test(target)) {
|
|
604
|
+
const response = await fetch(target);
|
|
605
|
+
if (!response.ok)
|
|
606
|
+
throw new Error(`HTTP ${response.status} fetching ${target}`);
|
|
607
|
+
installed = runtime.installSkillFromText(await response.text(), scope, profile);
|
|
608
|
+
}
|
|
609
|
+
else {
|
|
610
|
+
installed = runtime.installSkillFromFile(path.resolve(expandHome(target)), scope, profile);
|
|
611
|
+
}
|
|
612
|
+
refreshSkills();
|
|
613
|
+
notify(`Installed skill: $${installed.name} (${global ? 'global' : `profile ${profile}`})`, 'info');
|
|
614
|
+
}
|
|
615
|
+
catch (error) {
|
|
616
|
+
notify(`Install failed: ${errorText(error)}`, 'error');
|
|
617
|
+
}
|
|
618
|
+
};
|
|
619
|
+
void run();
|
|
620
|
+
}, [runtime, notify, refreshSkills]);
|
|
621
|
+
const readFileCmd = useCallback((file) => {
|
|
622
|
+
try {
|
|
623
|
+
const resolved = path.resolve(expandHome(unwrapPath(file)));
|
|
624
|
+
const content = fs.readFileSync(resolved, 'utf-8');
|
|
625
|
+
const truncated = content.length > READ_FILE_CHAR_LIMIT;
|
|
626
|
+
const body = truncated
|
|
627
|
+
? `${content.slice(0, READ_FILE_CHAR_LIMIT)}\n[truncated at ${READ_FILE_CHAR_LIMIT} characters]`
|
|
628
|
+
: content;
|
|
629
|
+
pendingContextRef.current.push(`## File: ${resolved}\n\n${body}`);
|
|
630
|
+
notify(`Attached ${resolved} (${content.length} chars) to your next message.`, 'info');
|
|
631
|
+
}
|
|
632
|
+
catch (error) {
|
|
633
|
+
notify(`Could not read ${file}: ${errorText(error)}`, 'error');
|
|
634
|
+
}
|
|
635
|
+
}, [notify]);
|
|
636
|
+
const setImage = useCallback((arg) => {
|
|
637
|
+
if (arg === '' || arg.toLowerCase() === 'clear') {
|
|
638
|
+
pendingImagesRef.current = [];
|
|
639
|
+
notify('Cleared pending images.', 'info');
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
const resolved = path.resolve(expandHome(unwrapPath(arg)));
|
|
643
|
+
if (!fs.existsSync(resolved)) {
|
|
644
|
+
notify(`Image not found: ${resolved}`, 'error');
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
pendingImagesRef.current.push({ path: resolved });
|
|
648
|
+
notify(`Attached image #${pendingImagesRef.current.length}: ${resolved}`, 'info');
|
|
649
|
+
}, [notify]);
|
|
650
|
+
const remember = useCallback((text) => {
|
|
651
|
+
const result = runtime.rememberMemory(stateRef.current.profile, 'auto_short', text, stateRef.current.sessionId);
|
|
652
|
+
notify(`${result.created ? 'Remembered' : 'Already remembered'}: ${result.entry.id}`, 'info');
|
|
653
|
+
}, [runtime, notify]);
|
|
654
|
+
const forget = useCallback((query) => {
|
|
655
|
+
const result = runtime.forgetMemories(stateRef.current.profile, query);
|
|
656
|
+
notify(result.count === 0 ? 'No matching memories.' : `Forgot ${result.count} memory record(s).`, 'info');
|
|
657
|
+
}, [runtime, notify]);
|
|
658
|
+
const deleteMemory = useCallback((query) => {
|
|
659
|
+
const result = runtime.deleteMemories(stateRef.current.profile, query);
|
|
660
|
+
notify(result.count === 0 ? 'No matching memories.' : `Deleted ${result.count} memory record(s).`, 'info');
|
|
661
|
+
}, [runtime, notify]);
|
|
662
|
+
const steer = useCallback((text) => {
|
|
663
|
+
// Steering only has meaning inside the agent loop; chat is a single-turn
|
|
664
|
+
// request that never reads the steering queue, so a `/btw` there would be
|
|
665
|
+
// silently dropped. Reject it with a clear message instead.
|
|
666
|
+
if (stateRef.current.mode === 'chat') {
|
|
667
|
+
notify('/btw (steering) only applies in agent mode.', 'warn');
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
if (stateRef.current.running) {
|
|
671
|
+
steeringRef.current.push(text);
|
|
672
|
+
setSteeringCount(count => count + 1);
|
|
673
|
+
notify(`Queued steering: ${text}`, 'info');
|
|
674
|
+
}
|
|
675
|
+
else {
|
|
676
|
+
startTextRun(text);
|
|
677
|
+
}
|
|
678
|
+
}, [notify, startTextRun]);
|
|
679
|
+
// Switch profile (from `/profile <name>` or the picker): starts a fresh
|
|
680
|
+
// session and confirms in the transcript.
|
|
681
|
+
const selectProfile = useCallback((value) => {
|
|
682
|
+
setOverlay(null);
|
|
683
|
+
try {
|
|
684
|
+
const settings = runtime.resolveSettings({ profile: value });
|
|
685
|
+
dispatch({
|
|
686
|
+
type: 'set_profile',
|
|
687
|
+
profile: settings.profile,
|
|
688
|
+
displayName: runtime.getProfile(settings.profile).displayName,
|
|
689
|
+
provider: settings.provider,
|
|
690
|
+
model: settings.model,
|
|
691
|
+
maxContextTokens: settings.maxContextTokens ?? loadedConfig.config.default.maxContextTokens,
|
|
692
|
+
});
|
|
693
|
+
dispatch({ type: 'set_mode', mode: settings.mode });
|
|
694
|
+
// Adopt the new profile's thinking default (bare setter — selectProfile
|
|
695
|
+
// emits its own notice; the wrapped ctx.setThink would add a spurious one).
|
|
696
|
+
setThink(settings.think);
|
|
697
|
+
dispatch({ type: 'new_session', sessionId: undefined });
|
|
698
|
+
sessionGrantsRef.current.clear();
|
|
699
|
+
sessionTrustedFoldersRef.current.clear();
|
|
700
|
+
refreshSkills(settings.profile);
|
|
701
|
+
notify(`Switched to profile: ${settings.profile} · ${settings.provider}/${settings.model} · ${settings.mode} (new session)`, 'info');
|
|
702
|
+
}
|
|
703
|
+
catch (error) {
|
|
704
|
+
notify(`Could not switch profile: ${errorText(error)}`, 'error');
|
|
705
|
+
}
|
|
706
|
+
}, [runtime, notify, refreshSkills]);
|
|
707
|
+
// CommandContext bound to the live handlers.
|
|
708
|
+
const commandContext = useMemo(() => ({
|
|
709
|
+
notify,
|
|
710
|
+
// The Header (mode shown) lives in the append-only <Static>, so a mode
|
|
711
|
+
// change needs a repaint to refresh it — otherwise the top line goes stale
|
|
712
|
+
// while the live StatusLine flips. repaint() remounts Static with the new mode.
|
|
713
|
+
setMode: (mode) => { dispatch({ type: 'set_mode', mode }); notify(`Mode: ${mode}`, 'info'); repaint(); },
|
|
714
|
+
setDefaultMode: (mode) => {
|
|
715
|
+
try {
|
|
716
|
+
runtime.setProfileMode(stateRef.current.profile, mode);
|
|
717
|
+
dispatch({ type: 'set_mode', mode });
|
|
718
|
+
notify(`Default mode for ${stateRef.current.profile} set to ${mode} (this and future sessions).`, 'info');
|
|
719
|
+
repaint();
|
|
720
|
+
}
|
|
721
|
+
catch (error) {
|
|
722
|
+
notify(`Could not set default mode: ${errorText(error)}`, 'error');
|
|
723
|
+
}
|
|
724
|
+
},
|
|
725
|
+
newSession: () => { dispatch({ type: 'new_session', sessionId: undefined }); notify('Started a new session.', 'info'); },
|
|
726
|
+
clear: () => dispatch({ type: 'clear' }),
|
|
727
|
+
stop,
|
|
728
|
+
steer,
|
|
729
|
+
exit: quit,
|
|
730
|
+
setThink: (on) => { setThink(on); notify(`Thinking ${on ? 'on' : 'off'}.`, 'info'); },
|
|
731
|
+
toggleForcePlan: () => setPlanNext(armed => {
|
|
732
|
+
notify(armed ? 'Planning disarmed.' : 'Next message will be planned step-by-step.', 'info');
|
|
733
|
+
return !armed;
|
|
734
|
+
}),
|
|
735
|
+
openModelPicker,
|
|
736
|
+
openProfilePicker,
|
|
737
|
+
selectProfile,
|
|
738
|
+
openSkills,
|
|
739
|
+
showPermissions,
|
|
740
|
+
showHelp,
|
|
741
|
+
showStatus,
|
|
742
|
+
copyLast,
|
|
743
|
+
retryLast,
|
|
744
|
+
sendOriginal: (text) => {
|
|
745
|
+
if (stateRef.current.running) {
|
|
746
|
+
notify('A task is running. Use /btw to steer or /stop to cancel.', 'warn');
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
startTextRun(text, { originalImages: true });
|
|
750
|
+
},
|
|
751
|
+
showSessions,
|
|
752
|
+
runDoctor,
|
|
753
|
+
installSkill,
|
|
754
|
+
readFile: readFileCmd,
|
|
755
|
+
setImage,
|
|
756
|
+
remember,
|
|
757
|
+
forget,
|
|
758
|
+
deleteMemory,
|
|
759
|
+
showContextWindow: () => {
|
|
760
|
+
const s = stateRef.current;
|
|
761
|
+
if (s.maxContextTokens == null || s.maxContextTokens <= 0) {
|
|
762
|
+
notify('Context budget: off (compaction disabled). Enable with /context-window set <tokens>.', 'info');
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
const used = s.contextTokens;
|
|
766
|
+
const pct = used != null ? ` · using ${used.toLocaleString()} (${Math.round((used / s.maxContextTokens) * 100)}%)` : ' · no turn measured yet';
|
|
767
|
+
notify(`Context budget: ${s.maxContextTokens.toLocaleString()} tokens${pct}. Compacts past ~80%.`, 'info');
|
|
768
|
+
},
|
|
769
|
+
setContextWindow: (tokens) => {
|
|
770
|
+
dispatch({ type: 'set_context_budget', maxContextTokens: tokens });
|
|
771
|
+
notify(tokens ? `Context budget set to ${tokens.toLocaleString()} tokens for this session.` : 'Compaction disabled for this session.', 'info');
|
|
772
|
+
},
|
|
773
|
+
setDefaultContextWindow: (tokens) => {
|
|
774
|
+
try {
|
|
775
|
+
const saved = runtime.setProfileMaxContextTokens(stateRef.current.profile, tokens);
|
|
776
|
+
dispatch({ type: 'set_context_budget', maxContextTokens: saved });
|
|
777
|
+
notify(saved
|
|
778
|
+
? `Default context budget for ${stateRef.current.profile} set to ${saved.toLocaleString()} tokens (this and future sessions).`
|
|
779
|
+
: `Compaction disabled by default for ${stateRef.current.profile}.`, 'info');
|
|
780
|
+
}
|
|
781
|
+
catch (error) {
|
|
782
|
+
notify(`Could not set default context budget: ${errorText(error)}`, 'error');
|
|
783
|
+
}
|
|
784
|
+
},
|
|
785
|
+
compactNow: () => {
|
|
786
|
+
const s = stateRef.current;
|
|
787
|
+
if (!s.sessionId) {
|
|
788
|
+
notify('No active session to compact yet — send a message first.', 'warn');
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
if (s.maxContextTokens == null) {
|
|
792
|
+
notify('Set a context budget first: /context-window set <tokens>.', 'warn');
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
notify('Compacting context…', 'info');
|
|
796
|
+
runtime.compactSession(s.sessionId, {
|
|
797
|
+
profile: s.profile, provider: s.provider, model: s.model, think: thinkRef.current, maxContextTokens: s.maxContextTokens,
|
|
798
|
+
}).then(result => {
|
|
799
|
+
notify(result.compacted ? 'Context compacted — older turns folded into a summary.' : 'Nothing to compact yet (history fits within the kept tail).', 'info');
|
|
800
|
+
}).catch(error => notify(`Compaction failed: ${errorText(error)}`, 'error'));
|
|
801
|
+
},
|
|
802
|
+
trustFolder: trustFolderForProfile,
|
|
803
|
+
}), [
|
|
804
|
+
notify, stop, steer, exit, openModelPicker, openProfilePicker, selectProfile, openSkills,
|
|
805
|
+
showPermissions, showHelp, showStatus, copyLast, retryLast, showSessions, runDoctor, installSkill,
|
|
806
|
+
readFileCmd, setImage, remember, forget, deleteMemory, repaint, runtime, trustFolderForProfile, startTextRun,
|
|
807
|
+
]);
|
|
808
|
+
// --- Input routing -------------------------------------------------------
|
|
809
|
+
const handleSubmit = useCallback((raw, attachedImages = []) => {
|
|
810
|
+
const trimmed = raw.trim();
|
|
811
|
+
if (pendingSkill) {
|
|
812
|
+
if (trimmed.length === 0)
|
|
813
|
+
return;
|
|
814
|
+
fillSkillVariable(trimmed);
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
const parsed = parseInput(raw);
|
|
818
|
+
// Dropped images (`[image #n]` tokens) attach to the message about to run.
|
|
819
|
+
// Both the chat path (runChat) and the agent path (runAgent) consume
|
|
820
|
+
// pendingImagesRef, so this works in either mode for a text/skill turn.
|
|
821
|
+
if (attachedImages.length > 0) {
|
|
822
|
+
if (parsed.kind === 'text' || parsed.kind === 'skill' || (parsed.kind === 'command' && parsed.name === 'attach-original')) {
|
|
823
|
+
for (const file of attachedImages)
|
|
824
|
+
pendingImagesRef.current.push({ path: file });
|
|
825
|
+
}
|
|
826
|
+
else {
|
|
827
|
+
notify(`Images attach to a message, not /${parsed.kind === 'command' ? 'commands' : 'input'}. Ignored ${attachedImages.length} image(s).`, 'warn');
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
if (parsed.kind !== 'empty')
|
|
831
|
+
setHistory(entries => [...entries, trimmed]);
|
|
832
|
+
switch (parsed.kind) {
|
|
833
|
+
case 'empty':
|
|
834
|
+
return;
|
|
835
|
+
case 'command':
|
|
836
|
+
// `/attach-original <prompt>` is a one-turn send action. Show the
|
|
837
|
+
// actual prompt once, not an extra command-echo row before it.
|
|
838
|
+
if (parsed.name === 'attach-original') {
|
|
839
|
+
runCommand(commandContext, parsed.name, parsed.args);
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
// Echo the command as a transcript divider (like a sent message) so its
|
|
843
|
+
// result is clearly separated from the previous turn.
|
|
844
|
+
dispatch({ type: 'add_user', text: trimmed });
|
|
845
|
+
if (!runCommand(commandContext, parsed.name, parsed.args)) {
|
|
846
|
+
notify(`Unknown command: /${parsed.name}. Type /help.`, 'warn');
|
|
847
|
+
}
|
|
848
|
+
return;
|
|
849
|
+
case 'skill':
|
|
850
|
+
if (stateRef.current.running) {
|
|
851
|
+
notify('A task is running. Use /btw to steer or /stop to cancel.', 'warn');
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
runSkill(parsed.name, parsed.argv);
|
|
855
|
+
return;
|
|
856
|
+
case 'text':
|
|
857
|
+
if (stateRef.current.running) {
|
|
858
|
+
notify('A task is running. Use /btw to steer or /stop to cancel.', 'warn');
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
startTextRun(parsed.text);
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
}, [pendingSkill, fillSkillVariable, commandContext, notify, runSkill, startTextRun]);
|
|
865
|
+
const handleInterrupt = useCallback((reason) => {
|
|
866
|
+
if (stateRef.current.running) {
|
|
867
|
+
stop();
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
if (reason === 'ctrl-c') {
|
|
871
|
+
// Require a second Ctrl+C within 1.5s to exit, so one stray press never
|
|
872
|
+
// drops the session.
|
|
873
|
+
const now = Date.now();
|
|
874
|
+
if (now - lastCtrlCRef.current < 1500) {
|
|
875
|
+
quit();
|
|
876
|
+
}
|
|
877
|
+
else {
|
|
878
|
+
lastCtrlCRef.current = now;
|
|
879
|
+
notify('Press Ctrl+C again to exit.', 'info');
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
}, [stop, quit, notify]);
|
|
883
|
+
const onModelSelect = useCallback((value) => {
|
|
884
|
+
setOverlay(null);
|
|
885
|
+
const [provider, ...rest] = value.split('/');
|
|
886
|
+
const model = rest.join('/');
|
|
887
|
+
if (provider && model) {
|
|
888
|
+
dispatch({ type: 'set_model', provider, model });
|
|
889
|
+
notify(`Model: ${provider}/${model}`, 'info');
|
|
890
|
+
}
|
|
891
|
+
}, [notify]);
|
|
892
|
+
// --- Render --------------------------------------------------------------
|
|
893
|
+
const activeOverlay = renderOverlay();
|
|
894
|
+
function renderOverlay() {
|
|
895
|
+
if (state.approval) {
|
|
896
|
+
return _jsx(ApprovalModal, { request: state.approval, onResolve: resolveApproval });
|
|
897
|
+
}
|
|
898
|
+
if (state.userInput) {
|
|
899
|
+
return (_jsx(QuestionModal, { request: state.userInput, onSubmit: resolveUserInput, onCancel: stop }));
|
|
900
|
+
}
|
|
901
|
+
if (!overlay)
|
|
902
|
+
return null;
|
|
903
|
+
// Cap overlay height so it fits between the banner and status line.
|
|
904
|
+
const overlayMaxRows = Math.max(4, rows - 9);
|
|
905
|
+
if (overlay.type === 'model') {
|
|
906
|
+
return _jsx(SelectList, { title: "Select model", items: overlay.items, onSelect: onModelSelect, onCancel: () => setOverlay(null), maxRows: overlayMaxRows });
|
|
907
|
+
}
|
|
908
|
+
if (overlay.type === 'profile') {
|
|
909
|
+
return _jsx(SelectList, { title: "Select profile", items: overlay.items, onSelect: selectProfile, onCancel: () => setOverlay(null), maxRows: overlayMaxRows });
|
|
910
|
+
}
|
|
911
|
+
if (overlay.type === 'sessions') {
|
|
912
|
+
return (_jsx(SelectList, { title: "Resume session", maxRows: overlayMaxRows, items: overlay.items, onSelect: value => {
|
|
913
|
+
setOverlay(null);
|
|
914
|
+
const detail = runtime.getSession(value);
|
|
915
|
+
if (!detail) {
|
|
916
|
+
notify(`Session not found: ${value}`, 'error');
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
dispatch({ type: 'new_session', sessionId: detail.id });
|
|
920
|
+
for (const turn of detail.turns) {
|
|
921
|
+
dispatch({ type: 'add_item', item: { kind: turn.role === 'user' ? 'user' : 'assistant', text: turn.content } });
|
|
922
|
+
}
|
|
923
|
+
notify(`Resumed session ${detail.id.slice(0, 8)} — your next message continues it.`, 'info');
|
|
924
|
+
}, onCancel: () => setOverlay(null), emptyHint: ['No saved sessions for this profile yet.'] }));
|
|
925
|
+
}
|
|
926
|
+
// skills (scope-aware: profile or global)
|
|
927
|
+
if (overlay.type === 'skills') {
|
|
928
|
+
const scope = overlay.scope;
|
|
929
|
+
return (_jsx(SelectList, { title: overlay.title, maxRows: overlayMaxRows, items: overlay.items, emptyHint: overlay.emptyHint, onSelect: value => { setOverlay(null); runSkill(value, []); }, onCancel: () => setOverlay(null), onDelete: value => {
|
|
930
|
+
runtime.removeSkill(value, stateRef.current.profile, scope);
|
|
931
|
+
refreshSkills();
|
|
932
|
+
notify(`Removed ${scope} skill: $${value}`, 'info');
|
|
933
|
+
openSkills(scope);
|
|
934
|
+
} }));
|
|
935
|
+
}
|
|
936
|
+
return null;
|
|
937
|
+
}
|
|
938
|
+
// Inline layout (Claude/Codex-style): the banner prints once at the top of the
|
|
939
|
+
// terminal's native scrollback and the committed transcript appends below it,
|
|
940
|
+
// so the whole conversation stays scrollable and copyable in the terminal — and
|
|
941
|
+
// survives after exit. The run line, input, and status stay pinned just below
|
|
942
|
+
// the latest content. A still-streaming assistant item renders in the live
|
|
943
|
+
// region until it ends, so the bottom UI doesn't flicker mid-stream.
|
|
944
|
+
//
|
|
945
|
+
// Ink 7 clears its live region on width-decrease (its `resized` handler), so
|
|
946
|
+
// the input/status don't duplicate on shrink. Committed history is left to the
|
|
947
|
+
// terminal's own reflow — which we keep clean by holding the live region small
|
|
948
|
+
// (the input box bounds its own height).
|
|
949
|
+
const committed = state.streamingAssistant ? state.transcript.slice(0, -1) : state.transcript;
|
|
950
|
+
const liveItem = state.streamingAssistant ? state.transcript[state.transcript.length - 1] : undefined;
|
|
951
|
+
const staticItems = [{ id: BANNER_ID }, ...committed];
|
|
952
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: staticItems, children: (item, index) => {
|
|
953
|
+
if (!('kind' in item)) {
|
|
954
|
+
// The banner (header) leads the scrollback; the next row's own top
|
|
955
|
+
// gap separates it from the first turn.
|
|
956
|
+
return (_jsx(Box, { marginTop: 1, children: _jsx(Header, { state: state }) }, item.id));
|
|
957
|
+
}
|
|
958
|
+
const prev = staticItems[index - 1];
|
|
959
|
+
const prevKind = prev && 'kind' in prev ? prev.kind : 'banner';
|
|
960
|
+
return (_jsx(Box, { paddingX: 1, marginTop: topGap(item.kind, prevKind), children: _jsx(TranscriptRow, { item: item }) }, item.id));
|
|
961
|
+
} }, staticEpoch), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [!resizing && liveItem ? (_jsx(Box, { paddingX: 1, children: _jsx(TranscriptRow, { item: liveItem }) })) : null, !resizing && state.running ? (_jsx(RunStatus, { startedAt: runStartedAt.current, activity: state.activity, think: think, steeringQueued: steeringCount })) : null, activeOverlay ?? (_jsx(InputBox, { onSubmit: handleSubmit, onInterrupt: handleInterrupt, history: history, commands: commandItems, skills: skillItems, resizing: resizing, placeholder: pendingSkill ? `value for ${pendingSkill.missing[pendingSkill.index]}` : planNext ? 'planned · your next message will be planned step-by-step' : state.mode === 'agent' ? 'message the agent · /help' : 'chat · /help' })), !resizing ? _jsx(StatusLine, { state: state }) : null] })] }));
|
|
962
|
+
}
|
|
963
|
+
const BANNER_ID = '__banner__';
|
|
964
|
+
function sessionItem(currentSessionId, session) {
|
|
965
|
+
const current = session.id === currentSessionId ? 'current · ' : '';
|
|
966
|
+
const turns = `${session.turnCount} ${session.turnCount === 1 ? 'turn' : 'turns'}`;
|
|
967
|
+
return {
|
|
968
|
+
label: session.preview ?? session.id.slice(0, 8),
|
|
969
|
+
value: session.id,
|
|
970
|
+
hint: `${current}${turns} · ${relativeTime(session.updatedAt)} · ${session.id.slice(0, 8)}`,
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
function relativeTime(value, now = Date.now()) {
|
|
974
|
+
const timestamp = Date.parse(value);
|
|
975
|
+
if (!Number.isFinite(timestamp))
|
|
976
|
+
return value;
|
|
977
|
+
const seconds = Math.max(0, Math.floor((now - timestamp) / 1000));
|
|
978
|
+
if (seconds < 60)
|
|
979
|
+
return 'just now';
|
|
980
|
+
const minutes = Math.floor(seconds / 60);
|
|
981
|
+
if (minutes < 60)
|
|
982
|
+
return `${minutes}m ago`;
|
|
983
|
+
const hours = Math.floor(minutes / 60);
|
|
984
|
+
if (hours < 24)
|
|
985
|
+
return `${hours}h ago`;
|
|
986
|
+
const days = Math.floor(hours / 24);
|
|
987
|
+
if (days < 30)
|
|
988
|
+
return `${days}d ago`;
|
|
989
|
+
return new Date(timestamp).toLocaleDateString();
|
|
990
|
+
}
|
|
991
|
+
//# sourceMappingURL=App.js.map
|