@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.
Files changed (104) hide show
  1. package/LICENSE +674 -0
  2. package/dist/core/appState.d.ts +158 -0
  3. package/dist/core/appState.d.ts.map +1 -0
  4. package/dist/core/appState.js +167 -0
  5. package/dist/core/appState.js.map +1 -0
  6. package/dist/core/commands.d.ts +70 -0
  7. package/dist/core/commands.d.ts.map +1 -0
  8. package/dist/core/commands.js +227 -0
  9. package/dist/core/commands.js.map +1 -0
  10. package/dist/core/displayPaths.d.ts +5 -0
  11. package/dist/core/displayPaths.d.ts.map +1 -0
  12. package/dist/core/displayPaths.js +14 -0
  13. package/dist/core/displayPaths.js.map +1 -0
  14. package/dist/core/eventView.d.ts +10 -0
  15. package/dist/core/eventView.d.ts.map +1 -0
  16. package/dist/core/eventView.js +77 -0
  17. package/dist/core/eventView.js.map +1 -0
  18. package/dist/core/inputGrammar.d.ts +29 -0
  19. package/dist/core/inputGrammar.d.ts.map +1 -0
  20. package/dist/core/inputGrammar.js +30 -0
  21. package/dist/core/inputGrammar.js.map +1 -0
  22. package/dist/core/skills.d.ts +2 -0
  23. package/dist/core/skills.d.ts.map +1 -0
  24. package/dist/core/skills.js +2 -0
  25. package/dist/core/skills.js.map +1 -0
  26. package/dist/core/transcriptWindow.d.ts.map +1 -0
  27. package/dist/core/transcriptWindow.js.map +1 -0
  28. package/dist/index.d.ts +3 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/index.js +2 -0
  31. package/dist/index.js.map +1 -0
  32. package/dist/runTui.d.ts +18 -0
  33. package/dist/runTui.d.ts.map +1 -0
  34. package/dist/runTui.js +146 -0
  35. package/dist/runTui.js.map +1 -0
  36. package/dist/ui/App.d.ts +23 -0
  37. package/dist/ui/App.d.ts.map +1 -0
  38. package/dist/ui/App.js +991 -0
  39. package/dist/ui/App.js.map +1 -0
  40. package/dist/ui/ApprovalModal.d.ts +12 -0
  41. package/dist/ui/ApprovalModal.d.ts.map +1 -0
  42. package/dist/ui/ApprovalModal.js +62 -0
  43. package/dist/ui/ApprovalModal.js.map +1 -0
  44. package/dist/ui/Header.d.ts +12 -0
  45. package/dist/ui/Header.d.ts.map +1 -0
  46. package/dist/ui/Header.js +15 -0
  47. package/dist/ui/Header.js.map +1 -0
  48. package/dist/ui/InfoPanel.d.ts +11 -0
  49. package/dist/ui/InfoPanel.d.ts.map +1 -0
  50. package/dist/ui/InfoPanel.js +16 -0
  51. package/dist/ui/InfoPanel.js.map +1 -0
  52. package/dist/ui/InputBox.d.ts +31 -0
  53. package/dist/ui/InputBox.d.ts.map +1 -0
  54. package/dist/ui/InputBox.js +410 -0
  55. package/dist/ui/InputBox.js.map +1 -0
  56. package/dist/ui/Markdown.d.ts +12 -0
  57. package/dist/ui/Markdown.d.ts.map +1 -0
  58. package/dist/ui/Markdown.js +111 -0
  59. package/dist/ui/Markdown.js.map +1 -0
  60. package/dist/ui/QuestionModal.d.ts +11 -0
  61. package/dist/ui/QuestionModal.d.ts.map +1 -0
  62. package/dist/ui/QuestionModal.js +211 -0
  63. package/dist/ui/QuestionModal.js.map +1 -0
  64. package/dist/ui/RunStatus.d.ts +17 -0
  65. package/dist/ui/RunStatus.d.ts.map +1 -0
  66. package/dist/ui/RunStatus.js +46 -0
  67. package/dist/ui/RunStatus.js.map +1 -0
  68. package/dist/ui/SelectList.d.ts +18 -0
  69. package/dist/ui/SelectList.d.ts.map +1 -0
  70. package/dist/ui/SelectList.js +54 -0
  71. package/dist/ui/SelectList.js.map +1 -0
  72. package/dist/ui/Spinner.d.ts +6 -0
  73. package/dist/ui/Spinner.d.ts.map +1 -0
  74. package/dist/ui/Spinner.js +14 -0
  75. package/dist/ui/Spinner.js.map +1 -0
  76. package/dist/ui/StatusLine.d.ts +6 -0
  77. package/dist/ui/StatusLine.d.ts.map +1 -0
  78. package/dist/ui/StatusLine.js +32 -0
  79. package/dist/ui/StatusLine.js.map +1 -0
  80. package/dist/ui/Transcript.d.ts +17 -0
  81. package/dist/ui/Transcript.d.ts.map +1 -0
  82. package/dist/ui/Transcript.js +58 -0
  83. package/dist/ui/Transcript.js.map +1 -0
  84. package/dist/ui/appHelpers.d.ts +16 -0
  85. package/dist/ui/appHelpers.d.ts.map +1 -0
  86. package/dist/ui/appHelpers.js +59 -0
  87. package/dist/ui/appHelpers.js.map +1 -0
  88. package/dist/ui/text.d.ts +7 -0
  89. package/dist/ui/text.d.ts.map +1 -0
  90. package/dist/ui/text.js +16 -0
  91. package/dist/ui/text.js.map +1 -0
  92. package/dist/ui/theme.d.ts +12 -0
  93. package/dist/ui/theme.d.ts.map +1 -0
  94. package/dist/ui/theme.js +12 -0
  95. package/dist/ui/theme.js.map +1 -0
  96. package/dist/ui/useResizing.d.ts +14 -0
  97. package/dist/ui/useResizing.d.ts.map +1 -0
  98. package/dist/ui/useResizing.js +37 -0
  99. package/dist/ui/useResizing.js.map +1 -0
  100. package/dist/ui/useTerminalSize.d.ts +8 -0
  101. package/dist/ui/useTerminalSize.d.ts.map +1 -0
  102. package/dist/ui/useTerminalSize.js +23 -0
  103. package/dist/ui/useTerminalSize.js.map +1 -0
  104. 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