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,56 @@
1
+ /**
2
+ * Remote path safety helpers (for sandbox-executed completion commands).
3
+ */
4
+
5
+ /**
6
+ * Return true if the input contains characters that are risky for shell command construction.
7
+ * This is a conservative deny-list for completion-only paths.
8
+ * @param {string} input
9
+ * @returns {boolean}
10
+ */
11
+ export function hasDangerousShellChars(input) {
12
+ if (!input) return false;
13
+ // Reject control chars and common shell metacharacters.
14
+ return /[\r\n\t\0]/.test(input) || /[;&|`$()<>]/.test(input);
15
+ }
16
+
17
+ /**
18
+ * POSIX shell-escape a string as a single-quoted argument.
19
+ * @param {string} value
20
+ * @returns {string}
21
+ */
22
+ export function shellEscapePosix(value) {
23
+ if (value === '') return "''";
24
+ // ' -> '\'' (close, escape, reopen)
25
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
26
+ }
27
+
28
+ /**
29
+ * Find the first unescaped space in a string, treating backslash as an escape character.
30
+ * Returns -1 if none.
31
+ * @param {string} input
32
+ * @returns {number}
33
+ */
34
+ export function findFirstUnescapedSpace(input) {
35
+ if (!input) return -1;
36
+ for (let i = 0; i < input.length; i++) {
37
+ const ch = input[i];
38
+ if (ch === '\\') {
39
+ i += 1; // skip escaped char (if any)
40
+ continue;
41
+ }
42
+ if (ch === ' ') return i;
43
+ }
44
+ return -1;
45
+ }
46
+
47
+ /**
48
+ * Escape spaces for display/value insertion (keeps paths unquoted, but makes them a single arg).
49
+ * @param {string} input
50
+ * @returns {string}
51
+ */
52
+ export function escapeSpaces(input) {
53
+ if (!input) return input;
54
+ return String(input).replace(/ /g, '\\ ');
55
+ }
56
+
@@ -0,0 +1,205 @@
1
+ /**
2
+ * REPL output selection utilities
3
+ * Handles mouse selection within output items, excluding borders
4
+ */
5
+
6
+ import { truncateOutputToLines, formatTruncationHint } from './truncate.js';
7
+
8
+ /**
9
+ * Calculate the screen position and dimensions of each output item
10
+ * @param {Array} outputs - Array of output items
11
+ * @param {number} topPadding - Top padding of the layout
12
+ * @param {Object} options
13
+ * @param {number} [options.contentWidth] - Outer width of cards (defaults to stdout columns)
14
+ * @param {number} [options.maxLines] - Max visual lines shown in a card (defaults to 10)
15
+ * @returns {Array} Array of position info for each output
16
+ */
17
+ export function calculateOutputPositions(outputs, topPadding = 2, options = {}) {
18
+ const { contentWidth: configuredWidth, maxLines = 50 } = options || {};
19
+ const positions = [];
20
+ let currentY = topPadding;
21
+
22
+ for (const output of outputs) {
23
+ const { id, command, output: text, isWelcome } = output;
24
+
25
+ if (isWelcome) {
26
+ // Welcome message: border + padding + single line + padding + border
27
+ // Total height: 3 (1 border top + 1 content + 1 border bottom)
28
+ const height = 3;
29
+ positions.push({
30
+ id,
31
+ startY: currentY,
32
+ endY: currentY + height - 1,
33
+ height,
34
+ type: 'welcome',
35
+ contentStartY: currentY + 1, // Skip top border
36
+ contentEndY: currentY + 1, // Single line
37
+ lines: [text],
38
+ });
39
+ currentY += height + 1; // Add gap between outputs
40
+ } else {
41
+ // Regular output: border + command line (with paddingBottom) + output lines + border
42
+ // IMPORTANT: the rendered OutputItem wraps by width and truncates by *visual* lines.
43
+ // For selection/positioning we must mirror that behavior, otherwise mouse coordinates drift.
44
+ // We approximate content width by terminal width (ShellLayout is widthFraction=1 by default).
45
+ const terminalWidth = process.stdout?.columns || 80;
46
+ const contentWidth = Math.max(20, configuredWidth || terminalWidth);
47
+ const boxInnerWidth = Math.max(10, contentWidth - 4);
48
+
49
+ const hasText = typeof text === 'string' ? text.length > 0 : !!text;
50
+ const { lines: visualLines, truncated, hiddenLines } = hasText
51
+ ? truncateOutputToLines(String(text), boxInnerWidth, maxLines)
52
+ : { lines: [], truncated: false, hiddenLines: 0 };
53
+
54
+ const outputLines = visualLines.slice();
55
+ if (truncated && hiddenLines > 0) {
56
+ outputLines.push(formatTruncationHint(hiddenLines));
57
+ }
58
+ const lines = [];
59
+
60
+ if (command) {
61
+ lines.push(command); // Command line (we'll prepend prompt when extracting)
62
+ }
63
+ lines.push(...outputLines);
64
+
65
+ // Height calculation:
66
+ // - 1 top border
67
+ // - 1 command line (if exists, includes paddingBottom space)
68
+ // - outputLines.length output lines
69
+ // - 1 bottom border
70
+ const height = 2 + lines.length;
71
+
72
+ const contentStartY = currentY + 1; // Skip top border
73
+ const contentEndY = contentStartY + lines.length - 1;
74
+
75
+ positions.push({
76
+ id,
77
+ startY: currentY,
78
+ endY: currentY + height - 1,
79
+ height,
80
+ type: 'output',
81
+ contentStartY,
82
+ contentEndY,
83
+ lines,
84
+ command,
85
+ });
86
+
87
+ currentY += height + 1; // Add gap (rowGap: 1) between outputs
88
+ }
89
+ }
90
+
91
+ return positions;
92
+ }
93
+
94
+ /**
95
+ * Find which output item contains a given Y coordinate
96
+ * @param {number} y - Y coordinate (1-based)
97
+ * @param {Array} positions - Output positions from calculateOutputPositions
98
+ * @returns {Object|null} Position info or null if not found
99
+ */
100
+ export function findOutputAtPosition(y, positions) {
101
+ if (!positions || !Array.isArray(positions)) {
102
+ return null;
103
+ }
104
+ for (const pos of positions) {
105
+ if (y >= pos.contentStartY && y <= pos.contentEndY) {
106
+ return pos;
107
+ }
108
+ }
109
+ return null;
110
+ }
111
+
112
+ /**
113
+ * Check if a coordinate is within the content area (excluding borders)
114
+ * @param {number} x - X coordinate (1-based)
115
+ * @param {number} y - Y coordinate (1-based)
116
+ * @param {number} contentWidth - Content area width
117
+ * @param {Array} positions - Output positions
118
+ * @returns {boolean} True if inside content area
119
+ */
120
+ export function isInsideContentArea(x, y, contentWidth, positions) {
121
+ if (!positions || !Array.isArray(positions)) {
122
+ return false;
123
+ }
124
+
125
+ // X must be inside border (left border at 0, right border at contentWidth-1)
126
+ // With paddingLeft=1, valid X range is [2, contentWidth-2]
127
+ if (x < 2 || x >= contentWidth - 1) {
128
+ return false;
129
+ }
130
+
131
+ // Y must be in a content area
132
+ return findOutputAtPosition(y, positions) !== null;
133
+ }
134
+
135
+ /**
136
+ * Extract selected text from output items
137
+ * @param {number} startX - Start X (1-based, screen coordinate)
138
+ * @param {number} startY - Start Y (1-based, screen coordinate)
139
+ * @param {number} endX - End X (1-based, screen coordinate)
140
+ * @param {number} endY - End Y (1-based, screen coordinate)
141
+ * @param {Array} positions - Output positions
142
+ * @param {string} shellPrompt - Shell prompt for command lines
143
+ * @returns {string} Selected text
144
+ */
145
+ export function extractSelectionFromOutputs(startX, startY, endX, endY, positions, shellPrompt) {
146
+ if (!positions || !Array.isArray(positions)) {
147
+ return '';
148
+ }
149
+
150
+ // Normalize selection (ensure start <= end)
151
+ if (startY > endY || (startY === endY && startX > endX)) {
152
+ [startX, endX] = [endX, startX];
153
+ [startY, endY] = [endY, startY];
154
+ }
155
+
156
+ // Find the output containing the selection
157
+ const outputPos = findOutputAtPosition(startY, positions);
158
+ if (!outputPos) {
159
+ return '';
160
+ }
161
+
162
+ // Only support single-output selection
163
+ if (endY < outputPos.contentStartY || endY > outputPos.contentEndY) {
164
+ return '';
165
+ }
166
+
167
+ // Calculate line indices within the output
168
+ const startLineIdx = startY - outputPos.contentStartY;
169
+ const endLineIdx = endY - outputPos.contentStartY;
170
+
171
+ // Adjust X coordinates to account for border (left border + paddingLeft = 2)
172
+ const adjustedStartX = Math.max(0, startX - 2);
173
+ const adjustedEndX = Math.max(0, endX - 2);
174
+
175
+ const selectedLines = [];
176
+
177
+ for (let lineIdx = startLineIdx; lineIdx <= endLineIdx; lineIdx++) {
178
+ if (lineIdx < 0 || lineIdx >= outputPos.lines.length) {
179
+ continue;
180
+ }
181
+
182
+ let line = outputPos.lines[lineIdx];
183
+
184
+ // Prepend prompt for command line
185
+ if (lineIdx === 0 && outputPos.command && outputPos.type === 'output') {
186
+ line = shellPrompt + line;
187
+ }
188
+
189
+ if (lineIdx === startLineIdx && lineIdx === endLineIdx) {
190
+ // Single line selection
191
+ selectedLines.push(line.substring(adjustedStartX, adjustedEndX));
192
+ } else if (lineIdx === startLineIdx) {
193
+ // First line
194
+ selectedLines.push(line.substring(adjustedStartX));
195
+ } else if (lineIdx === endLineIdx) {
196
+ // Last line
197
+ selectedLines.push(line.substring(0, adjustedEndX));
198
+ } else {
199
+ // Middle lines
200
+ selectedLines.push(line);
201
+ }
202
+ }
203
+
204
+ return selectedLines.join('\n').trimEnd();
205
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * 响应格式化工具
3
+ * 统一处理 API 响应格式化逻辑
4
+ */
5
+
6
+ import { getExitCodeTips } from './exitCodeTips.js';
7
+
8
+ /**
9
+ * 响应状态码常量
10
+ */
11
+ export const RESPONSE_CODE = {
12
+ SUCCESS: '2xxx', // 2000-2999
13
+ SERVER_ERROR: '5xxx', // 5000-5999
14
+ CMD_ERROR: '6xxx', // 6000-6999
15
+ };
16
+
17
+ /**
18
+ * 判断命令执行是否成功 (基于 exit_code)
19
+ * @param {Object} result - API result 对象
20
+ * @returns {boolean}
21
+ */
22
+ export function isCommandSuccess(result) {
23
+ return result?.exit_code === 0;
24
+ }
25
+
26
+ /**
27
+ * 判断响应码类型
28
+ * @param {number} code - result.code
29
+ * @returns {string} 'success' | 'server_error' | 'cmd_error' | 'unknown'
30
+ */
31
+ export function getCodeType(code) {
32
+ if (code === undefined || code === null) return 'success';
33
+ if (code >= 2000 && code < 3000) return 'success';
34
+ if (code >= 5000 && code < 6000) return 'server_error';
35
+ if (code >= 6000 && code < 7000) return 'cmd_error';
36
+ return 'unknown';
37
+ }
38
+
39
+ /**
40
+ * 提取错误信息
41
+ * @param {Object} response - API 响应 (从 SDK client 捕获的异常)
42
+ * @returns {{ error: string|null, failureReason: string|null, code: number|null }}
43
+ */
44
+ export function extractError(response) {
45
+ const data = response?.data || {};
46
+ const result = data.result || {};
47
+
48
+ return {
49
+ error: data.error || null,
50
+ failureReason: result.failure_reason || null,
51
+ code: result.code ?? null,
52
+ };
53
+ }
54
+
55
+ /**
56
+ * 格式化命令执行输出
57
+ * @param {Object} result - execute/run_in_session result
58
+ * @param {Object} options
59
+ * @param {string} options.locale - 'zh' | 'en'
60
+ * @returns {{ output: string, metaInfo: string|null, tips: string|null, exitCode: number|null }}
61
+ */
62
+ export function formatCommandOutput(result, options = {}) {
63
+ const { locale = 'zh' } = options;
64
+ const { exit_code, stdout, stderr, output } = result;
65
+
66
+ // 兼容两种字段名: output 或 stdout/stderr
67
+ const combinedOutput = output || [stderr, stdout].filter(Boolean).join('\n');
68
+
69
+ // 成功时:不显示 metaInfo 和 tips
70
+ if (exit_code === 0) {
71
+ return { output: combinedOutput, metaInfo: null, tips: null, exitCode: 0 };
72
+ }
73
+
74
+ // 没有 exit_code 时(如超时/网络错误):显示 tips (等同于 exit_code: -1),但不显示 metaInfo
75
+ if (exit_code === undefined || exit_code === null) {
76
+ const tips = getExitCodeTips(-1, locale);
77
+ return { output: combinedOutput, metaInfo: null, tips, exitCode: null };
78
+ }
79
+
80
+ // 失败时(有 exit_code 且不为 0):显示 metaInfo 和 tips
81
+ const metaInfo = `[exit code: ${exit_code}]`;
82
+ const tips = getExitCodeTips(exit_code, locale);
83
+
84
+ return { output: combinedOutput, metaInfo, tips, exitCode: exit_code };
85
+ }
86
+
87
+ /**
88
+ * 格式化上传结果
89
+ * @param {Object} result - uploadFile 返回结果
90
+ * @param {Object} options
91
+ * @returns {{ output: string, exitCode: number }}
92
+ */
93
+ export function formatUploadResult(result, options = {}) {
94
+ const { localPath, remotePath } = options;
95
+
96
+ if (result.success) {
97
+ return {
98
+ output: `✓ ${localPath} → ${remotePath}`,
99
+ exitCode: 0,
100
+ };
101
+ }
102
+
103
+ return {
104
+ output: `✗ 上传失败\n\nerror: ${result.message}`,
105
+ exitCode: 1,
106
+ };
107
+ }
108
+
109
+ /**
110
+ * 格式化接口错误 (5xxx 服务端错误)
111
+ * @param {Object} errorInfo - extractError 返回的错误信息
112
+ * @param {string} operation - 操作名称 (用于提示)
113
+ * @param {string} locale
114
+ * @returns {string}
115
+ */
116
+ export function formatServerError(errorInfo, operation, locale = 'zh') {
117
+ const tips = locale === 'en'
118
+ ? '💡 Server error, please retry'
119
+ : '💡 服务端异常,建议重试';
120
+
121
+ let msg = `${operation} 失败\n\n`;
122
+ if (errorInfo.error) msg += `error: ${errorInfo.error}\n`;
123
+ if (errorInfo.failureReason) msg += `${errorInfo.failureReason}\n`;
124
+ msg += tips;
125
+
126
+ return msg;
127
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Text wrapping utilities for Ink terminal rendering.
3
+ *
4
+ * Important: this implements a lightweight width calculation that treats a range
5
+ * of CJK code points as width=2. It is not a full wcwidth implementation, but
6
+ * is consistent with the existing OutputItem behavior.
7
+ */
8
+
9
+ /**
10
+ * Slice a string by display width.
11
+ * @param {string} str
12
+ * @param {number} maxWidth
13
+ * @returns {{ head: string, tail: string }}
14
+ */
15
+ export function sliceByWidth(str, maxWidth) {
16
+ if (maxWidth <= 0) return { head: '', tail: str };
17
+ let width = 0;
18
+ let idx = 0;
19
+ const chars = Array.from(str);
20
+ for (; idx < chars.length; idx++) {
21
+ const char = chars[idx];
22
+ const code = char.codePointAt(0);
23
+ const w =
24
+ (code >= 0x1100 && code <= 0x115f) ||
25
+ (code >= 0x2e80 && code <= 0xa4cf) ||
26
+ (code >= 0xac00 && code <= 0xd7a3) ||
27
+ (code >= 0xf900 && code <= 0xfaff) ||
28
+ (code >= 0xfe10 && code <= 0xfe1f) ||
29
+ (code >= 0xfe30 && code <= 0xfe6f) ||
30
+ (code >= 0xff00 && code <= 0xff60) ||
31
+ (code >= 0xffe0 && code <= 0xffe6) ||
32
+ (code >= 0x20000 && code <= 0x2fffd) ||
33
+ (code >= 0x30000 && code <= 0x3fffd)
34
+ ? 2
35
+ : 1;
36
+ if (width + w > maxWidth) break;
37
+ width += w;
38
+ }
39
+ return { head: chars.slice(0, idx).join(''), tail: chars.slice(idx).join('') };
40
+ }
41
+
42
+ /**
43
+ * Get a lightweight display width for a string (same width model as sliceByWidth).
44
+ * @param {string} str
45
+ * @returns {number}
46
+ */
47
+ export function getDisplayWidth(str) {
48
+ if (!str) return 0;
49
+ let width = 0;
50
+ for (const char of Array.from(String(str))) {
51
+ const code = char.codePointAt(0);
52
+ width +=
53
+ (code >= 0x1100 && code <= 0x115f) ||
54
+ (code >= 0x2e80 && code <= 0xa4cf) ||
55
+ (code >= 0xac00 && code <= 0xd7a3) ||
56
+ (code >= 0xf900 && code <= 0xfaff) ||
57
+ (code >= 0xfe10 && code <= 0xfe1f) ||
58
+ (code >= 0xfe30 && code <= 0xfe6f) ||
59
+ (code >= 0xff00 && code <= 0xff60) ||
60
+ (code >= 0xffe0 && code <= 0xffe6) ||
61
+ (code >= 0x20000 && code <= 0x2fffd) ||
62
+ (code >= 0x30000 && code <= 0x3fffd)
63
+ ? 2
64
+ : 1;
65
+ }
66
+ return width;
67
+ }
68
+
69
+ /**
70
+ * Pad a line with spaces up to the given display width.
71
+ * This is useful in Ink to avoid leaving "stale" characters on screen when a
72
+ * new line is shorter than the previous frame.
73
+ * @param {string} line
74
+ * @param {number} width
75
+ * @returns {string}
76
+ */
77
+ export function padToWidth(line, width) {
78
+ if (width <= 0) return '';
79
+ const { head } = sliceByWidth(String(line ?? ''), width);
80
+ const current = getDisplayWidth(head);
81
+ if (current >= width) return head;
82
+ return head + ' '.repeat(width - current);
83
+ }
84
+
85
+ /**
86
+ * Wrap a string to lines by display width.
87
+ * @param {string} text
88
+ * @param {number} width
89
+ * @returns {string[]}
90
+ */
91
+ export function wrapTextToLines(text, width) {
92
+ if (!text && text !== '') return [''];
93
+ if (width <= 0) return String(text).split('\n');
94
+
95
+ const out = [];
96
+ for (const rawLine of String(text).split('\n')) {
97
+ if (rawLine === '') {
98
+ out.push('');
99
+ continue;
100
+ }
101
+ let remaining = rawLine;
102
+ while (remaining.length > 0) {
103
+ const { head, tail } = sliceByWidth(remaining, width);
104
+ if (tail) {
105
+ const lastSpace = head.lastIndexOf(' ');
106
+ if (lastSpace > 0) {
107
+ out.push(head.slice(0, lastSpace));
108
+ remaining = head.slice(lastSpace + 1) + tail;
109
+ continue;
110
+ }
111
+ }
112
+ out.push(head);
113
+ remaining = tail;
114
+ }
115
+ }
116
+ return out.length === 0 ? [''] : out;
117
+ }
@@ -0,0 +1,115 @@
1
+ import { keybindingManager } from '../shortcuts/index.js';
2
+ import { wrapTextToLines } from './textWrap.js';
3
+
4
+ export const DEFAULT_MAX_LINES = 50;
5
+
6
+ /**
7
+ * Process carriage returns (\r) in output to simulate terminal behavior.
8
+ * - \r\n is treated as a regular newline
9
+ * - \r alone moves cursor to start of line, subsequent text overwrites
10
+ *
11
+ * @param {string} output - Raw output with possible \r characters
12
+ * @returns {string} Processed output with \r behavior applied
13
+ */
14
+ export function processCarriageReturns(output) {
15
+ if (!output || !output.includes('\r')) {
16
+ return output;
17
+ }
18
+
19
+ // First normalize \r\n to \n
20
+ let normalized = output.replace(/\r\n/g, '\n');
21
+
22
+ // Process remaining \r characters
23
+ // Split by \n, then process each line for \r
24
+ const lines = normalized.split('\n');
25
+ const processedLines = lines.map(line => {
26
+ if (!line.includes('\r')) {
27
+ return line;
28
+ }
29
+
30
+ // Split by \r and keep only the last segment that "overwrites" previous content
31
+ // For more accurate simulation, we'd need to handle partial overwrites,
32
+ // but for progress bars, usually the last segment is what matters
33
+ const segments = line.split('\r');
34
+ // Filter out empty segments and take the last non-empty one
35
+ const nonEmptySegments = segments.filter(s => s.length > 0);
36
+ return nonEmptySegments.length > 0 ? nonEmptySegments[nonEmptySegments.length - 1] : '';
37
+ });
38
+
39
+ return processedLines.join('\n');
40
+ }
41
+
42
+ /**
43
+ * Truncate output to maximum number of lines
44
+ * @param {string} output - Output text
45
+ * @param {number} maxLines - Maximum lines to show
46
+ * @returns {{ text: string, truncated: boolean, totalLines: number, hiddenLines: number }}
47
+ */
48
+ export function truncateOutput(output, maxLines = DEFAULT_MAX_LINES) {
49
+ const lines = output.split('\n');
50
+
51
+ if (lines.length <= maxLines) {
52
+ return {
53
+ text: output,
54
+ truncated: false,
55
+ totalLines: lines.length,
56
+ hiddenLines: 0,
57
+ };
58
+ }
59
+
60
+ const visibleLines = lines.slice(0, maxLines);
61
+ const hiddenLines = lines.length - maxLines;
62
+
63
+ return {
64
+ text: visibleLines.join('\n'),
65
+ truncated: true,
66
+ totalLines: lines.length,
67
+ hiddenLines,
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Truncate output by *visual* line count after wrapping to terminal width.
73
+ *
74
+ * This avoids a common layout issue where a single very long logical line
75
+ * (e.g. JSON log line) expands to many wrapped rows and breaks the page layout
76
+ * even when logical newline count is small.
77
+ *
78
+ * @param {string} output
79
+ * @param {number} width Visual line width
80
+ * @param {number} maxLines Max visual lines to show
81
+ * @returns {{ lines: string[], truncated: boolean, totalLines: number, hiddenLines: number, text: string }}
82
+ */
83
+ export function truncateOutputToLines(output, width, maxLines = DEFAULT_MAX_LINES) {
84
+ const allLines = wrapTextToLines(output ?? '', width);
85
+ if (allLines.length <= maxLines) {
86
+ return {
87
+ lines: allLines,
88
+ text: allLines.join('\n'),
89
+ truncated: false,
90
+ totalLines: allLines.length,
91
+ hiddenLines: 0,
92
+ };
93
+ }
94
+
95
+ const visibleLines = allLines.slice(0, maxLines);
96
+ const hiddenLines = allLines.length - maxLines;
97
+ return {
98
+ lines: visibleLines,
99
+ text: visibleLines.join('\n'),
100
+ truncated: true,
101
+ totalLines: allLines.length,
102
+ hiddenLines,
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Format truncation hint text
108
+ * @param {number} hiddenLines - Number of hidden lines
109
+ * @returns {string} Hint text
110
+ */
111
+ export function formatTruncationHint(hiddenLines) {
112
+ if (hiddenLines === 0) return '';
113
+ const keyLabel = keybindingManager.getActionKeyLabel('viewOutput');
114
+ return `... [${hiddenLines} more lines] press '${keyLabel}' to view`;
115
+ }