@bubblebrain-ai/bubble 0.0.10 → 0.0.12

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 (175) hide show
  1. package/dist/agent.d.ts +1 -0
  2. package/dist/agent.js +6 -2
  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 +302 -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 +286 -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 +98 -32
  73. package/dist/model-catalog.js +3 -0
  74. package/dist/prompt/compose.js +3 -3
  75. package/dist/prompt/environment.js +2 -0
  76. package/dist/prompt/reminders.js +1 -1
  77. package/dist/provider-openai-codex.d.ts +8 -1
  78. package/dist/provider-openai-codex.js +33 -9
  79. package/dist/provider.d.ts +2 -0
  80. package/dist/session-title.d.ts +16 -0
  81. package/dist/session-title.js +134 -0
  82. package/dist/session-types.d.ts +5 -0
  83. package/dist/session.d.ts +16 -0
  84. package/dist/session.js +154 -2
  85. package/dist/skills/invocation.js +0 -18
  86. package/dist/skills/registry.d.ts +1 -0
  87. package/dist/skills/registry.js +2 -0
  88. package/dist/slash-commands/commands.js +15 -22
  89. package/dist/slash-commands/feishu.d.ts +17 -0
  90. package/dist/slash-commands/feishu.js +400 -0
  91. package/dist/slash-commands/registry.js +1 -1
  92. package/dist/slash-commands/types.d.ts +3 -1
  93. package/dist/text-display.d.ts +3 -0
  94. package/dist/text-display.js +25 -0
  95. package/dist/tools/index.d.ts +1 -0
  96. package/dist/tools/index.js +3 -1
  97. package/dist/tools/skill-search.d.ts +10 -0
  98. package/dist/tools/skill-search.js +134 -0
  99. package/dist/tools/skill.js +1 -4
  100. package/dist/tui-ink/app.js +265 -118
  101. package/dist/tui-ink/code-highlight.js +2 -3
  102. package/dist/tui-ink/detect-theme.d.ts +1 -18
  103. package/dist/tui-ink/detect-theme.js +1 -37
  104. package/dist/tui-ink/display-history.d.ts +20 -3
  105. package/dist/tui-ink/display-history.js +26 -27
  106. package/dist/tui-ink/feedback-dialog.d.ts +19 -0
  107. package/dist/tui-ink/feedback-dialog.js +123 -0
  108. package/dist/tui-ink/feishu-setup-picker.d.ts +5 -0
  109. package/dist/tui-ink/feishu-setup-picker.js +261 -0
  110. package/dist/tui-ink/input-box.d.ts +25 -1
  111. package/dist/tui-ink/input-box.js +132 -11
  112. package/dist/tui-ink/input-history.js +3 -5
  113. package/dist/tui-ink/markdown.d.ts +32 -0
  114. package/dist/tui-ink/markdown.js +111 -4
  115. package/dist/tui-ink/message-list.d.ts +1 -6
  116. package/dist/tui-ink/message-list.js +86 -34
  117. package/dist/tui-ink/model-picker.d.ts +18 -0
  118. package/dist/tui-ink/model-picker.js +81 -27
  119. package/dist/tui-ink/run-session-picker.d.ts +10 -0
  120. package/dist/tui-ink/run-session-picker.js +22 -0
  121. package/dist/tui-ink/run.js +7 -2
  122. package/dist/tui-ink/session-picker.d.ts +10 -0
  123. package/dist/tui-ink/session-picker.js +110 -0
  124. package/dist/tui-ink/terminal-mouse.d.ts +4 -0
  125. package/dist/tui-ink/terminal-mouse.js +23 -0
  126. package/dist/tui-ink/theme.js +2 -2
  127. package/dist/tui-ink/trace-groups.js +25 -2
  128. package/dist/tui-ink/welcome.js +2 -4
  129. package/package.json +4 -5
  130. package/dist/tui/clipboard.d.ts +0 -1
  131. package/dist/tui/clipboard.js +0 -53
  132. package/dist/tui/display-history.d.ts +0 -44
  133. package/dist/tui/display-history.js +0 -243
  134. package/dist/tui/escape-confirmation.d.ts +0 -15
  135. package/dist/tui/escape-confirmation.js +0 -30
  136. package/dist/tui/file-mentions.d.ts +0 -29
  137. package/dist/tui/file-mentions.js +0 -174
  138. package/dist/tui/global-key-router.d.ts +0 -3
  139. package/dist/tui/global-key-router.js +0 -87
  140. package/dist/tui/image-paste.d.ts +0 -95
  141. package/dist/tui/image-paste.js +0 -505
  142. package/dist/tui/markdown-inline.d.ts +0 -22
  143. package/dist/tui/markdown-inline.js +0 -68
  144. package/dist/tui/markdown-theme-rules.d.ts +0 -23
  145. package/dist/tui/markdown-theme-rules.js +0 -164
  146. package/dist/tui/markdown-theme.d.ts +0 -5
  147. package/dist/tui/markdown-theme.js +0 -27
  148. package/dist/tui/opencode-spinner.d.ts +0 -21
  149. package/dist/tui/opencode-spinner.js +0 -216
  150. package/dist/tui/prompt-keybindings.d.ts +0 -42
  151. package/dist/tui/prompt-keybindings.js +0 -35
  152. package/dist/tui/recent-activity.d.ts +0 -8
  153. package/dist/tui/recent-activity.js +0 -71
  154. package/dist/tui/render-signature.d.ts +0 -1
  155. package/dist/tui/render-signature.js +0 -7
  156. package/dist/tui/run.d.ts +0 -38
  157. package/dist/tui/run.js +0 -6996
  158. package/dist/tui/sidebar-mcp.d.ts +0 -31
  159. package/dist/tui/sidebar-mcp.js +0 -62
  160. package/dist/tui/sidebar-state.d.ts +0 -12
  161. package/dist/tui/sidebar-state.js +0 -69
  162. package/dist/tui/streaming-tool-args.d.ts +0 -15
  163. package/dist/tui/streaming-tool-args.js +0 -30
  164. package/dist/tui/tool-renderers/fallback.d.ts +0 -2
  165. package/dist/tui/tool-renderers/fallback.js +0 -75
  166. package/dist/tui/tool-renderers/registry.d.ts +0 -3
  167. package/dist/tui/tool-renderers/registry.js +0 -11
  168. package/dist/tui/tool-renderers/subagent.d.ts +0 -2
  169. package/dist/tui/tool-renderers/subagent.js +0 -114
  170. package/dist/tui/tool-renderers/types.d.ts +0 -36
  171. package/dist/tui/tool-renderers/write-preview.d.ts +0 -12
  172. package/dist/tui/tool-renderers/write-preview.js +0 -30
  173. package/dist/tui/tool-renderers/write.d.ts +0 -6
  174. package/dist/tui/tool-renderers/write.js +0 -88
  175. /package/dist/{tui/tool-renderers → feedback}/types.js +0 -0
@@ -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,24 @@ 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 rawStatus = message.content.replace(/^✓\s*/, "").trim();
205
+ const status = rawStatus.replace(/^Compaction complete\s*(?:·\s*)?/i, "").trim() || "Session compacted";
206
+ const summary = message.compactionSummary?.trim();
207
+ return (_jsxs(Box, { marginTop: 1, marginBottom: 1, paddingX: 1, flexDirection: "column", borderStyle: "round", borderColor: theme.borderActive, children: [_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: theme.success, bold: true, children: "\u2713 " }), _jsx(Text, { color: theme.accent, bold: true, children: "Compaction checkpoint" }), _jsxs(Text, { color: theme.muted, children: [" \u00B7 ", status] })] }), summary && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: theme.muted, dimColor: true, children: "Preserved context summary" }), _jsx(Box, { paddingLeft: 2, flexDirection: "column", children: _jsx(MarkdownContent, { content: summary }) })] }))] }));
208
+ }
189
209
  function UserMessageBlock({ content, terminalColumns }) {
190
210
  const theme = useTheme();
191
- // Rail (▌ + space) takes 2 cols; reserve 2 cols inside the fill for left/right gutters.
211
+ // Rail and its right gutter must share the bubble background; otherwise the
212
+ // terminal background shows up as a dark seam between rail and message.
213
+ const railWidth = 2;
192
214
  const horizontalRoom = Math.max(20, terminalColumns - 2);
193
- const bubbleTextWidth = Math.max(1, horizontalRoom - 2);
215
+ const bubbleTextWidth = Math.max(1, horizontalRoom - railWidth - 2);
194
216
  const wrappedLines = content
195
217
  .split("\n")
196
218
  .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))) }));
219
+ 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
220
  }
199
221
  const TOOL_DISPLAY_NAMES = {
200
222
  read: "Read",
@@ -403,8 +425,8 @@ function ToolCallDisplay({ toolCall, isStreaming, verbose, terminalColumns, show
403
425
  ? theme.toolPending
404
426
  : theme.user;
405
427
  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.
428
+ // Compose summary: pending tools stay compact; waiting-for-approval gets an
429
+ // explicit badge so the trail survives the dialog closing.
408
430
  let summary;
409
431
  let summaryColor = theme.muted;
410
432
  if (waitingApproval) {
@@ -412,8 +434,8 @@ function ToolCallDisplay({ toolCall, isStreaming, verbose, terminalColumns, show
412
434
  summaryColor = theme.warning;
413
435
  }
414
436
  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";
437
+ void nowTick;
438
+ summary = "running";
417
439
  summaryColor = theme.toolPending;
418
440
  }
419
441
  else {
@@ -529,7 +551,8 @@ function DiffBlock({ diff, terminalColumns, maxLines, verbose, showExpandHint, }
529
551
  // row beneath it.
530
552
  const bandWidth = Math.max(10, terminalColumns - 7);
531
553
  const contentWidth = Math.max(1, bandWidth - prefixWidth);
532
- return (_jsxs(Box, { flexDirection: "column", marginLeft: leftMargin, children: [shown.map((line, i) => {
554
+ const blankPrefix = " ".repeat(prefixWidth);
555
+ return (_jsxs(Box, { flexDirection: "column", marginLeft: leftMargin, children: [shown.flatMap((line, i) => {
533
556
  const bg = line.type === "add"
534
557
  ? theme.diffAdd
535
558
  : line.type === "remove"
@@ -537,10 +560,17 @@ function DiffBlock({ diff, terminalColumns, maxLines, verbose, showExpandHint, }
537
560
  : undefined;
538
561
  const sign = line.type === "add" ? "+" : line.type === "remove" ? "-" : " ";
539
562
  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));
563
+ // Soft-wrap long lines at the terminal-derived content width so narrow
564
+ // splits still show the full content. Continuation rows reuse the same
565
+ // background but blank out the gutter (no line number, no +/-) so a
566
+ // reader can tell at a glance which rows belong to the same logical
567
+ // diff line.
568
+ const segments = wrapByVisualWidth(line.content, contentWidth);
569
+ return segments.map((segment, segIdx) => {
570
+ const padded = padVisual(segment, contentWidth);
571
+ const prefix = segIdx === 0 ? ` ${numStr} ${sign} ` : blankPrefix;
572
+ return (_jsx(Text, { backgroundColor: bg, color: theme.userMessageText, children: `${prefix}${padded}` }, `${i}-${segIdx}`));
573
+ });
544
574
  }), _jsx(TruncationHint, { remaining: remaining, verbose: verbose, showExpandHint: showExpandHint })] }));
545
575
  }
546
576
  /**
@@ -554,6 +584,10 @@ function TurnDigest({ toolCalls }) {
554
584
  return null;
555
585
  return (_jsx(Box, { marginLeft: 2, marginTop: 1, children: _jsx(Text, { color: theme.muted, dimColor: true, children: digest }) }));
556
586
  }
587
+ function TaskDurationLine({ elapsedMs }) {
588
+ const theme = useTheme();
589
+ return (_jsx(Box, { marginLeft: 2, marginTop: 1, children: _jsxs(Text, { color: theme.muted, dimColor: true, children: ["Task duration: ", formatDuration(elapsedMs)] }) }));
590
+ }
557
591
  function buildDigest(toolCalls) {
558
592
  const paths = new Set();
559
593
  let added = 0;
@@ -595,6 +629,24 @@ function buildDigest(toolCalls) {
595
629
  : "";
596
630
  return `↳ ${verb} ${paths.size} file${paths.size === 1 ? "" : "s"}${stats} — ${pathDisplay}`;
597
631
  }
632
+ function formatDuration(ms) {
633
+ if (!Number.isFinite(ms) || ms <= 0)
634
+ return "0s";
635
+ if (ms < 1000)
636
+ return `${Math.max(1, Math.round(ms))}ms`;
637
+ const seconds = ms / 1000;
638
+ if (seconds < 10)
639
+ return `${seconds.toFixed(1)}s`;
640
+ if (seconds < 60)
641
+ return `${Math.round(seconds)}s`;
642
+ let minutes = Math.floor(seconds / 60);
643
+ let remSec = Math.round(seconds - minutes * 60);
644
+ if (remSec >= 60) {
645
+ minutes += Math.floor(remSec / 60);
646
+ remSec %= 60;
647
+ }
648
+ return remSec === 0 ? `${minutes}m` : `${minutes}m ${remSec}s`;
649
+ }
598
650
  function truncateVisual(str, maxWidth) {
599
651
  if (maxWidth <= 0)
600
652
  return "";
@@ -1,10 +1,28 @@
1
1
  import { ProviderRegistry } from "../provider-registry.js";
2
+ export { padVisual, truncateVisual } from "../text-display.js";
2
3
  export interface ModelPickerOption {
3
4
  id: string;
4
5
  label: string;
5
6
  group: string;
6
7
  providerBadge: string;
7
8
  }
9
+ export type PickerKeyAction = "up" | "down" | "enter" | "escape" | "backspace" | "delete";
10
+ export declare function resolvePickerKeyAction(input: string, key: {
11
+ upArrow?: boolean;
12
+ downArrow?: boolean;
13
+ return?: boolean;
14
+ escape?: boolean;
15
+ backspace?: boolean;
16
+ delete?: boolean;
17
+ }): PickerKeyAction | undefined;
18
+ export declare function isPrintablePickerInput(input: string): boolean;
19
+ export declare function formatSkillPickerRow(skill: {
20
+ name: string;
21
+ description?: string;
22
+ }, options: {
23
+ selected: boolean;
24
+ width: number;
25
+ }): string;
8
26
  export interface ModelPickerProps {
9
27
  registry: ProviderRegistry;
10
28
  current: string;
@@ -4,13 +4,61 @@ import { Box, Text, useInput, usePaste, useStdout } from "ink";
4
4
  import { useTheme } from "./theme.js";
5
5
  import { encodeModel, decodeModel, displayModel, isUserVisibleProvider } from "../provider-registry.js";
6
6
  import { listBuiltinModels } from "../model-catalog.js";
7
+ import { padVisual, truncateVisual } from "../text-display.js";
8
+ export { padVisual, truncateVisual } from "../text-display.js";
9
+ export function resolvePickerKeyAction(input, key) {
10
+ if (key.escape)
11
+ return "escape";
12
+ if (key.return)
13
+ return "enter";
14
+ if (key.upArrow)
15
+ return "up";
16
+ if (key.downArrow)
17
+ return "down";
18
+ if (key.backspace)
19
+ return "backspace";
20
+ if (key.delete)
21
+ return "delete";
22
+ const sequence = normalizeEscapeSequence(input);
23
+ if (/^(?:O|\[[\d;:]*)A$/.test(sequence))
24
+ return "up";
25
+ if (/^(?:O|\[[\d;:]*)B$/.test(sequence))
26
+ return "down";
27
+ return undefined;
28
+ }
29
+ export function isPrintablePickerInput(input) {
30
+ if (!input)
31
+ return false;
32
+ if (input.startsWith("\x1b"))
33
+ return false;
34
+ if (isRawEscapeTail(input))
35
+ return false;
36
+ return !/[\x00-\x1f\x7f]/.test(input);
37
+ }
38
+ export function formatSkillPickerRow(skill, options) {
39
+ const width = Math.max(12, options.width);
40
+ const marker = options.selected ? "> " : " ";
41
+ const nameBudget = Math.max(8, Math.min(28, Math.floor(width * 0.35)));
42
+ const name = truncateVisual(skill.name, nameBudget);
43
+ const nameCell = padVisual(name, nameBudget);
44
+ const description = (skill.description ?? "").replace(/\s+/g, " ").trim();
45
+ const row = description
46
+ ? `${marker}${nameCell} ${description}`
47
+ : `${marker}${nameCell}`;
48
+ return padVisual(truncateVisual(row, width), width);
49
+ }
50
+ function normalizeEscapeSequence(input) {
51
+ return input.startsWith("\x1b") ? input.slice(1) : input;
52
+ }
53
+ function isRawEscapeTail(input) {
54
+ return /^(?:O[ABCDHF]|\[[\d;:]*[A-Za-z~])$/.test(input);
55
+ }
7
56
  export function ModelPicker({ registry, current, recent, onSelect, onCancel }) {
8
57
  const theme = useTheme();
9
58
  const { stdout } = useStdout();
10
59
  const termHeight = stdout?.rows || 24;
11
60
  const maxVisible = Math.max(5, termHeight - 10);
12
61
  const [rawOptions, setRawOptions] = useState(() => buildLocalModelOptions(registry, current, recent));
13
- const [refreshing, setRefreshing] = useState(false);
14
62
  const [query, setQuery] = useState("");
15
63
  const [selectedIndex, setSelectedIndex] = useState(() => preferredModelIndex(buildLocalModelOptions(registry, current, recent), current));
16
64
  useEffect(() => {
@@ -63,10 +111,8 @@ export function ModelPicker({ registry, current, recent, onSelect, onCancel }) {
63
111
  const currentIndex = preferredModelIndex(opts, current);
64
112
  return index === preferredModelIndex(localOptions, current) ? currentIndex : Math.min(index, Math.max(0, opts.length - 1));
65
113
  });
66
- setRefreshing(false);
67
114
  }
68
115
  }
69
- setRefreshing(true);
70
116
  void refreshRemote();
71
117
  return () => {
72
118
  cancelled = true;
@@ -79,25 +125,26 @@ export function ModelPicker({ registry, current, recent, onSelect, onCancel }) {
79
125
  return rawOptions.filter((opt) => opt.label.toLowerCase().includes(q) || opt.providerBadge.toLowerCase().includes(q));
80
126
  }, [rawOptions, query]);
81
127
  useInput((input, key) => {
82
- if (key.escape) {
128
+ const action = resolvePickerKeyAction(input, key);
129
+ if (action === "escape") {
83
130
  onCancel();
84
131
  return;
85
132
  }
86
- if (key.return) {
133
+ if (action === "enter") {
87
134
  const opt = options[selectedIndex];
88
135
  if (opt)
89
136
  onSelect(opt.id);
90
137
  return;
91
138
  }
92
- if (key.upArrow) {
139
+ if (action === "up") {
93
140
  setSelectedIndex((i) => Math.max(0, i - 1));
94
141
  return;
95
142
  }
96
- if (key.downArrow) {
143
+ if (action === "down") {
97
144
  setSelectedIndex((i) => Math.min(options.length - 1, i + 1));
98
145
  return;
99
146
  }
100
- if (key.backspace || key.delete) {
147
+ if (action === "backspace" || action === "delete") {
101
148
  setQuery((q) => {
102
149
  const next = q.slice(0, -1);
103
150
  setSelectedIndex(0);
@@ -105,7 +152,7 @@ export function ModelPicker({ registry, current, recent, onSelect, onCancel }) {
105
152
  });
106
153
  return;
107
154
  }
108
- if (input && !key.ctrl && !key.meta) {
155
+ if (isPrintablePickerInput(input) && !key.ctrl && !key.meta) {
109
156
  setQuery((q) => {
110
157
  const next = q + input;
111
158
  setSelectedIndex(0);
@@ -116,7 +163,7 @@ export function ModelPicker({ registry, current, recent, onSelect, onCancel }) {
116
163
  });
117
164
  const start = Math.max(0, Math.min(selectedIndex, options.length - maxVisible));
118
165
  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) => {
166
+ 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
167
  const actualIndex = start + i;
121
168
  const isSelected = actualIndex === selectedIndex;
122
169
  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));
@@ -200,25 +247,26 @@ export function ProviderPicker({ providers, current, onSelect, onCancel, title }
200
247
  return idx >= 0 ? idx : 0;
201
248
  });
202
249
  useInput((input, key) => {
203
- if (key.escape) {
250
+ const action = resolvePickerKeyAction(input, key);
251
+ if (action === "escape") {
204
252
  onCancel();
205
253
  return;
206
254
  }
207
- if (key.return) {
255
+ if (action === "enter") {
208
256
  const p = providers[selectedIndex];
209
257
  if (p)
210
258
  onSelect(p.id);
211
259
  return;
212
260
  }
213
- if (key.upArrow) {
261
+ if (action === "up") {
214
262
  setSelectedIndex((i) => Math.max(0, i - 1));
215
263
  return;
216
264
  }
217
- if (key.downArrow) {
265
+ if (action === "down") {
218
266
  setSelectedIndex((i) => Math.min(providers.length - 1, i + 1));
219
267
  return;
220
268
  }
221
- if (input && input.length === 1 && /[a-z]/i.test(input)) {
269
+ if (isPrintablePickerInput(input) && input.length === 1 && /[a-z]/i.test(input)) {
222
270
  const char = input.toLowerCase();
223
271
  for (let i = selectedIndex + 1; i < providers.length; i++) {
224
272
  if (providers[i].name.toLowerCase().startsWith(char)) {
@@ -246,20 +294,21 @@ export function KeyPicker({ providerName, onSubmit, onCancel }) {
246
294
  const theme = useTheme();
247
295
  const [value, setValue] = useState("");
248
296
  useInput((input, key) => {
249
- if (key.escape) {
297
+ const action = resolvePickerKeyAction(input, key);
298
+ if (action === "escape") {
250
299
  onCancel();
251
300
  return;
252
301
  }
253
- if (key.return) {
302
+ if (action === "enter") {
254
303
  if (value.trim())
255
304
  onSubmit(value.trim());
256
305
  return;
257
306
  }
258
- if (key.backspace || key.delete) {
307
+ if (action === "backspace" || action === "delete") {
259
308
  setValue((v) => v.slice(0, -1));
260
309
  return;
261
310
  }
262
- if (input && !key.ctrl && !key.meta) {
311
+ if (isPrintablePickerInput(input) && !key.ctrl && !key.meta) {
263
312
  setValue((v) => v + input);
264
313
  }
265
314
  });
@@ -277,7 +326,9 @@ export function SkillPicker({ skills, onSelect, onCancel }) {
277
326
  const theme = useTheme();
278
327
  const { stdout } = useStdout();
279
328
  const termHeight = stdout?.rows || 24;
329
+ const terminalColumns = stdout?.columns || 80;
280
330
  const maxVisible = Math.max(5, termHeight - 8);
331
+ const rowWidth = Math.max(36, Math.min(96, terminalColumns - 6));
281
332
  const [query, setQuery] = useState("");
282
333
  const [selectedIndex, setSelectedIndex] = useState(0);
283
334
  const options = useMemo(() => {
@@ -287,25 +338,26 @@ export function SkillPicker({ skills, onSelect, onCancel }) {
287
338
  return skills.filter((skill) => skill.name.toLowerCase().includes(q) || skill.description.toLowerCase().includes(q));
288
339
  }, [query, skills]);
289
340
  useInput((input, key) => {
290
- if (key.escape) {
341
+ const action = resolvePickerKeyAction(input, key);
342
+ if (action === "escape") {
291
343
  onCancel();
292
344
  return;
293
345
  }
294
- if (key.return) {
346
+ if (action === "enter") {
295
347
  const skill = options[selectedIndex];
296
348
  if (skill)
297
349
  onSelect(skill.name);
298
350
  return;
299
351
  }
300
- if (key.upArrow) {
352
+ if (action === "up") {
301
353
  setSelectedIndex((i) => Math.max(0, i - 1));
302
354
  return;
303
355
  }
304
- if (key.downArrow) {
356
+ if (action === "down") {
305
357
  setSelectedIndex((i) => Math.min(Math.max(0, options.length - 1), i + 1));
306
358
  return;
307
359
  }
308
- if (key.backspace || key.delete) {
360
+ if (action === "backspace" || action === "delete") {
309
361
  setQuery((q) => {
310
362
  const next = q.slice(0, -1);
311
363
  setSelectedIndex(0);
@@ -313,7 +365,7 @@ export function SkillPicker({ skills, onSelect, onCancel }) {
313
365
  });
314
366
  return;
315
367
  }
316
- if (input && !key.ctrl && !key.meta) {
368
+ if (isPrintablePickerInput(input) && !key.ctrl && !key.meta) {
317
369
  setQuery((q) => {
318
370
  const next = q + input;
319
371
  setSelectedIndex(0);
@@ -321,11 +373,13 @@ export function SkillPicker({ skills, onSelect, onCancel }) {
321
373
  });
322
374
  }
323
375
  });
324
- const start = Math.max(0, Math.min(selectedIndex, options.length - maxVisible));
376
+ const maxStart = Math.max(0, options.length - maxVisible);
377
+ const start = Math.max(0, Math.min(maxStart, selectedIndex - Math.floor(maxVisible / 2)));
325
378
  const visible = options.slice(start, start + maxVisible);
326
379
  return (_jsxs(Box, { flexDirection: "column", marginY: 1, paddingX: 1, borderStyle: "round", borderColor: theme.borderActive, children: [_jsx(Text, { bold: true, color: theme.accent, children: "Select Skill" }), _jsx(SearchField, { query: query, placeholder: "Type to search skills..." }), _jsx(Text, { color: theme.muted, children: "\u2191/\u2193 navigate \u00B7 Enter load \u00B7 Esc cancel \u00B7 Backspace clear" }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [options.length === 0 && (_jsxs(Text, { color: theme.muted, children: ["No skills match \"", query, "\""] })), visible.map((skill, i) => {
327
380
  const actualIndex = start + i;
328
381
  const isSelected = actualIndex === selectedIndex;
329
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: isSelected ? theme.accent : undefined, children: [isSelected ? "> " : " ", skill.name] }), skill.description && (_jsx(Box, { marginLeft: 2, children: _jsx(Text, { color: theme.muted, dimColor: true, children: skill.description }) }))] }, skill.name));
382
+ const row = formatSkillPickerRow(skill, { selected: isSelected, width: rowWidth });
383
+ return (_jsx(Box, { children: _jsx(Text, { inverse: isSelected, color: isSelected ? theme.accent : undefined, bold: isSelected, children: row }) }, skill.name));
330
384
  })] })] }));
331
385
  }
@@ -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;