@bubblebrain-ai/bubble 0.0.48 → 0.0.50

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 (51) hide show
  1. package/README.md +17 -0
  2. package/dist/agent/abort-errors.d.ts +4 -0
  3. package/dist/agent/abort-errors.js +30 -0
  4. package/dist/agent/provider-fingerprint.d.ts +6 -0
  5. package/dist/agent/provider-fingerprint.js +89 -0
  6. package/dist/agent/resident-history.d.ts +7 -0
  7. package/dist/agent/resident-history.js +61 -0
  8. package/dist/agent/transcript-helpers.d.ts +20 -0
  9. package/dist/agent/transcript-helpers.js +62 -0
  10. package/dist/agent/update-queue.d.ts +13 -0
  11. package/dist/agent/update-queue.js +69 -0
  12. package/dist/agent.d.ts +33 -16
  13. package/dist/agent.js +90 -300
  14. package/dist/external-runtime/grok-runtime.js +1 -1
  15. package/dist/feishu/agent-host/run-driver.js +1 -1
  16. package/dist/lsp/service.js +2 -2
  17. package/dist/model-catalog.d.ts +13 -0
  18. package/dist/model-catalog.js +52 -8
  19. package/dist/permissions/settings.d.ts +0 -1
  20. package/dist/permissions/settings.js +0 -2
  21. package/dist/provider-grok.d.ts +16 -18
  22. package/dist/provider-grok.js +28 -20
  23. package/dist/provider-registry.d.ts +9 -0
  24. package/dist/provider-registry.js +109 -6
  25. package/dist/provider-transform.js +7 -8
  26. package/dist/provider.js +11 -0
  27. package/dist/session-log.js +1 -0
  28. package/dist/slash-commands/commands.js +13 -9
  29. package/dist/tools/grep.js +1 -1
  30. package/dist/tui-ink/app-helpers.d.ts +43 -0
  31. package/dist/tui-ink/app-helpers.js +150 -0
  32. package/dist/tui-ink/app.d.ts +4 -11
  33. package/dist/tui-ink/app.js +104 -635
  34. package/dist/tui-ink/command-palette.d.ts +32 -0
  35. package/dist/tui-ink/command-palette.js +158 -0
  36. package/dist/tui-ink/display-history.d.ts +2 -0
  37. package/dist/tui-ink/display-reconstruct.d.ts +3 -0
  38. package/dist/tui-ink/display-reconstruct.js +82 -0
  39. package/dist/tui-ink/footer.d.ts +14 -3
  40. package/dist/tui-ink/footer.js +19 -5
  41. package/dist/tui-ink/rewind-picker.d.ts +8 -0
  42. package/dist/tui-ink/rewind-picker.js +102 -0
  43. package/dist/tui-ink/session-picker.js +1 -1
  44. package/dist/tui-ink/stats-panel.d.ts +11 -0
  45. package/dist/tui-ink/stats-panel.js +65 -0
  46. package/dist/tui-ink/waiting-indicator.d.ts +12 -0
  47. package/dist/tui-ink/waiting-indicator.js +129 -0
  48. package/dist/tui-ink/wordmark.d.ts +8 -0
  49. package/dist/tui-ink/wordmark.js +103 -0
  50. package/dist/types.d.ts +6 -0
  51. package/package.json +1 -1
@@ -0,0 +1,32 @@
1
+ import { SkillRegistry } from "../skills/registry.js";
2
+ import type { McpManager } from "../mcp/manager.js";
3
+ export interface PaletteItem {
4
+ label: string;
5
+ detail: string;
6
+ value: string;
7
+ command: string;
8
+ action?: "insert-skill";
9
+ }
10
+ export declare function buildCommandPaletteItems(skillRegistry: SkillRegistry, grokSessionBound?: boolean): PaletteItem[];
11
+ export declare function buildMcpReconnectItems(mcpManager?: McpManager): PaletteItem[];
12
+ export declare function CommandPalette({ items, terminalColumns, terminalRows, onSelect, onCancel, }: {
13
+ items: PaletteItem[];
14
+ terminalColumns: number;
15
+ terminalRows: number;
16
+ onSelect: (item: PaletteItem) => void;
17
+ onCancel: () => void;
18
+ }): import("react/jsx-runtime").JSX.Element;
19
+ export declare function McpReconnectPicker({ items, terminalColumns, terminalRows, onSelect, onCancel, }: {
20
+ items: PaletteItem[];
21
+ terminalColumns: number;
22
+ terminalRows: number;
23
+ onSelect: (item: PaletteItem) => void;
24
+ onCancel: () => void;
25
+ }): import("react/jsx-runtime").JSX.Element;
26
+ export declare const INK_LOCAL_SLASH_COMMANDS: readonly [{
27
+ readonly name: "goal";
28
+ readonly description: "Set/manage an autonomous goal (/goal <objective>|clear|pause|resume|edit)";
29
+ }, {
30
+ readonly name: "loop";
31
+ readonly description: "Run a prompt on a recurring interval (/loop 5m <prompt>|list|stop [id])";
32
+ }];
@@ -0,0 +1,158 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Slash-command and MCP-server picker overlays, the local /goal & /loop
4
+ * command table, and the shared PalettePicker list UI they render through.
5
+ */
6
+ import { useEffect, useMemo, useState } from "react";
7
+ import { Box, Text, useInput } from "ink";
8
+ import { registry as slashRegistry } from "../slash-commands/index.js";
9
+ import { isGrokLocalSlashCommand } from "../external-runtime/grok-input-policy.js";
10
+ import { useTheme } from "./theme.js";
11
+ import { isPrintablePickerInput } from "./model-picker.js";
12
+ import { isKeyReleaseEvent } from "./key-events.js";
13
+ import { clampWindowStartForIndex, truncate } from "./app-helpers.js";
14
+ export function buildCommandPaletteItems(skillRegistry, grokSessionBound = false) {
15
+ const items = new Map();
16
+ const add = (item) => {
17
+ const key = `${item.action ?? "command"}:${item.value}`;
18
+ if (!items.has(key))
19
+ items.set(key, item);
20
+ };
21
+ if (!grokSessionBound) {
22
+ for (const command of INK_LOCAL_SLASH_COMMANDS) {
23
+ add({
24
+ label: `/${command.name}`,
25
+ detail: command.description,
26
+ value: command.name,
27
+ command: `/${command.name}`,
28
+ });
29
+ }
30
+ }
31
+ for (const command of slashRegistry.list()) {
32
+ if (grokSessionBound && !isGrokLocalSlashCommand(command.name))
33
+ continue;
34
+ const source = command.source === "mcp" ? " :mcp" : "";
35
+ const sourceLabel = command.sourceLabel ? `[${command.sourceLabel}] ` : "";
36
+ add({
37
+ label: `/${command.name}${source}`,
38
+ detail: `${sourceLabel}${command.description}`,
39
+ value: command.name,
40
+ command: `/${command.name}`,
41
+ });
42
+ }
43
+ if (!grokSessionBound) {
44
+ for (const skill of skillRegistry.summaries()) {
45
+ add({
46
+ label: `/${skill.name} :skill`,
47
+ detail: `[${skill.source}] ${skill.description}`,
48
+ value: skill.name,
49
+ command: `/${skill.name}`,
50
+ action: "insert-skill",
51
+ });
52
+ }
53
+ }
54
+ return [...items.values()];
55
+ }
56
+ export function buildMcpReconnectItems(mcpManager) {
57
+ return (mcpManager?.getStates() ?? []).map((state) => {
58
+ let detail;
59
+ if (state.status.kind === "connected") {
60
+ const tools = state.status.tools.length;
61
+ const prompts = state.status.prompts.length;
62
+ detail = `connected · ${tools} tool${tools === 1 ? "" : "s"} · ${prompts} prompt${prompts === 1 ? "" : "s"}`;
63
+ }
64
+ else if (state.status.kind === "failed") {
65
+ detail = `failed · ${state.status.error}`;
66
+ }
67
+ else {
68
+ detail = "disabled";
69
+ }
70
+ return {
71
+ label: state.name,
72
+ detail,
73
+ value: state.name,
74
+ command: `/mcp reconnect ${state.name}`,
75
+ };
76
+ });
77
+ }
78
+ export function CommandPalette({ items, terminalColumns, terminalRows, onSelect, onCancel, }) {
79
+ return (_jsx(PalettePicker, { title: "Commands", hint: "Type to filter \u00B7 Up/Down choose \u00B7 Enter run \u00B7 Esc cancel", emptyText: "No commands found.", items: items, terminalColumns: terminalColumns, terminalRows: terminalRows, searchable: true, onSelect: onSelect, onCancel: onCancel }));
80
+ }
81
+ export function McpReconnectPicker({ items, terminalColumns, terminalRows, onSelect, onCancel, }) {
82
+ return (_jsx(PalettePicker, { title: "MCP servers", hint: "Up/Down choose \u00B7 Enter or r reconnect \u00B7 Esc cancel", emptyText: "No MCP servers configured.", items: items, terminalColumns: terminalColumns, terminalRows: terminalRows, reconnectAlias: true, onSelect: onSelect, onCancel: onCancel }));
83
+ }
84
+ function PalettePicker({ title, hint, emptyText, items, terminalColumns, terminalRows, searchable = false, reconnectAlias = false, onSelect, onCancel, }) {
85
+ const theme = useTheme();
86
+ const [query, setQuery] = useState("");
87
+ const [selectedIndex, setSelectedIndex] = useState(0);
88
+ const maxVisible = Math.max(5, Math.min(12, terminalRows - 10));
89
+ const filtered = useMemo(() => {
90
+ const needle = query.trim().toLowerCase();
91
+ if (!needle)
92
+ return items;
93
+ return items.filter((item) => item.label.toLowerCase().includes(needle) ||
94
+ item.detail.toLowerCase().includes(needle) ||
95
+ item.value.toLowerCase().includes(needle));
96
+ }, [items, query]);
97
+ useEffect(() => {
98
+ setSelectedIndex((current) => Math.min(Math.max(0, filtered.length - 1), current));
99
+ }, [filtered.length]);
100
+ useInput((input, key) => {
101
+ if (isKeyReleaseEvent(key))
102
+ return;
103
+ if (key.escape) {
104
+ onCancel();
105
+ return;
106
+ }
107
+ if (key.return || (reconnectAlias && input.toLowerCase() === "r")) {
108
+ const item = filtered[selectedIndex];
109
+ if (item)
110
+ onSelect(item);
111
+ return;
112
+ }
113
+ if (key.upArrow) {
114
+ setSelectedIndex((index) => Math.max(0, index - 1));
115
+ return;
116
+ }
117
+ if (key.downArrow) {
118
+ setSelectedIndex((index) => Math.min(Math.max(0, filtered.length - 1), index + 1));
119
+ return;
120
+ }
121
+ if (key.pageUp) {
122
+ setSelectedIndex((index) => Math.max(0, index - maxVisible));
123
+ return;
124
+ }
125
+ if (key.pageDown) {
126
+ setSelectedIndex((index) => Math.min(Math.max(0, filtered.length - 1), index + maxVisible));
127
+ return;
128
+ }
129
+ if (!searchable)
130
+ return;
131
+ if (key.backspace || key.delete) {
132
+ setQuery((current) => current.slice(0, -1));
133
+ return;
134
+ }
135
+ if (isPrintablePickerInput(input) && !key.ctrl && !key.meta) {
136
+ setQuery((current) => current + input);
137
+ }
138
+ });
139
+ const start = clampWindowStartForIndex(filtered.length, selectedIndex, maxVisible);
140
+ const visible = filtered.slice(start, start + maxVisible);
141
+ const labelWidth = Math.max(18, Math.min(36, Math.floor(terminalColumns * 0.32)));
142
+ const detailWidth = Math.max(20, terminalColumns - labelWidth - 10);
143
+ return (_jsxs(Box, { flexDirection: "column", marginY: 1, paddingX: 1, borderStyle: "round", borderColor: theme.borderActive, children: [_jsx(Text, { bold: true, color: theme.accent, children: title }), searchable && (_jsxs(Text, { color: theme.muted, children: ["Filter: ", _jsx(Text, { color: theme.userMessageText, children: query || " " })] })), _jsx(Text, { color: theme.muted, children: hint }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [filtered.length === 0 && _jsx(Text, { color: theme.muted, children: emptyText }), visible.map((item, offset) => {
144
+ const actualIndex = start + offset;
145
+ const selected = actualIndex === selectedIndex;
146
+ return (_jsxs(Box, { children: [_jsxs(Text, { color: selected ? theme.accent : undefined, children: [selected ? "> " : " ", truncate(item.label, labelWidth)] }), _jsxs(Text, { color: theme.muted, children: [" ", truncate(item.detail, detailWidth)] })] }, `${item.action ?? "command"}-${item.value}`));
147
+ })] })] }));
148
+ }
149
+ export const INK_LOCAL_SLASH_COMMANDS = [
150
+ {
151
+ name: "goal",
152
+ description: "Set/manage an autonomous goal (/goal <objective>|clear|pause|resume|edit)",
153
+ },
154
+ {
155
+ name: "loop",
156
+ description: "Run a prompt on a recurring interval (/loop 5m <prompt>|list|stop [id])",
157
+ },
158
+ ];
@@ -17,6 +17,8 @@ export interface DisplayMessage {
17
17
  compactionSummary?: string;
18
18
  hiddenCount?: number;
19
19
  taskElapsedMs?: number;
20
+ /** Backend configuration fingerprint reported for this model response. */
21
+ systemFingerprint?: string;
20
22
  }
21
23
  export declare function userInputStatusBadgeLabel(status?: UserInputStatus): string | undefined;
22
24
  export declare function setUserInputStatus(message: DisplayMessage, inputStatus?: UserInputStatus): DisplayMessage;
@@ -0,0 +1,3 @@
1
+ import { type DisplayMessage } from "./display-history.js";
2
+ import type { Message } from "../types.js";
3
+ export declare function reconstructDisplayMessages(agentMessages: Message[]): DisplayMessage[];
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Rebuilds UI DisplayMessages from the agent's raw transcript — used on
3
+ * initial mount and after transcript rewrites (rewind, resume).
4
+ */
5
+ import { INTERRUPTED_ASSISTANT_CONTENT } from "../agent.js";
6
+ import { isHiddenToolMetadata } from "../agent/tool-visibility.js";
7
+ import { isInternalBlockOnlyContent } from "../agent/internal-reminder-sanitizer.js";
8
+ import { nextDisplayMessageKey, stripInterruptedAssistantMarker } from "./display-history.js";
9
+ export function reconstructDisplayMessages(agentMessages) {
10
+ const result = [];
11
+ for (const m of agentMessages) {
12
+ if (m.role === "system" || m.role === "tool")
13
+ continue;
14
+ if (m.role === "user") {
15
+ if (m.isMeta)
16
+ continue; // <system-reminder> injections are not user-visible
17
+ // Harness-initiated kicks (goal continuations, task wakes) persist as
18
+ // user-role messages wrapped in internal blocks so the model keeps
19
+ // them across resume — but they were hidden live and must stay hidden
20
+ // when the transcript is rebuilt.
21
+ if (isInternalBlockOnlyContent(m.content))
22
+ continue;
23
+ result.push({
24
+ key: nextDisplayMessageKey("user"),
25
+ role: "user",
26
+ content: typeof m.content === "string" ? m.content : "(multimedia)",
27
+ });
28
+ }
29
+ else if (m.role === "assistant") {
30
+ const toolCalls = [];
31
+ if (m.toolCalls) {
32
+ for (const tc of m.toolCalls) {
33
+ let args = {};
34
+ try {
35
+ args = JSON.parse(tc.arguments || "{}");
36
+ }
37
+ catch {
38
+ args = {};
39
+ }
40
+ const toolResult = agentMessages.find((tm) => tm.role === "tool" && tm.toolCallId === tc.id);
41
+ if (isHiddenToolMetadata(toolResult ? toolResult.metadata : undefined))
42
+ continue;
43
+ toolCalls.push({
44
+ id: tc.id,
45
+ name: tc.name,
46
+ args,
47
+ result: toolResult ? toolResult.content : undefined,
48
+ isError: toolResult ? toolResult.content?.startsWith?.("Error:") : false,
49
+ metadata: toolResult ? toolResult.metadata : undefined,
50
+ });
51
+ }
52
+ }
53
+ // An aborted assistant message carries the model-facing interruption
54
+ // note in its content. Render only what the assistant actually said
55
+ // (partial streamed text, if any) plus a dedicated interrupt row —
56
+ // never the note itself, which reads like a leaked system prompt.
57
+ const interrupted = m.error?.aborted === true;
58
+ const content = interrupted
59
+ ? stripInterruptedAssistantMarker(m.content, INTERRUPTED_ASSISTANT_CONTENT)
60
+ : m.content;
61
+ if (content || m.reasoning || toolCalls.length > 0 || m.systemFingerprint) {
62
+ result.push({
63
+ key: nextDisplayMessageKey("asst"),
64
+ role: "assistant",
65
+ content,
66
+ reasoning: m.reasoning || undefined,
67
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
68
+ systemFingerprint: m.systemFingerprint,
69
+ });
70
+ }
71
+ if (interrupted) {
72
+ result.push({
73
+ key: nextDisplayMessageKey("asst"),
74
+ role: "assistant",
75
+ content: "Interrupted by user",
76
+ syntheticKind: "ui_interrupt",
77
+ });
78
+ }
79
+ }
80
+ }
81
+ return result;
82
+ }
@@ -3,11 +3,22 @@ export interface FooterData {
3
3
  mode?: PermissionMode;
4
4
  goalLine?: string;
5
5
  runtimeLabel?: string;
6
+ /** Friendly working directory (e.g. `~/project`). */
7
+ cwd?: string;
8
+ /** Current git branch, when the cwd is a git repo. */
9
+ branch?: string;
10
+ /** Display name of the active model. */
11
+ model?: string;
12
+ /** Current session title, when one has been assigned. */
13
+ sessionTitle?: string;
14
+ /** Compact context usage, e.g. `42K/500K`. */
15
+ contextUsage?: string;
6
16
  }
7
17
  /**
8
- * Bottom status line. Path / provider / model live in the welcome banner and
9
- * the model-switch transcript notices; the footer only surfaces the
10
- * permission-mode badge, so it renders nothing (zero rows) in the default mode.
18
+ * Bottom status line. Always surfaces a `cwd | branch | model | title | ctx`
19
+ * row (each segment only when its data is present), and appends the
20
+ * permission-mode badge for non-default modes. Renders nothing (zero rows)
21
+ * when no data is available.
11
22
  */
12
23
  export declare function FooterBar({ data }: {
13
24
  data: FooterData;
@@ -1,19 +1,33 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React from "react";
2
3
  import { Box, Text } from "ink";
3
4
  import { useTheme } from "./theme.js";
4
5
  import { PERMISSION_MODE_INFO } from "../permission/mode.js";
5
6
  /**
6
- * Bottom status line. Path / provider / model live in the welcome banner and
7
- * the model-switch transcript notices; the footer only surfaces the
8
- * permission-mode badge, so it renders nothing (zero rows) in the default mode.
7
+ * Bottom status line. Always surfaces a `cwd | branch | model | title | ctx`
8
+ * row (each segment only when its data is present), and appends the
9
+ * permission-mode badge for non-default modes. Renders nothing (zero rows)
10
+ * when no data is available.
9
11
  */
10
12
  export function FooterBar({ data }) {
13
+ const theme = useTheme();
11
14
  const showMode = !!data.mode && data.mode !== "default";
12
15
  const goalLine = data.goalLine?.trim();
13
16
  const runtimeLabel = data.runtimeLabel?.trim();
14
- if (!showMode && !goalLine && !runtimeLabel)
17
+ const segments = [];
18
+ if (data.cwd?.trim())
19
+ segments.push({ key: "cwd", text: data.cwd.trim(), color: theme.muted });
20
+ if (data.branch?.trim())
21
+ segments.push({ key: "branch", text: data.branch.trim(), color: theme.muted });
22
+ if (data.model?.trim())
23
+ segments.push({ key: "model", text: data.model.trim(), color: theme.accent });
24
+ if (data.sessionTitle?.trim())
25
+ segments.push({ key: "title", text: data.sessionTitle.trim(), color: theme.muted });
26
+ if (data.contextUsage?.trim())
27
+ segments.push({ key: "ctx", text: data.contextUsage.trim(), color: theme.muted });
28
+ if (!showMode && !goalLine && !runtimeLabel && segments.length === 0)
15
29
  return null;
16
- return (_jsxs(Box, { paddingX: 1, flexShrink: 0, flexDirection: "column", children: [goalLine && _jsx(GoalBadge, { line: goalLine }), runtimeLabel && _jsx(RuntimeBadge, { label: runtimeLabel }), showMode && _jsx(ModeBadge, { mode: data.mode })] }));
30
+ return (_jsxs(Box, { paddingX: 1, flexShrink: 0, flexDirection: "column", children: [goalLine && _jsx(GoalBadge, { line: goalLine }), runtimeLabel && _jsx(RuntimeBadge, { label: runtimeLabel }), (segments.length > 0 || showMode) && (_jsxs(Box, { children: [showMode && (_jsxs(React.Fragment, { children: [_jsx(ModeBadge, { mode: data.mode }), segments.length > 0 && _jsx(Text, { color: theme.muted, children: " | " })] })), segments.map((segment, index) => (_jsxs(React.Fragment, { children: [index > 0 && _jsx(Text, { color: theme.muted, children: " | " }), _jsx(Text, { color: segment.color, children: segment.text })] }, segment.key)))] }))] }));
17
31
  }
18
32
  function RuntimeBadge({ label }) {
19
33
  const theme = useTheme();
@@ -0,0 +1,8 @@
1
+ import type { SessionManager } from "../session.js";
2
+ export declare function RewindPicker({ sessionManager, terminalColumns, terminalRows, onSelect, onCancel, }: {
3
+ sessionManager: SessionManager;
4
+ terminalColumns: number;
5
+ terminalRows: number;
6
+ onSelect: (command: string) => void;
7
+ onCancel: () => void;
8
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,102 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * /rewind checkpoint picker: lists user turns with touched-file counts and
4
+ * cycles the restore scope (chat + files / chat only / files only).
5
+ */
6
+ import { useEffect, useMemo, useState } from "react";
7
+ import { Box, Text, useInput } from "ink";
8
+ import { useTheme } from "./theme.js";
9
+ import { isKeyReleaseEvent } from "./key-events.js";
10
+ import { clampWindowStartForIndex, truncate } from "./app-helpers.js";
11
+ const REWIND_SCOPE_ORDER = ["all", "chat", "code"];
12
+ const REWIND_SCOPE_LABEL = {
13
+ all: "chat + files",
14
+ chat: "chat only",
15
+ code: "files only",
16
+ };
17
+ function rewindCommand(turnIndex, scope) {
18
+ const base = `/rewind ${turnIndex + 1}`;
19
+ if (scope === "chat")
20
+ return `${base} --chat`;
21
+ if (scope === "code")
22
+ return `${base} --code`;
23
+ return base;
24
+ }
25
+ function cycleRewindScope(scope, direction) {
26
+ const index = REWIND_SCOPE_ORDER.indexOf(scope);
27
+ return REWIND_SCOPE_ORDER[(index + direction + REWIND_SCOPE_ORDER.length) % REWIND_SCOPE_ORDER.length];
28
+ }
29
+ export function RewindPicker({ sessionManager, terminalColumns, terminalRows, onSelect, onCancel, }) {
30
+ const theme = useTheme();
31
+ const turns = useMemo(() => sessionManager.listUserTurns(), [sessionManager]);
32
+ const checkpoints = useMemo(() => sessionManager.getCheckpoints(), [sessionManager]);
33
+ const fileCounts = useMemo(() => {
34
+ const entries = checkpoints.listEntries();
35
+ const byTurn = new Map();
36
+ for (const entry of entries) {
37
+ const files = byTurn.get(entry.turn);
38
+ if (files)
39
+ files.add(entry.path);
40
+ else
41
+ byTurn.set(entry.turn, new Set([entry.path]));
42
+ }
43
+ return new Map(turns.map((turn) => [turn.id, byTurn.get(turn.id)?.size ?? 0]));
44
+ }, [checkpoints, turns]);
45
+ const [selectedIndex, setSelectedIndex] = useState(() => Math.max(0, turns.length - 1));
46
+ const [scope, setScope] = useState("all");
47
+ const maxVisible = Math.max(4, Math.min(10, terminalRows - 10));
48
+ useEffect(() => {
49
+ setSelectedIndex((current) => Math.min(Math.max(0, turns.length - 1), current));
50
+ }, [turns.length]);
51
+ useInput((input, key) => {
52
+ if (isKeyReleaseEvent(key))
53
+ return;
54
+ if (key.escape) {
55
+ onCancel();
56
+ return;
57
+ }
58
+ if (key.return) {
59
+ if (turns[selectedIndex])
60
+ onSelect(rewindCommand(selectedIndex, scope));
61
+ return;
62
+ }
63
+ if (key.upArrow) {
64
+ setSelectedIndex((index) => Math.max(0, index - 1));
65
+ return;
66
+ }
67
+ if (key.downArrow) {
68
+ setSelectedIndex((index) => Math.min(Math.max(0, turns.length - 1), index + 1));
69
+ return;
70
+ }
71
+ if (key.pageUp) {
72
+ setSelectedIndex((index) => Math.max(0, index - maxVisible));
73
+ return;
74
+ }
75
+ if (key.pageDown) {
76
+ setSelectedIndex((index) => Math.min(Math.max(0, turns.length - 1), index + maxVisible));
77
+ return;
78
+ }
79
+ if (key.tab || key.rightArrow || input === "l") {
80
+ setScope((current) => cycleRewindScope(current, 1));
81
+ return;
82
+ }
83
+ if (key.leftArrow || input === "h") {
84
+ setScope((current) => cycleRewindScope(current, -1));
85
+ }
86
+ });
87
+ const start = clampWindowStartForIndex(turns.length, selectedIndex, maxVisible);
88
+ const visibleTurns = turns.slice(start, start + maxVisible);
89
+ const previewWidth = Math.max(18, Math.min(76, terminalColumns - 34));
90
+ return (_jsxs(Box, { flexDirection: "column", marginY: 1, paddingX: 1, borderStyle: "round", borderColor: theme.borderActive, children: [_jsx(Text, { bold: true, color: theme.accent, children: "Rewind" }), _jsxs(Text, { color: theme.muted, children: ["Restore: ", _jsx(Text, { color: theme.accent, children: REWIND_SCOPE_LABEL[scope] }), " · ", turns.length, " point", turns.length === 1 ? "" : "s"] }), _jsx(Text, { color: theme.muted, children: "Up/Down choose \u00B7 Left/Right scope \u00B7 Enter rewind \u00B7 Esc cancel" }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [turns.length === 0 && _jsx(Text, { color: theme.muted, children: "Nothing to rewind: no user messages in this session." }), visibleTurns.map((turn, offset) => {
91
+ const actualIndex = start + offset;
92
+ const isSelected = actualIndex === selectedIndex;
93
+ const touched = fileCounts.get(turn.id) ?? 0;
94
+ return (_jsx(RewindRow, { turn: turn, turnNumber: actualIndex + 1, selected: isSelected, fileCount: touched, previewWidth: previewWidth }, turn.id));
95
+ })] })] }));
96
+ }
97
+ function RewindRow({ turn, turnNumber, selected, fileCount, previewWidth, }) {
98
+ const theme = useTheme();
99
+ const time = new Date(turn.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
100
+ const fileNote = fileCount > 0 ? ` · ${fileCount} file${fileCount === 1 ? "" : "s"}` : "";
101
+ return (_jsxs(Box, { children: [_jsxs(Text, { color: selected ? theme.accent : undefined, children: [selected ? "> " : " ", String(turnNumber).padStart(2, " "), " ", time, " ", truncate(turn.preview, previewWidth)] }), _jsx(Text, { color: theme.muted, children: fileNote })] }));
102
+ }
@@ -19,7 +19,7 @@ export function SessionPicker({ currentCwd, currentSessions, allSessions, onSele
19
19
  ? 0
20
20
  : Math.min(selectedSessionIdx, sessionRowIndices.length - 1);
21
21
  const selectedRowIndex = sessionRowIndices[clampedIdx] ?? -1;
22
- useInput((input, key) => {
22
+ useInput((_input, key) => {
23
23
  if (isKeyReleaseEvent(key))
24
24
  return;
25
25
  if (key.escape) {
@@ -0,0 +1,11 @@
1
+ import { type StatsRange, type UsageStatsBundle } from "../stats/usage.js";
2
+ export declare function StatsPanel({ panel, terminalColumns, terminalRows, onRangeChange, onCancel, }: {
3
+ panel: {
4
+ range: StatsRange;
5
+ bundle: UsageStatsBundle;
6
+ };
7
+ terminalColumns: number;
8
+ terminalRows: number;
9
+ onRangeChange: (range: StatsRange) => void;
10
+ onCancel: () => void;
11
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,65 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * /stats overlay: range-switching, scrollable usage panel.
4
+ */
5
+ import { useEffect, useMemo, useState } from "react";
6
+ import { Box, Text, useInput } from "ink";
7
+ import { formatStatsPanelBody, rangeLabel } from "../stats/usage.js";
8
+ import { useTheme } from "./theme.js";
9
+ import { isKeyReleaseEvent } from "./key-events.js";
10
+ export function StatsPanel({ panel, terminalColumns, terminalRows, onRangeChange, onCancel, }) {
11
+ const theme = useTheme();
12
+ const [scroll, setScroll] = useState(0);
13
+ const bodyWidth = Math.max(48, Math.min(92, terminalColumns - 6));
14
+ const lines = useMemo(() => formatStatsPanelBody(panel.bundle.ranges[panel.range], bodyWidth).split("\n"), [bodyWidth, panel.bundle, panel.range]);
15
+ const maxVisible = Math.max(5, Math.min(16, terminalRows - 10));
16
+ const maxScroll = Math.max(0, lines.length - maxVisible);
17
+ useEffect(() => {
18
+ setScroll(0);
19
+ }, [panel.range]);
20
+ useEffect(() => {
21
+ setScroll((current) => Math.min(current, maxScroll));
22
+ }, [maxScroll]);
23
+ useInput((input, key) => {
24
+ if (isKeyReleaseEvent(key))
25
+ return;
26
+ if (key.escape) {
27
+ onCancel();
28
+ return;
29
+ }
30
+ if (key.tab) {
31
+ onRangeChange(panel.range === "30d" ? "7d" : "30d");
32
+ return;
33
+ }
34
+ if (key.leftArrow || input === "h") {
35
+ onRangeChange("7d");
36
+ return;
37
+ }
38
+ if (key.rightArrow || input === "l") {
39
+ onRangeChange("30d");
40
+ return;
41
+ }
42
+ if (key.upArrow) {
43
+ setScroll((current) => Math.max(0, current - 1));
44
+ return;
45
+ }
46
+ if (key.downArrow) {
47
+ setScroll((current) => Math.min(maxScroll, current + 1));
48
+ return;
49
+ }
50
+ if (key.pageUp) {
51
+ setScroll((current) => Math.max(0, current - maxVisible));
52
+ return;
53
+ }
54
+ if (key.pageDown) {
55
+ setScroll((current) => Math.min(maxScroll, current + maxVisible));
56
+ }
57
+ });
58
+ const visible = lines.slice(scroll, scroll + maxVisible);
59
+ const generatedAt = panel.bundle.generatedAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
60
+ return (_jsxs(Box, { flexDirection: "column", marginY: 1, paddingX: 1, borderStyle: "round", borderColor: theme.borderActive, children: [_jsx(Text, { bold: true, color: theme.accent, children: "Stats" }), _jsxs(Text, { color: theme.muted, children: [rangeLabel(panel.range), " \u00B7 generated ", generatedAt] }), _jsx(Text, { color: theme.muted, children: "Left/Right range \u00B7 Up/Down scroll \u00B7 Tab toggle \u00B7 Esc close" }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: visible.map((line, index) => {
61
+ const key = `${scroll + index}-${line}`;
62
+ const heading = line === "Activity" || line === "Model usage" || line === "Summary";
63
+ return (_jsx(Text, { color: heading ? theme.accent : undefined, bold: heading, children: line || " " }, key));
64
+ }) }), maxScroll > 0 && (_jsxs(Text, { color: theme.muted, children: [scroll + 1, "-", Math.min(lines.length, scroll + maxVisible), " of ", lines.length] }))] }));
65
+ }
@@ -0,0 +1,12 @@
1
+ import type { DisplayToolCall } from "./display-history.js";
2
+ interface WaitingIndicatorProps {
3
+ tools: DisplayToolCall[];
4
+ hasStreamingText: boolean;
5
+ hasStreamingReasoning: boolean;
6
+ streamedChars: number;
7
+ nowTick: number;
8
+ pendingSteerCount?: number;
9
+ queuedCount?: number;
10
+ }
11
+ export declare function WaitingIndicator({ tools, hasStreamingText, hasStreamingReasoning, streamedChars, nowTick, pendingSteerCount, queuedCount, }: WaitingIndicatorProps): import("react/jsx-runtime").JSX.Element;
12
+ export {};