@runloop/rl-cli 1.4.0 → 1.5.0
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.
- package/README.md +10 -0
- package/dist/commands/secret/create.js +27 -0
- package/dist/commands/secret/delete.js +54 -0
- package/dist/commands/secret/get.js +23 -0
- package/dist/commands/secret/list.js +23 -0
- package/dist/commands/secret/update.js +27 -0
- package/dist/components/Banner.js +27 -5
- package/dist/components/LogsViewer.js +140 -61
- package/dist/components/MainMenu.js +77 -22
- package/dist/components/NavigationTips.js +174 -4
- package/dist/components/ResourceDetailPage.js +44 -2
- package/dist/utils/commands.js +47 -0
- package/dist/utils/logFormatter.js +16 -17
- package/dist/utils/stdin.js +66 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -140,6 +140,16 @@ rli network-policy create # Create a new network policy
|
|
|
140
140
|
rli network-policy delete <id> # Delete a network policy
|
|
141
141
|
```
|
|
142
142
|
|
|
143
|
+
### Secret Commands (alias: `s`)
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
rli secret create <name> # Create a new secret. Value can be pip...
|
|
147
|
+
rli secret list # List all secrets
|
|
148
|
+
rli secret get <name> # Get secret metadata by name
|
|
149
|
+
rli secret update <name> # Update a secret value (value from std...
|
|
150
|
+
rli secret delete <name> # Delete a secret
|
|
151
|
+
```
|
|
152
|
+
|
|
143
153
|
### Mcp Commands
|
|
144
154
|
|
|
145
155
|
```bash
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Create secret command
|
|
3
|
+
*/
|
|
4
|
+
import { getClient } from "../../utils/client.js";
|
|
5
|
+
import { output, outputError } from "../../utils/output.js";
|
|
6
|
+
import { getSecretValue } from "../../utils/stdin.js";
|
|
7
|
+
export async function createSecret(name, options = {}) {
|
|
8
|
+
try {
|
|
9
|
+
// Get secret value from stdin (piped) or interactive prompt
|
|
10
|
+
const value = await getSecretValue();
|
|
11
|
+
if (!value) {
|
|
12
|
+
outputError("Secret value cannot be empty", new Error("Empty value"));
|
|
13
|
+
}
|
|
14
|
+
const client = getClient();
|
|
15
|
+
const secret = await client.secrets.create({ name, value });
|
|
16
|
+
// Default: just output the ID for easy scripting
|
|
17
|
+
if (!options.output || options.output === "text") {
|
|
18
|
+
console.log(secret.id);
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
output(secret, { format: options.output, defaultFormat: "json" });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
outputError("Failed to create secret", error);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delete secret command
|
|
3
|
+
*/
|
|
4
|
+
import * as readline from "readline";
|
|
5
|
+
import { getClient } from "../../utils/client.js";
|
|
6
|
+
import { output } from "../../utils/output.js";
|
|
7
|
+
/**
|
|
8
|
+
* Prompt for confirmation
|
|
9
|
+
*/
|
|
10
|
+
async function confirm(message) {
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
const rl = readline.createInterface({
|
|
13
|
+
input: process.stdin,
|
|
14
|
+
output: process.stdout,
|
|
15
|
+
});
|
|
16
|
+
rl.question(`${message} [y/N] `, (answer) => {
|
|
17
|
+
rl.close();
|
|
18
|
+
resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes");
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
export async function deleteSecret(name, options = {}) {
|
|
23
|
+
try {
|
|
24
|
+
const client = getClient();
|
|
25
|
+
// Confirm deletion unless --yes flag is passed
|
|
26
|
+
if (!options.yes) {
|
|
27
|
+
const confirmed = await confirm(`Are you sure you want to delete secret "${name}"?`);
|
|
28
|
+
if (!confirmed) {
|
|
29
|
+
console.log("Aborted.");
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// Delete by name
|
|
34
|
+
const secret = await client.secrets.delete(name);
|
|
35
|
+
// Default: show confirmation message
|
|
36
|
+
if (!options.output || options.output === "text") {
|
|
37
|
+
console.log(`Deleted secret "${name}" (${secret.id})`);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
output({ id: secret.id, name, status: "deleted" }, { format: options.output, defaultFormat: "json" });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
45
|
+
if (errorMessage.includes("404") || errorMessage.includes("not found")) {
|
|
46
|
+
console.error(`Error: Secret "${name}" not found`);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
console.error(`Error: Failed to delete secret`);
|
|
50
|
+
console.error(` ${errorMessage}`);
|
|
51
|
+
}
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Get secret metadata command
|
|
3
|
+
*
|
|
4
|
+
* Note: The API doesn't have a direct "get by name" endpoint,
|
|
5
|
+
* so we list all secrets and filter by name.
|
|
6
|
+
*/
|
|
7
|
+
import { getClient } from "../../utils/client.js";
|
|
8
|
+
import { output, outputError } from "../../utils/output.js";
|
|
9
|
+
export async function getSecret(name, options = {}) {
|
|
10
|
+
try {
|
|
11
|
+
const client = getClient();
|
|
12
|
+
// List all secrets and find by name
|
|
13
|
+
const result = await client.secrets.list({ limit: 5000 });
|
|
14
|
+
const secret = result.secrets?.find((s) => s.name === name);
|
|
15
|
+
if (!secret) {
|
|
16
|
+
outputError(`Secret "${name}" not found`, new Error("Secret not found"));
|
|
17
|
+
}
|
|
18
|
+
output(secret, { format: options.output, defaultFormat: "json" });
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
outputError("Failed to get secret", error);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List secrets command
|
|
3
|
+
*/
|
|
4
|
+
import { getClient } from "../../utils/client.js";
|
|
5
|
+
import { output, outputError } from "../../utils/output.js";
|
|
6
|
+
const DEFAULT_PAGE_SIZE = 20;
|
|
7
|
+
export async function listSecrets(options = {}) {
|
|
8
|
+
try {
|
|
9
|
+
const client = getClient();
|
|
10
|
+
const limit = options.limit
|
|
11
|
+
? parseInt(options.limit, 10)
|
|
12
|
+
: DEFAULT_PAGE_SIZE;
|
|
13
|
+
// Fetch secrets
|
|
14
|
+
const result = await client.secrets.list({ limit });
|
|
15
|
+
// Extract secrets array
|
|
16
|
+
const secrets = result.secrets || [];
|
|
17
|
+
// Default: output JSON for lists
|
|
18
|
+
output(secrets, { format: options.output, defaultFormat: "json" });
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
outputError("Failed to list secrets", error);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Update secret command
|
|
3
|
+
*/
|
|
4
|
+
import { getClient } from "../../utils/client.js";
|
|
5
|
+
import { output, outputError } from "../../utils/output.js";
|
|
6
|
+
import { getSecretValue } from "../../utils/stdin.js";
|
|
7
|
+
export async function updateSecret(name, options = {}) {
|
|
8
|
+
try {
|
|
9
|
+
// Get new secret value from stdin (piped) or interactive prompt
|
|
10
|
+
const value = await getSecretValue();
|
|
11
|
+
if (!value) {
|
|
12
|
+
outputError("Secret value cannot be empty", new Error("Empty value"));
|
|
13
|
+
}
|
|
14
|
+
const client = getClient();
|
|
15
|
+
const secret = await client.secrets.update(name, { value });
|
|
16
|
+
// Default: just output the ID for easy scripting
|
|
17
|
+
if (!options.output || options.output === "text") {
|
|
18
|
+
console.log(secret.id);
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
output(secret, { format: options.output, defaultFormat: "json" });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
outputError("Failed to update secret", error);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import React, { useState, useEffect, useRef } from "react";
|
|
3
|
-
import { Box, Text } from "ink";
|
|
3
|
+
import { Box, Text, useStdout } from "ink";
|
|
4
4
|
import BigText from "ink-big-text";
|
|
5
5
|
import Gradient from "ink-gradient";
|
|
6
6
|
import { isLightMode } from "../utils/theme.js";
|
|
7
|
-
import { useViewportHeight } from "../hooks/useViewportHeight.js";
|
|
8
7
|
// Dramatic shades of green shimmer - wide range
|
|
9
8
|
const DARK_SHIMMER_COLORS = [
|
|
10
9
|
"#024A38", // Very very dark emerald
|
|
@@ -83,15 +82,38 @@ const DARK_FRAMES = precomputeFrames(DARK_SHIMMER_COLORS.filter((_, i) => i % 2
|
|
|
83
82
|
const LIGHT_FRAMES = precomputeFrames(LIGHT_SHIMMER_COLORS.filter((_, i) => i % 2 === 0));
|
|
84
83
|
// Minimum width to show the full BigText banner (simple3d font needs ~80 chars for "RUNLOOP.ai")
|
|
85
84
|
const MIN_WIDTH_FOR_BIG_BANNER = 90;
|
|
85
|
+
// Minimum height to show the full BigText banner - require generous room (40 lines)
|
|
86
|
+
const MIN_HEIGHT_FOR_BIG_BANNER = 40;
|
|
86
87
|
// Animation interval in ms
|
|
87
88
|
const SHIMMER_INTERVAL = 400;
|
|
88
89
|
export const Banner = React.memo(() => {
|
|
89
90
|
const [frameIndex, setFrameIndex] = useState(0);
|
|
90
91
|
const frames = isLightMode() ? LIGHT_FRAMES : DARK_FRAMES;
|
|
91
|
-
const {
|
|
92
|
+
const { stdout } = useStdout();
|
|
92
93
|
const timeoutRef = useRef(null);
|
|
93
|
-
//
|
|
94
|
-
|
|
94
|
+
// Get raw terminal dimensions, responding to resize events
|
|
95
|
+
// Default to conservative values if we can't detect (triggers compact mode)
|
|
96
|
+
const getDimensions = React.useCallback(() => ({
|
|
97
|
+
width: stdout?.columns && stdout.columns > 0 ? stdout.columns : 80,
|
|
98
|
+
height: stdout?.rows && stdout.rows > 0 ? stdout.rows : 20,
|
|
99
|
+
}), [stdout]);
|
|
100
|
+
const [dimensions, setDimensions] = useState(getDimensions);
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
// Update immediately on mount and when stdout changes
|
|
103
|
+
setDimensions(getDimensions());
|
|
104
|
+
if (!stdout)
|
|
105
|
+
return;
|
|
106
|
+
const handleResize = () => {
|
|
107
|
+
setDimensions(getDimensions());
|
|
108
|
+
};
|
|
109
|
+
stdout.on("resize", handleResize);
|
|
110
|
+
return () => {
|
|
111
|
+
stdout.off("resize", handleResize);
|
|
112
|
+
};
|
|
113
|
+
}, [stdout, getDimensions]);
|
|
114
|
+
// Determine if we should show compact mode (not enough width OR height)
|
|
115
|
+
const isCompact = dimensions.width < MIN_WIDTH_FOR_BIG_BANNER ||
|
|
116
|
+
dimensions.height < MIN_HEIGHT_FOR_BIG_BANNER;
|
|
95
117
|
useEffect(() => {
|
|
96
118
|
const tick = () => {
|
|
97
119
|
setFrameIndex((prev) => (prev + 1) % frames.length);
|
|
@@ -16,32 +16,42 @@ export const LogsViewer = ({ logs, breadcrumbItems = [{ label: "Logs", active: t
|
|
|
16
16
|
const [logsScroll, setLogsScroll] = React.useState(0);
|
|
17
17
|
const [copyStatus, setCopyStatus] = React.useState(null);
|
|
18
18
|
// Calculate viewport for logs output:
|
|
19
|
-
// - Breadcrumb (
|
|
20
|
-
// - Log box borders: 2 lines
|
|
19
|
+
// - Breadcrumb (border top + content + border bottom + marginBottom): 4 lines
|
|
20
|
+
// - Log box borders: 2 lines (added to height by Ink)
|
|
21
21
|
// - Stats bar (marginTop + content): 2 lines
|
|
22
|
-
// - Help bar (
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
// - Help bar (content): 1 line
|
|
23
|
+
// Total: 9 lines
|
|
24
|
+
const logsViewport = useViewportHeight({ overhead: 9, minHeight: 10 });
|
|
25
|
+
// Calculate max scroll position based on current mode
|
|
26
|
+
// For wrap mode, we can scroll until the last entry is at the top
|
|
27
|
+
// For non-wrap mode, we stop when the last entries fill the viewport
|
|
28
|
+
const getMaxScroll = () => {
|
|
29
|
+
if (logsWrapMode) {
|
|
30
|
+
return Math.max(0, logs.length - 1);
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
return Math.max(0, logs.length - logsViewport.viewportHeight);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
26
36
|
// Handle input for logs navigation
|
|
27
37
|
useInput((input, key) => {
|
|
38
|
+
const maxScroll = getMaxScroll();
|
|
28
39
|
if (key.upArrow || input === "k") {
|
|
29
40
|
setLogsScroll(Math.max(0, logsScroll - 1));
|
|
30
41
|
}
|
|
31
42
|
else if (key.downArrow || input === "j") {
|
|
32
|
-
setLogsScroll(logsScroll + 1);
|
|
43
|
+
setLogsScroll(Math.min(maxScroll, logsScroll + 1));
|
|
33
44
|
}
|
|
34
45
|
else if (key.pageUp) {
|
|
35
46
|
setLogsScroll(Math.max(0, logsScroll - 10));
|
|
36
47
|
}
|
|
37
48
|
else if (key.pageDown) {
|
|
38
|
-
setLogsScroll(logsScroll + 10);
|
|
49
|
+
setLogsScroll(Math.min(maxScroll, logsScroll + 10));
|
|
39
50
|
}
|
|
40
51
|
else if (input === "g") {
|
|
41
52
|
setLogsScroll(0);
|
|
42
53
|
}
|
|
43
54
|
else if (input === "G") {
|
|
44
|
-
const maxScroll = Math.max(0, logs.length - logsViewport.viewportHeight);
|
|
45
55
|
setLogsScroll(maxScroll);
|
|
46
56
|
}
|
|
47
57
|
else if (input === "w") {
|
|
@@ -101,72 +111,141 @@ export const LogsViewer = ({ logs, breadcrumbItems = [{ label: "Logs", active: t
|
|
|
101
111
|
});
|
|
102
112
|
const viewportHeight = Math.max(1, logsViewport.viewportHeight);
|
|
103
113
|
const terminalWidth = logsViewport.terminalWidth;
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
const
|
|
114
|
+
// Account for box borders (2 chars) and paddingX={1} (2 chars)
|
|
115
|
+
// Add extra buffer (4 chars) for any edge cases with Ink rendering
|
|
116
|
+
const boxChrome = 8;
|
|
117
|
+
const contentWidth = Math.max(40, terminalWidth - boxChrome);
|
|
118
|
+
// Helper to sanitize log message
|
|
119
|
+
const sanitizeMessage = (message) => {
|
|
120
|
+
// Strip ANSI escape sequences (colors, cursor movement, etc.)
|
|
121
|
+
const strippedAnsi = message.replace(
|
|
122
|
+
// eslint-disable-next-line no-control-regex
|
|
123
|
+
/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
|
|
124
|
+
// Replace control characters with spaces
|
|
125
|
+
return (strippedAnsi
|
|
126
|
+
.replace(/\r\n/g, " ")
|
|
127
|
+
.replace(/\n/g, " ")
|
|
128
|
+
.replace(/\r/g, " ")
|
|
129
|
+
.replace(/\t/g, " ")
|
|
130
|
+
// Remove any other control characters (ASCII 0-31 except space)
|
|
131
|
+
// eslint-disable-next-line no-control-regex
|
|
132
|
+
.replace(/[\x00-\x1F]/g, ""));
|
|
133
|
+
};
|
|
134
|
+
// Helper to calculate how many lines a log entry will take when wrapped
|
|
135
|
+
const calculateWrappedLineCount = (log) => {
|
|
136
|
+
const parts = parseAnyLogEntry(log);
|
|
137
|
+
const sanitized = sanitizeMessage(parts.message);
|
|
138
|
+
const MAX_MESSAGE_LENGTH = 1000;
|
|
139
|
+
const fullMessage = sanitized.length > MAX_MESSAGE_LENGTH
|
|
140
|
+
? sanitized.substring(0, MAX_MESSAGE_LENGTH) + "..."
|
|
141
|
+
: sanitized;
|
|
142
|
+
const cmd = parts.cmd
|
|
143
|
+
? `$ ${parts.cmd.substring(0, 40)}${parts.cmd.length > 40 ? "..." : ""} `
|
|
144
|
+
: "";
|
|
145
|
+
const exitCode = parts.exitCode !== null ? `exit=${parts.exitCode} ` : "";
|
|
146
|
+
const shellPart = parts.shellName ? `(${parts.shellName}) ` : "";
|
|
147
|
+
// Calculate total line length
|
|
148
|
+
const totalLength = parts.timestamp.length +
|
|
149
|
+
1 + // space
|
|
150
|
+
parts.level.length +
|
|
151
|
+
1 + // space
|
|
152
|
+
parts.source.length +
|
|
153
|
+
2 + // brackets
|
|
154
|
+
1 + // space
|
|
155
|
+
shellPart.length +
|
|
156
|
+
cmd.length +
|
|
157
|
+
fullMessage.length +
|
|
158
|
+
(exitCode ? 1 + exitCode.length : 0);
|
|
159
|
+
// Calculate how many lines this will wrap to
|
|
160
|
+
// Use contentWidth directly since we now have proper width constraints
|
|
161
|
+
const lineCount = Math.ceil(totalLength / contentWidth);
|
|
162
|
+
return Math.max(1, lineCount);
|
|
163
|
+
};
|
|
164
|
+
// Calculate visible logs based on wrap mode
|
|
165
|
+
let visibleLogs;
|
|
166
|
+
let actualScroll;
|
|
167
|
+
let visibleLineCount;
|
|
168
|
+
if (logsWrapMode) {
|
|
169
|
+
// In wrap mode, we need to count lines and only show what fits
|
|
170
|
+
actualScroll = Math.min(logsScroll, Math.max(0, logs.length - 1));
|
|
171
|
+
visibleLogs = [];
|
|
172
|
+
visibleLineCount = 0;
|
|
173
|
+
for (let i = actualScroll; i < logs.length; i++) {
|
|
174
|
+
const lineCount = calculateWrappedLineCount(logs[i]);
|
|
175
|
+
if (visibleLineCount + lineCount > viewportHeight &&
|
|
176
|
+
visibleLogs.length > 0) {
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
visibleLogs.push(logs[i]);
|
|
180
|
+
visibleLineCount += lineCount;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
// In non-wrap mode, each log is exactly 1 line
|
|
185
|
+
const maxScroll = Math.max(0, logs.length - viewportHeight);
|
|
186
|
+
actualScroll = Math.min(logsScroll, maxScroll);
|
|
187
|
+
visibleLogs = logs.slice(actualScroll, actualScroll + viewportHeight);
|
|
188
|
+
visibleLineCount = visibleLogs.length;
|
|
189
|
+
}
|
|
190
|
+
const hasMore = actualScroll + visibleLogs.length < logs.length;
|
|
108
191
|
const hasLess = actualScroll > 0;
|
|
109
|
-
|
|
192
|
+
// Color maps (defined once outside the loop)
|
|
193
|
+
const levelColorMap = {
|
|
194
|
+
red: colors.error,
|
|
195
|
+
yellow: colors.warning,
|
|
196
|
+
blue: colors.primary,
|
|
197
|
+
gray: colors.textDim,
|
|
198
|
+
};
|
|
199
|
+
const sourceColorMap = {
|
|
200
|
+
magenta: "#d33682",
|
|
201
|
+
cyan: colors.info,
|
|
202
|
+
green: colors.success,
|
|
203
|
+
yellow: colors.warning,
|
|
204
|
+
gray: colors.textDim,
|
|
205
|
+
white: colors.text,
|
|
206
|
+
};
|
|
207
|
+
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: breadcrumbItems }), _jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.border, paddingX: 1, height: viewportHeight, children: logs.length === 0 ? (_jsx(Text, { color: colors.textDim, dimColor: true, children: "No logs available" })) : (visibleLogs.map((log, index) => {
|
|
110
208
|
const parts = parseAnyLogEntry(log);
|
|
111
|
-
|
|
112
|
-
const escapedMessage = parts.message
|
|
113
|
-
.replace(/\r\n/g, "\\n")
|
|
114
|
-
.replace(/\n/g, "\\n")
|
|
115
|
-
.replace(/\r/g, "\\r")
|
|
116
|
-
.replace(/\t/g, "\\t");
|
|
209
|
+
const sanitizedMessage = sanitizeMessage(parts.message);
|
|
117
210
|
// Limit message length to prevent Yoga layout engine errors
|
|
118
211
|
const MAX_MESSAGE_LENGTH = 1000;
|
|
119
|
-
const fullMessage =
|
|
120
|
-
?
|
|
121
|
-
:
|
|
212
|
+
const fullMessage = sanitizedMessage.length > MAX_MESSAGE_LENGTH
|
|
213
|
+
? sanitizedMessage.substring(0, MAX_MESSAGE_LENGTH) + "..."
|
|
214
|
+
: sanitizedMessage;
|
|
122
215
|
const cmd = parts.cmd
|
|
123
216
|
? `$ ${parts.cmd.substring(0, 40)}${parts.cmd.length > 40 ? "..." : ""} `
|
|
124
217
|
: "";
|
|
125
218
|
const exitCode = parts.exitCode !== null ? `exit=${parts.exitCode} ` : "";
|
|
126
|
-
// Map color names to theme colors
|
|
127
|
-
const levelColorMap = {
|
|
128
|
-
red: colors.error,
|
|
129
|
-
yellow: colors.warning,
|
|
130
|
-
blue: colors.primary,
|
|
131
|
-
gray: colors.textDim,
|
|
132
|
-
};
|
|
133
|
-
const sourceColorMap = {
|
|
134
|
-
magenta: "#d33682",
|
|
135
|
-
cyan: colors.info,
|
|
136
|
-
green: colors.success,
|
|
137
|
-
yellow: colors.warning,
|
|
138
|
-
gray: colors.textDim,
|
|
139
|
-
white: colors.text,
|
|
140
|
-
};
|
|
141
219
|
const levelColor = levelColorMap[parts.levelColor] || colors.textDim;
|
|
142
220
|
const sourceColor = sourceColorMap[parts.sourceColor] || colors.textDim;
|
|
143
221
|
if (logsWrapMode) {
|
|
144
|
-
|
|
222
|
+
// For wrap mode, render with explicit width to prevent layout issues
|
|
223
|
+
return (_jsx(Box, { width: contentWidth, flexDirection: "column", children: _jsxs(Text, { wrap: "wrap", children: [_jsx(Text, { color: colors.textDim, dimColor: true, children: parts.timestamp }), _jsx(Text, { children: " " }), _jsx(Text, { color: levelColor, bold: parts.levelColor === "red", children: parts.level }), _jsx(Text, { children: " " }), _jsxs(Text, { color: sourceColor, children: ["[", parts.source, "]"] }), _jsx(Text, { children: " " }), parts.shellName && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: ["(", parts.shellName, ")", " "] })), cmd && _jsx(Text, { color: colors.info, children: cmd }), _jsx(Text, { children: fullMessage }), exitCode && (_jsxs(Text, { color: parts.exitCode === 0 ? colors.success : colors.error, children: [" ", exitCode] }))] }) }, index));
|
|
145
224
|
}
|
|
146
225
|
else {
|
|
147
|
-
//
|
|
148
|
-
const
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
return (
|
|
226
|
+
// Non-wrap mode: build the complete line and truncate to fit exactly
|
|
227
|
+
const shellPart = parts.shellName ? `(${parts.shellName}) ` : "";
|
|
228
|
+
const exitPart = exitCode ? ` ${exitCode}` : "";
|
|
229
|
+
// Build the full line content
|
|
230
|
+
const prefix = `${parts.timestamp} ${parts.level} [${parts.source}] ${shellPart}${cmd}`;
|
|
231
|
+
const suffix = exitPart;
|
|
232
|
+
// Calculate how much space is available for the message
|
|
233
|
+
const availableForMessage = contentWidth - prefix.length - suffix.length;
|
|
234
|
+
let displayMessage;
|
|
235
|
+
if (availableForMessage <= 3) {
|
|
236
|
+
// No room for message
|
|
237
|
+
displayMessage = "";
|
|
238
|
+
}
|
|
239
|
+
else if (fullMessage.length <= availableForMessage) {
|
|
240
|
+
displayMessage = fullMessage;
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
displayMessage =
|
|
244
|
+
fullMessage.substring(0, availableForMessage - 3) + "...";
|
|
245
|
+
}
|
|
246
|
+
return (_jsx(Box, { width: contentWidth, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: colors.textDim, dimColor: true, children: parts.timestamp }), _jsx(Text, { children: " " }), _jsx(Text, { color: levelColor, bold: parts.levelColor === "red", children: parts.level }), _jsx(Text, { children: " " }), _jsxs(Text, { color: sourceColor, children: ["[", parts.source, "]"] }), _jsx(Text, { children: " " }), parts.shellName && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: ["(", parts.shellName, ")", " "] })), cmd && _jsx(Text, { color: colors.info, children: cmd }), _jsx(Text, { children: displayMessage }), exitCode && (_jsxs(Text, { color: parts.exitCode === 0 ? colors.success : colors.error, children: [" ", exitCode] }))] }) }, index));
|
|
168
247
|
}
|
|
169
|
-
})) }), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.hamburger, " ", logs.length] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "total logs"] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Viewing ", actualScroll + 1, "-", Math.min(actualScroll +
|
|
248
|
+
})) }), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.hamburger, " ", logs.length] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "total logs"] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Viewing ", actualScroll + 1, "-", Math.min(actualScroll + visibleLogs.length, logs.length), " of", " ", logs.length] }), hasLess && _jsxs(Text, { color: colors.primary, children: [" ", figures.arrowUp] }), hasMore && _jsxs(Text, { color: colors.primary, children: [" ", figures.arrowDown] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsx(Text, { color: logsWrapMode ? colors.success : colors.textDim, bold: logsWrapMode, children: logsWrapMode ? "Wrap: ON" : "Wrap: OFF" }), copyStatus && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsx(Text, { color: colors.success, bold: true, children: copyStatus })] }))] }), _jsx(NavigationTips, { showArrows: true, tips: [
|
|
170
249
|
{ key: "g", label: "Top" },
|
|
171
250
|
{ key: "G", label: "Bottom" },
|
|
172
251
|
{ key: "w", label: "Toggle Wrap" },
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import React from "react";
|
|
3
|
-
import { Box, Text, useInput, useApp } from "ink";
|
|
3
|
+
import { Box, Text, useInput, useApp, useStdout } from "ink";
|
|
4
4
|
import figures from "figures";
|
|
5
5
|
import { Banner } from "./Banner.js";
|
|
6
6
|
import { Breadcrumb } from "./Breadcrumb.js";
|
|
@@ -8,7 +8,6 @@ import { NavigationTips } from "./NavigationTips.js";
|
|
|
8
8
|
import { VERSION } from "../version.js";
|
|
9
9
|
import { colors } from "../utils/theme.js";
|
|
10
10
|
import { execCommand } from "../utils/exec.js";
|
|
11
|
-
import { useViewportHeight } from "../hooks/useViewportHeight.js";
|
|
12
11
|
import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
|
|
13
12
|
import { useUpdateCheck } from "../hooks/useUpdateCheck.js";
|
|
14
13
|
const menuItems = [
|
|
@@ -48,11 +47,44 @@ const menuItems = [
|
|
|
48
47
|
color: colors.info,
|
|
49
48
|
},
|
|
50
49
|
];
|
|
50
|
+
function getLayoutMode(height) {
|
|
51
|
+
if (height >= 40)
|
|
52
|
+
return "full"; // Big banner + bordered items + descriptions
|
|
53
|
+
if (height >= 22)
|
|
54
|
+
return "medium"; // Small banner + simple items + descriptions
|
|
55
|
+
if (height >= 15)
|
|
56
|
+
return "compact"; // No banner + simple items + short descriptions
|
|
57
|
+
return "minimal"; // No banner + labels only
|
|
58
|
+
}
|
|
51
59
|
export const MainMenu = ({ onSelect }) => {
|
|
52
60
|
const { exit } = useApp();
|
|
53
61
|
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
|
54
|
-
|
|
55
|
-
|
|
62
|
+
const { stdout } = useStdout();
|
|
63
|
+
// Get raw terminal dimensions, responding to resize events
|
|
64
|
+
// Default to 20 rows / 80 cols if we can't detect
|
|
65
|
+
const getTerminalDimensions = React.useCallback(() => {
|
|
66
|
+
return {
|
|
67
|
+
height: stdout?.rows && stdout.rows > 0 ? stdout.rows : 20,
|
|
68
|
+
width: stdout?.columns && stdout.columns > 0 ? stdout.columns : 80,
|
|
69
|
+
};
|
|
70
|
+
}, [stdout]);
|
|
71
|
+
const [terminalDimensions, setTerminalDimensions] = React.useState(getTerminalDimensions);
|
|
72
|
+
React.useEffect(() => {
|
|
73
|
+
// Update immediately on mount and when stdout changes
|
|
74
|
+
setTerminalDimensions(getTerminalDimensions());
|
|
75
|
+
if (!stdout)
|
|
76
|
+
return;
|
|
77
|
+
const handleResize = () => {
|
|
78
|
+
setTerminalDimensions(getTerminalDimensions());
|
|
79
|
+
};
|
|
80
|
+
stdout.on("resize", handleResize);
|
|
81
|
+
return () => {
|
|
82
|
+
stdout.off("resize", handleResize);
|
|
83
|
+
};
|
|
84
|
+
}, [stdout, getTerminalDimensions]);
|
|
85
|
+
const terminalHeight = terminalDimensions.height;
|
|
86
|
+
const terminalWidth = terminalDimensions.width;
|
|
87
|
+
const isNarrow = terminalWidth < 70;
|
|
56
88
|
// Check for updates
|
|
57
89
|
const { updateAvailable } = useUpdateCheck();
|
|
58
90
|
// Handle Ctrl+C to exit
|
|
@@ -93,26 +125,49 @@ export const MainMenu = ({ onSelect }) => {
|
|
|
93
125
|
]);
|
|
94
126
|
}
|
|
95
127
|
});
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
128
|
+
const layoutMode = getLayoutMode(terminalHeight);
|
|
129
|
+
// Navigation tips for all layouts
|
|
130
|
+
const navTips = (_jsx(NavigationTips, { showArrows: true, paddingX: 2, tips: [
|
|
131
|
+
{ key: "1-5", label: "Quick select" },
|
|
132
|
+
{ key: "Enter", label: "Select" },
|
|
133
|
+
{ key: "Esc", label: "Quit" },
|
|
134
|
+
{ key: "u", label: "Update", condition: !!updateAvailable },
|
|
135
|
+
] }));
|
|
136
|
+
// Minimal layout - just the essentials
|
|
137
|
+
if (layoutMode === "minimal") {
|
|
138
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { paddingX: 2, children: [_jsx(Text, { color: colors.primary, bold: true, children: "RUNLOOP" }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "v", VERSION] })] }), _jsx(Box, { flexDirection: "column", paddingX: 2, children: menuItems.map((item, index) => {
|
|
100
139
|
const isSelected = index === selectedIndex;
|
|
101
|
-
return (_jsxs(Box, {
|
|
102
|
-
}) }),
|
|
103
|
-
{ key: "1-5", label: "Quick select" },
|
|
104
|
-
{ key: "Enter", label: "Select" },
|
|
105
|
-
{ key: "Esc", label: "Quit" },
|
|
106
|
-
{ key: "u", label: "Update", condition: !!updateAvailable },
|
|
107
|
-
] })] }));
|
|
140
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: isSelected ? item.color : colors.textDim, children: isSelected ? figures.pointer : " " }), _jsxs(Text, { color: item.color, children: [" ", item.icon, " "] }), _jsx(Text, { color: isSelected ? item.color : colors.text, bold: isSelected, children: item.label }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "[", index + 1, "]"] })] }, item.key));
|
|
141
|
+
}) }), navTips] }));
|
|
108
142
|
}
|
|
109
|
-
|
|
143
|
+
// Compact layout - no banner, simple items with descriptions (or no descriptions if narrow)
|
|
144
|
+
if (layoutMode === "compact") {
|
|
145
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Breadcrumb, { items: [{ label: "Home", active: true }], showVersionCheck: true }), _jsxs(Box, { paddingX: 2, children: [_jsx(Text, { color: colors.primary, bold: true, children: "RUNLOOP.ai" }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 v", VERSION] })] }), _jsx(Box, { flexDirection: "column", paddingX: 2, children: menuItems.map((item, index) => {
|
|
146
|
+
const isSelected = index === selectedIndex;
|
|
147
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: isSelected ? item.color : colors.textDim, children: isSelected ? figures.pointer : " " }), _jsxs(Text, { color: item.color, children: [" ", item.icon, " "] }), _jsx(Text, { color: isSelected ? item.color : colors.text, bold: isSelected, children: item.label }), _jsx(Text, { color: colors.textDim, dimColor: true, children: isNarrow
|
|
148
|
+
? ` [${index + 1}]`
|
|
149
|
+
: ` - ${item.description} [${index + 1}]` })] }, item.key));
|
|
150
|
+
}) }), navTips] }));
|
|
151
|
+
}
|
|
152
|
+
// Medium layout - small banner, simple items with descriptions (or no descriptions if narrow)
|
|
153
|
+
if (layoutMode === "medium") {
|
|
154
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Breadcrumb, { items: [{ label: "Home", active: true }], showVersionCheck: true }), _jsxs(Box, { paddingX: 2, marginBottom: 1, children: [_jsx(Text, { color: colors.primary, bold: true, children: "RUNLOOP.ai" }), _jsx(Text, { color: colors.textDim, dimColor: true, children: isNarrow
|
|
155
|
+
? ` • v${VERSION}`
|
|
156
|
+
: ` • Cloud development environments • v${VERSION}` })] }), _jsx(Box, { flexDirection: "column", paddingX: 2, children: menuItems.map((item, index) => {
|
|
157
|
+
const isSelected = index === selectedIndex;
|
|
158
|
+
return (_jsxs(Box, { marginBottom: 0, children: [_jsx(Text, { color: isSelected ? item.color : colors.textDim, children: isSelected ? figures.pointer : " " }), _jsx(Text, { children: " " }), _jsx(Text, { color: item.color, bold: true, children: item.icon }), _jsx(Text, { children: " " }), _jsx(Text, { color: isSelected ? item.color : colors.text, bold: isSelected, children: item.label }), !isNarrow && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "- ", item.description] })), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "[", index + 1, "]"] })] }, item.key));
|
|
159
|
+
}) }), navTips] }));
|
|
160
|
+
}
|
|
161
|
+
// Full layout - big banner, bordered items (or simple items if narrow)
|
|
162
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Breadcrumb, { items: [{ label: "Home", active: true }], showVersionCheck: true }), _jsx(Banner, {}), _jsx(Box, { flexDirection: "column", paddingX: 2, flexShrink: 0, children: _jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Cloud development environments for your team \u2022 v", VERSION] }) }) }), _jsxs(Box, { flexDirection: "column", paddingX: 2, marginTop: 1, children: [_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(Text, { color: colors.text, bold: true, children: "Select a resource:" }) }), isNarrow ? (
|
|
163
|
+
// Narrow layout - no borders, compact items
|
|
164
|
+
_jsx(Box, { flexDirection: "column", marginTop: 1, children: menuItems.map((item, index) => {
|
|
165
|
+
const isSelected = index === selectedIndex;
|
|
166
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: isSelected ? item.color : colors.textDim, children: isSelected ? figures.pointer : " " }), _jsxs(Text, { color: item.color, children: [" ", item.icon, " "] }), _jsx(Text, { color: isSelected ? item.color : colors.text, bold: isSelected, children: item.label }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "[", index + 1, "]"] })] }, item.key));
|
|
167
|
+
}) })) : (
|
|
168
|
+
// Wide layout - bordered items with descriptions
|
|
169
|
+
menuItems.map((item, index) => {
|
|
110
170
|
const isSelected = index === selectedIndex;
|
|
111
171
|
return (_jsxs(Box, { paddingX: 2, paddingY: 0, borderStyle: "single", borderColor: isSelected ? item.color : colors.border, marginTop: index === 0 ? 1 : 0, flexShrink: 0, children: [isSelected && (_jsxs(_Fragment, { children: [_jsx(Text, { color: item.color, bold: true, children: figures.pointer }), _jsx(Text, { children: " " })] })), _jsx(Text, { color: item.color, bold: true, children: item.icon }), _jsx(Text, { children: " " }), _jsx(Text, { color: isSelected ? item.color : colors.text, bold: isSelected, children: item.label }), _jsx(Text, { color: colors.textDim, children: " " }), _jsx(Text, { color: colors.textDim, dimColor: true, children: item.description }), _jsx(Text, { children: " " }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["[", index + 1, "]"] })] }, item.key));
|
|
112
|
-
})] }),
|
|
113
|
-
{ key: "1-5", label: "Quick select" },
|
|
114
|
-
{ key: "Enter", label: "Select" },
|
|
115
|
-
{ key: "Esc", label: "Quit" },
|
|
116
|
-
{ key: "u", label: "Update", condition: !!updateAvailable },
|
|
117
|
-
] })] }));
|
|
172
|
+
}))] }), navTips] }));
|
|
118
173
|
};
|
|
@@ -1,11 +1,149 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* NavigationTips - Shared component for rendering keyboard navigation hints
|
|
4
|
+
* Supports responsive display with compact format for small terminal widths
|
|
5
|
+
*/
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { Box, Text, useStdout } from "ink";
|
|
3
8
|
import figures from "figures";
|
|
4
9
|
import { colors } from "../utils/theme.js";
|
|
5
10
|
/**
|
|
6
|
-
*
|
|
11
|
+
* Map of common labels to their compact versions
|
|
12
|
+
*/
|
|
13
|
+
const COMPACT_LABELS = {
|
|
14
|
+
// Navigation
|
|
15
|
+
Navigate: "Nav",
|
|
16
|
+
Page: "Pg",
|
|
17
|
+
Top: "Top",
|
|
18
|
+
Bottom: "Bot",
|
|
19
|
+
// Actions
|
|
20
|
+
Select: "Sel",
|
|
21
|
+
Details: "Info",
|
|
22
|
+
Execute: "Run",
|
|
23
|
+
Continue: "OK",
|
|
24
|
+
Back: "Back",
|
|
25
|
+
Quit: "Quit",
|
|
26
|
+
Search: "Find",
|
|
27
|
+
Actions: "Act",
|
|
28
|
+
Create: "New",
|
|
29
|
+
Delete: "Del",
|
|
30
|
+
Copy: "Cpy",
|
|
31
|
+
Update: "Upd",
|
|
32
|
+
Refresh: "Ref",
|
|
33
|
+
// Browser/Web
|
|
34
|
+
Browser: "Web",
|
|
35
|
+
"Open in Browser": "Web",
|
|
36
|
+
// Toggles
|
|
37
|
+
"Toggle Wrap": "Wrap",
|
|
38
|
+
"Full Details": "More",
|
|
39
|
+
// Quick select - short version that isn't redundant with key
|
|
40
|
+
"Quick select": "Jump",
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Get compact label - uses explicit compactLabel, then lookup, then truncates
|
|
44
|
+
*/
|
|
45
|
+
function getCompactLabel(tip) {
|
|
46
|
+
if (tip.compactLabel)
|
|
47
|
+
return tip.compactLabel;
|
|
48
|
+
if (COMPACT_LABELS[tip.label])
|
|
49
|
+
return COMPACT_LABELS[tip.label];
|
|
50
|
+
// Truncate long labels to first word or 4 chars
|
|
51
|
+
if (tip.label.length > 6) {
|
|
52
|
+
const firstWord = tip.label.split(" ")[0];
|
|
53
|
+
return firstWord.length <= 6 ? firstWord : firstWord.slice(0, 4);
|
|
54
|
+
}
|
|
55
|
+
return tip.label;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Shorten key for very compact display
|
|
59
|
+
*/
|
|
60
|
+
function getCompactKey(key) {
|
|
61
|
+
// Shorten common compound keys
|
|
62
|
+
if (key === "Enter/q/esc")
|
|
63
|
+
return "⏎/q";
|
|
64
|
+
if (key === "Enter/q")
|
|
65
|
+
return "⏎/q";
|
|
66
|
+
if (key === "Enter")
|
|
67
|
+
return "⏎";
|
|
68
|
+
if (key === "Esc")
|
|
69
|
+
return "⎋";
|
|
70
|
+
return key;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Calculate the width needed to render tips in a given mode
|
|
74
|
+
*/
|
|
75
|
+
function calculateWidth(tips, mode, separator) {
|
|
76
|
+
let width = 0;
|
|
77
|
+
tips.forEach((tip, index) => {
|
|
78
|
+
if (index > 0)
|
|
79
|
+
width += separator.length;
|
|
80
|
+
if (tip.icon) {
|
|
81
|
+
width += tip.icon.length;
|
|
82
|
+
if (mode !== "keysOnly")
|
|
83
|
+
width += 1; // space after icon
|
|
84
|
+
}
|
|
85
|
+
if (tip.key) {
|
|
86
|
+
const keyStr = mode === "keysOnly" ? getCompactKey(tip.key) : tip.key;
|
|
87
|
+
width += keyStr.length + 2; // "[key]"
|
|
88
|
+
if (mode !== "keysOnly")
|
|
89
|
+
width += 1; // space after key
|
|
90
|
+
}
|
|
91
|
+
if (mode === "full") {
|
|
92
|
+
width += tip.label.length;
|
|
93
|
+
}
|
|
94
|
+
else if (mode === "compact") {
|
|
95
|
+
width += getCompactLabel(tip).length;
|
|
96
|
+
}
|
|
97
|
+
// keysOnly mode adds no label width
|
|
98
|
+
});
|
|
99
|
+
return width;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Renders a single tip based on display mode
|
|
103
|
+
*/
|
|
104
|
+
function renderTip(tip, mode) {
|
|
105
|
+
let result = "";
|
|
106
|
+
if (tip.icon) {
|
|
107
|
+
result += tip.icon;
|
|
108
|
+
if (mode !== "keysOnly")
|
|
109
|
+
result += " ";
|
|
110
|
+
}
|
|
111
|
+
if (tip.key) {
|
|
112
|
+
const keyStr = mode === "keysOnly" ? getCompactKey(tip.key) : tip.key;
|
|
113
|
+
result += `[${keyStr}]`;
|
|
114
|
+
if (mode !== "keysOnly")
|
|
115
|
+
result += " ";
|
|
116
|
+
}
|
|
117
|
+
if (mode === "full") {
|
|
118
|
+
result += tip.label;
|
|
119
|
+
}
|
|
120
|
+
else if (mode === "compact") {
|
|
121
|
+
result += getCompactLabel(tip);
|
|
122
|
+
}
|
|
123
|
+
return result.trimEnd();
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Renders a responsive navigation tips bar with compact format for small screens
|
|
7
127
|
*/
|
|
8
128
|
export const NavigationTips = ({ tips, showArrows = false, arrowLabel = "Navigate", paddingX = 1, marginTop = 1, }) => {
|
|
129
|
+
const { stdout } = useStdout();
|
|
130
|
+
// Get raw terminal width, responding to resize events
|
|
131
|
+
const [terminalWidth, setTerminalWidth] = React.useState(() => {
|
|
132
|
+
return stdout?.columns && stdout.columns > 0 ? stdout.columns : 80;
|
|
133
|
+
});
|
|
134
|
+
React.useEffect(() => {
|
|
135
|
+
if (!stdout)
|
|
136
|
+
return;
|
|
137
|
+
const handleResize = () => {
|
|
138
|
+
const newWidth = stdout.columns && stdout.columns > 0 ? stdout.columns : 80;
|
|
139
|
+
setTerminalWidth(newWidth);
|
|
140
|
+
};
|
|
141
|
+
stdout.on("resize", handleResize);
|
|
142
|
+
handleResize(); // Check on mount
|
|
143
|
+
return () => {
|
|
144
|
+
stdout.off("resize", handleResize);
|
|
145
|
+
};
|
|
146
|
+
}, [stdout]);
|
|
9
147
|
// Filter tips by condition (undefined condition means always show)
|
|
10
148
|
const visibleTips = tips.filter((tip) => tip.condition === undefined || tip.condition === true);
|
|
11
149
|
// Build the tips array, prepending arrows if requested
|
|
@@ -20,5 +158,37 @@ export const NavigationTips = ({ tips, showArrows = false, arrowLabel = "Navigat
|
|
|
20
158
|
if (allTips.length === 0) {
|
|
21
159
|
return null;
|
|
22
160
|
}
|
|
23
|
-
|
|
161
|
+
// Calculate available width (terminal width minus padding)
|
|
162
|
+
const availableWidth = terminalWidth - paddingX * 2;
|
|
163
|
+
// Determine the best display mode that fits
|
|
164
|
+
const fullSeparator = " • ";
|
|
165
|
+
const compactSeparator = " ";
|
|
166
|
+
const keysOnlySeparator = " ";
|
|
167
|
+
let mode = "full";
|
|
168
|
+
let separator = fullSeparator;
|
|
169
|
+
const fullWidth = calculateWidth(allTips, "full", fullSeparator);
|
|
170
|
+
if (fullWidth > availableWidth) {
|
|
171
|
+
// Try compact mode with shorter separator
|
|
172
|
+
const compactWidth = calculateWidth(allTips, "compact", compactSeparator);
|
|
173
|
+
if (compactWidth <= availableWidth) {
|
|
174
|
+
mode = "compact";
|
|
175
|
+
separator = compactSeparator;
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
// Fall back to keys-only mode
|
|
179
|
+
const keysOnlyWidth = calculateWidth(allTips, "keysOnly", keysOnlySeparator);
|
|
180
|
+
if (keysOnlyWidth <= availableWidth) {
|
|
181
|
+
mode = "keysOnly";
|
|
182
|
+
separator = keysOnlySeparator;
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
// Even keys-only doesn't fit, use it anyway (best we can do)
|
|
186
|
+
mode = "keysOnly";
|
|
187
|
+
separator = keysOnlySeparator;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// Build the output string to ensure no wrapping
|
|
192
|
+
const output = allTips.map((tip) => renderTip(tip, mode)).join(separator);
|
|
193
|
+
return (_jsx(Box, { marginTop: marginTop, paddingX: paddingX, children: _jsx(Text, { color: colors.textDim, dimColor: true, children: output }) }));
|
|
24
194
|
};
|
|
@@ -50,6 +50,43 @@ export function ResourceDetailPage({ resource: initialResource, resourceType, ge
|
|
|
50
50
|
}, []);
|
|
51
51
|
// Local state for resource data (updated by polling)
|
|
52
52
|
const [currentResource, setCurrentResource] = React.useState(initialResource);
|
|
53
|
+
const [copyStatus, setCopyStatus] = React.useState(null);
|
|
54
|
+
// Copy to clipboard helper
|
|
55
|
+
const copyToClipboard = React.useCallback(async (text) => {
|
|
56
|
+
const { spawn } = await import("child_process");
|
|
57
|
+
const platform = process.platform;
|
|
58
|
+
let command;
|
|
59
|
+
let args;
|
|
60
|
+
if (platform === "darwin") {
|
|
61
|
+
command = "pbcopy";
|
|
62
|
+
args = [];
|
|
63
|
+
}
|
|
64
|
+
else if (platform === "win32") {
|
|
65
|
+
command = "clip";
|
|
66
|
+
args = [];
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
command = "xclip";
|
|
70
|
+
args = ["-selection", "clipboard"];
|
|
71
|
+
}
|
|
72
|
+
const proc = spawn(command, args);
|
|
73
|
+
proc.stdin.write(text);
|
|
74
|
+
proc.stdin.end();
|
|
75
|
+
proc.on("exit", (code) => {
|
|
76
|
+
if (code === 0) {
|
|
77
|
+
setCopyStatus("Copied ID to clipboard!");
|
|
78
|
+
setTimeout(() => setCopyStatus(null), 2000);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
setCopyStatus("Failed to copy");
|
|
82
|
+
setTimeout(() => setCopyStatus(null), 2000);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
proc.on("error", () => {
|
|
86
|
+
setCopyStatus("Copy not supported");
|
|
87
|
+
setTimeout(() => setCopyStatus(null), 2000);
|
|
88
|
+
});
|
|
89
|
+
}, []);
|
|
53
90
|
const [showDetailedInfo, setShowDetailedInfo] = React.useState(false);
|
|
54
91
|
const [detailScroll, setDetailScroll] = React.useState(0);
|
|
55
92
|
const [selectedOperation, setSelectedOperation] = React.useState(0);
|
|
@@ -106,6 +143,10 @@ export function ResourceDetailPage({ resource: initialResource, resourceType, ge
|
|
|
106
143
|
if (input === "q" || key.escape) {
|
|
107
144
|
onBack();
|
|
108
145
|
}
|
|
146
|
+
else if (input === "c") {
|
|
147
|
+
// Copy resource ID to clipboard
|
|
148
|
+
copyToClipboard(getId(currentResource));
|
|
149
|
+
}
|
|
109
150
|
else if (input === "i" && buildDetailLines) {
|
|
110
151
|
setShowDetailedInfo(true);
|
|
111
152
|
setDetailScroll(0);
|
|
@@ -176,11 +217,12 @@ export function ResourceDetailPage({ resource: initialResource, resourceType, ge
|
|
|
176
217
|
.map((field, fieldIndex) => (_jsxs(Box, { children: [_jsxs(Text, { color: colors.textDim, children: [field.label, " "] }), typeof field.value === "string" ? (_jsx(Text, { color: field.color, dimColor: !field.color, children: field.value })) : (field.value)] }, fieldIndex))) })] }, sectionIndex))), additionalContent, operations.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.play, " Actions"] }), _jsx(Box, { flexDirection: "column", paddingLeft: 2, children: operations.map((op, index) => {
|
|
177
218
|
const isSelected = index === selectedOperation;
|
|
178
219
|
return (_jsxs(Box, { children: [_jsxs(Text, { color: isSelected ? colors.primary : colors.textDim, children: [isSelected ? figures.pointer : " ", " "] }), _jsxs(Text, { color: isSelected ? op.color : colors.textDim, bold: isSelected, children: [op.icon, " ", op.label] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "[", op.shortcut, "]"] })] }, op.key));
|
|
179
|
-
}) })] })), _jsx(NavigationTips, { showArrows: true, tips: [
|
|
220
|
+
}) })] })), copyStatus && (_jsx(Box, { marginTop: 1, paddingX: 1, children: _jsx(Text, { color: colors.success, bold: true, children: copyStatus }) })), _jsx(NavigationTips, { showArrows: true, tips: [
|
|
180
221
|
{ key: "Enter", label: "Execute" },
|
|
222
|
+
{ key: "c", label: "Copy ID" },
|
|
181
223
|
{ key: "i", label: "Full Details", condition: !!buildDetailLines },
|
|
182
224
|
{ key: "o", label: "Browser", condition: !!getUrl },
|
|
183
|
-
{ key: "q", label: "Back" },
|
|
225
|
+
{ key: "q/Ctrl+C", label: "Back/Quit" },
|
|
184
226
|
] })] }));
|
|
185
227
|
}
|
|
186
228
|
// Helper to format timestamp as "time (ago)"
|
package/dist/utils/commands.js
CHANGED
|
@@ -439,6 +439,53 @@ export function createProgram() {
|
|
|
439
439
|
const { deleteNetworkPolicy } = await import("../commands/network-policy/delete.js");
|
|
440
440
|
await deleteNetworkPolicy(id, options);
|
|
441
441
|
});
|
|
442
|
+
// Secret commands
|
|
443
|
+
const secret = program
|
|
444
|
+
.command("secret")
|
|
445
|
+
.description("Manage secrets")
|
|
446
|
+
.alias("s");
|
|
447
|
+
secret
|
|
448
|
+
.command("create <name>")
|
|
449
|
+
.description("Create a new secret. Value can be piped via stdin (e.g., echo 'val' | rli secret create name) or entered interactively with masked input for security.")
|
|
450
|
+
.option("-o, --output [format]", "Output format: text|json|yaml (default: text)")
|
|
451
|
+
.action(async (name, options) => {
|
|
452
|
+
const { createSecret } = await import("../commands/secret/create.js");
|
|
453
|
+
await createSecret(name, options);
|
|
454
|
+
});
|
|
455
|
+
secret
|
|
456
|
+
.command("list")
|
|
457
|
+
.description("List all secrets")
|
|
458
|
+
.option("--limit <n>", "Max results", "20")
|
|
459
|
+
.option("-o, --output [format]", "Output format: text|json|yaml (default: json)")
|
|
460
|
+
.action(async (options) => {
|
|
461
|
+
const { listSecrets } = await import("../commands/secret/list.js");
|
|
462
|
+
await listSecrets(options);
|
|
463
|
+
});
|
|
464
|
+
secret
|
|
465
|
+
.command("get <name>")
|
|
466
|
+
.description("Get secret metadata by name")
|
|
467
|
+
.option("-o, --output [format]", "Output format: text|json|yaml (default: json)")
|
|
468
|
+
.action(async (name, options) => {
|
|
469
|
+
const { getSecret } = await import("../commands/secret/get.js");
|
|
470
|
+
await getSecret(name, options);
|
|
471
|
+
});
|
|
472
|
+
secret
|
|
473
|
+
.command("update <name>")
|
|
474
|
+
.description("Update a secret value (value from stdin or secure prompt)")
|
|
475
|
+
.option("-o, --output [format]", "Output format: text|json|yaml (default: text)")
|
|
476
|
+
.action(async (name, options) => {
|
|
477
|
+
const { updateSecret } = await import("../commands/secret/update.js");
|
|
478
|
+
await updateSecret(name, options);
|
|
479
|
+
});
|
|
480
|
+
secret
|
|
481
|
+
.command("delete <name>")
|
|
482
|
+
.description("Delete a secret")
|
|
483
|
+
.option("-y, --yes", "Skip confirmation prompt")
|
|
484
|
+
.option("-o, --output [format]", "Output format: text|json|yaml (default: text)")
|
|
485
|
+
.action(async (name, options) => {
|
|
486
|
+
const { deleteSecret } = await import("../commands/secret/delete.js");
|
|
487
|
+
await deleteSecret(name, options);
|
|
488
|
+
});
|
|
442
489
|
// MCP server commands
|
|
443
490
|
const mcp = program
|
|
444
491
|
.command("mcp")
|
|
@@ -13,36 +13,35 @@ const SOURCE_CONFIG = {
|
|
|
13
13
|
const SOURCE_WIDTH = 5;
|
|
14
14
|
/**
|
|
15
15
|
* Format timestamp based on how recent the log is
|
|
16
|
+
* Always returns a fixed-width string for consistent alignment
|
|
16
17
|
*/
|
|
17
18
|
export function formatTimestamp(timestampMs) {
|
|
18
19
|
const date = new Date(timestampMs);
|
|
19
20
|
const now = new Date();
|
|
20
21
|
const isToday = date.toDateString() === now.toDateString();
|
|
21
22
|
const isThisYear = date.getFullYear() === now.getFullYear();
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
second: "2-digit",
|
|
27
|
-
});
|
|
23
|
+
// Build time components manually for consistent formatting
|
|
24
|
+
const hours = date.getHours().toString().padStart(2, "0");
|
|
25
|
+
const minutes = date.getMinutes().toString().padStart(2, "0");
|
|
26
|
+
const seconds = date.getSeconds().toString().padStart(2, "0");
|
|
28
27
|
const ms = date.getMilliseconds().toString().padStart(3, "0");
|
|
28
|
+
const time = `${hours}:${minutes}:${seconds}`;
|
|
29
29
|
if (isToday) {
|
|
30
|
+
// Format: "HH:MM:SS.mmm" (12 chars)
|
|
30
31
|
return `${time}.${ms}`;
|
|
31
32
|
}
|
|
32
33
|
else if (isThisYear) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
return `${monthDay} ${time}`;
|
|
34
|
+
// Format: "Mon DD HH:MM:SS" (15 chars, pad day to 2)
|
|
35
|
+
const month = date.toLocaleDateString("en-US", { month: "short" });
|
|
36
|
+
const day = date.getDate().toString().padStart(2, " ");
|
|
37
|
+
return `${month} ${day} ${time}`;
|
|
38
38
|
}
|
|
39
39
|
else {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
return `${fullDate} ${time}`;
|
|
40
|
+
// Format: "YYYY Mon DD HH:MM:SS" (20 chars)
|
|
41
|
+
const year = date.getFullYear();
|
|
42
|
+
const month = date.toLocaleDateString("en-US", { month: "short" });
|
|
43
|
+
const day = date.getDate().toString().padStart(2, " ");
|
|
44
|
+
return `${year} ${month} ${day} ${time}`;
|
|
46
45
|
}
|
|
47
46
|
}
|
|
48
47
|
/**
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utilities for reading secure input from stdin
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Prompt for a secret value with masked input (shows * for each character)
|
|
6
|
+
* Only works when stdin is a TTY (interactive terminal)
|
|
7
|
+
*/
|
|
8
|
+
export async function promptSecretValue(prompt = "Enter secret value: ") {
|
|
9
|
+
return new Promise((resolve) => {
|
|
10
|
+
process.stdout.write(prompt);
|
|
11
|
+
let value = "";
|
|
12
|
+
process.stdin.setRawMode(true);
|
|
13
|
+
process.stdin.resume();
|
|
14
|
+
process.stdin.setEncoding("utf8");
|
|
15
|
+
const onData = (char) => {
|
|
16
|
+
if (char === "\n" || char === "\r") {
|
|
17
|
+
process.stdin.setRawMode(false);
|
|
18
|
+
process.stdin.removeListener("data", onData);
|
|
19
|
+
process.stdin.pause();
|
|
20
|
+
process.stdout.write("\n");
|
|
21
|
+
resolve(value);
|
|
22
|
+
}
|
|
23
|
+
else if (char === "\u0003") {
|
|
24
|
+
// Ctrl+C
|
|
25
|
+
process.stdin.setRawMode(false);
|
|
26
|
+
process.stdout.write("\n");
|
|
27
|
+
process.exit(0);
|
|
28
|
+
}
|
|
29
|
+
else if (char === "\u007F" || char === "\b") {
|
|
30
|
+
// Backspace (0x7F) or Ctrl+H (0x08)
|
|
31
|
+
if (value.length > 0) {
|
|
32
|
+
value = value.slice(0, -1);
|
|
33
|
+
process.stdout.write("\b \b");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
else if (char >= " ") {
|
|
37
|
+
// Only add printable characters
|
|
38
|
+
value += char;
|
|
39
|
+
process.stdout.write("*");
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
process.stdin.on("data", onData);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Read all data from stdin (for piped input)
|
|
47
|
+
*/
|
|
48
|
+
export async function readStdin() {
|
|
49
|
+
const chunks = [];
|
|
50
|
+
for await (const chunk of process.stdin) {
|
|
51
|
+
chunks.push(Buffer.from(chunk));
|
|
52
|
+
}
|
|
53
|
+
return Buffer.concat(chunks).toString().trim();
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Get a secret value from either piped stdin or interactive prompt
|
|
57
|
+
* Automatically detects whether input is piped or interactive
|
|
58
|
+
*/
|
|
59
|
+
export async function getSecretValue(prompt = "Enter secret value: ") {
|
|
60
|
+
if (process.stdin.isTTY) {
|
|
61
|
+
return promptSecretValue(prompt);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
return readStdin();
|
|
65
|
+
}
|
|
66
|
+
}
|