Merge branch 'main' into feat/feishu-doc-tool

This commit is contained in:
Chum Yin 2026-03-02 04:20:16 +08:00 committed by GitHub
commit 888bd4101d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1176 additions and 1034 deletions

View File

@ -78,6 +78,7 @@ Notes:
- You can restrict who can use approval-management commands via `[autonomy].non_cli_approval_approvers`.
- Configure natural-language approval mode via `[autonomy].non_cli_natural_language_approval_mode`.
- `autonomy.non_cli_excluded_tools` is reloaded from `config.toml` at runtime; `/approvals` shows the currently effective list.
- Default non-CLI exclusions include both `shell` and `process`; remove `process` from `[autonomy].non_cli_excluded_tools` only when you explicitly want background command execution in chat channels.
- Each incoming message injects a runtime tool-availability snapshot into the system prompt, derived from the same exclusion policy used by execution.
## Inbound Image Marker Protocol

View File

@ -868,7 +868,7 @@ Environment overrides:
| `allow_sensitive_file_writes` | `false` | allow `file_write`/`file_edit` on sensitive files/dirs (for example `.env`, `.aws/credentials`, private keys) |
| `auto_approve` | `[]` | tool operations always auto-approved |
| `always_ask` | `[]` | tool operations that always require approval |
| `non_cli_excluded_tools` | `[]` | tools hidden from non-CLI channel tool specs |
| `non_cli_excluded_tools` | built-in denylist (includes `shell`, `process`, `file_write`, ...) | tools hidden from non-CLI channel tool specs |
| `non_cli_approval_approvers` | `[]` | optional allowlist for who can run non-CLI approval-management commands |
| `non_cli_natural_language_approval_mode` | `direct` | natural-language behavior for approval-management commands (`direct`, `request_confirm`, `disabled`) |
| `non_cli_natural_language_approval_mode_by_channel` | `{}` | per-channel override map for natural-language approval mode |
@ -908,6 +908,7 @@ Notes:
- `telegram:alice` allows only that channel+sender pair.
- `telegram:*` allows any sender on Telegram.
- `*:alice` allows `alice` on any channel.
- By default, `process` is excluded on non-CLI channels alongside `shell`. To opt in intentionally, remove `"process"` from `[autonomy].non_cli_excluded_tools` in `config.toml`.
- Use `/unapprove <tool>` to remove persisted approval from `autonomy.auto_approve`.
- `/approve-pending` lists pending requests for the current sender+chat/channel scope.
- If a tool remains unavailable after approval, check `autonomy.non_cli_excluded_tools` (runtime `/approvals` shows this list). Channel runtime reloads this list from `config.toml` automatically.

View File

@ -6747,6 +6747,36 @@ BTC is currently around $65,000 based on latest tool output."#
}
}
struct MockProcessTool;
#[async_trait::async_trait]
impl Tool for MockProcessTool {
fn name(&self) -> &str {
"process"
}
fn description(&self) -> &str {
"Mock process tool for runtime visibility tests"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"action": { "type": "string" }
}
})
}
async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
Ok(ToolResult {
success: true,
output: String::new(),
error: None,
})
}
}
#[test]
fn build_runtime_tool_visibility_prompt_respects_excluded_snapshot() {
let tools: Vec<Box<dyn Tool>> = vec![Box::new(MockPriceTool), Box::new(MockEchoTool)];
@ -6765,6 +6795,23 @@ BTC is currently around $65,000 based on latest tool output."#
assert!(!native.contains("## Tool Use Protocol"));
}
#[test]
fn build_runtime_tool_visibility_prompt_excludes_process_with_default_policy() {
let tools: Vec<Box<dyn Tool>> = vec![Box::new(MockProcessTool), Box::new(MockEchoTool)];
let excluded = crate::config::AutonomyConfig::default().non_cli_excluded_tools;
assert!(
excluded.contains(&"process".to_string()),
"default non-CLI exclusion list must include process"
);
let prompt = build_runtime_tool_visibility_prompt(&tools, &excluded, false);
assert!(prompt.contains("Excluded by runtime policy:"));
assert!(prompt.contains("process"));
assert!(!prompt.contains("**process**:"));
assert!(prompt.contains("`mock_echo`"));
}
#[tokio::test]
async fn process_channel_message_injects_runtime_tool_visibility_prompt() {
let channel_impl = Arc::new(RecordingChannel::default());

View File

@ -3320,6 +3320,7 @@ fn default_always_ask() -> Vec<String> {
fn default_non_cli_excluded_tools() -> Vec<String> {
[
"shell",
"process",
"file_write",
"file_edit",
"git_operations",
@ -9427,6 +9428,7 @@ mod tests {
assert!(!a.allow_sensitive_file_reads);
assert!(!a.allow_sensitive_file_writes);
assert!(a.non_cli_excluded_tools.contains(&"shell".to_string()));
assert!(a.non_cli_excluded_tools.contains(&"process".to_string()));
assert!(a.non_cli_excluded_tools.contains(&"delegate".to_string()));
}
@ -9460,6 +9462,9 @@ allowed_roots = []
"Missing command_context_rules must default to empty"
);
assert!(parsed.non_cli_excluded_tools.contains(&"shell".to_string()));
assert!(parsed
.non_cli_excluded_tools
.contains(&"process".to_string()));
assert!(parsed
.non_cli_excluded_tools
.contains(&"browser".to_string()));

View File

@ -5,6 +5,7 @@
//! - Google Cloud ADC (`GOOGLE_APPLICATION_CREDENTIALS`)
use crate::auth::AuthService;
use crate::multimodal;
use crate::providers::traits::{ChatMessage, ChatResponse, Provider, TokenUsage};
use async_trait::async_trait;
use base64::Engine;
@ -135,8 +136,22 @@ struct Content {
}
#[derive(Debug, Serialize, Clone)]
struct Part {
text: String,
#[serde(untagged)]
enum Part {
Text {
text: String,
},
InlineData {
#[serde(rename = "inlineData")]
inline_data: InlineDataPart,
},
}
#[derive(Debug, Serialize, Clone)]
struct InlineDataPart {
#[serde(rename = "mimeType")]
mime_type: String,
data: String,
}
#[derive(Debug, Serialize, Clone)]
@ -930,6 +945,57 @@ impl GeminiProvider {
|| status.is_server_error()
|| error_text.contains("RESOURCE_EXHAUSTED")
}
fn parse_inline_image_marker(image_ref: &str) -> Option<InlineDataPart> {
let rest = image_ref.strip_prefix("data:")?;
let semi_index = rest.find(';')?;
let mime_type = rest[..semi_index].trim();
if mime_type.is_empty() {
return None;
}
let payload = rest[semi_index + 1..].strip_prefix("base64,")?.trim();
if payload.is_empty() {
return None;
}
Some(InlineDataPart {
mime_type: mime_type.to_string(),
data: payload.to_string(),
})
}
fn build_user_parts(content: &str) -> Vec<Part> {
let (cleaned_text, image_refs) = multimodal::parse_image_markers(content);
if image_refs.is_empty() {
return vec![Part::Text {
text: content.to_string(),
}];
}
let mut parts: Vec<Part> = Vec::with_capacity(image_refs.len() + 1);
if !cleaned_text.is_empty() {
parts.push(Part::Text { text: cleaned_text });
}
for image_ref in image_refs {
if let Some(inline_data) = Self::parse_inline_image_marker(&image_ref) {
parts.push(Part::InlineData { inline_data });
} else {
parts.push(Part::Text {
text: format!("[IMAGE:{image_ref}]"),
});
}
}
if parts.is_empty() {
vec![Part::Text {
text: String::new(),
}]
} else {
parts
}
}
}
impl GeminiProvider {
@ -1154,16 +1220,14 @@ impl Provider for GeminiProvider {
) -> anyhow::Result<String> {
let system_instruction = system_prompt.map(|sys| Content {
role: None,
parts: vec![Part {
parts: vec![Part::Text {
text: sys.to_string(),
}],
});
let contents = vec![Content {
role: Some("user".to_string()),
parts: vec![Part {
text: message.to_string(),
}],
parts: Self::build_user_parts(message),
}];
let (text, _usage) = self
@ -1189,16 +1253,14 @@ impl Provider for GeminiProvider {
"user" => {
contents.push(Content {
role: Some("user".to_string()),
parts: vec![Part {
text: msg.content.clone(),
}],
parts: Self::build_user_parts(&msg.content),
});
}
"assistant" => {
// Gemini API uses "model" role instead of "assistant"
contents.push(Content {
role: Some("model".to_string()),
parts: vec![Part {
parts: vec![Part::Text {
text: msg.content.clone(),
}],
});
@ -1212,7 +1274,7 @@ impl Provider for GeminiProvider {
} else {
Some(Content {
role: None,
parts: vec![Part {
parts: vec![Part::Text {
text: system_parts.join("\n\n"),
}],
})
@ -1238,13 +1300,11 @@ impl Provider for GeminiProvider {
"system" => system_parts.push(&msg.content),
"user" => contents.push(Content {
role: Some("user".to_string()),
parts: vec![Part {
text: msg.content.clone(),
}],
parts: Self::build_user_parts(&msg.content),
}),
"assistant" => contents.push(Content {
role: Some("model".to_string()),
parts: vec![Part {
parts: vec![Part::Text {
text: msg.content.clone(),
}],
}),
@ -1257,7 +1317,7 @@ impl Provider for GeminiProvider {
} else {
Some(Content {
role: None,
parts: vec![Part {
parts: vec![Part::Text {
text: system_parts.join("\n\n"),
}],
})
@ -1545,7 +1605,7 @@ mod tests {
let body = GenerateContentRequest {
contents: vec![Content {
role: Some("user".into()),
parts: vec![Part {
parts: vec![Part::Text {
text: "hello".into(),
}],
}],
@ -1586,7 +1646,7 @@ mod tests {
let body = GenerateContentRequest {
contents: vec![Content {
role: Some("user".into()),
parts: vec![Part {
parts: vec![Part::Text {
text: "hello".into(),
}],
}],
@ -1630,7 +1690,7 @@ mod tests {
let body = GenerateContentRequest {
contents: vec![Content {
role: Some("user".into()),
parts: vec![Part {
parts: vec![Part::Text {
text: "hello".into(),
}],
}],
@ -1662,13 +1722,13 @@ mod tests {
let request = GenerateContentRequest {
contents: vec![Content {
role: Some("user".to_string()),
parts: vec![Part {
parts: vec![Part::Text {
text: "Hello".to_string(),
}],
}],
system_instruction: Some(Content {
role: None,
parts: vec![Part {
parts: vec![Part::Text {
text: "You are helpful".to_string(),
}],
}),
@ -1687,6 +1747,74 @@ mod tests {
assert!(json.contains("\"maxOutputTokens\":8192"));
}
#[test]
fn build_user_parts_text_only_is_backward_compatible() {
let content = "Plain text message without image markers.";
let parts = GeminiProvider::build_user_parts(content);
assert_eq!(parts.len(), 1);
match &parts[0] {
Part::Text { text } => assert_eq!(text, content),
Part::InlineData { .. } => panic!("text-only message must stay text-only"),
}
}
#[test]
fn build_user_parts_single_image() {
let parts = GeminiProvider::build_user_parts(
"Describe this image [IMAGE:data:image/png;base64,aGVsbG8=]",
);
assert_eq!(parts.len(), 2);
match &parts[0] {
Part::Text { text } => assert_eq!(text, "Describe this image"),
Part::InlineData { .. } => panic!("first part should be text"),
}
match &parts[1] {
Part::InlineData { inline_data } => {
assert_eq!(inline_data.mime_type, "image/png");
assert_eq!(inline_data.data, "aGVsbG8=");
}
Part::Text { .. } => panic!("second part should be inline image data"),
}
}
#[test]
fn build_user_parts_multiple_images() {
let parts = GeminiProvider::build_user_parts(
"Compare [IMAGE:data:image/png;base64,aQ==] and [IMAGE:data:image/jpeg;base64,ag==]",
);
assert_eq!(parts.len(), 3);
assert!(matches!(parts[0], Part::Text { .. }));
assert!(matches!(parts[1], Part::InlineData { .. }));
assert!(matches!(parts[2], Part::InlineData { .. }));
}
#[test]
fn build_user_parts_image_only() {
let parts = GeminiProvider::build_user_parts("[IMAGE:data:image/webp;base64,YWJjZA==]");
assert_eq!(parts.len(), 1);
match &parts[0] {
Part::InlineData { inline_data } => {
assert_eq!(inline_data.mime_type, "image/webp");
assert_eq!(inline_data.data, "YWJjZA==");
}
Part::Text { .. } => panic!("image-only message should create inline image part"),
}
}
#[test]
fn build_user_parts_fallback_for_non_data_uri_markers() {
let parts = GeminiProvider::build_user_parts("Inspect [IMAGE:https://example.com/img.png]");
assert_eq!(parts.len(), 2);
match &parts[0] {
Part::Text { text } => assert_eq!(text, "Inspect"),
Part::InlineData { .. } => panic!("first part should be text"),
}
match &parts[1] {
Part::Text { text } => assert_eq!(text, "[IMAGE:https://example.com/img.png]"),
Part::InlineData { .. } => panic!("invalid markers should fall back to text"),
}
}
#[test]
fn internal_request_includes_model() {
let request = InternalGenerateContentEnvelope {
@ -1696,7 +1824,7 @@ mod tests {
request: InternalGenerateContentRequest {
contents: vec![Content {
role: Some("user".to_string()),
parts: vec![Part {
parts: vec![Part::Text {
text: "Hello".to_string(),
}],
}],
@ -1728,7 +1856,7 @@ mod tests {
request: InternalGenerateContentRequest {
contents: vec![Content {
role: Some("user".to_string()),
parts: vec![Part {
parts: vec![Part::Text {
text: "Hello".to_string(),
}],
}],
@ -1751,7 +1879,7 @@ mod tests {
request: InternalGenerateContentRequest {
contents: vec![Content {
role: Some("user".to_string()),
parts: vec![Part {
parts: vec![Part::Text {
text: "Hello".to_string(),
}],
}],

1
web/dist/assets/index-BCWngppm.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

706
web/dist/assets/index-DOPK6_Za.js vendored Normal file

File diff suppressed because one or more lines are too long

4
web/dist/index.html vendored
View File

@ -9,8 +9,8 @@
/>
<meta name="color-scheme" content="dark" />
<title>ZeroClaw</title>
<script type="module" crossorigin src="/_app/assets/index-D0O_BdVX.js"></script>
<link rel="stylesheet" crossorigin href="/_app/assets/index-BarGrDiR.css">
<script type="module" crossorigin src="/_app/assets/index-DOPK6_Za.js"></script>
<link rel="stylesheet" crossorigin href="/_app/assets/index-BCWngppm.css">
</head>
<body>
<div id="root"></div>

232
web/package-lock.json generated
View File

@ -9,6 +9,11 @@
"version": "0.1.0",
"license": "(MIT OR Apache-2.0)",
"dependencies": {
"@codemirror/language": "^6.12.2",
"@codemirror/legacy-modes": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.39.15",
"@uiw/react-codemirror": "^4.25.5",
"lucide-react": "^0.468.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@ -260,6 +265,15 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@ -308,6 +322,108 @@
"node": ">=6.9.0"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz",
"integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.2",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.2.tgz",
"integrity": "sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.4.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.2",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.2.tgz",
"integrity": "sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/legacy-modes": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.2.tgz",
"integrity": "sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.4",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.4.tgz",
"integrity": "sha512-ABc9vJ8DEmvOWuH26P3i8FpMWPQkduD9Rvba5iwb6O3hxASgclm3T3krGo8NASXkHCidz6b++LWlzWIUfEPSWw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.35.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz",
"integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.5.4",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.4.tgz",
"integrity": "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/theme-one-dark": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
"integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.39.15",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.15.tgz",
"integrity": "sha512-aCWjgweIIXLBHh7bY6cACvXuyrZ0xGafjQ2VInjp4RM4gMfscK5uESiNdrH0pE+e1lZr2B4ONGsjchl2KsKZzg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@ -800,6 +916,36 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lezer/common": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz",
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
"license": "MIT"
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.8",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz",
"integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@ -1511,6 +1657,59 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@uiw/codemirror-extensions-basic-setup": {
"version": "4.25.5",
"resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.5.tgz",
"integrity": "sha512-2KWS4NqrS9SQzlPs/3sxFhuArvjB3JF6WpsrZqBtGHM5/smCNTULX3lUGeRH+f3mkfMt0k6DR+q0xCW9k+Up5w==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
},
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@codemirror/autocomplete": ">=6.0.0",
"@codemirror/commands": ">=6.0.0",
"@codemirror/language": ">=6.0.0",
"@codemirror/lint": ">=6.0.0",
"@codemirror/search": ">=6.0.0",
"@codemirror/state": ">=6.0.0",
"@codemirror/view": ">=6.0.0"
}
},
"node_modules/@uiw/react-codemirror": {
"version": "4.25.5",
"resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.5.tgz",
"integrity": "sha512-WUMBGwfstufdbnaiMzQzmOf+6Mzf0IbiOoleexC9ItWcDTJybidLtEi20aP2N58Wn/AQxsd5Otebydaimh7Opw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.6",
"@codemirror/commands": "^6.1.0",
"@codemirror/state": "^6.1.1",
"@codemirror/theme-one-dark": "^6.0.0",
"@uiw/codemirror-extensions-basic-setup": "4.25.5",
"codemirror": "^6.0.0"
},
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
},
"peerDependencies": {
"@babel/runtime": ">=7.11.0",
"@codemirror/state": ">=6.0.0",
"@codemirror/theme-one-dark": ">=6.0.0",
"@codemirror/view": ">=6.0.0",
"codemirror": ">=6.0.0",
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@ -1600,6 +1799,21 @@
],
"license": "CC-BY-4.0"
},
"node_modules/codemirror": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@ -1620,6 +1834,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@ -2351,6 +2571,12 @@
"node": ">=0.10.0"
}
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/tailwindcss": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.0.tgz",
@ -2516,6 +2742,12 @@
}
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@ -10,6 +10,11 @@
"preview": "vite preview"
},
"dependencies": {
"@codemirror/language": "^6.12.2",
"@codemirror/legacy-modes": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.39.15",
"@uiw/react-codemirror": "^4.25.5",
"lucide-react": "^0.468.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@ -1,9 +1,17 @@
import { StreamLanguage } from '@codemirror/language';
import { oneDark } from '@codemirror/theme-one-dark';
import { EditorView } from '@codemirror/view';
import { toml } from '@codemirror/legacy-modes/mode/toml';
import CodeMirror from '@uiw/react-codemirror';
interface Props {
rawToml: string;
onChange: (raw: string) => void;
disabled?: boolean;
}
const tomlLanguage = StreamLanguage.define(toml);
export default function ConfigRawEditor({ rawToml, onChange, disabled }: Props) {
return (
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
@ -15,14 +23,22 @@ export default function ConfigRawEditor({ rawToml, onChange, disabled }: Props)
{rawToml.split('\n').length} lines
</span>
</div>
<textarea
<CodeMirror
value={rawToml}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
spellCheck={false}
aria-label="Raw TOML configuration editor"
className="w-full min-h-[500px] bg-gray-950 text-gray-200 font-mono text-sm p-4 resize-y focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset disabled:opacity-50"
style={{ tabSize: 4 }}
onChange={onChange}
theme={oneDark}
readOnly={Boolean(disabled)}
editable={!disabled}
height="500px"
basicSetup={{
lineNumbers: true,
foldGutter: false,
highlightActiveLineGutter: false,
highlightActiveLine: false,
}}
extensions={[tomlLanguage, EditorView.lineWrapping]}
className="text-sm [&_.cm-scroller]:font-mono [&_.cm-scroller]:leading-6 [&_.cm-content]:py-4 [&_.cm-content]:px-0 [&_.cm-gutters]:border-r [&_.cm-gutters]:border-gray-800 [&_.cm-gutters]:bg-gray-950 [&_.cm-editor]:bg-gray-950 [&_.cm-editor]:focus:outline-none [&_.cm-focused]:ring-2 [&_.cm-focused]:ring-blue-500/70 [&_.cm-focused]:ring-inset"
aria-label="Raw TOML configuration editor with syntax highlighting"
/>
</div>
);