rl-rockcli 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 (90) hide show
  1. package/commands/attach/basic-repl.js +212 -0
  2. package/commands/attach/cleanup-history.js +189 -0
  3. package/commands/attach/cleanup-manager.js +163 -0
  4. package/commands/attach/copy-ui/copyRepl.js +195 -0
  5. package/commands/attach/copy-ui/index.js +7 -0
  6. package/commands/attach/copy-ui/render/outputBlock.js +25 -0
  7. package/commands/attach/copy-ui/viewport/viewport.js +23 -0
  8. package/commands/attach/copy-ui/viewport/wheel.js +14 -0
  9. package/commands/attach/history-manager.js +507 -0
  10. package/commands/attach/history-session.js +48 -0
  11. package/commands/attach/ink-repl/InkREPL.js +1507 -0
  12. package/commands/attach/ink-repl/builtinCommands.js +1253 -0
  13. package/commands/attach/ink-repl/components/ConnectingScreen.js +76 -0
  14. package/commands/attach/ink-repl/components/Console.js +191 -0
  15. package/commands/attach/ink-repl/components/DetailView.js +148 -0
  16. package/commands/attach/ink-repl/components/DropdownMenu.js +86 -0
  17. package/commands/attach/ink-repl/components/InputArea.js +125 -0
  18. package/commands/attach/ink-repl/components/InputLine.js +18 -0
  19. package/commands/attach/ink-repl/components/OutputArea.js +22 -0
  20. package/commands/attach/ink-repl/components/OutputItem.js +96 -0
  21. package/commands/attach/ink-repl/components/ShellLayout.js +61 -0
  22. package/commands/attach/ink-repl/components/Spinner.js +79 -0
  23. package/commands/attach/ink-repl/components/StatusBar.js +106 -0
  24. package/commands/attach/ink-repl/components/WelcomeBanner.js +48 -0
  25. package/commands/attach/ink-repl/contexts/LayoutContext.js +12 -0
  26. package/commands/attach/ink-repl/contexts/ThemeContext.js +43 -0
  27. package/commands/attach/ink-repl/hooks/useFunctionKeys.js +70 -0
  28. package/commands/attach/ink-repl/hooks/useMouse.js +162 -0
  29. package/commands/attach/ink-repl/hooks/useResources.js +132 -0
  30. package/commands/attach/ink-repl/hooks/useSpinner.js +49 -0
  31. package/commands/attach/ink-repl/index.js +112 -0
  32. package/commands/attach/ink-repl/package.json +3 -0
  33. package/commands/attach/ink-repl/replState.js +947 -0
  34. package/commands/attach/ink-repl/shortcuts/defaultKeybindings.js +138 -0
  35. package/commands/attach/ink-repl/shortcuts/index.js +332 -0
  36. package/commands/attach/ink-repl/themes/defaultDark.js +18 -0
  37. package/commands/attach/ink-repl/themes/defaultLight.js +18 -0
  38. package/commands/attach/ink-repl/themes/index.js +4 -0
  39. package/commands/attach/ink-repl/themes/themeManager.js +45 -0
  40. package/commands/attach/ink-repl/themes/themeTokens.js +15 -0
  41. package/commands/attach/ink-repl/utils/atCompletion.js +346 -0
  42. package/commands/attach/ink-repl/utils/clipboard.js +50 -0
  43. package/commands/attach/ink-repl/utils/consoleLogger.js +81 -0
  44. package/commands/attach/ink-repl/utils/exitCodeHandler.js +49 -0
  45. package/commands/attach/ink-repl/utils/exitCodeTips.js +56 -0
  46. package/commands/attach/ink-repl/utils/formatTime.js +12 -0
  47. package/commands/attach/ink-repl/utils/outputSelection.js +120 -0
  48. package/commands/attach/ink-repl/utils/outputViewport.js +77 -0
  49. package/commands/attach/ink-repl/utils/paginatedFileLoading.js +76 -0
  50. package/commands/attach/ink-repl/utils/paramHint.js +60 -0
  51. package/commands/attach/ink-repl/utils/parseError.js +174 -0
  52. package/commands/attach/ink-repl/utils/pathCompletion.js +167 -0
  53. package/commands/attach/ink-repl/utils/remotePathSafety.js +56 -0
  54. package/commands/attach/ink-repl/utils/replSelection.js +205 -0
  55. package/commands/attach/ink-repl/utils/responseFormatter.js +127 -0
  56. package/commands/attach/ink-repl/utils/textWrap.js +117 -0
  57. package/commands/attach/ink-repl/utils/truncate.js +115 -0
  58. package/commands/attach/opentui-repl/App.tsx +891 -0
  59. package/commands/attach/opentui-repl/builtinCommands.ts +80 -0
  60. package/commands/attach/opentui-repl/components/ConfirmDialog.tsx +116 -0
  61. package/commands/attach/opentui-repl/components/ConnectingScreen.tsx +131 -0
  62. package/commands/attach/opentui-repl/components/Console.tsx +73 -0
  63. package/commands/attach/opentui-repl/components/DetailView.tsx +45 -0
  64. package/commands/attach/opentui-repl/components/DropdownMenu.tsx +130 -0
  65. package/commands/attach/opentui-repl/components/ExecutionStatus.tsx +66 -0
  66. package/commands/attach/opentui-repl/components/Header.tsx +24 -0
  67. package/commands/attach/opentui-repl/components/OutputArea.tsx +25 -0
  68. package/commands/attach/opentui-repl/components/OutputBlock.tsx +108 -0
  69. package/commands/attach/opentui-repl/components/PromptInput.tsx +109 -0
  70. package/commands/attach/opentui-repl/components/StatusBar.tsx +63 -0
  71. package/commands/attach/opentui-repl/components/Toast.tsx +65 -0
  72. package/commands/attach/opentui-repl/components/WelcomeBanner.tsx +41 -0
  73. package/commands/attach/opentui-repl/contexts/ReplContext.tsx +137 -0
  74. package/commands/attach/opentui-repl/contexts/SessionContext.tsx +32 -0
  75. package/commands/attach/opentui-repl/contexts/ThemeContext.tsx +70 -0
  76. package/commands/attach/opentui-repl/contexts/ToastContext.tsx +69 -0
  77. package/commands/attach/opentui-repl/contexts/toast-logic.js +71 -0
  78. package/commands/attach/opentui-repl/hooks/useResources.ts +102 -0
  79. package/commands/attach/opentui-repl/hooks/useSpinner.ts +46 -0
  80. package/commands/attach/opentui-repl/index.js +99 -0
  81. package/commands/attach/opentui-repl/keybindings.ts +39 -0
  82. package/commands/attach/opentui-repl/package.json +3 -0
  83. package/commands/attach/opentui-repl/render.tsx +72 -0
  84. package/commands/attach/opentui-repl/tsconfig.json +12 -0
  85. package/commands/attach/repl.js +791 -0
  86. package/commands/attach/sandbox-id-resolver.js +56 -0
  87. package/commands/attach/session-manager.js +307 -0
  88. package/commands/attach/ui-mode.js +146 -0
  89. package/commands/attach.js +186 -0
  90. package/package.json +1 -1
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Adapter for ink-repl/builtinCommands.js
3
+ *
4
+ * Reuses the existing command handlers (which are UI-agnostic)
5
+ * and wires them into the OpenTUI REPL's state management.
6
+ */
7
+
8
+ import { createRequire } from 'module';
9
+
10
+ // Use sync require for reliable path resolution across TypeScript runtimes
11
+ let _builtinModule: any = null;
12
+
13
+ function getBuiltinModule() {
14
+ if (!_builtinModule) {
15
+ try {
16
+ const require = createRequire(import.meta.url);
17
+ _builtinModule = require('../ink-repl/builtinCommands.js');
18
+ } catch (err) {
19
+ console.error('[builtinCommands] Failed to load ink-repl/builtinCommands.js:', err);
20
+ throw err;
21
+ }
22
+ }
23
+ return _builtinModule;
24
+ }
25
+
26
+ export async function isBuiltinCommand(input: string): Promise<boolean> {
27
+ const mod = getBuiltinModule();
28
+ return mod.isBuiltinCommand(input);
29
+ }
30
+
31
+ export interface ConfirmOption {
32
+ label: string;
33
+ value: boolean;
34
+ }
35
+
36
+ export interface ConfirmData {
37
+ title: string;
38
+ message: string;
39
+ items?: string[];
40
+ moreCount?: number;
41
+ options: ConfirmOption[];
42
+ onConfirm: (confirmed: boolean) => Promise<{ output: string; exitCode: number }>;
43
+ }
44
+
45
+ export interface BuiltinResult {
46
+ output?: string;
47
+ exitCode?: number;
48
+ action?: 'exit' | 'clear' | 'retry' | 'confirm';
49
+ command?: string;
50
+ confirmData?: ConfirmData;
51
+ }
52
+
53
+ export interface BuiltinContext {
54
+ client: any;
55
+ sessionManager: any;
56
+ historyManager: any;
57
+ sandboxId: string;
58
+ version: string;
59
+ stats: { startTime: number; shellCommands: number; builtinCommands: number };
60
+ lastCommand: string;
61
+ lastOutput: string;
62
+ themeManager?: any;
63
+ setTheme?: (name: string) => void;
64
+ setUIConfig?: (key: string, value: any) => void;
65
+ }
66
+
67
+ export async function executeBuiltinCommand(
68
+ input: string,
69
+ context: BuiltinContext
70
+ ): Promise<BuiltinResult> {
71
+ const mod = getBuiltinModule();
72
+ return mod.executeBuiltinCommand(input, context);
73
+ }
74
+
75
+ export async function checkInteractiveCommand(
76
+ command: string
77
+ ): Promise<{ isInteractive: boolean; cmdName: string; alternative: string | null }> {
78
+ const mod = getBuiltinModule();
79
+ return mod.checkInteractiveCommand(command);
80
+ }
@@ -0,0 +1,116 @@
1
+ import { Show, For, createMemo } from 'solid-js';
2
+ import { useTheme } from '../contexts/ThemeContext.tsx';
3
+ import { useRepl, useReplSetters } from '../contexts/ReplContext.tsx';
4
+
5
+ export interface ConfirmOption {
6
+ label: string;
7
+ value: boolean;
8
+ }
9
+
10
+ export interface ConfirmData {
11
+ title: string;
12
+ message: string;
13
+ items?: string[];
14
+ moreCount?: number;
15
+ options: ConfirmOption[];
16
+ onConfirm: (confirmed: boolean) => Promise<{ output: string; exitCode: number }>;
17
+ }
18
+
19
+ export function ConfirmDialog() {
20
+ const theme = useTheme();
21
+ const [state] = useRepl();
22
+ const setState = useReplSetters();
23
+
24
+ const confirmData = () => state.confirmData as ConfirmData | undefined;
25
+ const selectedIndex = () => state.confirmSelectedIndex || 0;
26
+
27
+ const hasItems = () => confirmData()?.items && confirmData()!.items!.length > 0;
28
+ const showMoreCount = () => (confirmData()?.moreCount || 0) > 0;
29
+
30
+ return (
31
+ <Show when={confirmData()}>
32
+ <box
33
+ flexDirection="column"
34
+ flexGrow={1}
35
+ backgroundColor={theme.colors.background}
36
+ border={{ type: 'line', fg: theme.colors.border }}
37
+ paddingX={2}
38
+ paddingY={1}
39
+ >
40
+ {/* Title */}
41
+ <text>
42
+ <span style={{ fg: theme.colors.accent, bold: true }}>
43
+ {confirmData()!.title}
44
+ </span>
45
+ </text>
46
+
47
+ {/* Message */}
48
+ <text marginTop={1}>
49
+ <span style={{ fg: theme.colors.textPrimary }}>
50
+ {confirmData()!.message}
51
+ </span>
52
+ </text>
53
+
54
+ {/* Items list */}
55
+ <Show when={hasItems()}>
56
+ <box marginTop={1} flexDirection="column">
57
+ <text marginBottom={1}>
58
+ <span style={{ fg: theme.colors.textSecondary }}>
59
+ {'─'.repeat(50)}
60
+ </span>
61
+ </text>
62
+ <For each={confirmData()!.items}>
63
+ {(item) => (
64
+ <text>
65
+ <span style={{ fg: theme.colors.textPrimary }}>
66
+ {' '}{item}
67
+ </span>
68
+ </text>
69
+ )}
70
+ </For>
71
+ <Show when={showMoreCount()}>
72
+ <text>
73
+ <span style={{ fg: theme.colors.textSecondary }}>
74
+ {' '}and {confirmData()!.moreCount} more sessions not shown
75
+ </span>
76
+ </text>
77
+ </Show>
78
+ <text marginTop={1}>
79
+ <span style={{ fg: theme.colors.textSecondary }}>
80
+ {'─'.repeat(50)}
81
+ </span>
82
+ </text>
83
+ </box>
84
+ </Show>
85
+
86
+ {/* Options */}
87
+ <box marginTop={1} flexDirection="column">
88
+ <For each={confirmData()!.options}>
89
+ {(option, i) => {
90
+ const isSelected = () => i() === selectedIndex();
91
+ return (
92
+ <text>
93
+ <span
94
+ style={{
95
+ fg: isSelected() ? theme.colors.accent : theme.colors.textPrimary,
96
+ bold: isSelected(),
97
+ }}
98
+ >
99
+ {isSelected() ? '● ' : '○ '}{option.label}
100
+ </span>
101
+ </text>
102
+ );
103
+ }}
104
+ </For>
105
+ </box>
106
+
107
+ {/* Help text */}
108
+ <text marginTop={1}>
109
+ <span style={{ fg: theme.colors.textSecondary }}>
110
+ ↑/↓ Select Enter Confirm Esc Cancel
111
+ </span>
112
+ </text>
113
+ </box>
114
+ </Show>
115
+ );
116
+ }
@@ -0,0 +1,131 @@
1
+ import { Show, createSignal, onCleanup } from 'solid-js';
2
+ import { useTheme } from '../contexts/ThemeContext';
3
+ import i18n from '../../../../utils/i18n.js';
4
+ import { getLogoLines, getGradientColors } from '../../../../utils/asciiArt.js';
5
+ const { t } = i18n;
6
+
7
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
8
+
9
+ interface ConnectingScreenProps {
10
+ sandboxId: string;
11
+ attempt?: number;
12
+ maxAttempts?: number;
13
+ error?: string;
14
+ }
15
+
16
+ export function ConnectingScreen(props: ConnectingScreenProps) {
17
+ const theme = useTheme();
18
+ const [spinnerIndex, setSpinnerIndex] = createSignal(0);
19
+ const gradient = getGradientColors();
20
+ const ROCK_ASCII_LINES = getLogoLines();
21
+
22
+ const timer = setInterval(() => {
23
+ setSpinnerIndex((i) => (i + 1) % SPINNER_FRAMES.length);
24
+ }, 80);
25
+
26
+ onCleanup(() => clearInterval(timer));
27
+
28
+ const connectingText = () => t('connecting.message');
29
+ const attemptText = () => {
30
+ if ((props.attempt ?? 1) > 1) {
31
+ return t('connecting.attempt')
32
+ .replace('${attempt}', String(props.attempt))
33
+ .replace('${maxAttempts}', String(props.maxAttempts ?? 30));
34
+ }
35
+ return '';
36
+ };
37
+
38
+ // If error is provided, show error screen
39
+ if (props.error) {
40
+ return (
41
+ <box
42
+ width="100%"
43
+ height="100%"
44
+ flexDirection="column"
45
+ justifyContent="center"
46
+ alignItems="center"
47
+ backgroundColor={theme.colors.background}
48
+ >
49
+ {/* ASCII Art */}
50
+ <box flexDirection="column" alignItems="center">
51
+ {ROCK_ASCII_LINES.map((line, index) => (
52
+ <text selectable={false}>
53
+ <span style={{ fg: gradient[index % gradient.length], bold: true }}>{line}</span>
54
+ </text>
55
+ ))}
56
+ </box>
57
+
58
+ {/* Error message */}
59
+ <box marginTop={2}>
60
+ <text selectable={false}>
61
+ <span style={{ fg: theme.colors.danger }}>❌ {props.error}</span>
62
+ </text>
63
+ </box>
64
+
65
+ {/* Sandbox ID */}
66
+ <box marginTop={1}>
67
+ <text selectable={false}>
68
+ <span style={{ fg: theme.colors.textSecondary }}>{props.sandboxId}</span>
69
+ </text>
70
+ </box>
71
+
72
+ {/* Exit hint */}
73
+ <box marginTop={2}>
74
+ <text selectable={false}>
75
+ <span style={{ fg: theme.colors.textSecondary }}>Exiting...</span>
76
+ </text>
77
+ </box>
78
+ </box>
79
+ );
80
+ }
81
+
82
+ return (
83
+ <box
84
+ width="100%"
85
+ height="100%"
86
+ flexDirection="column"
87
+ justifyContent="center"
88
+ alignItems="center"
89
+ backgroundColor={theme.colors.background}
90
+ >
91
+ {/* ASCII Art */}
92
+ <box flexDirection="column" alignItems="center">
93
+ {ROCK_ASCII_LINES.map((line, index) => (
94
+ <text selectable={false}>
95
+ <span style={{ fg: gradient[index % gradient.length], bold: true }}>{line}</span>
96
+ </text>
97
+ ))}
98
+ </box>
99
+
100
+ {/* Connecting message */}
101
+ <box marginTop={2}>
102
+ <text selectable={false}>
103
+ <span style={{ fg: theme.colors.accent }}>{connectingText()}</span>
104
+ </text>
105
+ </box>
106
+
107
+ {/* Sandbox ID */}
108
+ <box marginTop={1}>
109
+ <text selectable={false}>
110
+ <span style={{ fg: theme.colors.textSecondary }}>{props.sandboxId}</span>
111
+ </text>
112
+ </box>
113
+
114
+ {/* Spinner */}
115
+ <box marginTop={2}>
116
+ <text selectable={false}>
117
+ <span style={{ fg: theme.colors.accent }}>{SPINNER_FRAMES[spinnerIndex()]} Loading...</span>
118
+ </text>
119
+ </box>
120
+
121
+ {/* Attempt counter (only show if attempt > 1) */}
122
+ <Show when={(props.attempt ?? 1) > 1}>
123
+ <box marginTop={1}>
124
+ <text selectable={false}>
125
+ <span style={{ fg: theme.colors.textSecondary }}>{attemptText()}</span>
126
+ </text>
127
+ </box>
128
+ </Show>
129
+ </box>
130
+ );
131
+ }
@@ -0,0 +1,73 @@
1
+ import { Show, For } from 'solid-js';
2
+ import { useTheme } from '../contexts/ThemeContext.tsx';
3
+ import { useRepl } from '../contexts/ReplContext.tsx';
4
+
5
+ /**
6
+ * Get color based on log level
7
+ */
8
+ function getLogColor(level: string, theme: any): string {
9
+ switch (level) {
10
+ case 'error': return theme.colors.danger;
11
+ case 'warn': return theme.colors.warning;
12
+ case 'info': return theme.colors.accent;
13
+ case 'debug': return theme.colors.textSecondary;
14
+ default: return theme.colors.textPrimary;
15
+ }
16
+ }
17
+
18
+ export function Console() {
19
+ const theme = useTheme();
20
+ const [state] = useRepl();
21
+
22
+ return (
23
+ <Show when={state.consoleVisible}>
24
+ <box
25
+ flexDirection="column"
26
+ height={10}
27
+ minHeight={5}
28
+ borderStyle="rounded"
29
+ border
30
+ borderColor={theme.colors.border}
31
+ backgroundColor={theme.colors.surface}
32
+ paddingLeft={1}
33
+ paddingRight={1}
34
+ >
35
+ {/* Header */}
36
+ <box justifyContent="flex-start">
37
+ <text selectable={false}>
38
+ <span style={{ fg: theme.colors.accent, bold: true }}>CONSOLE</span>
39
+ <span style={{ fg: theme.colors.textSecondary }}>
40
+ {' '}{state.consoleLogs.length} logs F12 close
41
+ </span>
42
+ </text>
43
+ </box>
44
+
45
+ {/* Log entries */}
46
+ <scrollbox
47
+ flexGrow={1}
48
+ scrollY
49
+ stickyScroll
50
+ stickyStart="bottom"
51
+ >
52
+ <box flexDirection="column">
53
+ <Show when={state.consoleLogs.length === 0}>
54
+ <text selectable={false}>
55
+ <span style={{ fg: theme.colors.textSecondary }}>No logs yet</span>
56
+ </text>
57
+ </Show>
58
+ <For each={state.consoleLogs}>
59
+ {(log: any) => (
60
+ <text selectable={true}>
61
+ <span style={{ fg: theme.colors.textSecondary }}>[{log.timestamp}]</span>
62
+ <span style={{ fg: getLogColor(log.level, theme), bold: log.level === 'error' }}>
63
+ {' '}{log.message}
64
+ </span>
65
+ </text>
66
+ )}
67
+ </For>
68
+ </box>
69
+ </scrollbox>
70
+ </box>
71
+ </Show>
72
+ );
73
+ }
@@ -0,0 +1,45 @@
1
+ import { Show } from 'solid-js';
2
+ import { useTheme } from '../contexts/ThemeContext.tsx';
3
+ import { useRepl } from '../contexts/ReplContext.tsx';
4
+
5
+ export function DetailView() {
6
+ const theme = useTheme();
7
+ const [state] = useRepl();
8
+
9
+ return (
10
+ <Show when={state.viewMode === 'detail' && state.detailContent !== null}>
11
+ <box
12
+ width="100%"
13
+ height="100%"
14
+ flexDirection="column"
15
+ backgroundColor={theme.colors.background}
16
+ >
17
+ <box
18
+ flexShrink={0}
19
+ paddingLeft={2}
20
+ paddingRight={2}
21
+ borderStyle="single"
22
+ border={['bottom']}
23
+ borderColor={theme.colors.border}
24
+ >
25
+ <text selectable={false}>
26
+ <span style={{ fg: theme.colors.accent, bold: true }}>Full Output</span>
27
+ <span style={{ fg: theme.colors.textSecondary }}>{' '}Press Esc to go back</span>
28
+ </text>
29
+ </box>
30
+ <scrollbox
31
+ flexGrow={1}
32
+ scrollY
33
+ stickyStart="top"
34
+ paddingLeft={2}
35
+ paddingRight={2}
36
+ paddingTop={1}
37
+ >
38
+ <text>
39
+ <span style={{ fg: theme.colors.textPrimary }}>{state.detailContent}</span>
40
+ </text>
41
+ </scrollbox>
42
+ </box>
43
+ </Show>
44
+ );
45
+ }
@@ -0,0 +1,130 @@
1
+ import { Show, For, createMemo } from 'solid-js';
2
+ import { useTheme } from '../contexts/ThemeContext.tsx';
3
+ import { useRepl } from '../contexts/ReplContext.tsx';
4
+ import { useRenderer } from '@opentui/solid';
5
+
6
+ /**
7
+ * Truncate text with ellipsis if it exceeds maxLength
8
+ */
9
+ export function truncateText(text: string, maxLength: number): string {
10
+ if (!text || text.length <= maxLength) return text || '';
11
+ return text.slice(0, maxLength - 1) + '…';
12
+ }
13
+
14
+ /**
15
+ * Calculate the label width based on the longest label in items
16
+ * Adds padding and caps at 40 characters
17
+ */
18
+ export function calculateLabelWidth(items: Array<{ label: string }>): number {
19
+ if (!items || items.length === 0) return 10;
20
+ const maxLabel = Math.max(...items.map(i => i.label.length));
21
+ return Math.min(maxLabel + 2, 40); // Cap at 40 chars
22
+ }
23
+
24
+ /**
25
+ * Dropdown menu with scrolling - Claude Code style
26
+ * No border, uses › prefix for selection indicator.
27
+ *
28
+ * Layout:
29
+ * ↑ 2 more
30
+ * › /command description text...
31
+ * /other another description
32
+ * ↓ 3 more
33
+ */
34
+ export function DropdownMenu() {
35
+ const theme = useTheme();
36
+ const [state] = useRepl();
37
+ const renderer = useRenderer();
38
+ const maxVisible = 8;
39
+
40
+ // Calculate column widths based on terminal size and item labels
41
+ const labelWidth = createMemo(() => calculateLabelWidth(state.menuItems));
42
+
43
+ // Get terminal width for description truncation
44
+ const termWidth = createMemo(() => {
45
+ const width = renderer?.width || 80;
46
+ return Math.max(40, width);
47
+ });
48
+
49
+ // Description takes remaining space (minus prefix and gaps: 2 + 4 + some buffer)
50
+ const descWidth = createMemo(() => {
51
+ return Math.max(20, termWidth() - labelWidth() - 10);
52
+ });
53
+
54
+ // Calculate scroll offset
55
+ const scrollOffset = createMemo(() => {
56
+ const items = state.menuItems;
57
+ if (items.length <= maxVisible) return 0;
58
+ const halfVisible = Math.floor(maxVisible / 2);
59
+ let offset = state.selectedIndex - halfVisible;
60
+ offset = Math.max(0, offset);
61
+ offset = Math.min(items.length - maxVisible, offset);
62
+ return offset;
63
+ });
64
+
65
+ const visibleItems = createMemo(() => {
66
+ return state.menuItems.slice(scrollOffset(), scrollOffset() + maxVisible);
67
+ });
68
+
69
+ const hasMoreAbove = createMemo(() => scrollOffset() > 0);
70
+ const hasMoreBelow = createMemo(() => scrollOffset() + maxVisible < state.menuItems.length);
71
+
72
+ return (
73
+ <Show when={state.menuVisible && state.menuItems.length > 0}>
74
+ <box flexDirection="column" paddingLeft={2}>
75
+ {/* Scroll up indicator */}
76
+ <Show when={hasMoreAbove()}>
77
+ <text selectable={false}>
78
+ <span style={{ fg: theme.colors.textSecondary }}> ↑ {scrollOffset()} more</span>
79
+ </text>
80
+ </Show>
81
+
82
+ {/* Menu items */}
83
+ <For each={visibleItems()}>
84
+ {(item, i) => {
85
+ const actualIndex = () => i() + scrollOffset();
86
+ const isSelected = () => actualIndex() === state.selectedIndex;
87
+ // Pad label to fixed width for alignment
88
+ const paddedLabel = () => item.label.padEnd(labelWidth());
89
+ // Truncate description to prevent overflow
90
+ const truncatedDesc = () => truncateText(item.description, descWidth());
91
+ // Use › indicator for selected item
92
+ const prefix = () => isSelected() ? '› ' : ' ';
93
+
94
+ return (
95
+ <text selectable={false}>
96
+ <span
97
+ style={{
98
+ fg: isSelected() ? theme.colors.accent : theme.colors.textSecondary,
99
+ }}
100
+ >
101
+ {prefix()}
102
+ </span>
103
+ <span
104
+ style={{
105
+ fg: isSelected() ? theme.colors.accent : theme.colors.textPrimary,
106
+ bold: isSelected(),
107
+ }}
108
+ >
109
+ {paddedLabel()}
110
+ </span>
111
+ <span style={{ fg: theme.colors.textSecondary }}>
112
+ {' '}{truncatedDesc()}
113
+ </span>
114
+ </text>
115
+ );
116
+ }}
117
+ </For>
118
+
119
+ {/* Scroll down indicator */}
120
+ <Show when={hasMoreBelow()}>
121
+ <text selectable={false}>
122
+ <span style={{ fg: theme.colors.textSecondary }}>
123
+ {' '}↓ {state.menuItems.length - scrollOffset() - maxVisible} more
124
+ </span>
125
+ </text>
126
+ </Show>
127
+ </box>
128
+ </Show>
129
+ );
130
+ }
@@ -0,0 +1,66 @@
1
+ import { Show } from 'solid-js';
2
+ import { useTheme } from '../contexts/ThemeContext.tsx';
3
+ import { useRepl } from '../contexts/ReplContext.tsx';
4
+ import { useSpinner, formatSpinnerText } from '../hooks/useSpinner.ts';
5
+
6
+ /**
7
+ * Format duration in human readable format
8
+ */
9
+ export function formatDuration(ms: number | null | undefined): string {
10
+ if (ms === null || ms === undefined) return '';
11
+ if (ms < 1000) return `${ms}ms`;
12
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
13
+ const minutes = Math.floor(ms / 60000);
14
+ const seconds = ((ms % 60000) / 1000).toFixed(0);
15
+ return `${minutes}m${seconds}s`;
16
+ }
17
+
18
+ /**
19
+ * Execution status area - shows spinner when executing, duration when done.
20
+ * This component sits in the output area (not input area) like in the Ink version.
21
+ *
22
+ * Layout when executing:
23
+ * ⏵ executing command...
24
+ *
25
+ * Layout when done:
26
+ * ⏵ completed in 1.5s
27
+ *
28
+ * Layout when ready:
29
+ * ⏵ ready
30
+ */
31
+ export function ExecutionStatus(props: {
32
+ isExecuting: boolean;
33
+ command?: string;
34
+ lastDuration?: number | null;
35
+ maxWidth?: number;
36
+ }) {
37
+ const theme = useTheme();
38
+ const spinnerFrame = useSpinner(() => props.isExecuting);
39
+ const maxWidth = props.maxWidth || 60;
40
+
41
+ return (
42
+ <box flexDirection="column" marginTop={1} marginBottom={1} flexShrink={0}>
43
+ <box paddingLeft={1}>
44
+ <Show when={props.isExecuting} fallback={
45
+ <Show when={props.lastDuration !== null && props.lastDuration !== undefined} fallback={
46
+ <text selectable={false}>
47
+ <span style={{ fg: theme.colors.textSecondary }}>⏵ ready</span>
48
+ </text>
49
+ }>
50
+ <text selectable={false}>
51
+ <span style={{ fg: theme.colors.textSecondary }}>
52
+ ⏵ completed in {formatDuration(props.lastDuration)}
53
+ </span>
54
+ </text>
55
+ </Show>
56
+ }>
57
+ <text selectable={false}>
58
+ <span style={{ fg: theme.colors.warning }}>
59
+ {formatSpinnerText(spinnerFrame(), props.command || '', maxWidth)}
60
+ </span>
61
+ </text>
62
+ </Show>
63
+ </box>
64
+ </box>
65
+ );
66
+ }
@@ -0,0 +1,24 @@
1
+ import { useTheme } from '../contexts/ThemeContext.tsx';
2
+ import { useSession } from '../contexts/SessionContext.tsx';
3
+
4
+ export function Header() {
5
+ const theme = useTheme();
6
+ const session = useSession();
7
+
8
+ return (
9
+ <box
10
+ flexShrink={0}
11
+ flexDirection="row"
12
+ paddingLeft={2}
13
+ paddingRight={2}
14
+ borderStyle="single"
15
+ border={['bottom']}
16
+ borderColor={theme.colors.border}
17
+ >
18
+ <box flexGrow={1} />
19
+ <text selectable={false}>
20
+ <span style={{ fg: theme.colors.textSecondary }}>v{session.version}</span>
21
+ </text>
22
+ </box>
23
+ );
24
+ }
@@ -0,0 +1,25 @@
1
+ import { For } from 'solid-js';
2
+ import { useRepl } from '../contexts/ReplContext.tsx';
3
+ import { OutputBlock } from './OutputBlock.tsx';
4
+ import { WelcomeBanner } from './WelcomeBanner.tsx';
5
+
6
+ export function OutputArea() {
7
+ const [state] = useRepl();
8
+
9
+ return (
10
+ <scrollbox
11
+ flexGrow={1}
12
+ flexShrink={1}
13
+ stickyScroll
14
+ stickyStart="bottom"
15
+ scrollY
16
+ >
17
+ <box flexDirection="column" gap={1}>
18
+ <WelcomeBanner />
19
+ <For each={state.outputs}>
20
+ {(item) => <OutputBlock item={item} />}
21
+ </For>
22
+ </box>
23
+ </scrollbox>
24
+ );
25
+ }