@bubblebrain-ai/bubble 0.0.10 → 0.0.11
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.d.ts +1 -0
- package/dist/agent.js +5 -0
- package/dist/cli.d.ts +10 -0
- package/dist/cli.js +31 -3
- package/dist/feedback/collect.d.ts +7 -0
- package/dist/feedback/collect.js +119 -0
- package/dist/feedback/config.d.ts +14 -0
- package/dist/feedback/config.js +16 -0
- package/dist/feedback/redact.d.ts +1 -0
- package/dist/feedback/redact.js +25 -0
- package/dist/feedback/submit.d.ts +6 -0
- package/dist/feedback/submit.js +43 -0
- package/dist/feedback/types.d.ts +22 -0
- package/dist/feishu/agent-host/approval-card.d.ts +11 -0
- package/dist/feishu/agent-host/approval-card.js +46 -0
- package/dist/feishu/agent-host/approval-ui.d.ts +59 -0
- package/dist/feishu/agent-host/approval-ui.js +214 -0
- package/dist/feishu/agent-host/run-driver.d.ts +51 -0
- package/dist/feishu/agent-host/run-driver.js +295 -0
- package/dist/feishu/agent-host/runtime-deps.d.ts +33 -0
- package/dist/feishu/agent-host/runtime-deps.js +8 -0
- package/dist/feishu/card/budget.d.ts +40 -0
- package/dist/feishu/card/budget.js +134 -0
- package/dist/feishu/card/renderer.d.ts +29 -0
- package/dist/feishu/card/renderer.js +245 -0
- package/dist/feishu/card/run-state-types.d.ts +49 -0
- package/dist/feishu/card/run-state-types.js +15 -0
- package/dist/feishu/card/run-state.d.ts +21 -0
- package/dist/feishu/card/run-state.js +217 -0
- package/dist/feishu/channel/channel.d.ts +52 -0
- package/dist/feishu/channel/channel.js +74 -0
- package/dist/feishu/config.d.ts +24 -0
- package/dist/feishu/config.js +97 -0
- package/dist/feishu/format.d.ts +6 -0
- package/dist/feishu/format.js +14 -0
- package/dist/feishu/index.d.ts +4 -0
- package/dist/feishu/index.js +4 -0
- package/dist/feishu/logger.d.ts +31 -0
- package/dist/feishu/logger.js +62 -0
- package/dist/feishu/paths.d.ts +12 -0
- package/dist/feishu/paths.js +38 -0
- package/dist/feishu/process-registry.d.ts +29 -0
- package/dist/feishu/process-registry.js +90 -0
- package/dist/feishu/router/commands.d.ts +38 -0
- package/dist/feishu/router/commands.js +285 -0
- package/dist/feishu/router/event-router.d.ts +40 -0
- package/dist/feishu/router/event-router.js +208 -0
- package/dist/feishu/router/whitelist.d.ts +23 -0
- package/dist/feishu/router/whitelist.js +20 -0
- package/dist/feishu/runtime/active-runs.d.ts +32 -0
- package/dist/feishu/runtime/active-runs.js +84 -0
- package/dist/feishu/runtime/pending-queue.d.ts +36 -0
- package/dist/feishu/runtime/pending-queue.js +98 -0
- package/dist/feishu/runtime/process-pool.d.ts +29 -0
- package/dist/feishu/runtime/process-pool.js +49 -0
- package/dist/feishu/schema.d.ts +17 -0
- package/dist/feishu/schema.js +252 -0
- package/dist/feishu/scope/scope-registry.d.ts +39 -0
- package/dist/feishu/scope/scope-registry.js +148 -0
- package/dist/feishu/scope/session-binder.d.ts +44 -0
- package/dist/feishu/scope/session-binder.js +100 -0
- package/dist/feishu/scope/session-store.d.ts +24 -0
- package/dist/feishu/scope/session-store.js +73 -0
- package/dist/feishu/secrets.d.ts +37 -0
- package/dist/feishu/secrets.js +129 -0
- package/dist/feishu/serve.d.ts +12 -0
- package/dist/feishu/serve.js +288 -0
- package/dist/feishu/types.d.ts +75 -0
- package/dist/feishu/types.js +23 -0
- package/dist/feishu/wizard.d.ts +24 -0
- package/dist/feishu/wizard.js +121 -0
- package/dist/main.js +78 -29
- package/dist/model-catalog.js +3 -0
- package/dist/session.d.ts +11 -0
- package/dist/session.js +88 -2
- package/dist/slash-commands/commands.js +13 -0
- package/dist/slash-commands/feishu.d.ts +17 -0
- package/dist/slash-commands/feishu.js +400 -0
- package/dist/slash-commands/types.d.ts +3 -1
- package/dist/tui-ink/app.js +218 -60
- package/dist/tui-ink/code-highlight.js +2 -3
- package/dist/tui-ink/detect-theme.d.ts +1 -18
- package/dist/tui-ink/detect-theme.js +1 -37
- package/dist/tui-ink/display-history.d.ts +20 -3
- package/dist/tui-ink/display-history.js +26 -27
- package/dist/tui-ink/feedback-dialog.d.ts +19 -0
- package/dist/tui-ink/feedback-dialog.js +123 -0
- package/dist/tui-ink/feishu-setup-picker.d.ts +5 -0
- package/dist/tui-ink/feishu-setup-picker.js +261 -0
- package/dist/tui-ink/input-box.d.ts +3 -0
- package/dist/tui-ink/input-box.js +27 -0
- package/dist/tui-ink/input-history.js +3 -5
- package/dist/tui-ink/markdown.d.ts +32 -0
- package/dist/tui-ink/markdown.js +111 -4
- package/dist/tui-ink/message-list.d.ts +1 -6
- package/dist/tui-ink/message-list.js +85 -34
- package/dist/tui-ink/model-picker.js +1 -4
- package/dist/tui-ink/run-session-picker.d.ts +10 -0
- package/dist/tui-ink/run-session-picker.js +22 -0
- package/dist/tui-ink/run.js +7 -2
- package/dist/tui-ink/session-picker.d.ts +10 -0
- package/dist/tui-ink/session-picker.js +112 -0
- package/dist/tui-ink/terminal-mouse.d.ts +4 -0
- package/dist/tui-ink/terminal-mouse.js +23 -0
- package/dist/tui-ink/trace-groups.js +25 -2
- package/dist/tui-ink/welcome.js +2 -4
- package/package.json +4 -5
- package/dist/tui/clipboard.d.ts +0 -1
- package/dist/tui/clipboard.js +0 -53
- package/dist/tui/display-history.d.ts +0 -44
- package/dist/tui/display-history.js +0 -243
- package/dist/tui/escape-confirmation.d.ts +0 -15
- package/dist/tui/escape-confirmation.js +0 -30
- package/dist/tui/file-mentions.d.ts +0 -29
- package/dist/tui/file-mentions.js +0 -174
- package/dist/tui/global-key-router.d.ts +0 -3
- package/dist/tui/global-key-router.js +0 -87
- package/dist/tui/image-paste.d.ts +0 -95
- package/dist/tui/image-paste.js +0 -505
- package/dist/tui/markdown-inline.d.ts +0 -22
- package/dist/tui/markdown-inline.js +0 -68
- package/dist/tui/markdown-theme-rules.d.ts +0 -23
- package/dist/tui/markdown-theme-rules.js +0 -164
- package/dist/tui/markdown-theme.d.ts +0 -5
- package/dist/tui/markdown-theme.js +0 -27
- package/dist/tui/opencode-spinner.d.ts +0 -21
- package/dist/tui/opencode-spinner.js +0 -216
- package/dist/tui/prompt-keybindings.d.ts +0 -42
- package/dist/tui/prompt-keybindings.js +0 -35
- package/dist/tui/recent-activity.d.ts +0 -8
- package/dist/tui/recent-activity.js +0 -71
- package/dist/tui/render-signature.d.ts +0 -1
- package/dist/tui/render-signature.js +0 -7
- package/dist/tui/run.d.ts +0 -38
- package/dist/tui/run.js +0 -6996
- package/dist/tui/sidebar-mcp.d.ts +0 -31
- package/dist/tui/sidebar-mcp.js +0 -62
- package/dist/tui/sidebar-state.d.ts +0 -12
- package/dist/tui/sidebar-state.js +0 -69
- package/dist/tui/streaming-tool-args.d.ts +0 -15
- package/dist/tui/streaming-tool-args.js +0 -30
- package/dist/tui/tool-renderers/fallback.d.ts +0 -2
- package/dist/tui/tool-renderers/fallback.js +0 -75
- package/dist/tui/tool-renderers/registry.d.ts +0 -3
- package/dist/tui/tool-renderers/registry.js +0 -11
- package/dist/tui/tool-renderers/subagent.d.ts +0 -2
- package/dist/tui/tool-renderers/subagent.js +0 -114
- package/dist/tui/tool-renderers/types.d.ts +0 -36
- package/dist/tui/tool-renderers/write-preview.d.ts +0 -12
- package/dist/tui/tool-renderers/write-preview.js +0 -30
- package/dist/tui/tool-renderers/write.d.ts +0 -6
- package/dist/tui/tool-renderers/write.js +0 -88
- /package/dist/{tui/tool-renderers → feedback}/types.js +0 -0
package/dist/tui-ink/markdown.js
CHANGED
|
@@ -89,6 +89,85 @@ export function parseMarkdownBlocks(text) {
|
|
|
89
89
|
}
|
|
90
90
|
return blocks;
|
|
91
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* Return the byte offset where the LAST markdown block begins in `text`.
|
|
94
|
+
*
|
|
95
|
+
* Used by the streaming renderer to split incoming content into a "stable
|
|
96
|
+
* prefix" (everything before the in-flight block — already-closed blocks)
|
|
97
|
+
* and an "unstable suffix" (the block currently being typed by the model).
|
|
98
|
+
* Mirrors parseMarkdownBlocks's lexing rules so the boundary it produces is
|
|
99
|
+
* compatible with how MarkdownContent will later parse the prefix.
|
|
100
|
+
*
|
|
101
|
+
* Returns `text.length` when no blocks are present (empty / whitespace-only
|
|
102
|
+
* input), and `0` when the entire text is a single in-flight block.
|
|
103
|
+
*/
|
|
104
|
+
export function findLastBlockStart(text) {
|
|
105
|
+
if (text.length === 0)
|
|
106
|
+
return 0;
|
|
107
|
+
const lines = text.split("\n");
|
|
108
|
+
// `split("\n")` on "abc\n" yields ["abc", ""]; on "abc" yields ["abc"]. The
|
|
109
|
+
// trailing newline only contributes a length-1 separator for every line
|
|
110
|
+
// except the final element produced by split.
|
|
111
|
+
const lineLengthWithSeparator = (idx) => {
|
|
112
|
+
const len = lines[idx].length;
|
|
113
|
+
return idx < lines.length - 1 ? len + 1 : len;
|
|
114
|
+
};
|
|
115
|
+
let i = 0;
|
|
116
|
+
let offset = 0;
|
|
117
|
+
let lastBlockStart = text.length;
|
|
118
|
+
while (i < lines.length) {
|
|
119
|
+
const line = lines[i];
|
|
120
|
+
// Code block (fenced). Unclosed fences extend through EOF — that's exactly
|
|
121
|
+
// what we want for streaming, since marked-lexer-style "single token until
|
|
122
|
+
// close" keeps the in-flight code in the unstable suffix.
|
|
123
|
+
if (line.startsWith("```")) {
|
|
124
|
+
lastBlockStart = offset;
|
|
125
|
+
offset += lineLengthWithSeparator(i);
|
|
126
|
+
i++;
|
|
127
|
+
while (i < lines.length && !lines[i].startsWith("```")) {
|
|
128
|
+
offset += lineLengthWithSeparator(i);
|
|
129
|
+
i++;
|
|
130
|
+
}
|
|
131
|
+
if (i < lines.length) {
|
|
132
|
+
offset += lineLengthWithSeparator(i);
|
|
133
|
+
i++;
|
|
134
|
+
}
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
// Table — same shape as parseMarkdownBlocks: consume consecutive `|` lines.
|
|
138
|
+
if (line.trim().startsWith("|")) {
|
|
139
|
+
lastBlockStart = offset;
|
|
140
|
+
while (i < lines.length && lines[i].trim().startsWith("|")) {
|
|
141
|
+
offset += lineLengthWithSeparator(i);
|
|
142
|
+
i++;
|
|
143
|
+
}
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
// Heading.
|
|
147
|
+
if (/^#{1,6}\s+/.test(line)) {
|
|
148
|
+
lastBlockStart = offset;
|
|
149
|
+
offset += lineLengthWithSeparator(i);
|
|
150
|
+
i++;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
// Blank line — does not start a block; just advances the offset.
|
|
154
|
+
if (line.trim() === "") {
|
|
155
|
+
offset += lineLengthWithSeparator(i);
|
|
156
|
+
i++;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
// Paragraph — runs until blank line or start of another block.
|
|
160
|
+
lastBlockStart = offset;
|
|
161
|
+
while (i < lines.length &&
|
|
162
|
+
lines[i].trim() !== "" &&
|
|
163
|
+
!lines[i].startsWith("```") &&
|
|
164
|
+
!lines[i].trim().startsWith("|")) {
|
|
165
|
+
offset += lineLengthWithSeparator(i);
|
|
166
|
+
i++;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return lastBlockStart;
|
|
170
|
+
}
|
|
92
171
|
function parseTableRow(line) {
|
|
93
172
|
let body = line.trim();
|
|
94
173
|
if (body.startsWith("|"))
|
|
@@ -263,10 +342,8 @@ function InlineText({ text }) {
|
|
|
263
342
|
function CodeBlock({ lang, lines }) {
|
|
264
343
|
const theme = useTheme();
|
|
265
344
|
// Lazy init: try sync highlight when shiki is already warm so the very first
|
|
266
|
-
// paint carries highlighted output.
|
|
267
|
-
//
|
|
268
|
-
// — anything we ship via setState in useEffect lands too late to appear in
|
|
269
|
-
// scrollback. Fall back to raw lines if shiki hasn't loaded yet.
|
|
345
|
+
// paint carries highlighted output. Fall back to raw lines if shiki hasn't
|
|
346
|
+
// loaded yet; useEffect will refresh the mounted transcript item later.
|
|
270
347
|
const [highlighted, setHighlighted] = React.useState(() => {
|
|
271
348
|
const code = lines.join("\n");
|
|
272
349
|
if (!code)
|
|
@@ -377,6 +454,36 @@ function HeadingBlock({ level, text }) {
|
|
|
377
454
|
}
|
|
378
455
|
return (_jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { ...props, children: text }) }));
|
|
379
456
|
}
|
|
457
|
+
/**
|
|
458
|
+
* Streaming-aware wrapper around `MarkdownContent`.
|
|
459
|
+
*
|
|
460
|
+
* On every render, splits the incoming `content` into a stable prefix
|
|
461
|
+
* (everything before the in-flight block) and an unstable suffix (the block
|
|
462
|
+
* currently being typed). The two halves are rendered as two separate
|
|
463
|
+
* `MarkdownContent` instances; the stable one uses the same `content` prop
|
|
464
|
+
* across deltas, so its internal `useMemo([content])` short-circuits and
|
|
465
|
+
* does NOT re-parse on each token — which is the whole point. Only the
|
|
466
|
+
* shorter unstable suffix re-parses per delta.
|
|
467
|
+
*
|
|
468
|
+
* The boundary advances monotonically (the prefix only grows). A defensive
|
|
469
|
+
* reset handles the rare case where `content` is replaced wholesale (e.g.,
|
|
470
|
+
* the user re-enters a turn).
|
|
471
|
+
*/
|
|
472
|
+
export function StreamingMarkdown({ content, maxWidth, }) {
|
|
473
|
+
const stablePrefixRef = React.useRef("");
|
|
474
|
+
if (!content.startsWith(stablePrefixRef.current)) {
|
|
475
|
+
stablePrefixRef.current = "";
|
|
476
|
+
}
|
|
477
|
+
const boundary = stablePrefixRef.current.length;
|
|
478
|
+
const tail = content.substring(boundary);
|
|
479
|
+
const advance = findLastBlockStart(tail);
|
|
480
|
+
if (advance > 0) {
|
|
481
|
+
stablePrefixRef.current = content.substring(0, boundary + advance);
|
|
482
|
+
}
|
|
483
|
+
const stablePrefix = stablePrefixRef.current;
|
|
484
|
+
const unstableSuffix = content.substring(stablePrefix.length);
|
|
485
|
+
return (_jsxs(Box, { flexDirection: "column", children: [stablePrefix && _jsx(MarkdownContent, { content: stablePrefix, maxWidth: maxWidth }), unstableSuffix && _jsx(MarkdownContent, { content: unstableSuffix, maxWidth: maxWidth })] }));
|
|
486
|
+
}
|
|
380
487
|
export function MarkdownContent({ content, maxWidth, }) {
|
|
381
488
|
const blocks = React.useMemo(() => parseMarkdownBlocks(content), [content]);
|
|
382
489
|
return (_jsx(Box, { flexDirection: "column", children: blocks.map((block, i) => {
|
|
@@ -21,12 +21,7 @@ interface MessageListProps {
|
|
|
21
21
|
pendingApproval?: PendingApprovalHint | null;
|
|
22
22
|
/** Animation tick used to refresh in-progress elapsed counters. */
|
|
23
23
|
nowTick?: number;
|
|
24
|
-
/**
|
|
25
|
-
* Optional banner rendered as the first item of the scrollback Static
|
|
26
|
-
* stream. Committed to scrollback once on initial mount so it doesn't
|
|
27
|
-
* float between older messages and the live tail as the conversation
|
|
28
|
-
* progresses.
|
|
29
|
-
*/
|
|
24
|
+
/** Optional banner rendered as the first item in the app-controlled transcript. */
|
|
30
25
|
welcomeBanner?: React.ReactNode;
|
|
31
26
|
}
|
|
32
27
|
export declare function MessageList({ messages, streamingContent, streamingReasoning, streamingTools, streamingParts, terminalColumns, verboseTrace, pendingApproval, nowTick, welcomeBanner, }: MessageListProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -3,8 +3,8 @@ import React from "react";
|
|
|
3
3
|
import { Box, Static, Text } from "ink";
|
|
4
4
|
import { useTheme } from "./theme.js";
|
|
5
5
|
import { highlightCode, inferLang } from "./code-highlight.js";
|
|
6
|
-
import { MarkdownContent } from "./markdown.js";
|
|
7
|
-
import { buildTraceGroups,
|
|
6
|
+
import { MarkdownContent, StreamingMarkdown } from "./markdown.js";
|
|
7
|
+
import { buildTraceGroups, formatTracePath, traceGroupLabel } from "./trace-groups.js";
|
|
8
8
|
import { EDIT_COLLAPSED_DIFF_LINES, formatEditSuccessSummary, getEditDiffDetails } from "./edit-diff.js";
|
|
9
9
|
import { formatSubagentRoute } from "../agent/subagent-route-format.js";
|
|
10
10
|
export function MessageList({ messages, streamingContent, streamingReasoning, streamingTools, streamingParts, terminalColumns, verboseTrace, pendingApproval, nowTick, welcomeBanner, }) {
|
|
@@ -12,12 +12,6 @@ export function MessageList({ messages, streamingContent, streamingReasoning, st
|
|
|
12
12
|
streamingReasoning ||
|
|
13
13
|
streamingTools.length > 0 ||
|
|
14
14
|
streamingParts.length > 0);
|
|
15
|
-
// Committed messages enter ink's <Static> region immediately and never move
|
|
16
|
-
// between a live <Box> and Static. Moving the same message across those
|
|
17
|
-
// regions writes it into terminal scrollback twice. Mutable assistant output
|
|
18
|
-
// stays in StreamingMessage until the agent reports a final turn_end. Keep
|
|
19
|
-
// the Static instance identity stable across terminal resizes; remounting it
|
|
20
|
-
// would replay all previously-written scrollback items.
|
|
21
15
|
const staticItems = [];
|
|
22
16
|
if (welcomeBanner) {
|
|
23
17
|
staticItems.push({ kind: "welcome", key: "welcome" });
|
|
@@ -32,7 +26,7 @@ export function MessageList({ messages, streamingContent, streamingReasoning, st
|
|
|
32
26
|
showExpandHint: !hasStreaming && i === lastMessageIndex,
|
|
33
27
|
});
|
|
34
28
|
}
|
|
35
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: staticItems, children: (item) => {
|
|
29
|
+
return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsx(Static, { items: staticItems, children: (item) => {
|
|
36
30
|
if (item.kind === "welcome") {
|
|
37
31
|
return _jsx(React.Fragment, { children: welcomeBanner }, item.key);
|
|
38
32
|
}
|
|
@@ -47,13 +41,16 @@ function MessageItem({ message, terminalColumns, verboseTrace, showExpandHint, n
|
|
|
47
41
|
if (message.role === "error") {
|
|
48
42
|
return (_jsx(Box, { marginBottom: 1, flexDirection: "column", children: _jsxs(Text, { color: theme.error, children: ["Error: ", message.content] }) }));
|
|
49
43
|
}
|
|
44
|
+
if (message.syntheticKind === "ui_compact_summary") {
|
|
45
|
+
return _jsx(CompactionSummaryBlock, { message: message });
|
|
46
|
+
}
|
|
50
47
|
const hasVisibleAssistantContent = !!message.content ||
|
|
51
48
|
(message.toolCalls?.length ?? 0) > 0 ||
|
|
52
49
|
(message.parts?.length ?? 0) > 0 ||
|
|
53
50
|
(!!message.reasoning && verboseTrace);
|
|
54
51
|
if (!hasVisibleAssistantContent)
|
|
55
52
|
return null;
|
|
56
|
-
return (_jsxs(Box, { marginTop: 1, marginBottom: 1, flexDirection: "column", children: [message.reasoning && verboseTrace && _jsx(ReasoningTraceBlock, { reasoning: message.reasoning }), message.parts && message.parts.length > 0 ? (_jsx(MessageParts, { parts: message.parts, terminalColumns: terminalColumns, verboseTrace: verboseTrace, pendingApproval: undefined, showExpandHint: showExpandHint, nowTick: nowTick })) : (_jsxs(_Fragment, { children: [message.toolCalls && (_jsx(ToolsPart, { toolCalls: message.toolCalls, terminalColumns: terminalColumns, verboseTrace: verboseTrace, pendingApproval: undefined, showExpandHint: showExpandHint, nowTick: nowTick })), message.content && _jsx(MarkdownContent, { content: message.content })] })), verboseTrace && message.toolCalls && message.toolCalls.length > 0 && (_jsx(TurnDigest, { toolCalls: message.toolCalls }))] }));
|
|
53
|
+
return (_jsxs(Box, { marginTop: 1, marginBottom: 1, flexDirection: "column", children: [message.reasoning && verboseTrace && _jsx(ReasoningTraceBlock, { reasoning: message.reasoning }), message.parts && message.parts.length > 0 ? (_jsx(MessageParts, { parts: message.parts, terminalColumns: terminalColumns, verboseTrace: verboseTrace, pendingApproval: undefined, showExpandHint: showExpandHint, nowTick: nowTick })) : (_jsxs(_Fragment, { children: [message.toolCalls && (_jsx(ToolsPart, { toolCalls: message.toolCalls, terminalColumns: terminalColumns, verboseTrace: verboseTrace, pendingApproval: undefined, showExpandHint: showExpandHint, nowTick: nowTick })), message.content && _jsx(MarkdownContent, { content: message.content })] })), verboseTrace && message.toolCalls && message.toolCalls.length > 0 && (_jsx(TurnDigest, { toolCalls: message.toolCalls })), message.taskElapsedMs !== undefined && (_jsx(TaskDurationLine, { elapsedMs: message.taskElapsedMs }))] }));
|
|
57
54
|
}
|
|
58
55
|
function StreamingMessage({ content, reasoning, tools, parts, terminalColumns, verboseTrace, pendingApproval, nowTick, }) {
|
|
59
56
|
const deferredContent = React.useDeferredValue(content);
|
|
@@ -62,18 +59,26 @@ function StreamingMessage({ content, reasoning, tools, parts, terminalColumns, v
|
|
|
62
59
|
const visibleParts = deferredParts.length > 0
|
|
63
60
|
? deferredParts
|
|
64
61
|
: fallbackStreamingParts(deferredContent, tools);
|
|
65
|
-
return (_jsxs(Box, { flexDirection: "column", children: [deferredReasoning && verboseTrace && (_jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(ReasoningTraceBlock, { reasoning: deferredReasoning }) })), visibleParts.length > 0 && (_jsx(Box, { marginTop: 1, marginBottom: 1, flexDirection: "column", children: _jsx(MessageParts, { parts: visibleParts, terminalColumns: terminalColumns, verboseTrace: verboseTrace, pendingApproval: pendingApproval, showExpandHint: true, nowTick: nowTick, showActivity: true }) }))] }));
|
|
62
|
+
return (_jsxs(Box, { flexDirection: "column", children: [deferredReasoning && verboseTrace && (_jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(ReasoningTraceBlock, { reasoning: deferredReasoning }) })), visibleParts.length > 0 && (_jsx(Box, { marginTop: 1, marginBottom: 1, flexDirection: "column", children: _jsx(MessageParts, { parts: visibleParts, terminalColumns: terminalColumns, verboseTrace: verboseTrace, pendingApproval: pendingApproval, showExpandHint: true, nowTick: nowTick, showActivity: true, streaming: true }) }))] }));
|
|
66
63
|
}
|
|
67
|
-
function MessageParts({ parts, terminalColumns, verboseTrace, pendingApproval, showExpandHint, nowTick, showActivity = false, }) {
|
|
64
|
+
function MessageParts({ parts, terminalColumns, verboseTrace, pendingApproval, showExpandHint, nowTick, showActivity = false, streaming = false, }) {
|
|
68
65
|
const lastToolsPartIndex = findLastToolsPartIndex(parts);
|
|
66
|
+
const lastTextPartIndex = findLastTextPartIndex(parts);
|
|
69
67
|
return (_jsx(Box, { flexDirection: "column", children: parts.map((part, idx) => {
|
|
70
68
|
if (part.type === "text") {
|
|
71
|
-
return (_jsx(TimelineText, { content: part.content, compactTop: idx === 0, terminalColumns: terminalColumns }, `text-${idx}`));
|
|
69
|
+
return (_jsx(TimelineText, { content: part.content, compactTop: idx === 0, terminalColumns: terminalColumns, streaming: streaming && idx === lastTextPartIndex }, `text-${idx}`));
|
|
72
70
|
}
|
|
73
71
|
return (_jsx(ToolsPart, { toolCalls: part.toolCalls, terminalColumns: terminalColumns, verboseTrace: verboseTrace, pendingApproval: pendingApproval, showExpandHint: showExpandHint && idx === lastToolsPartIndex, compactTop: idx === 0, nowTick: nowTick, showActivity: showActivity && idx === lastToolsPartIndex }, `tools-${idx}`));
|
|
74
72
|
}) }));
|
|
75
73
|
}
|
|
76
|
-
function
|
|
74
|
+
function findLastTextPartIndex(parts) {
|
|
75
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
76
|
+
if (parts[i]?.type === "text")
|
|
77
|
+
return i;
|
|
78
|
+
}
|
|
79
|
+
return -1;
|
|
80
|
+
}
|
|
81
|
+
function TimelineText({ content, compactTop, terminalColumns, streaming = false, }) {
|
|
77
82
|
const theme = useTheme();
|
|
78
83
|
if (!content.trim())
|
|
79
84
|
return null;
|
|
@@ -81,7 +86,8 @@ function TimelineText({ content, compactTop, terminalColumns, }) {
|
|
|
81
86
|
// timeline gutter; pass the remaining width so wide blocks like tables size
|
|
82
87
|
// themselves against the actual content area instead of the raw terminal.
|
|
83
88
|
const available = terminalColumns ? Math.max(20, terminalColumns - 5) : undefined;
|
|
84
|
-
|
|
89
|
+
const trimmed = content.trim();
|
|
90
|
+
return (_jsxs(Box, { marginLeft: 2, marginTop: compactTop ? 0 : 1, children: [_jsx(Text, { color: theme.agent, children: "\u26EC " }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: streaming ? (_jsx(StreamingMarkdown, { content: trimmed, maxWidth: available })) : (_jsx(MarkdownContent, { content: trimmed, maxWidth: available })) })] }));
|
|
85
91
|
}
|
|
86
92
|
function ToolsPart({ toolCalls, terminalColumns, verboseTrace, pendingApproval, showExpandHint, compactTop = false, nowTick, showActivity = false, }) {
|
|
87
93
|
if (toolCalls.length === 0)
|
|
@@ -120,10 +126,10 @@ function TraceGroupList({ toolCalls, terminalColumns, pendingApproval, nowTick,
|
|
|
120
126
|
function TraceActivityLine({ group, pendingApproval, nowTick, terminalColumns, }) {
|
|
121
127
|
const theme = useTheme();
|
|
122
128
|
const waiting = isTraceGroupWaitingForApproval(group, pendingApproval);
|
|
123
|
-
|
|
129
|
+
void nowTick;
|
|
124
130
|
const labelWidth = Math.max(20, terminalColumns - 26);
|
|
125
131
|
const label = truncateVisual(traceGroupLabel(group), labelWidth);
|
|
126
|
-
return (_jsxs(Box, { marginLeft: 2, children: [_jsx(Text, { color: waiting ? theme.warning : theme.tracePending, children: "\u25CF " }), _jsxs(Text, { color: theme.traceDetail, children: [waiting ? "Waiting for approval" : "Working on", " "] }), _jsx(Text, { color: theme.traceAction, children: label })
|
|
132
|
+
return (_jsxs(Box, { marginLeft: 2, children: [_jsx(Text, { color: waiting ? theme.warning : theme.tracePending, children: "\u25CF " }), _jsxs(Text, { color: theme.traceDetail, children: [waiting ? "Waiting for approval" : "Working on", " "] }), _jsx(Text, { color: theme.traceAction, children: label })] }));
|
|
127
133
|
}
|
|
128
134
|
function TraceGroupBlock({ group, terminalColumns, pendingApproval, compactTop, nowTick, }) {
|
|
129
135
|
const theme = useTheme();
|
|
@@ -137,23 +143,30 @@ function TraceGroupBlock({ group, terminalColumns, pendingApproval, compactTop,
|
|
|
137
143
|
const allErrored = group.hasError && group.errorCount >= group.raw.length && !group.pending;
|
|
138
144
|
const titleColor = allErrored ? theme.error : theme.traceAction;
|
|
139
145
|
const detailColor = allErrored ? theme.error : theme.traceDetail;
|
|
140
|
-
const commandWidth = Math.max(14, terminalColumns - group.title.length -
|
|
146
|
+
const commandWidth = Math.max(14, terminalColumns - group.title.length - 20);
|
|
141
147
|
const detailWidth = Math.max(20, terminalColumns - 8);
|
|
142
148
|
const detailLines = group.previewLines.length > 0 ? group.previewLines : group.items;
|
|
143
|
-
|
|
149
|
+
// When a bash command is too long to fit on the title line, drop it onto its
|
|
150
|
+
// own indented rows so narrow splits keep the full command visible instead of
|
|
151
|
+
// silently truncating mid-flag.
|
|
152
|
+
const commandFitsInline = !group.command || visualWidth(group.command) <= commandWidth;
|
|
153
|
+
const wrappedCommandLines = group.command && !commandFitsInline
|
|
154
|
+
? wrapByVisualWidth(group.command, Math.max(10, detailWidth - 2))
|
|
155
|
+
: null;
|
|
156
|
+
return (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: compactTop ? 0 : 1, children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, color: titleColor, children: group.title }), group.command && commandFitsInline ? (_jsxs(Text, { color: theme.traceCommand, children: [" ", group.command] })) : !group.command && group.count !== undefined && group.noun ? (_jsxs(Text, { color: theme.traceCount, children: [" ", group.count, " ", group.noun] })) : null, status && _jsxs(Text, { color: status.color, children: [" ", status.text] })] }), wrappedCommandLines && (_jsx(Box, { flexDirection: "column", marginLeft: 2, children: wrappedCommandLines.map((seg, idx) => (_jsx(Text, { color: theme.traceCommand, children: seg }, `cmd-${idx}`))) })), detailLines.length > 0 && (_jsx(Box, { flexDirection: "column", marginLeft: 2, children: detailLines.map((line, index) => (_jsxs(Box, { marginLeft: index === 0 ? 0 : 2, children: [index === 0 && _jsx(Text, { color: theme.traceDetail, children: "\u21B3 " }), _jsx(Text, { color: detailColor, children: truncateVisual(line, detailWidth - (index === 0 ? 2 : 0)) })] }, index))) })), group.errorLines.length > 0 && (_jsx(Box, { flexDirection: "column", marginLeft: 2, children: group.errorLines.map((line, index) => (_jsxs(Box, { marginLeft: index === 0 ? 0 : 2, children: [index === 0 && _jsx(Text, { color: theme.traceDetail, children: "\u21B3 " }), _jsx(Text, { color: theme.error, children: truncateVisual(line, detailWidth - (index === 0 ? 2 : 0)) })] }, `error-${index}`))) })), group.omitted > 0 && (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { color: theme.traceDetail, children: ["... ", group.omitted, " more, Ctrl+O to view"] }) }))] }));
|
|
144
157
|
}
|
|
145
158
|
function EditTraceBlock({ tool, details, terminalColumns, compactTop, status, }) {
|
|
146
159
|
const theme = useTheme();
|
|
147
160
|
const path = formatTracePath(details.path ?? tool.args.path ?? "");
|
|
148
161
|
const pathWidth = Math.max(14, terminalColumns - 12);
|
|
149
|
-
return (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: compactTop ? 0 : 1, children: [_jsxs(
|
|
162
|
+
return (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: compactTop ? 0 : 1, children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, color: theme.traceAction, children: "Edit" }), path && _jsxs(Text, { color: theme.traceCommand, children: [" ", truncateVisual(path, pathWidth)] }), status && _jsxs(Text, { color: status.color, children: [" ", status.text] })] }), _jsxs(Box, { marginLeft: 2, children: [_jsx(Text, { color: theme.traceDetail, children: "\u23BF " }), _jsx(Text, { color: theme.success, children: formatEditSuccessSummary(details) })] }), _jsx(DiffBlock, { diff: details.diff, terminalColumns: terminalColumns, maxLines: EDIT_COLLAPSED_DIFF_LINES, verbose: false, showExpandHint: true })] }));
|
|
150
163
|
}
|
|
151
164
|
function traceGroupStatus(group, waitingApproval, theme, nowTick) {
|
|
152
165
|
if (waitingApproval)
|
|
153
166
|
return { text: "waiting for approval", color: theme.warning };
|
|
154
167
|
if (group.pending) {
|
|
155
|
-
|
|
156
|
-
return { text:
|
|
168
|
+
void nowTick;
|
|
169
|
+
return { text: "running", color: theme.tracePending };
|
|
157
170
|
}
|
|
158
171
|
if (group.hasError) {
|
|
159
172
|
const count = group.errorCount || 1;
|
|
@@ -186,15 +199,23 @@ function ReasoningTraceBlock({ reasoning }) {
|
|
|
186
199
|
const lines = React.useMemo(() => reasoning.split("\n").filter((l) => l.trim() !== ""), [reasoning]);
|
|
187
200
|
return (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginBottom: 1, children: [_jsxs(Text, { color: theme.thinkingDim, dimColor: true, children: ["\u273B Reasoning trace", lines.length > 0 ? ` · ${lines.length} line${lines.length === 1 ? "" : "s"}` : ""] }), lines.map((line, i) => (_jsx(Text, { color: theme.thinkingDim, dimColor: true, italic: true, children: line }, i)))] }));
|
|
188
201
|
}
|
|
202
|
+
function CompactionSummaryBlock({ message }) {
|
|
203
|
+
const theme = useTheme();
|
|
204
|
+
const status = message.content.replace(/^✓\s*/, "").trim() || "Session compacted";
|
|
205
|
+
const summary = message.compactionSummary?.trim();
|
|
206
|
+
return (_jsxs(Box, { marginTop: 1, marginBottom: 1, flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: theme.success, bold: true, children: "\u2713 " }), _jsx(Text, { color: theme.accent, bold: true, children: "Compaction" }), _jsxs(Text, { color: theme.muted, children: [" \u00B7 ", status] })] }), summary && (_jsx(Box, { marginTop: 1, paddingLeft: 3, flexDirection: "column", children: _jsx(MarkdownContent, { content: summary }) }))] }));
|
|
207
|
+
}
|
|
189
208
|
function UserMessageBlock({ content, terminalColumns }) {
|
|
190
209
|
const theme = useTheme();
|
|
191
|
-
// Rail
|
|
210
|
+
// Rail and its right gutter must share the bubble background; otherwise the
|
|
211
|
+
// terminal background shows up as a dark seam between rail and message.
|
|
212
|
+
const railWidth = 2;
|
|
192
213
|
const horizontalRoom = Math.max(20, terminalColumns - 2);
|
|
193
|
-
const bubbleTextWidth = Math.max(1, horizontalRoom - 2);
|
|
214
|
+
const bubbleTextWidth = Math.max(1, horizontalRoom - railWidth - 2);
|
|
194
215
|
const wrappedLines = content
|
|
195
216
|
.split("\n")
|
|
196
217
|
.flatMap((line) => wrapByVisualWidth(line, bubbleTextWidth));
|
|
197
|
-
return (_jsx(Box, { flexDirection: "column", children: wrappedLines.map((line, index) => (_jsxs(Box, { children: [_jsx(Text, { color: theme.userRail, children: "
|
|
218
|
+
return (_jsx(Box, { flexDirection: "column", children: wrappedLines.map((line, index) => (_jsxs(Box, { children: [_jsx(Text, { backgroundColor: theme.userMessageBg, color: theme.userRail, children: index === 0 ? "▌ " : " " }), _jsx(Text, { backgroundColor: theme.userMessageBg, color: theme.userMessageText, children: ` ${padVisual(line || " ", bubbleTextWidth)} ` })] }, index))) }));
|
|
198
219
|
}
|
|
199
220
|
const TOOL_DISPLAY_NAMES = {
|
|
200
221
|
read: "Read",
|
|
@@ -403,8 +424,8 @@ function ToolCallDisplay({ toolCall, isStreaming, verbose, terminalColumns, show
|
|
|
403
424
|
? theme.toolPending
|
|
404
425
|
: theme.user;
|
|
405
426
|
const name = displayToolName(toolCall.name);
|
|
406
|
-
// Compose summary: pending tools
|
|
407
|
-
//
|
|
427
|
+
// Compose summary: pending tools stay compact; waiting-for-approval gets an
|
|
428
|
+
// explicit badge so the trail survives the dialog closing.
|
|
408
429
|
let summary;
|
|
409
430
|
let summaryColor = theme.muted;
|
|
410
431
|
if (waitingApproval) {
|
|
@@ -412,8 +433,8 @@ function ToolCallDisplay({ toolCall, isStreaming, verbose, terminalColumns, show
|
|
|
412
433
|
summaryColor = theme.warning;
|
|
413
434
|
}
|
|
414
435
|
else if (toolCall.result === undefined && toolCall.startedAt) {
|
|
415
|
-
|
|
416
|
-
summary =
|
|
436
|
+
void nowTick;
|
|
437
|
+
summary = "running";
|
|
417
438
|
summaryColor = theme.toolPending;
|
|
418
439
|
}
|
|
419
440
|
else {
|
|
@@ -529,7 +550,8 @@ function DiffBlock({ diff, terminalColumns, maxLines, verbose, showExpandHint, }
|
|
|
529
550
|
// row beneath it.
|
|
530
551
|
const bandWidth = Math.max(10, terminalColumns - 7);
|
|
531
552
|
const contentWidth = Math.max(1, bandWidth - prefixWidth);
|
|
532
|
-
|
|
553
|
+
const blankPrefix = " ".repeat(prefixWidth);
|
|
554
|
+
return (_jsxs(Box, { flexDirection: "column", marginLeft: leftMargin, children: [shown.flatMap((line, i) => {
|
|
533
555
|
const bg = line.type === "add"
|
|
534
556
|
? theme.diffAdd
|
|
535
557
|
: line.type === "remove"
|
|
@@ -537,10 +559,17 @@ function DiffBlock({ diff, terminalColumns, maxLines, verbose, showExpandHint, }
|
|
|
537
559
|
: undefined;
|
|
538
560
|
const sign = line.type === "add" ? "+" : line.type === "remove" ? "-" : " ";
|
|
539
561
|
const numStr = String(line.num).padStart(numWidth, " ");
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
562
|
+
// Soft-wrap long lines at the terminal-derived content width so narrow
|
|
563
|
+
// splits still show the full content. Continuation rows reuse the same
|
|
564
|
+
// background but blank out the gutter (no line number, no +/-) so a
|
|
565
|
+
// reader can tell at a glance which rows belong to the same logical
|
|
566
|
+
// diff line.
|
|
567
|
+
const segments = wrapByVisualWidth(line.content, contentWidth);
|
|
568
|
+
return segments.map((segment, segIdx) => {
|
|
569
|
+
const padded = padVisual(segment, contentWidth);
|
|
570
|
+
const prefix = segIdx === 0 ? ` ${numStr} ${sign} ` : blankPrefix;
|
|
571
|
+
return (_jsx(Text, { backgroundColor: bg, color: theme.userMessageText, children: `${prefix}${padded}` }, `${i}-${segIdx}`));
|
|
572
|
+
});
|
|
544
573
|
}), _jsx(TruncationHint, { remaining: remaining, verbose: verbose, showExpandHint: showExpandHint })] }));
|
|
545
574
|
}
|
|
546
575
|
/**
|
|
@@ -554,6 +583,10 @@ function TurnDigest({ toolCalls }) {
|
|
|
554
583
|
return null;
|
|
555
584
|
return (_jsx(Box, { marginLeft: 2, marginTop: 1, children: _jsx(Text, { color: theme.muted, dimColor: true, children: digest }) }));
|
|
556
585
|
}
|
|
586
|
+
function TaskDurationLine({ elapsedMs }) {
|
|
587
|
+
const theme = useTheme();
|
|
588
|
+
return (_jsx(Box, { marginLeft: 2, marginTop: 1, children: _jsxs(Text, { color: theme.muted, dimColor: true, children: ["Task duration: ", formatDuration(elapsedMs)] }) }));
|
|
589
|
+
}
|
|
557
590
|
function buildDigest(toolCalls) {
|
|
558
591
|
const paths = new Set();
|
|
559
592
|
let added = 0;
|
|
@@ -595,6 +628,24 @@ function buildDigest(toolCalls) {
|
|
|
595
628
|
: "";
|
|
596
629
|
return `↳ ${verb} ${paths.size} file${paths.size === 1 ? "" : "s"}${stats} — ${pathDisplay}`;
|
|
597
630
|
}
|
|
631
|
+
function formatDuration(ms) {
|
|
632
|
+
if (!Number.isFinite(ms) || ms <= 0)
|
|
633
|
+
return "0s";
|
|
634
|
+
if (ms < 1000)
|
|
635
|
+
return `${Math.max(1, Math.round(ms))}ms`;
|
|
636
|
+
const seconds = ms / 1000;
|
|
637
|
+
if (seconds < 10)
|
|
638
|
+
return `${seconds.toFixed(1)}s`;
|
|
639
|
+
if (seconds < 60)
|
|
640
|
+
return `${Math.round(seconds)}s`;
|
|
641
|
+
let minutes = Math.floor(seconds / 60);
|
|
642
|
+
let remSec = Math.round(seconds - minutes * 60);
|
|
643
|
+
if (remSec >= 60) {
|
|
644
|
+
minutes += Math.floor(remSec / 60);
|
|
645
|
+
remSec %= 60;
|
|
646
|
+
}
|
|
647
|
+
return remSec === 0 ? `${minutes}m` : `${minutes}m ${remSec}s`;
|
|
648
|
+
}
|
|
598
649
|
function truncateVisual(str, maxWidth) {
|
|
599
650
|
if (maxWidth <= 0)
|
|
600
651
|
return "";
|
|
@@ -10,7 +10,6 @@ export function ModelPicker({ registry, current, recent, onSelect, onCancel }) {
|
|
|
10
10
|
const termHeight = stdout?.rows || 24;
|
|
11
11
|
const maxVisible = Math.max(5, termHeight - 10);
|
|
12
12
|
const [rawOptions, setRawOptions] = useState(() => buildLocalModelOptions(registry, current, recent));
|
|
13
|
-
const [refreshing, setRefreshing] = useState(false);
|
|
14
13
|
const [query, setQuery] = useState("");
|
|
15
14
|
const [selectedIndex, setSelectedIndex] = useState(() => preferredModelIndex(buildLocalModelOptions(registry, current, recent), current));
|
|
16
15
|
useEffect(() => {
|
|
@@ -63,10 +62,8 @@ export function ModelPicker({ registry, current, recent, onSelect, onCancel }) {
|
|
|
63
62
|
const currentIndex = preferredModelIndex(opts, current);
|
|
64
63
|
return index === preferredModelIndex(localOptions, current) ? currentIndex : Math.min(index, Math.max(0, opts.length - 1));
|
|
65
64
|
});
|
|
66
|
-
setRefreshing(false);
|
|
67
65
|
}
|
|
68
66
|
}
|
|
69
|
-
setRefreshing(true);
|
|
70
67
|
void refreshRemote();
|
|
71
68
|
return () => {
|
|
72
69
|
cancelled = true;
|
|
@@ -116,7 +113,7 @@ export function ModelPicker({ registry, current, recent, onSelect, onCancel }) {
|
|
|
116
113
|
});
|
|
117
114
|
const start = Math.max(0, Math.min(selectedIndex, options.length - maxVisible));
|
|
118
115
|
const visible = options.slice(start, start + maxVisible);
|
|
119
|
-
return (_jsxs(Box, { flexDirection: "column", marginY: 1, paddingX: 1, borderStyle: "round", borderColor: theme.borderActive, children: [_jsx(Text, { bold: true, color: theme.accent, children: "Select Model" }), _jsx(SearchField, { query: query, placeholder: "Type to search models..." }), _jsx(Text, { color: theme.muted, children: "\u2191/\u2193 navigate \u00B7 Enter select \u00B7 Esc cancel \u00B7 Backspace clear" }),
|
|
116
|
+
return (_jsxs(Box, { flexDirection: "column", marginY: 1, paddingX: 1, borderStyle: "round", borderColor: theme.borderActive, children: [_jsx(Text, { bold: true, color: theme.accent, children: "Select Model" }), _jsx(SearchField, { query: query, placeholder: "Type to search models..." }), _jsx(Text, { color: theme.muted, children: "\u2191/\u2193 navigate \u00B7 Enter select \u00B7 Esc cancel \u00B7 Backspace clear" }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [options.length === 0 && (_jsxs(Text, { color: theme.muted, children: ["No models match \"", query, "\""] })), visible.map((opt, i) => {
|
|
120
117
|
const actualIndex = start + i;
|
|
121
118
|
const isSelected = actualIndex === selectedIndex;
|
|
122
119
|
return (_jsxs(Box, { children: [_jsxs(Text, { color: isSelected ? theme.accent : undefined, children: [isSelected ? "> " : " ", opt.label] }), _jsx(Box, { marginLeft: 1, children: _jsx(Text, { color: theme.muted, dimColor: true, children: opt.providerBadge }) }), opt.id === current && (_jsx(Box, { marginLeft: 1, children: _jsx(Text, { color: theme.accent, children: "\u25CF" }) }))] }, opt.id));
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ResolvedTheme } from "./detect-theme.js";
|
|
2
|
+
import type { SessionSummary } from "../session.js";
|
|
3
|
+
export interface RunSessionPickerOptions {
|
|
4
|
+
currentCwd: string;
|
|
5
|
+
currentSessions: SessionSummary[];
|
|
6
|
+
allSessions: SessionSummary[];
|
|
7
|
+
resolvedTheme: ResolvedTheme;
|
|
8
|
+
themeOverrides?: Record<string, string>;
|
|
9
|
+
}
|
|
10
|
+
export declare function runSessionPicker(options: RunSessionPickerOptions): Promise<string | undefined>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render } from "ink";
|
|
3
|
+
import { SessionPicker } from "./session-picker.js";
|
|
4
|
+
import { ThemeProvider, paletteFor } from "./theme.js";
|
|
5
|
+
export async function runSessionPicker(options) {
|
|
6
|
+
const theme = paletteFor(options.resolvedTheme, options.themeOverrides);
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
let done = false;
|
|
9
|
+
const finish = (value) => {
|
|
10
|
+
if (done)
|
|
11
|
+
return;
|
|
12
|
+
done = true;
|
|
13
|
+
try {
|
|
14
|
+
instance.unmount();
|
|
15
|
+
}
|
|
16
|
+
catch { /* ignore */ }
|
|
17
|
+
resolve(value);
|
|
18
|
+
};
|
|
19
|
+
const instance = render(_jsx(ThemeProvider, { value: theme, children: _jsx(SessionPicker, { currentCwd: options.currentCwd, currentSessions: options.currentSessions, allSessions: options.allSessions, onSelect: (file) => finish(file), onCancel: () => finish(undefined) }) }));
|
|
20
|
+
void instance.waitUntilExit().then(() => finish(undefined));
|
|
21
|
+
});
|
|
22
|
+
}
|
package/dist/tui-ink/run.js
CHANGED
|
@@ -4,7 +4,7 @@ import chalk from "chalk";
|
|
|
4
4
|
import { App } from "./app.js";
|
|
5
5
|
import { warmHighlighter } from "./code-highlight.js";
|
|
6
6
|
export async function runTui(agent, args, options = {}) {
|
|
7
|
-
// Kick off shiki load before the first code block
|
|
7
|
+
// Kick off shiki load before the first code block is rendered. Fire and
|
|
8
8
|
// forget — CodeBlock's lazy init falls back to raw lines if this isn't ready
|
|
9
9
|
// yet, so callers don't need to await it.
|
|
10
10
|
warmHighlighter();
|
|
@@ -18,6 +18,11 @@ export async function runTui(agent, args, options = {}) {
|
|
|
18
18
|
// teardown so it lands in the real shell scrollback (Claude-Code style).
|
|
19
19
|
exitSummary = summary;
|
|
20
20
|
} }), {
|
|
21
|
+
// Bubble owns Ctrl+C so it can route both raw ETX and kitty keyboard
|
|
22
|
+
// Ctrl+C through App.requestExit(). Ink's default only exits reliably
|
|
23
|
+
// for raw "\x03"; with kitty keyboard it can swallow the parsed
|
|
24
|
+
// ctrl+c event before our useInput handlers see it.
|
|
25
|
+
exitOnCtrlC: false,
|
|
21
26
|
kittyKeyboard: {
|
|
22
27
|
mode: "enabled",
|
|
23
28
|
flags: ["disambiguateEscapeCodes"],
|
|
@@ -36,7 +41,7 @@ export async function runTui(agent, args, options = {}) {
|
|
|
36
41
|
}
|
|
37
42
|
}
|
|
38
43
|
function formatExitSummary(summary) {
|
|
39
|
-
const label = "Total duration
|
|
44
|
+
const label = "Total duration:";
|
|
40
45
|
return chalk.dim(`${label} ${formatWallMs(summary.wallMs)}`);
|
|
41
46
|
}
|
|
42
47
|
function formatWallMs(ms) {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { SessionSummary } from "../session.js";
|
|
2
|
+
export type SessionPickerMode = "current" | "all";
|
|
3
|
+
export interface SessionPickerProps {
|
|
4
|
+
currentCwd: string;
|
|
5
|
+
currentSessions: SessionSummary[];
|
|
6
|
+
allSessions: SessionSummary[];
|
|
7
|
+
onSelect: (file: string) => void;
|
|
8
|
+
onCancel: () => void;
|
|
9
|
+
}
|
|
10
|
+
export declare function SessionPicker({ currentCwd, currentSessions, allSessions, onSelect, onCancel }: SessionPickerProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo, useState } from "react";
|
|
3
|
+
import { Box, Text, useInput, useStdout } from "ink";
|
|
4
|
+
import { useTheme } from "./theme.js";
|
|
5
|
+
import { formatRelativeTime } from "./recent-activity.js";
|
|
6
|
+
export function SessionPicker({ currentCwd, currentSessions, allSessions, onSelect, onCancel }) {
|
|
7
|
+
const theme = useTheme();
|
|
8
|
+
const { stdout } = useStdout();
|
|
9
|
+
const termHeight = stdout?.rows || 24;
|
|
10
|
+
const maxVisible = Math.max(6, termHeight - 10);
|
|
11
|
+
const [mode, setMode] = useState("current");
|
|
12
|
+
const [selectedSessionIdx, setSelectedSessionIdx] = useState(0);
|
|
13
|
+
const rows = useMemo(() => buildRows(mode, currentCwd, currentSessions, allSessions), [mode, currentCwd, currentSessions, allSessions]);
|
|
14
|
+
const sessionRowIndices = useMemo(() => rows.map((row, i) => (row.type === "session" ? i : -1)).filter((i) => i >= 0), [rows]);
|
|
15
|
+
const clampedIdx = sessionRowIndices.length === 0
|
|
16
|
+
? 0
|
|
17
|
+
: Math.min(selectedSessionIdx, sessionRowIndices.length - 1);
|
|
18
|
+
const selectedRowIndex = sessionRowIndices[clampedIdx] ?? -1;
|
|
19
|
+
useInput((input, key) => {
|
|
20
|
+
if (key.escape) {
|
|
21
|
+
onCancel();
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (key.tab) {
|
|
25
|
+
setMode((m) => (m === "current" ? "all" : "current"));
|
|
26
|
+
setSelectedSessionIdx(0);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (key.return) {
|
|
30
|
+
const row = rows[selectedRowIndex];
|
|
31
|
+
if (row?.type === "session" && row.session)
|
|
32
|
+
onSelect(row.session.file);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (key.upArrow) {
|
|
36
|
+
setSelectedSessionIdx((i) => Math.max(0, i - 1));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (key.downArrow) {
|
|
40
|
+
setSelectedSessionIdx((i) => Math.min(Math.max(0, sessionRowIndices.length - 1), i + 1));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
// Window the visible rows around the selected session.
|
|
45
|
+
const start = clampWindowStart(rows, selectedRowIndex, maxVisible);
|
|
46
|
+
const visible = rows.slice(start, start + maxVisible);
|
|
47
|
+
const modeLabel = mode === "current" ? "Current dir" : "All directories";
|
|
48
|
+
const totalSessions = sessionRowIndices.length;
|
|
49
|
+
return (_jsxs(Box, { flexDirection: "column", marginY: 1, paddingX: 1, borderStyle: "round", borderColor: theme.borderActive, children: [_jsx(Text, { bold: true, color: theme.accent, children: "Resume session" }), _jsxs(Text, { color: theme.muted, children: ["View: ", _jsx(Text, { color: theme.accent, children: modeLabel }), " · ", totalSessions, " session", totalSessions === 1 ? "" : "s"] }), _jsx(Text, { color: theme.muted, children: "\u2191/\u2193 navigate \u00B7 Enter resume \u00B7 Tab toggle scope \u00B7 Esc start fresh" }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [totalSessions === 0 && (_jsx(Text, { color: theme.muted, children: mode === "current"
|
|
50
|
+
? "No previous sessions in this directory."
|
|
51
|
+
: "No previous sessions found." })), visible.map((row, i) => {
|
|
52
|
+
const actualIndex = start + i;
|
|
53
|
+
if (row.type === "header") {
|
|
54
|
+
return (_jsx(Box, { marginTop: i === 0 ? 0 : 1, children: _jsx(Text, { color: theme.muted, bold: true, children: row.label }) }, `h-${actualIndex}`));
|
|
55
|
+
}
|
|
56
|
+
const session = row.session;
|
|
57
|
+
const isSelected = actualIndex === selectedRowIndex;
|
|
58
|
+
const time = formatRelativeTime(session.mtime).padEnd(9);
|
|
59
|
+
return (_jsxs(Box, { children: [_jsxs(Text, { color: isSelected ? theme.accent : undefined, children: [isSelected ? "> " : " ", time, " ", truncate(session.firstUserMessage, 60)] }), _jsx(Box, { marginLeft: 1, children: _jsxs(Text, { color: theme.muted, dimColor: true, children: ["\u00B7 ", session.messageCount, " msg", session.messageCount === 1 ? "" : "s"] }) })] }, session.file));
|
|
60
|
+
})] })] }));
|
|
61
|
+
}
|
|
62
|
+
function buildRows(mode, currentCwd, currentSessions, allSessions) {
|
|
63
|
+
if (mode === "current") {
|
|
64
|
+
if (currentSessions.length === 0)
|
|
65
|
+
return [];
|
|
66
|
+
return [
|
|
67
|
+
{ type: "header", label: currentCwd },
|
|
68
|
+
...currentSessions.map((session) => ({ type: "session", session })),
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
const grouped = new Map();
|
|
72
|
+
for (const session of allSessions) {
|
|
73
|
+
const key = session.cwdLabel;
|
|
74
|
+
const list = grouped.get(key);
|
|
75
|
+
if (list)
|
|
76
|
+
list.push(session);
|
|
77
|
+
else
|
|
78
|
+
grouped.set(key, [session]);
|
|
79
|
+
}
|
|
80
|
+
const sortedGroups = Array.from(grouped.entries()).sort((a, b) => {
|
|
81
|
+
if (a[0] === currentCwd)
|
|
82
|
+
return -1;
|
|
83
|
+
if (b[0] === currentCwd)
|
|
84
|
+
return 1;
|
|
85
|
+
const aLatest = a[1][0]?.mtime ?? 0;
|
|
86
|
+
const bLatest = b[1][0]?.mtime ?? 0;
|
|
87
|
+
return bLatest - aLatest;
|
|
88
|
+
});
|
|
89
|
+
const rows = [];
|
|
90
|
+
for (const [label, sessions] of sortedGroups) {
|
|
91
|
+
rows.push({ type: "header", label });
|
|
92
|
+
for (const session of sessions)
|
|
93
|
+
rows.push({ type: "session", session });
|
|
94
|
+
}
|
|
95
|
+
return rows;
|
|
96
|
+
}
|
|
97
|
+
function clampWindowStart(rows, selectedRowIndex, maxVisible) {
|
|
98
|
+
if (rows.length <= maxVisible)
|
|
99
|
+
return 0;
|
|
100
|
+
if (selectedRowIndex < 0)
|
|
101
|
+
return 0;
|
|
102
|
+
const half = Math.floor(maxVisible / 2);
|
|
103
|
+
let start = Math.max(0, selectedRowIndex - half);
|
|
104
|
+
if (start + maxVisible > rows.length)
|
|
105
|
+
start = rows.length - maxVisible;
|
|
106
|
+
return Math.max(0, start);
|
|
107
|
+
}
|
|
108
|
+
function truncate(text, max) {
|
|
109
|
+
if (text.length <= max)
|
|
110
|
+
return text.padEnd(max);
|
|
111
|
+
return text.slice(0, max - 1) + "…";
|
|
112
|
+
}
|