@bubblebrain-ai/bubble 0.0.19 → 0.0.21
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/dist/agent/internal-reminder-sanitizer.d.ts +1 -0
- package/dist/agent/internal-reminder-sanitizer.js +46 -0
- package/dist/agent.d.ts +10 -0
- package/dist/agent.js +310 -18
- package/dist/approval/controller.d.ts +6 -0
- package/dist/approval/controller.js +104 -11
- package/dist/checkpoints.d.ts +57 -0
- package/dist/checkpoints.js +0 -0
- package/dist/debug-trace.js +4 -0
- package/dist/feishu/agent-host/run-driver.js +29 -0
- package/dist/hooks/config.d.ts +9 -0
- package/dist/hooks/config.js +278 -0
- package/dist/hooks/controller.d.ts +24 -0
- package/dist/hooks/controller.js +254 -0
- package/dist/hooks/index.d.ts +6 -0
- package/dist/hooks/index.js +4 -0
- package/dist/hooks/log.d.ts +14 -0
- package/dist/hooks/log.js +54 -0
- package/dist/hooks/runner.d.ts +5 -0
- package/dist/hooks/runner.js +225 -0
- package/dist/hooks/trust.d.ts +37 -0
- package/dist/hooks/trust.js +143 -0
- package/dist/hooks/types.d.ts +173 -0
- package/dist/hooks/types.js +46 -0
- package/dist/main.js +86 -13
- package/dist/memory/prompts.js +3 -1
- package/dist/model-catalog.js +2 -0
- package/dist/model-pricing.js +8 -0
- package/dist/network/chatgpt-transport.d.ts +0 -1
- package/dist/network/chatgpt-transport.js +40 -121
- package/dist/network/provider-transport.d.ts +32 -0
- package/dist/network/provider-transport.js +265 -0
- package/dist/network/retry.d.ts +29 -0
- package/dist/network/retry.js +88 -0
- package/dist/network/system-proxy.d.ts +18 -0
- package/dist/network/system-proxy.js +175 -0
- package/dist/provider-anthropic.d.ts +1 -0
- package/dist/provider-anthropic.js +127 -52
- package/dist/provider-openai-codex.js +19 -29
- package/dist/session-log.js +3 -3
- package/dist/session.d.ts +31 -0
- package/dist/session.js +69 -0
- package/dist/slash-commands/commands.js +164 -0
- package/dist/slash-commands/types.d.ts +6 -0
- package/dist/tools/bash.js +4 -0
- package/dist/tools/edit-apply.js +63 -3
- package/dist/tools/edit.d.ts +2 -1
- package/dist/tools/edit.js +6 -5
- package/dist/tools/index.d.ts +7 -0
- package/dist/tools/index.js +2 -2
- package/dist/tools/write.d.ts +2 -1
- package/dist/tools/write.js +2 -1
- package/dist/tui/display-history.d.ts +4 -3
- package/dist/tui/display-history.js +34 -57
- package/dist/tui/display-sanitizer.d.ts +3 -0
- package/dist/tui/display-sanitizer.js +38 -0
- package/dist/tui/image-paste.d.ts +18 -0
- package/dist/tui/image-paste.js +60 -0
- package/dist/tui/paste-placeholder.d.ts +1 -0
- package/dist/tui/paste-placeholder.js +7 -0
- package/dist/tui/run.d.ts +2 -0
- package/dist/tui/run.js +568 -223
- package/dist/tui/trace-groups.d.ts +16 -0
- package/dist/tui/trace-groups.js +82 -5
- package/dist/tui/transcript-scroll.d.ts +25 -0
- package/dist/tui/transcript-scroll.js +20 -0
- package/dist/tui/wordmark.d.ts +1 -0
- package/dist/tui/wordmark.js +56 -54
- package/dist/tui-ink/app.d.ts +4 -1
- package/dist/tui-ink/app.js +303 -248
- package/dist/tui-ink/display-history.d.ts +16 -1
- package/dist/tui-ink/display-history.js +50 -21
- package/dist/tui-ink/footer.d.ts +6 -12
- package/dist/tui-ink/footer.js +10 -29
- package/dist/tui-ink/image-paste.d.ts +59 -0
- package/dist/tui-ink/image-paste.js +277 -0
- package/dist/tui-ink/input-box.d.ts +26 -1
- package/dist/tui-ink/input-box.js +171 -41
- package/dist/tui-ink/message-list.d.ts +1 -1
- package/dist/tui-ink/message-list.js +46 -29
- package/dist/tui-ink/run.d.ts +7 -2
- package/dist/tui-ink/run.js +73 -23
- package/dist/tui-ink/terminal-mouse.d.ts +1 -0
- package/dist/tui-ink/terminal-mouse.js +4 -0
- package/dist/tui-ink/trace-groups.d.ts +16 -0
- package/dist/tui-ink/trace-groups.js +90 -6
- package/dist/tui-ink/transcript-viewport-math.d.ts +11 -0
- package/dist/tui-ink/transcript-viewport-math.js +17 -0
- package/dist/tui-ink/transcript-viewport.d.ts +24 -0
- package/dist/tui-ink/transcript-viewport.js +83 -0
- package/dist/tui-ink/welcome.d.ts +9 -7
- package/dist/tui-ink/welcome.js +7 -33
- package/dist/tui-opentui/app.js +2 -1
- package/dist/tui-opentui/trace-groups.js +40 -4
- package/dist/types.d.ts +27 -0
- package/package.json +1 -1
package/dist/tui-ink/app.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
3
|
import { Box, Text, useApp, useInput } from "ink";
|
|
4
|
-
import { AgentAbortError } from "../agent.js";
|
|
4
|
+
import { AgentAbortError, INTERRUPTED_ASSISTANT_CONTENT } from "../agent.js";
|
|
5
5
|
import { isHiddenToolMetadata } from "../agent/discovery-barrier.js";
|
|
6
6
|
import { registry as slashRegistry } from "../slash-commands/index.js";
|
|
7
7
|
import { UserConfig, maskKey } from "../config.js";
|
|
8
8
|
import { createPastedContentMarker, InputBox, isCtrlCInput, shouldCollapsePastedContent, } from "./input-box.js";
|
|
9
9
|
import { MessageList } from "./message-list.js";
|
|
10
|
-
import { appendTextPart, appendToolPart, compactDisplayMessages, contentFromParts, latestCompactionSummary, nextDisplayMessageKey, snapshotDisplayParts, toolCallsFromParts, } from "./display-history.js";
|
|
10
|
+
import { appendTextPart, appendToolPart, compactDisplayMessages, contentFromParts, latestCompactionSummary, nextDisplayMessageKey, setUserInputStatus, snapshotDisplayParts, stripInterruptedAssistantMarker, toolCallsFromParts, } from "./display-history.js";
|
|
11
|
+
import { AgentRunInputQueue } from "../agent/input-controller.js";
|
|
11
12
|
import { paletteFor, ThemeProvider, useTheme } from "./theme.js";
|
|
12
13
|
import { ModelPicker, ProviderPicker, KeyPicker, SkillPicker } from "./model-picker.js";
|
|
13
14
|
import { FeishuSetupPicker } from "./feishu-setup-picker.js";
|
|
@@ -28,9 +29,8 @@ import { QuestionDialog } from "./question-dialog.js";
|
|
|
28
29
|
import { FeedbackDialog } from "./feedback-dialog.js";
|
|
29
30
|
import { collectFeedback } from "../feedback/collect.js";
|
|
30
31
|
import { hasTerminalMouseSequence } from "./terminal-mouse.js";
|
|
32
|
+
import { TranscriptViewport } from "./transcript-viewport.js";
|
|
31
33
|
import os from "node:os";
|
|
32
|
-
import { existsSync } from "node:fs";
|
|
33
|
-
import { join } from "node:path";
|
|
34
34
|
function buildTips(agent, registry) {
|
|
35
35
|
const tips = [];
|
|
36
36
|
const hasProvider = registry.getEnabled().length > 0;
|
|
@@ -95,13 +95,31 @@ function reconstructDisplayMessages(agentMessages) {
|
|
|
95
95
|
});
|
|
96
96
|
}
|
|
97
97
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
98
|
+
// An aborted assistant message carries the model-facing interruption
|
|
99
|
+
// note in its content. Render only what the assistant actually said
|
|
100
|
+
// (partial streamed text, if any) plus a dedicated interrupt row —
|
|
101
|
+
// never the note itself, which reads like a leaked system prompt.
|
|
102
|
+
const interrupted = m.error?.aborted === true;
|
|
103
|
+
const content = interrupted
|
|
104
|
+
? stripInterruptedAssistantMarker(m.content, INTERRUPTED_ASSISTANT_CONTENT)
|
|
105
|
+
: m.content;
|
|
106
|
+
if (content || m.reasoning || toolCalls.length > 0) {
|
|
107
|
+
result.push({
|
|
108
|
+
key: nextDisplayMessageKey("asst"),
|
|
109
|
+
role: "assistant",
|
|
110
|
+
content,
|
|
111
|
+
reasoning: m.reasoning || undefined,
|
|
112
|
+
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
if (interrupted) {
|
|
116
|
+
result.push({
|
|
117
|
+
key: nextDisplayMessageKey("asst"),
|
|
118
|
+
role: "assistant",
|
|
119
|
+
content: "Interrupted by user",
|
|
120
|
+
syntheticKind: "ui_interrupt",
|
|
121
|
+
});
|
|
122
|
+
}
|
|
105
123
|
}
|
|
106
124
|
}
|
|
107
125
|
return result;
|
|
@@ -183,116 +201,12 @@ function withMessageKey(message) {
|
|
|
183
201
|
const prefix = message.role === "user" ? "user" : message.role === "error" ? "err" : "asst";
|
|
184
202
|
return { ...message, key: nextDisplayMessageKey(prefix) };
|
|
185
203
|
}
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const STREAMING_STATIC_FLUSH_TARGET_CHARS = 400;
|
|
193
|
-
const STREAMING_STATIC_FLUSH_MIN_TAIL = 120;
|
|
194
|
-
/**
|
|
195
|
-
* True iff `prefix` ends inside an open ```/~~~ fenced code block. Splitting
|
|
196
|
-
* the streaming buffer at such a point would let the flushed half render
|
|
197
|
-
* without its closing fence — `MarkdownContent` would then treat the body as
|
|
198
|
-
* plain prose and the trailing half would render as an isolated code block
|
|
199
|
-
* with no opener. Fence delimiters of different families don't close each
|
|
200
|
-
* other (a `~~~` inside a ``` block is just text). We use a permissive
|
|
201
|
-
* "line starts with three or more of the same char" rule, ignoring the info
|
|
202
|
-
* string — that's enough to spot when we're mid-block.
|
|
203
|
-
*/
|
|
204
|
-
function endsInsideUnclosedCodeFence(prefix) {
|
|
205
|
-
let openMarker = null;
|
|
206
|
-
for (const rawLine of prefix.split("\n")) {
|
|
207
|
-
const line = rawLine.replace(/^ {0,3}/, "");
|
|
208
|
-
if (openMarker === null) {
|
|
209
|
-
if (line.startsWith("```"))
|
|
210
|
-
openMarker = "`";
|
|
211
|
-
else if (line.startsWith("~~~"))
|
|
212
|
-
openMarker = "~";
|
|
213
|
-
}
|
|
214
|
-
else if (line.startsWith(openMarker.repeat(3))) {
|
|
215
|
-
openMarker = null;
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
return openMarker !== null;
|
|
219
|
-
}
|
|
220
|
-
function findStreamingStaticFlushIndex(content) {
|
|
221
|
-
if (content.length < STREAMING_STATIC_FLUSH_MIN_CHARS)
|
|
222
|
-
return -1;
|
|
223
|
-
const upper = Math.min(STREAMING_STATIC_FLUSH_TARGET_CHARS, content.length - STREAMING_STATIC_FLUSH_MIN_TAIL);
|
|
224
|
-
if (upper <= 0)
|
|
225
|
-
return -1;
|
|
226
|
-
const search = content.slice(0, upper);
|
|
227
|
-
const paragraphBreak = search.lastIndexOf("\n\n");
|
|
228
|
-
if (paragraphBreak >= STREAMING_STATIC_FLUSH_TARGET_CHARS / 2) {
|
|
229
|
-
const splitIndex = paragraphBreak + 2;
|
|
230
|
-
if (!endsInsideUnclosedCodeFence(content.slice(0, splitIndex))) {
|
|
231
|
-
return splitIndex;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
const lineBreak = search.lastIndexOf("\n");
|
|
235
|
-
if (lineBreak >= STREAMING_STATIC_FLUSH_TARGET_CHARS / 2) {
|
|
236
|
-
const splitIndex = lineBreak + 1;
|
|
237
|
-
if (!endsInsideUnclosedCodeFence(content.slice(0, splitIndex))) {
|
|
238
|
-
return splitIndex;
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
// Inside an open code fence: hold off flushing until the closing fence
|
|
242
|
-
// arrives. The live region grows a bit, but Markdown rendering stays correct.
|
|
243
|
-
return -1;
|
|
244
|
-
}
|
|
245
|
-
function cloneDisplayPart(part) {
|
|
246
|
-
if (part.type === "text") {
|
|
247
|
-
return { type: "text", content: part.content };
|
|
248
|
-
}
|
|
249
|
-
return {
|
|
250
|
-
type: "tools",
|
|
251
|
-
toolCalls: part.toolCalls.map((toolCall) => ({
|
|
252
|
-
...toolCall,
|
|
253
|
-
args: { ...toolCall.args },
|
|
254
|
-
})),
|
|
255
|
-
};
|
|
256
|
-
}
|
|
257
|
-
function splitDisplayPartsAtTextOffset(parts, offset) {
|
|
258
|
-
const flushedParts = [];
|
|
259
|
-
const remainingParts = [];
|
|
260
|
-
let remainingOffset = Math.max(0, offset);
|
|
261
|
-
let reachedTail = false;
|
|
262
|
-
for (const part of parts) {
|
|
263
|
-
if (part.type === "text") {
|
|
264
|
-
if (!reachedTail && remainingOffset >= part.content.length) {
|
|
265
|
-
if (part.content)
|
|
266
|
-
flushedParts.push(cloneDisplayPart(part));
|
|
267
|
-
remainingOffset -= part.content.length;
|
|
268
|
-
continue;
|
|
269
|
-
}
|
|
270
|
-
if (!reachedTail && remainingOffset > 0) {
|
|
271
|
-
const head = part.content.slice(0, remainingOffset);
|
|
272
|
-
const tail = part.content.slice(remainingOffset);
|
|
273
|
-
if (head)
|
|
274
|
-
flushedParts.push({ type: "text", content: head });
|
|
275
|
-
if (tail)
|
|
276
|
-
remainingParts.push({ type: "text", content: tail });
|
|
277
|
-
remainingOffset = 0;
|
|
278
|
-
reachedTail = true;
|
|
279
|
-
continue;
|
|
280
|
-
}
|
|
281
|
-
remainingParts.push(cloneDisplayPart(part));
|
|
282
|
-
reachedTail = true;
|
|
283
|
-
continue;
|
|
284
|
-
}
|
|
285
|
-
if (!reachedTail && remainingOffset > 0) {
|
|
286
|
-
flushedParts.push(cloneDisplayPart(part));
|
|
287
|
-
}
|
|
288
|
-
else {
|
|
289
|
-
remainingParts.push(cloneDisplayPart(part));
|
|
290
|
-
reachedTail = true;
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
return { flushedParts, remainingParts };
|
|
294
|
-
}
|
|
295
|
-
export function App({ agent, args, sessionManager, createProvider, registry, skillRegistry, planHandlerRef, approvalHandlerRef, questionController, bashAllowlist, settingsManager, lspService, mcpManager, themeMode: initialThemeMode, themeOverrides, detectedTheme, onThemeModeChange, flushMemory, runMemoryCompaction, runMemorySummary, runMemoryRefresh, bypassEnabled, onExit }) {
|
|
204
|
+
// Batch streaming text deltas before committing them to React state. Without
|
|
205
|
+
// <Static>, every commit re-renders the full-screen frame; per-token commits
|
|
206
|
+
// would make Yoga re-lay-out the transcript for every few bytes of output.
|
|
207
|
+
// 40ms keeps perceived latency invisible while capping layout work at 25fps.
|
|
208
|
+
const STREAMING_FLUSH_INTERVAL_MS = 40;
|
|
209
|
+
export function App({ agent, args, sessionManager, createProvider, registry, skillRegistry, planHandlerRef, approvalHandlerRef, questionController, bashAllowlist, settingsManager, lspService, mcpManager, themeMode: initialThemeMode, themeOverrides, detectedTheme, onThemeModeChange, flushMemory, runMemoryCompaction, runMemorySummary, runMemoryRefresh, bypassEnabled, updateNotice, hookController, onExit }) {
|
|
296
210
|
const [themeMode, setThemeMode] = useState(initialThemeMode ?? "auto");
|
|
297
211
|
// `detectedTheme` is captured once at startup in main.ts. We keep it in state
|
|
298
212
|
// so future re-detection (e.g. if a user runs `/theme auto` after switching
|
|
@@ -310,13 +224,11 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
|
|
|
310
224
|
const themeResolved = themeMode === "auto" ? autoResolved : themeMode;
|
|
311
225
|
const { exit } = useApp();
|
|
312
226
|
const [messages, setMessages] = useState(() => compactDisplayMessages(reconstructDisplayMessages(agent.messages)));
|
|
313
|
-
const [clearEpoch, setClearEpoch] = useState(0);
|
|
314
227
|
const [isRunning, setIsRunning] = useState(false);
|
|
315
228
|
const [streamingContent, setStreamingContent] = useState("");
|
|
316
229
|
const [streamingReasoning, setStreamingReasoning] = useState("");
|
|
317
230
|
const [streamingTools, setStreamingTools] = useState([]);
|
|
318
231
|
const [streamingParts, setStreamingParts] = useState([]);
|
|
319
|
-
const [usageTotals, setUsageTotals] = useState({ prompt: 0, completion: 0 });
|
|
320
232
|
const [thinkingLevel, setThinkingLevel] = useState(agent.thinking);
|
|
321
233
|
const [permissionMode, setPermissionMode] = useState(agent.mode);
|
|
322
234
|
const [todos, setTodos] = useState(() => agent.getTodos());
|
|
@@ -330,7 +242,7 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
|
|
|
330
242
|
const [keyProviderId, setKeyProviderId] = useState(null);
|
|
331
243
|
const [verboseTrace, setVerboseTrace] = useState(false);
|
|
332
244
|
const startedWithVisibleHistoryRef = useRef(messages.some((message) => message.syntheticKind !== "ui_summary"));
|
|
333
|
-
const { columns: terminalColumns } = useTerminalSize();
|
|
245
|
+
const { columns: terminalColumns, rows: terminalRows } = useTerminalSize();
|
|
334
246
|
const showWelcome = shouldShowWelcomeBanner({
|
|
335
247
|
messages,
|
|
336
248
|
startedWithVisibleHistory: startedWithVisibleHistoryRef.current,
|
|
@@ -338,27 +250,17 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
|
|
|
338
250
|
const activeAbortRef = useRef(null);
|
|
339
251
|
const exitRequestedRef = useRef(false);
|
|
340
252
|
const sessionStartRef = useRef(Date.now());
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
// so cursor-up based repaint can leave stale progress frames behind.
|
|
353
|
-
// Debounce resize storms, then clear and replay Static at the settled width.
|
|
354
|
-
const timer = setTimeout(() => {
|
|
355
|
-
if (exitRequestedRef.current)
|
|
356
|
-
return;
|
|
357
|
-
process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
|
|
358
|
-
setClearEpoch((epoch) => epoch + 1);
|
|
359
|
-
}, 300);
|
|
360
|
-
return () => clearTimeout(timer);
|
|
361
|
-
}, [terminalColumns]);
|
|
253
|
+
const viewportRef = useRef(null);
|
|
254
|
+
// Steer/queue while the agent runs (parity with the OpenTUI composer):
|
|
255
|
+
// Enter steers the current run via the agent's input controller; Tab (or an
|
|
256
|
+
// ineligible input) queues for the next turn. Both render placeholder user
|
|
257
|
+
// rows whose badge tracks the input's lifecycle.
|
|
258
|
+
const inputControllerRef = useRef(null);
|
|
259
|
+
const pendingSteersRef = useRef(new Map());
|
|
260
|
+
const queuedInputsRef = useRef([]);
|
|
261
|
+
const [pendingSteerCount, setPendingSteerCount] = useState(0);
|
|
262
|
+
const [queuedCount, setQueuedCount] = useState(0);
|
|
263
|
+
const nextRunIdRef = useRef(0);
|
|
362
264
|
// Set true the moment /quit is invoked so we can hide dynamic UI (composer,
|
|
363
265
|
// waiting indicator, footer) before Ink snapshots its final frame into the
|
|
364
266
|
// shell scrollback. Without this, the last visible "> " input row stays
|
|
@@ -488,6 +390,14 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
|
|
|
488
390
|
syncFirstPending();
|
|
489
391
|
return unsubscribe;
|
|
490
392
|
}, [questionController]);
|
|
393
|
+
// An approval or question demands the user's attention: re-engage
|
|
394
|
+
// bottom-follow even if they had scrolled up (second force trigger
|
|
395
|
+
// documented in transcript-scroll.ts).
|
|
396
|
+
useEffect(() => {
|
|
397
|
+
if (pendingApproval || pendingQuestion) {
|
|
398
|
+
viewportRef.current?.forceScrollToBottom();
|
|
399
|
+
}
|
|
400
|
+
}, [pendingApproval, pendingQuestion]);
|
|
491
401
|
const rebuildSystemPrompt = useCallback((overrides) => {
|
|
492
402
|
const modelParts = agent.model.includes(":")
|
|
493
403
|
? agent.model.split(":")
|
|
@@ -509,10 +419,22 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
|
|
|
509
419
|
requestExit();
|
|
510
420
|
return;
|
|
511
421
|
}
|
|
512
|
-
|
|
513
|
-
|
|
422
|
+
// Mouse reporting is off (native drag-select/copy works directly), so no
|
|
423
|
+
// SGR wheel events arrive — wheel scrolling reaches the app as Up/Down
|
|
424
|
+
// arrows via the terminal's alternate-scroll mode, classified in the
|
|
425
|
+
// composer. Defensively drop any stray mouse report bytes.
|
|
514
426
|
if (hasTerminalMouseSequence(input))
|
|
515
427
|
return;
|
|
428
|
+
if (!pickerMode && key.pageUp) {
|
|
429
|
+
viewportRef.current?.scrollPage("up");
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
if (!pickerMode && key.pageDown) {
|
|
433
|
+
viewportRef.current?.scrollPage("down");
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (pendingPlan || pendingApproval || pendingQuestion || pendingFeedback)
|
|
437
|
+
return;
|
|
516
438
|
if (key.ctrl && input === "o" && !pickerMode) {
|
|
517
439
|
setVerboseTrace((v) => !v);
|
|
518
440
|
return;
|
|
@@ -560,12 +482,34 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
|
|
|
560
482
|
updateDisplayMessages((prev) => [...prev, withMessageKey({ role, content })]);
|
|
561
483
|
}, [updateDisplayMessages]);
|
|
562
484
|
const clearMessages = useCallback(() => {
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
|
|
485
|
+
// The transcript lives entirely in React state now (alt-screen viewport,
|
|
486
|
+
// no terminal scrollback) — clearing state clears the screen. Writing
|
|
487
|
+
// \x1b[2J here would just flash a black frame before the next paint.
|
|
566
488
|
setMessages([]);
|
|
567
|
-
setClearEpoch((epoch) => epoch + 1);
|
|
568
489
|
}, []);
|
|
490
|
+
// Render a placeholder user row for input waiting to enter the run.
|
|
491
|
+
const addStatusUserMessage = useCallback((content, status) => {
|
|
492
|
+
const key = nextDisplayMessageKey("user");
|
|
493
|
+
updateDisplayMessages((prev) => [...prev, { key, role: "user", content, inputStatus: status }]);
|
|
494
|
+
viewportRef.current?.forceScrollToBottom();
|
|
495
|
+
return key;
|
|
496
|
+
}, [updateDisplayMessages]);
|
|
497
|
+
const queueInput = useCallback((payload) => {
|
|
498
|
+
const displayKey = addStatusUserMessage(payload.displayText ?? payload.text, "queued");
|
|
499
|
+
queuedInputsRef.current.push({ payload, displayKey });
|
|
500
|
+
setQueuedCount(queuedInputsRef.current.length);
|
|
501
|
+
}, [addStatusUserMessage]);
|
|
502
|
+
const submitSteer = useCallback((payload) => {
|
|
503
|
+
const controller = inputControllerRef.current;
|
|
504
|
+
if (!controller) {
|
|
505
|
+
queueInput(payload);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
const displayKey = addStatusUserMessage(payload.displayText ?? payload.text, "pending_steer");
|
|
509
|
+
const pending = controller.enqueue(payload.text);
|
|
510
|
+
pendingSteersRef.current.set(pending.id, { displayKey });
|
|
511
|
+
setPendingSteerCount(pendingSteersRef.current.size);
|
|
512
|
+
}, [addStatusUserMessage, queueInput]);
|
|
569
513
|
const openPicker = useCallback((mode, providerId) => {
|
|
570
514
|
if (mode === "key") {
|
|
571
515
|
setKeyProviderId(providerId ?? null);
|
|
@@ -683,6 +627,7 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
|
|
|
683
627
|
settingsManager,
|
|
684
628
|
lspService,
|
|
685
629
|
mcpManager,
|
|
630
|
+
hookController,
|
|
686
631
|
flushMemory,
|
|
687
632
|
runMemoryCompaction,
|
|
688
633
|
runMemorySummary,
|
|
@@ -716,6 +661,7 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
|
|
|
716
661
|
settingsManager,
|
|
717
662
|
lspService,
|
|
718
663
|
mcpManager,
|
|
664
|
+
hookController,
|
|
719
665
|
flushMemory,
|
|
720
666
|
runMemoryCompaction,
|
|
721
667
|
runMemorySummary,
|
|
@@ -753,6 +699,26 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
|
|
|
753
699
|
const images = normalized.images;
|
|
754
700
|
if (!input.trim() && images.length === 0)
|
|
755
701
|
return;
|
|
702
|
+
// Agent already running: route the submit into the live run instead of
|
|
703
|
+
// starting a new one. Plain prose steers the current turn; slash
|
|
704
|
+
// commands, @-mentions and image payloads queue for the next turn
|
|
705
|
+
// (mirrors the OpenTUI boundary-steer eligibility rules).
|
|
706
|
+
if (activeAbortRef.current) {
|
|
707
|
+
if (/^\/(?:quit|exit)\s*$/.test(input.trim())) {
|
|
708
|
+
requestExit();
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
const steerEligible = !displayInput.trim().startsWith("/") &&
|
|
712
|
+
!input.includes("@") &&
|
|
713
|
+
images.length === 0;
|
|
714
|
+
if (steerEligible) {
|
|
715
|
+
submitSteer(normalized);
|
|
716
|
+
}
|
|
717
|
+
else {
|
|
718
|
+
queueInput(normalized);
|
|
719
|
+
}
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
756
722
|
const runAgentInput = async (actualInput, displayInput, attachedImages = []) => {
|
|
757
723
|
const activeProviderId = agent.providerId || safeRegistry.getDefault()?.id;
|
|
758
724
|
const hasActiveProvider = !!activeProviderId && safeRegistry.getEnabled().some((provider) => provider.id === activeProviderId);
|
|
@@ -773,6 +739,9 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
|
|
|
773
739
|
...prev,
|
|
774
740
|
withMessageKey({ role: "user", content: displayContent }),
|
|
775
741
|
]);
|
|
742
|
+
// Sending is an explicit "watch the newest turn" intent: snap the
|
|
743
|
+
// transcript back to the bottom even if the user had scrolled up.
|
|
744
|
+
viewportRef.current?.forceScrollToBottom();
|
|
776
745
|
setIsRunning(true);
|
|
777
746
|
runStartRef.current = Date.now();
|
|
778
747
|
setStreamingContent("");
|
|
@@ -785,9 +754,31 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
|
|
|
785
754
|
const assistantParts = [];
|
|
786
755
|
const abortController = new AbortController();
|
|
787
756
|
activeAbortRef.current = abortController;
|
|
757
|
+
const inputController = new AgentRunInputQueue(`run-${++nextRunIdRef.current}`);
|
|
758
|
+
inputControllerRef.current = inputController;
|
|
788
759
|
const syncStreamingParts = () => {
|
|
789
760
|
setStreamingParts(snapshotDisplayParts(assistantParts));
|
|
790
761
|
};
|
|
762
|
+
// Text/reasoning deltas arrive far faster than the screen needs to
|
|
763
|
+
// update; batch them so the full-frame re-render runs at most every
|
|
764
|
+
// STREAMING_FLUSH_INTERVAL_MS. Tool events stay immediate.
|
|
765
|
+
let streamingFlushTimer = null;
|
|
766
|
+
const cancelStreamingFlush = () => {
|
|
767
|
+
if (streamingFlushTimer !== null) {
|
|
768
|
+
clearTimeout(streamingFlushTimer);
|
|
769
|
+
streamingFlushTimer = null;
|
|
770
|
+
}
|
|
771
|
+
};
|
|
772
|
+
const scheduleStreamingFlush = () => {
|
|
773
|
+
if (streamingFlushTimer !== null)
|
|
774
|
+
return;
|
|
775
|
+
streamingFlushTimer = setTimeout(() => {
|
|
776
|
+
streamingFlushTimer = null;
|
|
777
|
+
setStreamingContent(assistantContent);
|
|
778
|
+
setStreamingReasoning(assistantReasoning);
|
|
779
|
+
syncStreamingParts();
|
|
780
|
+
}, STREAMING_FLUSH_INTERVAL_MS);
|
|
781
|
+
};
|
|
791
782
|
const hasAssistantOutput = () => (!!assistantContent ||
|
|
792
783
|
!!assistantReasoning ||
|
|
793
784
|
toolCalls.length > 0 ||
|
|
@@ -821,6 +812,9 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
|
|
|
821
812
|
updateDisplayMessages((prev) => [...prev, msg]);
|
|
822
813
|
};
|
|
823
814
|
const clearAssistantStream = () => {
|
|
815
|
+
// A timer firing after this reset would resurrect the just-committed
|
|
816
|
+
// text as a phantom streaming block — cancel before clearing.
|
|
817
|
+
cancelStreamingFlush();
|
|
824
818
|
setStreamingContent("");
|
|
825
819
|
setStreamingReasoning("");
|
|
826
820
|
setStreamingTools([]);
|
|
@@ -830,58 +824,20 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
|
|
|
830
824
|
toolCalls.length = 0;
|
|
831
825
|
assistantParts.length = 0;
|
|
832
826
|
};
|
|
833
|
-
const flushAssistantStaticChunk = () => {
|
|
834
|
-
if (toolCalls.some((toolCall) => toolCall.result === undefined)) {
|
|
835
|
-
return false;
|
|
836
|
-
}
|
|
837
|
-
const splitIndex = findStreamingStaticFlushIndex(assistantContent);
|
|
838
|
-
if (splitIndex <= 0)
|
|
839
|
-
return false;
|
|
840
|
-
const { flushedParts, remainingParts } = splitDisplayPartsAtTextOffset(assistantParts, splitIndex);
|
|
841
|
-
const flushedContent = contentFromParts(flushedParts);
|
|
842
|
-
const flushedToolCalls = toolCallsFromParts(flushedParts);
|
|
843
|
-
if (!flushedContent && flushedToolCalls.length === 0)
|
|
844
|
-
return false;
|
|
845
|
-
const msg = {
|
|
846
|
-
key: nextDisplayMessageKey("asst"),
|
|
847
|
-
role: "assistant",
|
|
848
|
-
content: flushedContent,
|
|
849
|
-
};
|
|
850
|
-
if (assistantReasoning) {
|
|
851
|
-
msg.reasoning = assistantReasoning;
|
|
852
|
-
assistantReasoning = "";
|
|
853
|
-
setStreamingReasoning("");
|
|
854
|
-
}
|
|
855
|
-
if (flushedToolCalls.length > 0) {
|
|
856
|
-
msg.toolCalls = flushedToolCalls;
|
|
857
|
-
}
|
|
858
|
-
if (flushedParts.length > 0) {
|
|
859
|
-
msg.parts = flushedParts;
|
|
860
|
-
}
|
|
861
|
-
updateDisplayMessages((prev) => [...prev, msg]);
|
|
862
|
-
assistantParts.splice(0, assistantParts.length, ...remainingParts);
|
|
863
|
-
assistantContent = contentFromParts(assistantParts);
|
|
864
|
-
const remainingToolCalls = toolCallsFromParts(assistantParts);
|
|
865
|
-
toolCalls.splice(0, toolCalls.length, ...remainingToolCalls);
|
|
866
|
-
setStreamingContent(assistantContent);
|
|
867
|
-
setStreamingTools([...toolCalls]);
|
|
868
|
-
syncStreamingParts();
|
|
869
|
-
return true;
|
|
870
|
-
};
|
|
871
827
|
try {
|
|
872
|
-
for await (const event of agent.run(actualInput, args.cwd, {
|
|
828
|
+
for await (const event of agent.run(actualInput, args.cwd, {
|
|
829
|
+
abortSignal: abortController.signal,
|
|
830
|
+
inputController,
|
|
831
|
+
})) {
|
|
873
832
|
switch (event.type) {
|
|
874
833
|
case "text_delta":
|
|
875
834
|
assistantContent += event.content;
|
|
876
835
|
appendTextPart(assistantParts, event.content);
|
|
877
|
-
|
|
878
|
-
setStreamingContent(assistantContent);
|
|
879
|
-
syncStreamingParts();
|
|
880
|
-
}
|
|
836
|
+
scheduleStreamingFlush();
|
|
881
837
|
break;
|
|
882
838
|
case "reasoning_delta":
|
|
883
839
|
assistantReasoning += event.content;
|
|
884
|
-
|
|
840
|
+
scheduleStreamingFlush();
|
|
885
841
|
break;
|
|
886
842
|
case "tool_call_start": {
|
|
887
843
|
// The LLM has begun emitting this tool call. Args are still
|
|
@@ -980,13 +936,41 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
|
|
|
980
936
|
sessionManager?.appendMarker("mode_switch", event.mode);
|
|
981
937
|
break;
|
|
982
938
|
}
|
|
983
|
-
case "
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
939
|
+
case "input_applied": {
|
|
940
|
+
// The steer joined the current turn — its placeholder row
|
|
941
|
+
// becomes a regular user message (badge cleared).
|
|
942
|
+
const steer = pendingSteersRef.current.get(event.id);
|
|
943
|
+
if (steer) {
|
|
944
|
+
pendingSteersRef.current.delete(event.id);
|
|
945
|
+
setPendingSteerCount(pendingSteersRef.current.size);
|
|
946
|
+
updateDisplayMessages((prev) => prev.map((message) => message.key === steer.displayKey ? setUserInputStatus(message) : message));
|
|
947
|
+
}
|
|
948
|
+
break;
|
|
949
|
+
}
|
|
950
|
+
case "input_rejected": {
|
|
951
|
+
// No model continuation left in this run: the steer moves to
|
|
952
|
+
// the next turn's queue, badge flips to QUEUED.
|
|
953
|
+
const steer = pendingSteersRef.current.get(event.id);
|
|
954
|
+
if (steer) {
|
|
955
|
+
pendingSteersRef.current.delete(event.id);
|
|
956
|
+
setPendingSteerCount(pendingSteersRef.current.size);
|
|
957
|
+
updateDisplayMessages((prev) => prev.map((message) => message.key === steer.displayKey ? setUserInputStatus(message, "queued") : message));
|
|
958
|
+
queuedInputsRef.current.push({
|
|
959
|
+
payload: { text: event.content, images: [] },
|
|
960
|
+
displayKey: steer.displayKey,
|
|
961
|
+
});
|
|
962
|
+
setQueuedCount(queuedInputsRef.current.length);
|
|
963
|
+
}
|
|
964
|
+
break;
|
|
965
|
+
}
|
|
966
|
+
case "input_pending_changed": {
|
|
967
|
+
if (event.pending === 0 && pendingSteersRef.current.size > 0) {
|
|
968
|
+
pendingSteersRef.current.clear();
|
|
989
969
|
}
|
|
970
|
+
setPendingSteerCount(event.pending === 0 ? 0 : event.pending);
|
|
971
|
+
break;
|
|
972
|
+
}
|
|
973
|
+
case "turn_end": {
|
|
990
974
|
if (event.willContinue) {
|
|
991
975
|
syncStreamingParts();
|
|
992
976
|
break;
|
|
@@ -1011,6 +995,32 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
|
|
|
1011
995
|
}
|
|
1012
996
|
}
|
|
1013
997
|
finally {
|
|
998
|
+
cancelStreamingFlush();
|
|
999
|
+
// Leftover steers that never reached a model-call boundary: drop
|
|
1000
|
+
// them on cancel (the user asked the run to stop); requeue them for
|
|
1001
|
+
// the next turn on a normal end (mirrors the OpenTUI run teardown).
|
|
1002
|
+
const cancelled = abortController.signal.aborted;
|
|
1003
|
+
for (const leftover of inputController.clear()) {
|
|
1004
|
+
const steer = pendingSteersRef.current.get(leftover.id);
|
|
1005
|
+
pendingSteersRef.current.delete(leftover.id);
|
|
1006
|
+
if (cancelled) {
|
|
1007
|
+
if (steer) {
|
|
1008
|
+
updateDisplayMessages((prev) => prev.filter((message) => message.key !== steer.displayKey));
|
|
1009
|
+
}
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1012
|
+
if (steer) {
|
|
1013
|
+
updateDisplayMessages((prev) => prev.map((message) => message.key === steer.displayKey ? setUserInputStatus(message, "queued") : message));
|
|
1014
|
+
}
|
|
1015
|
+
queuedInputsRef.current.push({
|
|
1016
|
+
payload: { text: leftover.content, images: [] },
|
|
1017
|
+
displayKey: steer?.displayKey,
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
setPendingSteerCount(0);
|
|
1021
|
+
setQueuedCount(queuedInputsRef.current.length);
|
|
1022
|
+
if (inputControllerRef.current === inputController)
|
|
1023
|
+
inputControllerRef.current = null;
|
|
1014
1024
|
if (activeAbortRef.current === abortController)
|
|
1015
1025
|
activeAbortRef.current = null;
|
|
1016
1026
|
setIsRunning(false);
|
|
@@ -1051,12 +1061,14 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
|
|
|
1051
1061
|
}),
|
|
1052
1062
|
openPicker,
|
|
1053
1063
|
openFeedback,
|
|
1064
|
+
fillComposer,
|
|
1054
1065
|
registry: safeRegistry,
|
|
1055
1066
|
skillRegistry: safeSkillRegistry,
|
|
1056
1067
|
bashAllowlist,
|
|
1057
1068
|
settingsManager,
|
|
1058
1069
|
lspService,
|
|
1059
1070
|
mcpManager,
|
|
1071
|
+
hookController,
|
|
1060
1072
|
flushMemory,
|
|
1061
1073
|
runMemoryCompaction,
|
|
1062
1074
|
runMemorySummary,
|
|
@@ -1085,6 +1097,14 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
|
|
|
1085
1097
|
},
|
|
1086
1098
|
]);
|
|
1087
1099
|
}
|
|
1100
|
+
else if (result.startsWith("⏪")) {
|
|
1101
|
+
// /rewind truncated agent.messages — rebuild the transcript from
|
|
1102
|
+
// the rewound state before appending the summary.
|
|
1103
|
+
updateDisplayMessages(() => [
|
|
1104
|
+
...reconstructDisplayMessages(agent.messages),
|
|
1105
|
+
{ role: "assistant", content: result },
|
|
1106
|
+
]);
|
|
1107
|
+
}
|
|
1088
1108
|
else {
|
|
1089
1109
|
addMessage("assistant", result);
|
|
1090
1110
|
}
|
|
@@ -1100,7 +1120,8 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
|
|
|
1100
1120
|
addMessage("error", `Could not resolve @mention: ${expansion.missing.join(", ")}`);
|
|
1101
1121
|
}
|
|
1102
1122
|
for (const skip of expansion.skipped) {
|
|
1103
|
-
|
|
1123
|
+
if (skip.reason !== "too large")
|
|
1124
|
+
addMessage("error", `Skipped @${skip.path}: ${skip.reason}`);
|
|
1104
1125
|
}
|
|
1105
1126
|
const agentInput = images.length > 0
|
|
1106
1127
|
? [
|
|
@@ -1112,7 +1133,32 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
|
|
|
1112
1133
|
]
|
|
1113
1134
|
: expansion.text;
|
|
1114
1135
|
await runAgentInput(agentInput, displayInput, images.map((img) => ({ filename: img.filename, bytes: img.bytes })));
|
|
1115
|
-
}, [addMessage, agent, args.cwd, openPicker, createProvider, safeRegistry, safeSkillRegistry, updateDisplayMessages]);
|
|
1136
|
+
}, [addMessage, agent, args.cwd, openPicker, createProvider, fillComposer, safeRegistry, safeSkillRegistry, updateDisplayMessages, queueInput, submitSteer, requestExit]);
|
|
1137
|
+
// Drain the queue once the run ends and no modal needs the user first.
|
|
1138
|
+
// The placeholder row is removed right before resubmitting — handleSubmit
|
|
1139
|
+
// renders the message again as a regular user row.
|
|
1140
|
+
const drainQueuedInput = useCallback(() => {
|
|
1141
|
+
if (activeAbortRef.current)
|
|
1142
|
+
return;
|
|
1143
|
+
if (pendingPlan || pendingApproval || pendingQuestion || pendingFeedback || pickerMode)
|
|
1144
|
+
return;
|
|
1145
|
+
const next = queuedInputsRef.current.shift();
|
|
1146
|
+
if (!next)
|
|
1147
|
+
return;
|
|
1148
|
+
setQueuedCount(queuedInputsRef.current.length);
|
|
1149
|
+
if (next.displayKey) {
|
|
1150
|
+
updateDisplayMessages((prev) => prev.filter((message) => message.key !== next.displayKey));
|
|
1151
|
+
}
|
|
1152
|
+
void handleSubmit(next.payload);
|
|
1153
|
+
}, [pendingPlan, pendingApproval, pendingQuestion, pendingFeedback, pickerMode, updateDisplayMessages, handleSubmit]);
|
|
1154
|
+
useEffect(() => {
|
|
1155
|
+
if (isRunning || queuedCount === 0)
|
|
1156
|
+
return;
|
|
1157
|
+
if (pendingPlan || pendingApproval || pendingQuestion || pendingFeedback || pickerMode)
|
|
1158
|
+
return;
|
|
1159
|
+
const timer = setTimeout(drainQueuedInput, 0);
|
|
1160
|
+
return () => clearTimeout(timer);
|
|
1161
|
+
}, [isRunning, queuedCount, pendingPlan, pendingApproval, pendingQuestion, pendingFeedback, pickerMode, drainQueuedInput]);
|
|
1116
1162
|
const currentProviderId = agent.providerId || safeRegistry.getDefault()?.id;
|
|
1117
1163
|
const keyTarget = keyProviderId
|
|
1118
1164
|
? safeRegistry.getConfigured().find((p) => p.id === keyProviderId)
|
|
@@ -1132,39 +1178,47 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
|
|
|
1132
1178
|
return null;
|
|
1133
1179
|
})()
|
|
1134
1180
|
: null;
|
|
1135
|
-
const
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
const welcomeBannerNode = showWelcome ? (_jsx(WelcomeBanner, { terminalColumns: terminalColumns,
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
}, onCancel: closePicker })), pickerMode === "
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1181
|
+
const showThinkingLabel = getAvailableThinkingLevels(agent.providerId, agent.apiModel).length > 2
|
|
1182
|
+
&& thinkingLevel
|
|
1183
|
+
&& thinkingLevel !== "off";
|
|
1184
|
+
const welcomeBannerNode = showWelcome ? (_jsx(WelcomeBanner, { terminalColumns: terminalColumns, tips: buildTips(agent, safeRegistry), updateNotice: updateNotice, cwd: friendlyCwd(args.cwd), providerId: agent.providerId || safeRegistry.getDefault()?.id, modelLabel: agent.model ? displayModel(agent.model) : undefined, thinkingLabel: showThinkingLabel ? thinkingLevel : undefined })) : null;
|
|
1185
|
+
// One row shorter than the terminal on purpose. A frame that exactly fills
|
|
1186
|
+
// the screen makes Ink omit the trailing newline ("fullscreen" mode), and
|
|
1187
|
+
// Ink's cursor-only repositioning (buildReturnToBottom) miscalculates by one
|
|
1188
|
+
// row for such frames — the composer cursor lands one row above the prompt
|
|
1189
|
+
// after any cursor-only update (e.g. restoreLastOutput following an external
|
|
1190
|
+
// stdout write). Keeping every frame below viewport height keeps all of
|
|
1191
|
+
// Ink's cursor paths on the consistent trailing-newline math.
|
|
1192
|
+
const frameRows = Math.max(4, terminalRows - 1);
|
|
1193
|
+
return (_jsx(ThemeProvider, { value: palette, children: _jsxs(Box, { flexDirection: "column", width: terminalColumns, height: frameRows, children: [_jsx(TranscriptViewport, { ref: viewportRef, children: _jsx(Box, { flexDirection: "column", paddingX: 1, paddingTop: 1, flexShrink: 0, children: _jsx(MessageList, { messages: messages, streamingContent: streamingContent, streamingReasoning: streamingReasoning, streamingTools: streamingTools, streamingParts: streamingParts, terminalColumns: terminalColumns, verboseTrace: verboseTrace, pendingApproval: approvalHint, nowTick: nowTick, welcomeBanner: welcomeBannerNode }) }) }), pickerMode === "model" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(ModelPicker, { registry: safeRegistry, current: agent.model, recent: userConfig.getRecentModels(), onSelect: handleModelSelect, onCancel: closePicker }) })), pickerMode === "provider" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(ProviderPicker, { providers: BUILTIN_PROVIDERS
|
|
1194
|
+
.filter((p) => isUserVisibleProvider(p.id))
|
|
1195
|
+
.map((p) => {
|
|
1196
|
+
const configured = safeRegistry.getConfigured().find((item) => item.id === p.id);
|
|
1197
|
+
const configuredLabel = configured?.apiKey ? "configured" : "needs key";
|
|
1198
|
+
return {
|
|
1199
|
+
id: p.id,
|
|
1200
|
+
name: `${p.name} [${configuredLabel}]`,
|
|
1201
|
+
enabled: true,
|
|
1202
|
+
};
|
|
1203
|
+
}), current: currentProviderId, onSelect: handleProviderSelect, onCancel: closePicker }) })), pickerMode === "provider-add" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(ProviderPicker, { providers: BUILTIN_PROVIDERS
|
|
1204
|
+
.filter((p) => isUserVisibleProvider(p.id))
|
|
1205
|
+
.map((p) => ({ id: p.id, name: p.name, enabled: true })), current: currentProviderId, onSelect: handleProviderAddSelect, onCancel: closePicker, title: "Add Provider" }) })), pickerMode === "login" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(ProviderPicker, { providers: BUILTIN_PROVIDERS
|
|
1206
|
+
.filter((p) => isUserVisibleProvider(p.id) && safeRegistry.supportsOAuth(p.id))
|
|
1207
|
+
.map((p) => ({ id: p.id, name: p.name, enabled: true })), current: currentProviderId, onSelect: handleLoginProviderSelect, onCancel: closePicker, title: "Select Login Provider" }) })), pickerMode === "logout" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(ProviderPicker, { providers: safeRegistry.getConfigured()
|
|
1208
|
+
.filter((p) => safeRegistry.getAuthStorage().has(p.id))
|
|
1209
|
+
.map((p) => ({ id: p.id, name: p.name, enabled: true })), current: currentProviderId, onSelect: handleLogoutProviderSelect, onCancel: closePicker, title: "Select Logout Provider" }) })), pickerMode === "key" && keyTarget && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(KeyPicker, { providerName: keyTarget.name, onSubmit: handleKeySubmit, onCancel: () => {
|
|
1210
|
+
closePicker();
|
|
1211
|
+
setKeyProviderId(null);
|
|
1212
|
+
} }) })), pickerMode === "skill" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(SkillPicker, { skills: safeSkillRegistry.summaries(), onSelect: (name) => {
|
|
1213
|
+
fillComposer(`/${name} `);
|
|
1214
|
+
closePicker();
|
|
1215
|
+
}, onCancel: closePicker }) })), pickerMode === "feishu-setup" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(FeishuSetupPicker, { onComplete: (summary) => {
|
|
1216
|
+
closePicker();
|
|
1217
|
+
addMessage("assistant", summary);
|
|
1218
|
+
}, onCancel: () => {
|
|
1219
|
+
closePicker();
|
|
1220
|
+
addMessage("assistant", "已取消 Feishu setup。");
|
|
1221
|
+
} }) })), todos.length > 0 && !pickerMode && !pendingPlan && !pendingQuestion && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(TodosPanel, { todos: todos, terminalColumns: terminalColumns }) })), pendingPlan && !pickerMode && !pendingQuestion && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(PlanConfirm, { initialPlan: pendingPlan.plan, onApprove: (finalPlan) => {
|
|
1168
1222
|
const resolve = pendingPlan.resolve;
|
|
1169
1223
|
setPendingPlan(null);
|
|
1170
1224
|
resolve({ action: "approve", plan: finalPlan });
|
|
@@ -1191,16 +1245,9 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
|
|
|
1191
1245
|
else if (result.kind === "error") {
|
|
1192
1246
|
addMessage("error", `Feedback failed: ${result.message}`);
|
|
1193
1247
|
}
|
|
1194
|
-
} }) })), !isExiting && isRunning && !pickerMode && !pendingPlan && !pendingApproval && !pendingQuestion && !pendingFeedback && (_jsx(Box, { paddingX: 1, paddingBottom: 1, flexShrink: 0, children: _jsx(WaitingIndicator, { tools: streamingTools, hasStreamingText: streamingContent.length > 0, hasStreamingReasoning: streamingReasoning.length > 0, streamedChars: streamingContent.length + streamingReasoning.length, nowTick: nowTick }) })), !isExiting && !pickerMode && (_jsx(Box, { paddingBottom: 1, flexShrink: 0, children: _jsx(InputBox, { onSubmit: handleSubmit,
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
model: displayModel(agent.model) || "no model",
|
|
1198
|
-
thinkingLevel,
|
|
1199
|
-
showThinking: getAvailableThinkingLevels(agent.providerId, agent.apiModel).length > 2,
|
|
1200
|
-
mode: permissionMode,
|
|
1201
|
-
usageTotals,
|
|
1202
|
-
verboseTrace,
|
|
1203
|
-
}) }) }))] }) }));
|
|
1248
|
+
} }) })), !isExiting && isRunning && !pickerMode && !pendingPlan && !pendingApproval && !pendingQuestion && !pendingFeedback && (_jsx(Box, { paddingX: 1, paddingBottom: 1, flexShrink: 0, children: _jsx(WaitingIndicator, { tools: streamingTools, hasStreamingText: streamingContent.length > 0, hasStreamingReasoning: streamingReasoning.length > 0, streamedChars: streamingContent.length + streamingReasoning.length, nowTick: nowTick, pendingSteerCount: pendingSteerCount, queuedCount: queuedCount }) })), !isExiting && !pickerMode && (_jsx(Box, { paddingBottom: 1, flexShrink: 0, children: _jsx(InputBox, { onSubmit: handleSubmit, onQueue: isRunning ? queueInput : undefined, onWheelScroll: (direction, lines) => {
|
|
1249
|
+
viewportRef.current?.scrollBy(direction === "up" ? -lines : lines);
|
|
1250
|
+
}, disabled: !!pendingPlan || !!pendingApproval || !!pendingQuestion || !!pendingFeedback, cursorResetEpoch: cursorResetEpoch, draftText: composerDraft?.text, draftEpoch: composerDraft?.epoch, onDraftApplied: clearComposerDraft, skillRegistry: safeSkillRegistry, terminalColumns: terminalColumns, cwd: args.cwd }) })), !isExiting && (_jsx(Box, { flexShrink: 0, children: _jsx(FooterBar, { data: buildFooterData({ mode: permissionMode }) }) }))] }) }));
|
|
1204
1251
|
}
|
|
1205
1252
|
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
1206
1253
|
const GENERIC_PHRASES = [
|
|
@@ -1265,7 +1312,7 @@ function formatTokensApprox(chars) {
|
|
|
1265
1312
|
return `${(tokens / 1000).toFixed(1)}k`;
|
|
1266
1313
|
return `${Math.round(tokens / 1000)}k`;
|
|
1267
1314
|
}
|
|
1268
|
-
function WaitingIndicator({ tools, hasStreamingText, hasStreamingReasoning, streamedChars, nowTick, }) {
|
|
1315
|
+
function WaitingIndicator({ tools, hasStreamingText, hasStreamingReasoning, streamedChars, nowTick, pendingSteerCount = 0, queuedCount = 0, }) {
|
|
1269
1316
|
void nowTick;
|
|
1270
1317
|
const theme = useTheme();
|
|
1271
1318
|
const [frameIndex, setFrameIndex] = useState(0);
|
|
@@ -1313,5 +1360,13 @@ function WaitingIndicator({ tools, hasStreamingText, hasStreamingReasoning, stre
|
|
|
1313
1360
|
phrase = idlePhrase;
|
|
1314
1361
|
}
|
|
1315
1362
|
const tokenText = streamedChars > 0 ? `↓${formatTokensApprox(streamedChars)} tok` : "";
|
|
1316
|
-
|
|
1363
|
+
const hintParts = [];
|
|
1364
|
+
if (tokenText)
|
|
1365
|
+
hintParts.push(tokenText);
|
|
1366
|
+
if (pendingSteerCount > 0)
|
|
1367
|
+
hintParts.push(`${pendingSteerCount} pending steer${pendingSteerCount === 1 ? "" : "s"}`);
|
|
1368
|
+
if (queuedCount > 0)
|
|
1369
|
+
hintParts.push(`${queuedCount} queued`);
|
|
1370
|
+
hintParts.push("enter steer", "tab queue", "esc stop");
|
|
1371
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, children: SPINNER_FRAMES[frameIndex] }), _jsxs(Text, { color: theme.muted, children: [" ", phrase, " "] }), _jsxs(Text, { color: theme.muted, dimColor: true, children: ["(", hintParts.join(" · "), ")"] })] }));
|
|
1317
1372
|
}
|