@bubblebrain-ai/bubble 0.0.9 → 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.
Files changed (153) hide show
  1. package/dist/agent.d.ts +1 -0
  2. package/dist/agent.js +5 -0
  3. package/dist/cli.d.ts +10 -0
  4. package/dist/cli.js +31 -3
  5. package/dist/feedback/collect.d.ts +7 -0
  6. package/dist/feedback/collect.js +119 -0
  7. package/dist/feedback/config.d.ts +14 -0
  8. package/dist/feedback/config.js +16 -0
  9. package/dist/feedback/redact.d.ts +1 -0
  10. package/dist/feedback/redact.js +25 -0
  11. package/dist/feedback/submit.d.ts +6 -0
  12. package/dist/feedback/submit.js +43 -0
  13. package/dist/feedback/types.d.ts +22 -0
  14. package/dist/feishu/agent-host/approval-card.d.ts +11 -0
  15. package/dist/feishu/agent-host/approval-card.js +46 -0
  16. package/dist/feishu/agent-host/approval-ui.d.ts +59 -0
  17. package/dist/feishu/agent-host/approval-ui.js +214 -0
  18. package/dist/feishu/agent-host/run-driver.d.ts +51 -0
  19. package/dist/feishu/agent-host/run-driver.js +295 -0
  20. package/dist/feishu/agent-host/runtime-deps.d.ts +33 -0
  21. package/dist/feishu/agent-host/runtime-deps.js +8 -0
  22. package/dist/feishu/card/budget.d.ts +40 -0
  23. package/dist/feishu/card/budget.js +134 -0
  24. package/dist/feishu/card/renderer.d.ts +29 -0
  25. package/dist/feishu/card/renderer.js +245 -0
  26. package/dist/feishu/card/run-state-types.d.ts +49 -0
  27. package/dist/feishu/card/run-state-types.js +15 -0
  28. package/dist/feishu/card/run-state.d.ts +21 -0
  29. package/dist/feishu/card/run-state.js +217 -0
  30. package/dist/feishu/channel/channel.d.ts +52 -0
  31. package/dist/feishu/channel/channel.js +74 -0
  32. package/dist/feishu/config.d.ts +24 -0
  33. package/dist/feishu/config.js +97 -0
  34. package/dist/feishu/format.d.ts +6 -0
  35. package/dist/feishu/format.js +14 -0
  36. package/dist/feishu/index.d.ts +4 -0
  37. package/dist/feishu/index.js +4 -0
  38. package/dist/feishu/logger.d.ts +31 -0
  39. package/dist/feishu/logger.js +62 -0
  40. package/dist/feishu/paths.d.ts +12 -0
  41. package/dist/feishu/paths.js +38 -0
  42. package/dist/feishu/process-registry.d.ts +29 -0
  43. package/dist/feishu/process-registry.js +90 -0
  44. package/dist/feishu/router/commands.d.ts +38 -0
  45. package/dist/feishu/router/commands.js +285 -0
  46. package/dist/feishu/router/event-router.d.ts +40 -0
  47. package/dist/feishu/router/event-router.js +208 -0
  48. package/dist/feishu/router/whitelist.d.ts +23 -0
  49. package/dist/feishu/router/whitelist.js +20 -0
  50. package/dist/feishu/runtime/active-runs.d.ts +32 -0
  51. package/dist/feishu/runtime/active-runs.js +84 -0
  52. package/dist/feishu/runtime/pending-queue.d.ts +36 -0
  53. package/dist/feishu/runtime/pending-queue.js +98 -0
  54. package/dist/feishu/runtime/process-pool.d.ts +29 -0
  55. package/dist/feishu/runtime/process-pool.js +49 -0
  56. package/dist/feishu/schema.d.ts +17 -0
  57. package/dist/feishu/schema.js +252 -0
  58. package/dist/feishu/scope/scope-registry.d.ts +39 -0
  59. package/dist/feishu/scope/scope-registry.js +148 -0
  60. package/dist/feishu/scope/session-binder.d.ts +44 -0
  61. package/dist/feishu/scope/session-binder.js +100 -0
  62. package/dist/feishu/scope/session-store.d.ts +24 -0
  63. package/dist/feishu/scope/session-store.js +73 -0
  64. package/dist/feishu/secrets.d.ts +37 -0
  65. package/dist/feishu/secrets.js +129 -0
  66. package/dist/feishu/serve.d.ts +12 -0
  67. package/dist/feishu/serve.js +288 -0
  68. package/dist/feishu/types.d.ts +75 -0
  69. package/dist/feishu/types.js +23 -0
  70. package/dist/feishu/wizard.d.ts +24 -0
  71. package/dist/feishu/wizard.js +121 -0
  72. package/dist/main.js +78 -29
  73. package/dist/model-catalog.js +3 -0
  74. package/dist/session.d.ts +11 -0
  75. package/dist/session.js +88 -2
  76. package/dist/slash-commands/commands.js +13 -0
  77. package/dist/slash-commands/feishu.d.ts +17 -0
  78. package/dist/slash-commands/feishu.js +400 -0
  79. package/dist/slash-commands/types.d.ts +3 -1
  80. package/dist/tui-ink/app.js +218 -60
  81. package/dist/tui-ink/code-highlight.js +2 -3
  82. package/dist/tui-ink/detect-theme.d.ts +1 -18
  83. package/dist/tui-ink/detect-theme.js +1 -37
  84. package/dist/tui-ink/display-history.d.ts +20 -3
  85. package/dist/tui-ink/display-history.js +26 -27
  86. package/dist/tui-ink/feedback-dialog.d.ts +19 -0
  87. package/dist/tui-ink/feedback-dialog.js +123 -0
  88. package/dist/tui-ink/feishu-setup-picker.d.ts +5 -0
  89. package/dist/tui-ink/feishu-setup-picker.js +261 -0
  90. package/dist/tui-ink/input-box.d.ts +3 -0
  91. package/dist/tui-ink/input-box.js +27 -0
  92. package/dist/tui-ink/input-history.js +3 -5
  93. package/dist/tui-ink/markdown.d.ts +32 -0
  94. package/dist/tui-ink/markdown.js +111 -4
  95. package/dist/tui-ink/message-list.d.ts +1 -6
  96. package/dist/tui-ink/message-list.js +85 -34
  97. package/dist/tui-ink/model-picker.js +1 -4
  98. package/dist/tui-ink/run-session-picker.d.ts +10 -0
  99. package/dist/tui-ink/run-session-picker.js +22 -0
  100. package/dist/tui-ink/run.js +7 -2
  101. package/dist/tui-ink/session-picker.d.ts +10 -0
  102. package/dist/tui-ink/session-picker.js +112 -0
  103. package/dist/tui-ink/terminal-mouse.d.ts +4 -0
  104. package/dist/tui-ink/terminal-mouse.js +23 -0
  105. package/dist/tui-ink/trace-groups.js +25 -2
  106. package/dist/tui-ink/welcome.js +2 -4
  107. package/package.json +4 -5
  108. package/dist/tui/clipboard.d.ts +0 -1
  109. package/dist/tui/clipboard.js +0 -53
  110. package/dist/tui/display-history.d.ts +0 -44
  111. package/dist/tui/display-history.js +0 -243
  112. package/dist/tui/escape-confirmation.d.ts +0 -15
  113. package/dist/tui/escape-confirmation.js +0 -30
  114. package/dist/tui/file-mentions.d.ts +0 -29
  115. package/dist/tui/file-mentions.js +0 -174
  116. package/dist/tui/global-key-router.d.ts +0 -3
  117. package/dist/tui/global-key-router.js +0 -87
  118. package/dist/tui/image-paste.d.ts +0 -95
  119. package/dist/tui/image-paste.js +0 -505
  120. package/dist/tui/markdown-inline.d.ts +0 -22
  121. package/dist/tui/markdown-inline.js +0 -68
  122. package/dist/tui/markdown-theme-rules.d.ts +0 -23
  123. package/dist/tui/markdown-theme-rules.js +0 -164
  124. package/dist/tui/markdown-theme.d.ts +0 -5
  125. package/dist/tui/markdown-theme.js +0 -27
  126. package/dist/tui/opencode-spinner.d.ts +0 -21
  127. package/dist/tui/opencode-spinner.js +0 -216
  128. package/dist/tui/prompt-keybindings.d.ts +0 -42
  129. package/dist/tui/prompt-keybindings.js +0 -35
  130. package/dist/tui/recent-activity.d.ts +0 -8
  131. package/dist/tui/recent-activity.js +0 -71
  132. package/dist/tui/render-signature.d.ts +0 -1
  133. package/dist/tui/render-signature.js +0 -7
  134. package/dist/tui/run.d.ts +0 -38
  135. package/dist/tui/run.js +0 -6996
  136. package/dist/tui/sidebar-mcp.d.ts +0 -31
  137. package/dist/tui/sidebar-mcp.js +0 -62
  138. package/dist/tui/sidebar-state.d.ts +0 -12
  139. package/dist/tui/sidebar-state.js +0 -69
  140. package/dist/tui/streaming-tool-args.d.ts +0 -15
  141. package/dist/tui/streaming-tool-args.js +0 -30
  142. package/dist/tui/tool-renderers/fallback.d.ts +0 -2
  143. package/dist/tui/tool-renderers/fallback.js +0 -75
  144. package/dist/tui/tool-renderers/registry.d.ts +0 -3
  145. package/dist/tui/tool-renderers/registry.js +0 -11
  146. package/dist/tui/tool-renderers/subagent.d.ts +0 -2
  147. package/dist/tui/tool-renderers/subagent.js +0 -114
  148. package/dist/tui/tool-renderers/types.d.ts +0 -36
  149. package/dist/tui/tool-renderers/write-preview.d.ts +0 -12
  150. package/dist/tui/tool-renderers/write-preview.js +0 -30
  151. package/dist/tui/tool-renderers/write.d.ts +0 -6
  152. package/dist/tui/tool-renderers/write.js +0 -88
  153. /package/dist/{tui/tool-renderers → feedback}/types.js +0 -0
@@ -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. This matters because MessageList renders
267
- // committed messages inside Ink's <Static>, which only paints each item once
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, formatElapsed, formatTracePath, traceGroupLabel } from "./trace-groups.js";
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 TimelineText({ content, compactTop, terminalColumns, }) {
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
- return (_jsxs(Box, { marginLeft: 2, marginTop: compactTop ? 0 : 1, children: [_jsx(Text, { color: theme.agent, children: "\u26EC " }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: _jsx(MarkdownContent, { content: content.trim(), maxWidth: available }) })] }));
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
- const elapsed = formatElapsed(group.startedAt, nowTick);
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 }), elapsed && _jsxs(Text, { color: theme.traceDetail, children: [" \u00B7 ", elapsed] })] }));
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 - 16);
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
- return (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: compactTop ? 0 : 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: titleColor, children: group.title }), group.command ? (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { color: theme.traceCommand, children: truncateVisual(group.command, commandWidth) })] })) : 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] })] }), 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"] }) }))] }));
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(Box, { children: [_jsx(Text, { bold: true, color: theme.traceAction, children: "Edit" }), path && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(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 })] }));
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
- const elapsed = formatElapsed(group.startedAt, nowTick);
156
- return { text: elapsed ? `running · ${elapsed}` : "running", color: theme.tracePending };
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 (▌ + space) takes 2 cols; reserve 2 cols inside the fill for left/right gutters.
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: "\u258C " }), _jsx(Text, { backgroundColor: theme.userMessageBg, color: theme.userMessageText, children: ` ${padVisual(line || " ", bubbleTextWidth)} ` })] }, index))) }));
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 get an elapsed counter; waiting-for-approval
407
- // gets an explicit badge so the trail survives the dialog closing.
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
- const elapsedSec = Math.max(0, Math.floor(((nowTick ?? Date.now()) - toolCall.startedAt) / 1000));
416
- summary = elapsedSec > 0 ? `running · ${elapsedSec}s` : "running";
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
- return (_jsxs(Box, { flexDirection: "column", marginLeft: leftMargin, children: [shown.map((line, i) => {
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
- const truncated = truncateVisual(line.content, contentWidth);
541
- const padded = padVisual(truncated, contentWidth);
542
- const lineText = ` ${numStr} ${sign} ${padded}`;
543
- return (_jsx(Text, { backgroundColor: bg, color: theme.userMessageText, children: lineText }, i));
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" }), refreshing && _jsx(Text, { color: theme.muted, children: "Refreshing remote model list..." }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [options.length === 0 && (_jsxs(Text, { color: theme.muted, children: ["No models match \"", query, "\""] })), visible.map((opt, i) => {
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
+ }
@@ -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 reaches <Static>. Fire and
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 (wall):";
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
+ }