Compare commits

...

8 Commits

Author SHA1 Message Date
MusiCode1
09284ee2ce feat(ui): add RTL support for Hebrew/Arabic text (#229)
## What and why

CodeNomad had no RTL (right-to-left) support, so users writing in Hebrew
or Arabic would see their messages displayed left-to-right — misaligned
text, broken reading flow, wrong punctuation placement.

This PR adds automatic direction detection to all elements that display
user or model text. The browser detects direction from the first strong
character in each text block: Hebrew/Arabic → RTL, Latin/code → LTR. No
configuration needed — it just works per message, per paragraph.

## Technical notes

The natural fix is `dir="auto"` on the containing elements. However,
Chromium does not propagate direction detection from a parent `<div>`
into its `<p>` children — so Hebrew inside `<p>` rendered via
`innerHTML` (as markdown is) was still detected as LTR. The fix is to
apply `unicode-bidi: plaintext` via CSS directly on the block-level
elements (`p`, `li`, headings, etc.), which has the same auto-detection
semantics but applies per element.

## Summary

- Add `dir="auto"` to all elements containing user-generated or
model-generated text (message content, prompt input, session names, tool
outputs) so the browser auto-detects text direction
- Add `unicode-bidi: plaintext` via CSS to markdown block elements (`p`,
`li`, headings, `blockquote`, `td`/`th`) to fix per-paragraph RTL
detection in Chromium (where `dir="auto"` on a parent div does not
recurse into block children)
- Convert physical CSS properties to logical equivalents in
`markdown.css`: `border-left` → `border-inline-start`, `padding-left` →
`padding-inline-start`, `text-align: left` → `text-align: start`,
`margin-left` → `margin-inline-start`

## Affected components

- `markdown.tsx` — main markdown renderer
- `message-part.tsx` — text part wrapper and plain-text fallback
- `message-item.tsx` — message body and error blocks
- `prompt-input.tsx` — user input textarea
- `session-list.tsx` — session titles in sidebar
- `session-rename-dialog.tsx` — session rename input
- `instance-welcome-view.tsx` — Resume Session dialog
- `tool-call/markdown-render.tsx` — tool output markdown fallback
- `tool-call/ansi-render.tsx` — ANSI output
- `tool-call/diagnostics-section.tsx` — diagnostic messages

## Test plan

- [ ] Send a Hebrew-only message → text right-aligned
- [ ] Send a mixed Hebrew + English message → correct per-paragraph
direction
- [ ] Message containing a code block → code stays LTR
- [ ] Type Hebrew in the prompt textarea → input flows right-to-left
- [ ] Hebrew session name in sidebar → right-aligned
- [ ] Hebrew session name in Resume Session dialog → right-aligned

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 20:18:24 +00:00
Pascal André
a2e30f1b54 fix(tauri): restore desktop menu controls and fullscreen shortcut (#226)
## Summary
- restore the missing desktop View and Window menu controls
- use native reload and window actions where supported instead of
brittle webview-only behavior
- restore the working fullscreen keyboard shortcut while keeping the
zoom menu labels aligned with the intended desktop behavior

## Testing
- cargo check --manifest-path packages/tauri-app/src-tauri/Cargo.toml
2026-03-22 20:13:29 +00:00
Shantur Rathore
a4af811de3 Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev 2026-03-22 20:09:24 +00:00
Shantur Rathore
c5aa59ca75 fix(ui): keep reasoning streams pinned to bottom 2026-03-22 20:04:45 +00:00
Shantur Rathore
b8e0714b68 fix(ui): reduce message stream follow threshold 2026-03-22 19:54:28 +00:00
Shantur Rathore
3f890e5de1 fix(ui): restore spacing between virtualized message parts 2026-03-22 19:46:44 +00:00
Shantur Rathore
935926d875 ci: skip draft PR builds until ready 2026-03-22 19:41:48 +00:00
Pascal André
74f753abf4 perf(ui): lazy-load markdown and defer diff rendering (#215)
## Summary
- lazy-load the markdown and diff render paths so they stop inflating
initial UI startup work
- move shared text rendering helpers out of the markdown path and keep
diff rendering on the deferred path
- defer the Monaco secondary viewers so the markdown and diff path no
longer keeps that work in the main bundle

## Follow-ups
- related fork follow-up: Pagecran/CodeNomad#1
- that follow-up is now independent on dev and only keeps the remaining
right panel, picker, and tool-call secondary chunking work

## Testing
- npm run typecheck --workspace @codenomad/ui
- npm run build --workspace @codenomad/ui
2026-03-22 11:54:05 +00:00
40 changed files with 915 additions and 318 deletions

View File

@@ -6,6 +6,7 @@ on:
- opened - opened
- synchronize - synchronize
- reopened - reopened
- ready_for_review
permissions: permissions:
actions: read actions: read
@@ -20,6 +21,7 @@ jobs:
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }} ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
ACTOR: ${{ github.actor }} ACTOR: ${{ github.actor }}
BASE_REF: ${{ github.event.pull_request.base.ref }} BASE_REF: ${{ github.event.pull_request.base.ref }}
IS_DRAFT: ${{ github.event.pull_request.draft }}
PR_NUMBER: ${{ github.event.pull_request.number }} PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }}
RETENTION_DAYS: 7 RETENTION_DAYS: 7
@@ -42,7 +44,7 @@ jobs:
fi fi
- name: Wait for PR build and comment - name: Wait for PR build and comment
if: ${{ steps.auth.outputs.allowed == 'true' }} if: ${{ steps.auth.outputs.allowed == 'true' && env.IS_DRAFT != 'true' }}
uses: actions/github-script@v8 uses: actions/github-script@v8
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -6,6 +6,7 @@ on:
- opened - opened
- synchronize - synchronize
- reopened - reopened
- ready_for_review
permissions: permissions:
contents: read contents: read
@@ -45,7 +46,7 @@ jobs:
build: build:
needs: authorize needs: authorize
if: ${{ needs.authorize.outputs.allowed == 'true' }} if: ${{ needs.authorize.outputs.allowed == 'true' && !github.event.pull_request.draft }}
uses: ./.github/workflows/build-and-upload.yml uses: ./.github/workflows/build-and-upload.yml
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}

6
manifest.json Normal file
View File

@@ -0,0 +1,6 @@
{
"minServerVersion": "0.12.3",
"latestUIVersion": "0.12.3-rtl",
"uiPackageURL": "https://github.com/MusiCode1/CodeNomad/releases/download/v0.12.3-rtl/codenomad-ui-rtl.zip",
"sha256": "a2ce1aaa04345a2f9ca9d3c3149567867f3a5e477cf6eb269381e6dc1bec7ca2"
}

View File

@@ -473,6 +473,7 @@ dependencies = [
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-global-shortcut",
"tauri-plugin-notification", "tauri-plugin-notification",
"tauri-plugin-opener", "tauri-plugin-opener",
"thiserror 1.0.69", "thiserror 1.0.69",
@@ -1350,6 +1351,16 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "gethostname"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [
"rustix 1.1.4",
"windows-link 0.2.1",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.1.16" version = "0.1.16"
@@ -1482,6 +1493,24 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "global-hotkey"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7"
dependencies = [
"crossbeam-channel",
"keyboard-types",
"objc2",
"objc2-app-kit",
"once_cell",
"serde",
"thiserror 2.0.18",
"windows-sys 0.59.0",
"x11rb",
"xkeysym",
]
[[package]] [[package]]
name = "gobject-sys" name = "gobject-sys"
version = "0.18.0" version = "0.18.0"
@@ -4055,6 +4084,21 @@ dependencies = [
"url", "url",
] ]
[[package]]
name = "tauri-plugin-global-shortcut"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "424af23c7e88d05e4a1a6fc2c7be077912f8c76bd7900fd50aa2b7cbf5a2c405"
dependencies = [
"global-hotkey",
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
]
[[package]] [[package]]
name = "tauri-plugin-notification" name = "tauri-plugin-notification"
version = "2.3.3" version = "2.3.3"
@@ -5735,6 +5779,29 @@ dependencies = [
"pkg-config", "pkg-config",
] ]
[[package]]
name = "x11rb"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
dependencies = [
"gethostname",
"rustix 1.1.4",
"x11rb-protocol",
]
[[package]]
name = "x11rb-protocol"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "xkeysym"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.1" version = "0.8.1"

View File

@@ -23,6 +23,7 @@ keepawake = "0.6"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
dirs = "5" dirs = "5"
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-global-shortcut = "2"
url = "2" url = "2"
tauri-plugin-notification = "2" tauri-plugin-notification = "2"

File diff suppressed because one or more lines are too long

View File

@@ -2378,6 +2378,72 @@
"const": "dialog:deny-save", "const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope." "markdownDescription": "Denies the save command without any pre-configured scope."
}, },
{
"description": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n",
"type": "string",
"const": "global-shortcut:default",
"markdownDescription": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n"
},
{
"description": "Enables the is_registered command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:allow-is-registered",
"markdownDescription": "Enables the is_registered command without any pre-configured scope."
},
{
"description": "Enables the register command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:allow-register",
"markdownDescription": "Enables the register command without any pre-configured scope."
},
{
"description": "Enables the register_all command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:allow-register-all",
"markdownDescription": "Enables the register_all command without any pre-configured scope."
},
{
"description": "Enables the unregister command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:allow-unregister",
"markdownDescription": "Enables the unregister command without any pre-configured scope."
},
{
"description": "Enables the unregister_all command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:allow-unregister-all",
"markdownDescription": "Enables the unregister_all command without any pre-configured scope."
},
{
"description": "Denies the is_registered command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:deny-is-registered",
"markdownDescription": "Denies the is_registered command without any pre-configured scope."
},
{
"description": "Denies the register command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:deny-register",
"markdownDescription": "Denies the register command without any pre-configured scope."
},
{
"description": "Denies the register_all command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:deny-register-all",
"markdownDescription": "Denies the register_all command without any pre-configured scope."
},
{
"description": "Denies the unregister command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:deny-unregister",
"markdownDescription": "Denies the unregister command without any pre-configured scope."
},
{
"description": "Denies the unregister_all command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:deny-unregister-all",
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
},
{ {
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`", "description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string", "type": "string",

View File

@@ -8,10 +8,14 @@ use serde::Deserialize;
use serde_json::json; use serde_json::json;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex; use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder}; use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin}; use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
use tauri::webview::Webview; use tauri::webview::Webview;
use tauri::{AppHandle, Emitter, Manager, Runtime, Wry}; use tauri::{AppHandle, Emitter, Manager, Runtime, WindowEvent, Wry};
use tauri_plugin_global_shortcut::{
Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState,
};
use tauri_plugin_opener::OpenerExt; use tauri_plugin_opener::OpenerExt;
use url::Url; use url::Url;
@@ -25,6 +29,10 @@ use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID; use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false); static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
const DEFAULT_ZOOM_LEVEL: f64 = 1.0;
const ZOOM_STEP: f64 = 0.2;
const MIN_ZOOM_LEVEL: f64 = 0.2;
const MAX_ZOOM_LEVEL: f64 = 5.0;
#[cfg(windows)] #[cfg(windows)]
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client"; const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
@@ -32,6 +40,7 @@ const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
pub struct AppState { pub struct AppState {
pub manager: CliProcessManager, pub manager: CliProcessManager,
pub wake_lock: Mutex<Option<KeepAwake>>, pub wake_lock: Mutex<Option<KeepAwake>>,
pub zoom_level: Mutex<f64>,
} }
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
@@ -157,6 +166,83 @@ fn emit_folder_drop_event(
} }
} }
fn clamp_zoom_level(value: f64) -> f64 {
value.clamp(MIN_ZOOM_LEVEL, MAX_ZOOM_LEVEL)
}
fn set_main_window_zoom(app_handle: &AppHandle, next_zoom: f64) {
if let Some(window) = app_handle.get_webview_window("main") {
let normalized = clamp_zoom_level(next_zoom);
if window.set_zoom(normalized).is_ok() {
if let Ok(mut zoom_level) = app_handle.state::<AppState>().zoom_level.lock() {
*zoom_level = normalized;
}
}
}
}
fn reload_main_window(app_handle: &AppHandle) {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.reload();
}
}
fn force_reload_main_window(app_handle: &AppHandle) {
if let Some(window) = app_handle.get_webview_window("main") {
if let Ok(mut url) = window.url() {
if should_allow_internal(&url) {
let reload_token = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.to_string();
let existing_pairs: Vec<(String, String)> = url
.query_pairs()
.into_owned()
.filter(|(key, _)| key != "__codenomad_force_reload")
.collect();
{
let mut pairs = url.query_pairs_mut();
pairs.clear();
for (key, value) in existing_pairs {
pairs.append_pair(&key, &value);
}
pairs.append_pair("__codenomad_force_reload", &reload_token);
}
let _ = window.navigate(url);
return;
}
}
let _ = window.reload();
}
}
fn toggle_fullscreen_window(app_handle: &AppHandle) {
if let Some(window) = app_handle.get_webview_window("main") {
let next_fullscreen = !window.is_fullscreen().unwrap_or(false);
let _ = window.set_fullscreen(next_fullscreen);
if cfg!(not(target_os = "macos")) {
if next_fullscreen {
let _ = window.hide_menu();
} else {
let _ = window.show_menu();
}
}
}
}
fn fullscreen_shortcut() -> Option<Shortcut> {
if cfg!(target_os = "macos") {
None
} else {
Some(Shortcut::new(None, ShortcutCode::F11))
}
}
#[cfg(windows)] #[cfg(windows)]
fn set_windows_app_user_model_id() { fn set_windows_app_user_model_id() {
let app_id: Vec<u16> = OsStr::new(WINDOWS_APP_USER_MODEL_ID) let app_id: Vec<u16> = OsStr::new(WINDOWS_APP_USER_MODEL_ID)
@@ -181,15 +267,48 @@ fn main() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(
tauri_plugin_global_shortcut::Builder::new()
.with_handler(|app, shortcut, event| {
if event.state() != ShortcutState::Pressed {
return;
}
if fullscreen_shortcut().as_ref() == Some(shortcut) {
toggle_fullscreen_window(app);
}
})
.build(),
)
.plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_notification::init())
.plugin(navigation_guard) .plugin(navigation_guard)
.manage(AppState { .manage(AppState {
manager: CliProcessManager::new(), manager: CliProcessManager::new(),
wake_lock: Mutex::new(None), wake_lock: Mutex::new(None),
zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL),
}) })
.setup(|app| { .setup(|app| {
set_windows_app_user_model_id(); set_windows_app_user_model_id();
build_menu(&app.handle())?; build_menu(&app.handle())?;
if let Some(shortcut) = fullscreen_shortcut() {
let shortcut_manager = app.handle().global_shortcut();
let _ = shortcut_manager.register(shortcut.clone());
if let Some(window) = app.get_webview_window("main") {
let app_handle = app.handle().clone();
window.on_window_event(move |event| {
if let WindowEvent::Focused(focused) = event {
let shortcut_manager = app_handle.global_shortcut();
if *focused {
let _ = shortcut_manager.register(shortcut.clone());
} else {
let _ = shortcut_manager.unregister(shortcut.clone());
}
}
});
}
}
let dev_mode = is_dev_mode(); let dev_mode = is_dev_mode();
let app_handle = app.handle().clone(); let app_handle = app.handle().clone();
let manager = app.state::<AppState>().manager.clone(); let manager = app.state::<AppState>().manager.clone();
@@ -214,36 +333,42 @@ fn main() {
let _ = window.emit("menu:newInstance", ()); let _ = window.emit("menu:newInstance", ());
} }
} }
"close" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.close();
}
}
"quit" => { "quit" => {
app_handle.exit(0); app_handle.exit(0);
} }
// View menu // View menu
"reload" => { "reload" => {
if let Some(window) = app_handle.get_webview_window("main") { reload_main_window(app_handle);
let _ = window.eval("window.location.reload()");
}
} }
"force_reload" => { "force_reload" => {
if let Some(window) = app_handle.get_webview_window("main") { force_reload_main_window(app_handle);
let _ = window.eval("window.location.reload(true)");
}
} }
"toggle_devtools" => { "toggle_devtools" => {
if let Some(window) = app_handle.get_webview_window("main") { if let Some(window) = app_handle.get_webview_window("main") {
window.open_devtools(); if window.is_devtools_open() {
window.close_devtools();
} else {
window.open_devtools();
}
}
}
"reset_zoom" => {
set_main_window_zoom(app_handle, DEFAULT_ZOOM_LEVEL);
}
"zoom_in" => {
if let Ok(zoom_level) = app_handle.state::<AppState>().zoom_level.lock() {
set_main_window_zoom(app_handle, *zoom_level + ZOOM_STEP);
}
}
"zoom_out" => {
if let Ok(zoom_level) = app_handle.state::<AppState>().zoom_level.lock() {
set_main_window_zoom(app_handle, *zoom_level - ZOOM_STEP);
} }
} }
"toggle_fullscreen" => { "toggle_fullscreen" => {
if let Some(window) = app_handle.get_webview_window("main") { toggle_fullscreen_window(app_handle);
let _ = window.set_fullscreen(!window.is_fullscreen().unwrap_or(false));
}
} }
// Window menu // Window menu
@@ -257,6 +382,11 @@ fn main() {
let _ = window.maximize(); let _ = window.maximize();
} }
} }
"close_window" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.close();
}
}
// App menu (macOS) // App menu (macOS)
"about" => { "about" => {
@@ -344,6 +474,7 @@ fn main() {
fn build_menu(app: &AppHandle) -> tauri::Result<()> { fn build_menu(app: &AppHandle) -> tauri::Result<()> {
let is_mac = cfg!(target_os = "macos"); let is_mac = cfg!(target_os = "macos");
let is_linux = cfg!(target_os = "linux");
// Create submenus // Create submenus
let mut submenus = Vec::new(); let mut submenus = Vec::new();
@@ -371,16 +502,74 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
Some("CmdOrCtrl+N"), Some("CmdOrCtrl+N"),
)?; )?;
let file_menu = SubmenuBuilder::new(app, "File") let file_menu = if is_mac {
.item(&new_instance_item) SubmenuBuilder::new(app, "File")
.separator() .item(&new_instance_item)
.text( .separator()
if is_mac { "close" } else { "quit" }, .close_window()
if is_mac { "Close" } else { "Quit" }, .build()?
) } else {
.build()?; SubmenuBuilder::new(app, "File")
.item(&new_instance_item)
.separator()
.text("quit", "Quit")
.build()?
};
submenus.push(file_menu); submenus.push(file_menu);
let reload_item = MenuItem::with_id(app, "reload", "Reload", true, Some("CmdOrCtrl+R"))?;
let force_reload_item = MenuItem::with_id(
app,
"force_reload",
"Force Reload",
true,
Some("CmdOrCtrl+Shift+R"),
)?;
let toggle_devtools_item = MenuItem::with_id(
app,
"toggle_devtools",
"Toggle Developer Tools",
true,
Some("Alt+CmdOrCtrl+I"),
)?;
let reset_zoom_item =
MenuItem::with_id(app, "reset_zoom", "Actual Size", true, Some("CmdOrCtrl+0"))?;
let zoom_in_item = MenuItem::with_id(
app,
"zoom_in",
if is_mac { "Zoom In" } else { "Zoom In\tCtrl++" },
true,
None::<&str>,
)?;
let zoom_out_item = MenuItem::with_id(
app,
"zoom_out",
if is_mac {
"Zoom Out"
} else {
"Zoom Out\tCtrl+-"
},
true,
None::<&str>,
)?;
let toggle_fullscreen_item = MenuItem::with_id(
app,
"toggle_fullscreen",
if is_mac {
"Toggle Full Screen"
} else {
"Toggle Full Screen\tF11"
},
true,
if is_mac {
Some("Ctrl+Cmd+F")
} else {
None::<&str>
},
)?;
let close_window_item =
MenuItem::with_id(app, "close_window", "Close", true, Some("CmdOrCtrl+W"))?;
// Edit menu with predefined items for standard functionality // Edit menu with predefined items for standard functionality
let edit_menu = SubmenuBuilder::new(app, "Edit") let edit_menu = SubmenuBuilder::new(app, "Edit")
.undo() .undo()
@@ -396,20 +585,39 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
// View menu // View menu
let view_menu = SubmenuBuilder::new(app, "View") let view_menu = SubmenuBuilder::new(app, "View")
.text("reload", "Reload") .item(&reload_item)
.text("force_reload", "Force Reload") .item(&force_reload_item)
.text("toggle_devtools", "Toggle Developer Tools") .item(&toggle_devtools_item)
.separator() .separator()
.item(&reset_zoom_item)
.item(&zoom_in_item)
.item(&zoom_out_item)
.separator() .separator()
.text("toggle_fullscreen", "Toggle Full Screen") .item(&toggle_fullscreen_item)
.build()?; .build()?;
submenus.push(view_menu); submenus.push(view_menu);
// Window menu // Window menu
let window_menu = SubmenuBuilder::new(app, "Window") let window_menu = if is_linux {
.text("minimize", "Minimize") SubmenuBuilder::new(app, "Window")
.text("zoom", "Zoom") .text("minimize", "Minimize")
.build()?; .text("zoom", "Zoom")
.separator()
.item(&close_window_item)
.build()?
} else if is_mac {
SubmenuBuilder::new(app, "Window")
.minimize()
.maximize()
.build()?
} else {
SubmenuBuilder::new(app, "Window")
.minimize()
.maximize()
.separator()
.close_window()
.build()?
};
submenus.push(window_menu); submenus.push(window_menu);
// Build the main menu with all submenus // Build the main menu with all submenus

View File

@@ -11,10 +11,8 @@ import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
import InstanceShell from "./components/instance/instance-shell2" import InstanceShell from "./components/instance/instance-shell2"
import { SettingsScreen } from "./components/settings-screen" import { SettingsScreen } from "./components/settings-screen"
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context" import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
import { initMarkdown } from "./lib/markdown"
import { initGithubStars } from "./stores/github-stars" import { initGithubStars } from "./stores/github-stars"
import { useTheme } from "./lib/theme"
import { useCommands } from "./lib/hooks/use-commands" import { useCommands } from "./lib/hooks/use-commands"
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle" import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
import { getLogger } from "./lib/logger" import { getLogger } from "./lib/logger"
@@ -59,7 +57,6 @@ import { openSettings } from "./stores/settings-screen"
const log = getLogger("actions") const log = getLogger("actions")
const App: Component = () => { const App: Component = () => {
const { isDark } = useTheme()
const { t } = useI18n() const { t } = useI18n()
const { const {
preferences, preferences,
@@ -183,10 +180,6 @@ const App: Component = () => {
} }
}) })
createEffect(() => {
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
})
createEffect(() => { createEffect(() => {
initReleaseNotifications() initReleaseNotifications()
}) })

View File

@@ -1,7 +1,8 @@
import { createSignal, onMount, Show, createEffect } from "solid-js" import { createSignal, onMount, Show, createEffect } from "solid-js"
import type { Highlighter } from "shiki/bundle/full" import type { Highlighter } from "shiki/bundle/full"
import { useTheme } from "../lib/theme" import { useTheme } from "../lib/theme"
import { getSharedHighlighter, escapeHtml } from "../lib/markdown" import { getSharedHighlighter } from "../lib/markdown"
import { escapeHtml } from "../lib/text-render-utils"
import { copyToClipboard } from "../lib/clipboard" import { copyToClipboard } from "../lib/clipboard"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"

View File

@@ -1,9 +1,10 @@
import { createMemo, Show, createEffect, onCleanup } from "solid-js" import { createMemo, Show, createEffect, onCleanup } from "solid-js"
import { DiffView, DiffModeEnum } from "@git-diff-view/solid" import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
import "@git-diff-view/solid/styles/diff-view-pure.css"
import { disableCache } from "@git-diff-view/core" import { disableCache } from "@git-diff-view/core"
import type { DiffHighlighterLang } from "@git-diff-view/core" import type { DiffHighlighterLang } from "@git-diff-view/core"
import { ErrorBoundary } from "solid-js" import { ErrorBoundary } from "solid-js"
import { getLanguageFromPath } from "../lib/markdown" import { getLanguageFromPath } from "../lib/text-render-utils"
import { normalizeDiffText } from "../lib/diff-utils" import { normalizeDiffText } from "../lib/diff-utils"
import { setCacheEntry } from "../lib/global-cache" import { setCacheEntry } from "../lib/global-cache"
import type { CacheEntryParams } from "../lib/global-cache" import type { CacheEntryParams } from "../lib/global-cache"

View File

@@ -404,6 +404,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span <span
class="text-sm font-medium text-primary whitespace-normal break-words transition-colors" class="text-sm font-medium text-primary whitespace-normal break-words transition-colors"
dir="auto"
classList={{ classList={{
"text-accent": isFocused(), "text-accent": isFocused(),
}} }}

View File

@@ -1,11 +1,13 @@
import { For, Show, createMemo, type Accessor, type Component, type JSX } from "solid-js" import { For, Show, Suspense, createMemo, lazy, type Accessor, type Component, type JSX } from "solid-js"
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
import DiffToolbar from "../components/DiffToolbar" import DiffToolbar from "../components/DiffToolbar"
import SplitFilePanel from "../components/SplitFilePanel" import SplitFilePanel from "../components/SplitFilePanel"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types" import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
const LazyMonacoDiffViewer = lazy(() =>
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
)
interface ChangesTabProps { interface ChangesTabProps {
t: (key: string, vars?: Record<string, any>) => string t: (key: string, vars?: Record<string, any>) => string
@@ -113,15 +115,23 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
} }
> >
{(file) => ( {(file) => (
<MonacoDiffViewer <Suspense
scopeKey={scopeKey()} fallback={
path={String(file().file || "")} <div class="file-viewer-empty">
before={String((file() as any).before || "")} <span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
after={String((file() as any).after || "")} </div>
viewMode={props.diffViewMode()} }
contextMode={props.diffContextMode()} >
wordWrap={props.diffWordWrapMode()} <LazyMonacoDiffViewer
/> scopeKey={scopeKey()}
path={String(file().file || "")}
before={String((file() as any).before || "")}
after={String((file() as any).after || "")}
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
wordWrap={props.diffWordWrapMode()}
/>
</Suspense>
)} )}
</Show> </Show>
</div> </div>
@@ -220,7 +230,7 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
onResizeMouseDown={props.onResizeMouseDown} onResizeMouseDown={props.onResizeMouseDown}
onResizeTouchStart={props.onResizeTouchStart} onResizeTouchStart={props.onResizeTouchStart}
isPhoneLayout={props.isPhoneLayout()} isPhoneLayout={props.isPhoneLayout()}
overlayAriaLabel="Changes" overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.changes")}
/> />
) )
} }

View File

@@ -1,12 +1,14 @@
import { For, Show, type Accessor, type Component, type JSX } from "solid-js" import { For, Show, Suspense, lazy, type Accessor, type Component, type JSX } from "solid-js"
import type { FileNode } from "@opencode-ai/sdk/v2/client" import type { FileNode } from "@opencode-ai/sdk/v2/client"
import { RefreshCw } from "lucide-solid" import { RefreshCw } from "lucide-solid"
import { MonacoFileViewer } from "../../../../file-viewer/monaco-file-viewer"
import SplitFilePanel from "../components/SplitFilePanel" import SplitFilePanel from "../components/SplitFilePanel"
const LazyMonacoFileViewer = lazy(() =>
import("../../../../file-viewer/monaco-file-viewer").then((module) => ({ default: module.MonacoFileViewer })),
)
interface FilesTabProps { interface FilesTabProps {
t: (key: string, vars?: Record<string, any>) => string t: (key: string, vars?: Record<string, any>) => string
@@ -51,8 +53,8 @@ const FilesTab: Component<FilesTabProps> = (props) => {
const headerDisplayedPath = () => props.browserSelectedPath() || props.browserPath() const headerDisplayedPath = () => props.browserSelectedPath() || props.browserPath()
const emptyViewerMessage = () => { const emptyViewerMessage = () => {
if (props.browserLoading() && entriesValue === null) return "Loading files..." if (props.browserLoading() && entriesValue === null) return props.t("instanceInfo.loading")
return "Select a file to preview" return props.t("instanceShell.filesShell.viewerEmpty")
} }
const renderViewer = () => ( const renderViewer = () => (
@@ -77,7 +79,15 @@ const FilesTab: Component<FilesTabProps> = (props) => {
} }
> >
{(payload) => ( {(payload) => (
<MonacoFileViewer scopeKey={props.scopeKey()} path={payload().path} content={payload().content} /> <Suspense
fallback={
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
</div>
}
>
<LazyMonacoFileViewer scopeKey={props.scopeKey()} path={payload().path} content={payload().content} />
</Suspense>
)} )}
</Show> </Show>
} }
@@ -91,7 +101,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
} }
> >
<div class="file-viewer-empty"> <div class="file-viewer-empty">
<span class="file-viewer-empty-text">Loading</span> <span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
</div> </div>
</Show> </Show>
</div> </div>
@@ -113,7 +123,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
</Show> </Show>
<Show when={props.browserLoading() && entriesValue === null}> <Show when={props.browserLoading() && entriesValue === null}>
<div class="p-3 text-xs text-secondary">Loading files...</div> <div class="p-3 text-xs text-secondary">{props.t("instanceInfo.loading")}</div>
</Show> </Show>
<For each={sorted}> <For each={sorted}>
@@ -154,7 +164,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
</span> </span>
</span> </span>
<Show when={props.browserLoading()}> <Show when={props.browserLoading()}>
<span>Loading</span> <span>{props.t("instanceInfo.loading")}</span>
</Show> </Show>
<Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show> <Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
</div> </div>
@@ -180,7 +190,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
onResizeMouseDown={props.onResizeMouseDown} onResizeMouseDown={props.onResizeMouseDown}
onResizeTouchStart={props.onResizeTouchStart} onResizeTouchStart={props.onResizeTouchStart}
isPhoneLayout={props.isPhoneLayout()} isPhoneLayout={props.isPhoneLayout()}
overlayAriaLabel="Files" overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.files")}
/> />
) )
} }

View File

@@ -1,14 +1,16 @@
import { For, Show, createMemo, type Accessor, type Component, type JSX } from "solid-js" import { For, Show, Suspense, createMemo, lazy, type Accessor, type Component, type JSX } from "solid-js"
import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client" import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
import { RefreshCw } from "lucide-solid" import { RefreshCw } from "lucide-solid"
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
import DiffToolbar from "../components/DiffToolbar" import DiffToolbar from "../components/DiffToolbar"
import SplitFilePanel from "../components/SplitFilePanel" import SplitFilePanel from "../components/SplitFilePanel"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types" import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
const LazyMonacoDiffViewer = lazy(() =>
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
)
interface GitChangesTabProps { interface GitChangesTabProps {
t: (key: string, vars?: Record<string, any>) => string t: (key: string, vars?: Record<string, any>) => string
@@ -80,11 +82,11 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
}) })
const emptyViewerMessage = createMemo(() => { const emptyViewerMessage = createMemo(() => {
if (!hasSession()) return "Select a session to view changes." if (!hasSession()) return props.t("instanceShell.sessionChanges.noSessionSelected")
const currentEntries = entries() const currentEntries = entries()
if (currentEntries === null) return "Loading git changes…" if (currentEntries === null) return props.t("instanceShell.gitChanges.loading")
if (nonDeleted().length === 0) return "No git changes yet." if (nonDeleted().length === 0) return props.t("instanceShell.gitChanges.empty")
return "No file selected." return props.t("instanceShell.filesShell.viewerEmpty")
}) })
const renderContent = (): JSX.Element => { const renderContent = (): JSX.Element => {
@@ -122,7 +124,14 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
} }
> >
{(file) => ( {(file) => (
<MonacoDiffViewer <Suspense
fallback={
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
</div>
}
>
<LazyMonacoDiffViewer
scopeKey={props.scopeKey()} scopeKey={props.scopeKey()}
path={String(file().path || "")} path={String(file().path || "")}
before={String((file() as any).before || "")} before={String((file() as any).before || "")}
@@ -131,7 +140,8 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
contextMode={props.diffContextMode()} contextMode={props.diffContextMode()}
wordWrap={props.diffWordWrapMode()} wordWrap={props.diffWordWrapMode()}
/> />
)} </Suspense>
)}
</Show> </Show>
} }
> >
@@ -144,7 +154,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
} }
> >
<div class="file-viewer-empty"> <div class="file-viewer-empty">
<span class="file-viewer-empty-text">Loading</span> <span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
</div> </div>
</Show> </Show>
</div> </div>
@@ -169,7 +179,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
</div> </div>
<div class="file-list-item-stats"> <div class="file-list-item-stats">
<Show when={item.status === "deleted"}> <Show when={item.status === "deleted"}>
<span class="text-[10px] text-secondary">deleted</span> <span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span>
</Show> </Show>
<Show when={item.status !== "deleted"}> <Show when={item.status !== "deleted"}>
<> <>
@@ -200,7 +210,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
</div> </div>
<div class="file-list-item-stats"> <div class="file-list-item-stats">
<Show when={item.status === "deleted"}> <Show when={item.status === "deleted"}>
<span class="text-[10px] text-secondary">deleted</span> <span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span>
</Show> </Show>
<Show when={item.status !== "deleted"}> <Show when={item.status !== "deleted"}>
<> <>
@@ -220,8 +230,8 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
<SplitFilePanel <SplitFilePanel
header={ header={
<> <>
<span class="files-tab-selected-path" title={selected?.path || "Git Changes"}> <span class="files-tab-selected-path" title={selected?.path || props.t("instanceShell.rightPanel.tabs.gitChanges")}>
<span class="file-path-text">{selected?.path || "Git Changes"}</span> <span class="file-path-text">{selected?.path || props.t("instanceShell.rightPanel.tabs.gitChanges")}</span>
</span> </span>
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}> <div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
@@ -264,7 +274,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
onResizeMouseDown={props.onResizeMouseDown} onResizeMouseDown={props.onResizeMouseDown}
onResizeTouchStart={props.onResizeTouchStart} onResizeTouchStart={props.onResizeTouchStart}
isPhoneLayout={props.isPhoneLayout()} isPhoneLayout={props.isPhoneLayout()}
overlayAriaLabel="Git Changes" overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.gitChanges")}
/> />
) )
} }

View File

@@ -1,5 +1,4 @@
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js" import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { renderMarkdown, onLanguagesLoaded, decodeHtmlEntities, setMarkdownTheme } from "../lib/markdown"
import { useGlobalCache } from "../lib/hooks/use-global-cache" import { useGlobalCache } from "../lib/hooks/use-global-cache"
import type { TextPart, RenderCache } from "../types/message" import type { TextPart, RenderCache } from "../types/message"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
@@ -8,6 +7,20 @@ import { useI18n } from "../lib/i18n"
const log = getLogger("session") const log = getLogger("session")
type MarkdownModule = typeof import("../lib/markdown")
let markdownModulePromise: Promise<MarkdownModule> | null = null
function loadMarkdownModule(): Promise<MarkdownModule> {
if (!markdownModulePromise) {
markdownModulePromise = import("../lib/markdown").catch((error) => {
markdownModulePromise = null
throw error
})
}
return markdownModulePromise
}
function hashText(value: string): string { function hashText(value: string): string {
let hash = 2166136261 let hash = 2166136261
for (let index = 0; index < value.length; index++) { for (let index = 0; index < value.length; index++) {
@@ -24,6 +37,45 @@ function resolvePartVersion(part: TextPart, text: string): string {
return `text-${hashText(text)}` return `text-${hashText(text)}`
} }
function resolvePartCacheId(part: TextPart, text: string): string {
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : ""
if (partId) {
return partId
}
return `anonymous:${hashText(text)}`
}
function decodeHtmlEntitiesLocally(content: string): string {
if (!content.includes("&") || typeof document === "undefined") {
return content
}
const textarea = document.createElement("textarea")
textarea.innerHTML = content
return textarea.value
}
function escapeHtml(content: string): string {
const map: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
}
return content.replace(/[&<>"']/g, (match) => map[match] ?? match)
}
function renderFallbackHtml(content: string): string {
if (!content) {
return ""
}
return escapeHtml(content).replace(/\n/g, "<br />")
}
interface MarkdownProps { interface MarkdownProps {
part: TextPart part: TextPart
instanceId?: string instanceId?: string
@@ -38,7 +90,8 @@ export function Markdown(props: MarkdownProps) {
const { t } = useI18n() const { t } = useI18n()
const [html, setHtml] = createSignal("") const [html, setHtml] = createSignal("")
let containerRef: HTMLDivElement | undefined let containerRef: HTMLDivElement | undefined
let latestRequestedText = "" let latestRequestKey = ""
let cleanupLanguageListener: (() => void) | undefined
const notifyRendered = () => { const notifyRendered = () => {
Promise.resolve().then(() => props.onRendered?.()) Promise.resolve().then(() => props.onRendered?.())
@@ -47,15 +100,14 @@ export function Markdown(props: MarkdownProps) {
const resolved = createMemo(() => { const resolved = createMemo(() => {
const part = props.part const part = props.part
const rawText = typeof part.text === "string" ? part.text : "" const rawText = typeof part.text === "string" ? part.text : ""
const text = decodeHtmlEntities(rawText) const text = decodeHtmlEntitiesLocally(rawText)
const themeKey = Boolean(props.isDark) ? "dark" : "light" const themeKey = Boolean(props.isDark) ? "dark" : "light"
const highlightEnabled = !props.disableHighlight const highlightEnabled = !props.disableHighlight
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : "" const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : undefined
if (!partId) { const cacheId = resolvePartCacheId(part, text)
throw new Error("Markdown rendering requires a part id")
}
const version = resolvePartVersion(part, text) const version = resolvePartVersion(part, text)
return { part, text, themeKey, highlightEnabled, partId, version } const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${version}`
return { part, text, themeKey, highlightEnabled, partId, cacheId, version, requestKey }
}) })
const cacheHandle = useGlobalCache({ const cacheHandle = useGlobalCache({
@@ -63,26 +115,46 @@ export function Markdown(props: MarkdownProps) {
sessionId: () => props.sessionId, sessionId: () => props.sessionId,
scope: "markdown", scope: "markdown",
cacheId: () => { cacheId: () => {
const { partId, themeKey, highlightEnabled } = resolved() const { cacheId, themeKey, highlightEnabled } = resolved()
return `${partId}:${themeKey}:${highlightEnabled ? 1 : 0}` return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}`
}, },
version: () => resolved().version, version: () => resolved().version,
}) })
createEffect(async () => { const commitCacheEntry = (snapshot: ReturnType<typeof resolved>, renderedHtml: string) => {
const { part, text, themeKey, highlightEnabled, version } = resolved() const cacheEntry: RenderCache = {
text: snapshot.text,
html: renderedHtml,
theme: snapshot.themeKey,
mode: snapshot.version,
}
setHtml(renderedHtml)
cacheHandle.set(cacheEntry)
notifyRendered()
}
// Ensure the markdown highlighter theme matches the active UI theme. const renderSnapshot = async (snapshot: ReturnType<typeof resolved>) => {
setMarkdownTheme(themeKey === "dark") const markdown = await loadMarkdownModule()
markdown.setMarkdownTheme(snapshot.themeKey === "dark")
const rendered = await markdown.renderMarkdown(snapshot.text, {
suppressHighlight: !snapshot.highlightEnabled,
})
latestRequestedText = text if (latestRequestKey === snapshot.requestKey) {
commitCacheEntry(snapshot, rendered)
}
}
createEffect(() => {
const snapshot = resolved()
latestRequestKey = snapshot.requestKey
const cacheMatches = (cache: RenderCache | undefined) => { const cacheMatches = (cache: RenderCache | undefined) => {
if (!cache) return false if (!cache) return false
return cache.theme === themeKey && cache.mode === version return cache.theme === snapshot.themeKey && cache.mode === snapshot.version
} }
const localCache = part.renderCache const localCache = snapshot.part.renderCache
if (localCache && cacheMatches(localCache)) { if (localCache && cacheMatches(localCache)) {
setHtml(localCache.html) setHtml(localCache.html)
notifyRendered() notifyRendered()
@@ -96,111 +168,83 @@ export function Markdown(props: MarkdownProps) {
return return
} }
const commitCacheEntry = (renderedHtml: string) => { setHtml(renderFallbackHtml(snapshot.text))
const cacheEntry: RenderCache = { text, html: renderedHtml, theme: themeKey, mode: version } notifyRendered()
setHtml(renderedHtml)
cacheHandle.set(cacheEntry)
notifyRendered()
}
if (!highlightEnabled) { void renderSnapshot(snapshot).catch((error) => {
try {
const rendered = await renderMarkdown(text, { suppressHighlight: true })
if (latestRequestedText === text) {
commitCacheEntry(rendered)
}
} catch (error) {
log.error("Failed to render markdown:", error)
if (latestRequestedText === text) {
commitCacheEntry(text)
}
}
return
}
try {
const rendered = await renderMarkdown(text)
if (latestRequestedText === text) {
commitCacheEntry(rendered)
}
} catch (error) {
log.error("Failed to render markdown:", error) log.error("Failed to render markdown:", error)
if (latestRequestedText === text) { if (latestRequestKey === snapshot.requestKey) {
commitCacheEntry(text) commitCacheEntry(snapshot, renderFallbackHtml(snapshot.text))
} }
} })
}) })
onMount(() => { onMount(() => {
const handleClick = async (e: Event) => { const handleClick = async (event: Event) => {
const target = e.target as HTMLElement const target = event.target as HTMLElement
const copyButton = target.closest(".code-block-copy") as HTMLButtonElement const copyButton = target.closest(".code-block-copy") as HTMLButtonElement
if (copyButton) { if (!copyButton) {
e.preventDefault() return
const code = copyButton.getAttribute("data-code")
if (code) {
const decodedCode = decodeURIComponent(code)
const success = await copyToClipboard(decodedCode)
const copyText = copyButton.querySelector(".copy-text")
if (copyText) {
if (success) {
copyText.textContent = t("markdown.codeBlock.copy.copied")
setTimeout(() => {
copyText.textContent = t("markdown.codeBlock.copy.label")
}, 2000)
} else {
copyText.textContent = t("markdown.codeBlock.copy.failed")
setTimeout(() => {
copyText.textContent = t("markdown.codeBlock.copy.label")
}, 2000)
}
}
}
} }
event.preventDefault()
const code = copyButton.getAttribute("data-code")
if (!code) {
return
}
const decodedCode = decodeURIComponent(code)
const success = await copyToClipboard(decodedCode)
const copyText = copyButton.querySelector(".copy-text")
if (!copyText) {
return
}
copyText.textContent = success ? t("markdown.codeBlock.copy.copied") : t("markdown.codeBlock.copy.failed")
setTimeout(() => {
copyText.textContent = t("markdown.codeBlock.copy.label")
}, 2000)
} }
containerRef?.addEventListener("click", handleClick) containerRef?.addEventListener("click", handleClick)
const cleanupLanguageListener = onLanguagesLoaded(async () => { let disposed = false
if (props.disableHighlight) { void loadMarkdownModule()
return .then((markdown) => {
} if (disposed) {
return
const { part, text, themeKey, version } = resolved()
setMarkdownTheme(themeKey === "dark")
if (latestRequestedText !== text) {
return
}
try {
const rendered = await renderMarkdown(text)
if (latestRequestedText === text) {
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey, mode: version }
setHtml(rendered)
cacheHandle.set(cacheEntry)
notifyRendered()
} }
} catch (error) {
log.error("Failed to re-render markdown after language load:", error) cleanupLanguageListener = markdown.onLanguagesLoaded(() => {
} const snapshot = resolved()
}) if (!snapshot.highlightEnabled) {
return
}
latestRequestKey = snapshot.requestKey
void renderSnapshot(snapshot).catch((error) => {
log.error("Failed to re-render markdown after language load:", error)
})
})
})
.catch((error) => {
log.error("Failed to load markdown module:", error)
})
onCleanup(() => { onCleanup(() => {
disposed = true
containerRef?.removeEventListener("click", handleClick) containerRef?.removeEventListener("click", handleClick)
cleanupLanguageListener() cleanupLanguageListener?.()
cleanupLanguageListener = undefined
}) })
}) })
const proseClass = () => "markdown-body"
return ( return (
<div <div
ref={containerRef} ref={containerRef}
class={proseClass()} class="markdown-body"
dir="auto"
data-view="markdown" data-view="markdown"
data-part-id={resolved().partId} data-part-id={resolved().partId}
data-markdown-theme={resolved().themeKey} data-markdown-theme={resolved().themeKey}

View File

@@ -902,6 +902,7 @@ export default function MessageBlock(props: MessageBlockProps) {
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo} onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds} selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage} onToggleSelectedMessage={props.onToggleSelectedMessage}
onContentRendered={props.onContentRendered}
/> />
</Match> </Match>
</Switch> </Switch>
@@ -1280,6 +1281,7 @@ interface ReasoningCardProps {
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void> onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
selectedMessageIds?: () => Set<string> selectedMessageIds?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
onContentRendered?: () => void
} }
function ReasoningCard(props: ReasoningCardProps) { function ReasoningCard(props: ReasoningCardProps) {
@@ -1288,6 +1290,25 @@ function ReasoningCard(props: ReasoningCardProps) {
const [deletingMessage, setDeletingMessage] = createSignal(false) const [deletingMessage, setDeletingMessage] = createSignal(false)
const [deletingUpTo, setDeletingUpTo] = createSignal(false) const [deletingUpTo, setDeletingUpTo] = createSignal(false)
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId)) const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
let pendingRenderNotificationFrame: number | null = null
const notifyContentRendered = () => {
if (!props.onContentRendered || typeof requestAnimationFrame !== "function") return
if (pendingRenderNotificationFrame !== null) {
cancelAnimationFrame(pendingRenderNotificationFrame)
}
pendingRenderNotificationFrame = requestAnimationFrame(() => {
pendingRenderNotificationFrame = null
props.onContentRendered?.()
})
}
onCleanup(() => {
if (pendingRenderNotificationFrame !== null) {
cancelAnimationFrame(pendingRenderNotificationFrame)
pendingRenderNotificationFrame = null
}
})
createEffect(() => { createEffect(() => {
setExpanded(Boolean(props.defaultExpanded)) setExpanded(Boolean(props.defaultExpanded))
@@ -1356,6 +1377,12 @@ function ReasoningCard(props: ReasoningCardProps) {
const viewHideLabel = () => const viewHideLabel = () =>
expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view") expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view")
createEffect(() => {
if (!expanded()) return
reasoningText()
notifyContentRendered()
})
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage() const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
const handleDeleteMessage = async (event: MouseEvent) => { const handleDeleteMessage = async (event: MouseEvent) => {

View File

@@ -542,7 +542,7 @@ export default function MessageItem(props: MessageItemProps) {
</header> </header>
<div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]"> <div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]" dir="auto">
<Show when={props.isQueued && isUser()}> <Show when={props.isQueued && isUser()}>
@@ -550,7 +550,7 @@ export default function MessageItem(props: MessageItemProps) {
</Show> </Show>
<Show when={errorMessage()}> <Show when={errorMessage()}>
<div class="message-error-block"> {errorMessage()}</div> <div class="message-error-block" dir="auto"> {errorMessage()}</div>
</Show> </Show>
<Show when={isGenerating()}> <Show when={isGenerating()}>

View File

@@ -133,11 +133,12 @@ export default function MessagePart(props: MessagePartProps) {
<Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}> <Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}>
<div <div
class={canRenderMarkdown() ? markdownContainerClass() : textContainerClass()} class={canRenderMarkdown() ? markdownContainerClass() : textContainerClass()}
dir="auto"
data-role={textContainerRole()} data-role={textContainerRole()}
data-part-type="text" data-part-type="text"
data-part-id={typeof (props.part as any)?.id === "string" ? (props.part as any).id : undefined} data-part-id={typeof (props.part as any)?.id === "string" ? (props.part as any).id : undefined}
> >
<Show when={canRenderMarkdown()} fallback={<span class="text-primary">{plainTextContent()}</span>}> <Show when={canRenderMarkdown()} fallback={<span class="text-primary" dir="auto">{plainTextContent()}</span>}>
<Markdown <Markdown
part={createTextPartForMarkdown()} part={createTextPartForMarkdown()}
instanceId={props.instanceId} instanceId={props.instanceId}

View File

@@ -19,7 +19,7 @@ import type { DeleteHoverState } from "../types/delete-hover"
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache" import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
import { getPartCharCount } from "../lib/token-utils" import { getPartCharCount } from "../lib/token-utils"
const SCROLL_SENTINEL_MARGIN_PX = 48 const SCROLL_SENTINEL_MARGIN_PX = 8
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream" const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
const QUOTE_SELECTION_MAX_LENGTH = 2000 const QUOTE_SELECTION_MAX_LENGTH = 2000
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href

View File

@@ -488,6 +488,7 @@ export default function PromptInput(props: PromptInputProps) {
<textarea <textarea
ref={textareaRef} ref={textareaRef}
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""} ${expandState() === "expanded" ? "is-expanded" : ""}`} class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""} ${expandState() === "expanded" ? "is-expanded" : ""}`}
dir="auto"
placeholder={getPlaceholder()} placeholder={getPlaceholder()}
value={prompt()} value={prompt()}
onInput={handleInput} onInput={handleInput}

View File

@@ -444,7 +444,7 @@ const SessionList: Component<SessionListProps> = (props) => {
</Show> </Show>
{rowProps.isChild ? <Bot class="w-4 h-4 flex-shrink-0" /> : <User class="w-4 h-4 flex-shrink-0" />} {rowProps.isChild ? <Bot class="w-4 h-4 flex-shrink-0" /> : <User class="w-4 h-4 flex-shrink-0" />}
<span class="session-item-title session-item-title--clamp">{title()}</span> <span class="session-item-title session-item-title--clamp" dir="auto">{title()}</span>
</div> </div>
</div> </div>
<div class="session-item-row session-item-meta"> <div class="session-item-row session-item-meta">

View File

@@ -76,6 +76,7 @@ const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
inputRef = element inputRef = element
}} }}
type="text" type="text"
dir="auto"
value={title()} value={title()}
onInput={(event) => setTitle(event.currentTarget.value)} onInput={(event) => setTitle(event.currentTarget.value)}
placeholder={t("sessionRenameDialog.input.placeholder")} placeholder={t("sessionRenameDialog.input.placeholder")}

View File

@@ -1,7 +1,7 @@
import type { Accessor, JSXElement } from "solid-js" import type { Accessor, JSXElement } from "solid-js"
import type { RenderCache } from "../../types/message" import type { RenderCache } from "../../types/message"
import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../../lib/ansi" import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../../lib/ansi"
import { escapeHtml } from "../../lib/markdown" import { escapeHtml } from "../../lib/text-render-utils"
import type { AnsiRenderOptions, ToolScrollHelpers } from "./types" import type { AnsiRenderOptions, ToolScrollHelpers } from "./types"
type AnsiRenderCache = RenderCache & { hasAnsi: boolean } type AnsiRenderCache = RenderCache & { hasAnsi: boolean }
@@ -88,7 +88,7 @@ export function createAnsiContentRenderer(params: {
return ( return (
<div class={messageClass} ref={params.scrollHelpers.registerContainer} onScroll={params.scrollHelpers.handleScroll}> <div class={messageClass} ref={params.scrollHelpers.registerContainer} onScroll={params.scrollHelpers.handleScroll}>
<pre class="tool-call-content tool-call-ansi" innerHTML={nextCache.html} /> <pre class="tool-call-content tool-call-ansi" dir="auto" innerHTML={nextCache.html} />
{params.scrollHelpers.renderSentinel()} {params.scrollHelpers.renderSentinel()}
</div> </div>
) )

View File

@@ -42,7 +42,7 @@ export function renderDiagnosticsSection(
{entry.displayPath} {entry.displayPath}
<span class="tool-call-diagnostic-coords">:L{entry.line || "-"}:C{entry.column || "-"}</span> <span class="tool-call-diagnostic-coords">:L{entry.line || "-"}:C{entry.column || "-"}</span>
</span> </span>
<span class="tool-call-diagnostic-message">{entry.message}</span> <span class="tool-call-diagnostic-message" dir="auto">{entry.message}</span>
</div> </div>
)} )}
</For> </For>

View File

@@ -1,11 +1,26 @@
import type { Accessor, JSXElement } from "solid-js" import { Suspense, lazy, onMount, type Accessor, type JSXElement } from "solid-js"
import type { RenderCache } from "../../types/message" import type { RenderCache } from "../../types/message"
import type { DiffViewMode } from "../../stores/preferences" import type { DiffViewMode } from "../../stores/preferences"
import { ToolCallDiffViewer } from "../diff-viewer"
import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types" import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types"
import { getRelativePath } from "./utils" import { getRelativePath } from "./utils"
import { getCacheEntry } from "../../lib/global-cache" import { getCacheEntry } from "../../lib/global-cache"
const LazyToolCallDiffViewer = lazy(() =>
import("../diff-viewer").then((module) => ({ default: module.ToolCallDiffViewer })),
)
function CachedDiffMarkup(props: { html: string; onRendered?: () => void }) {
onMount(() => {
props.onRendered?.()
})
return (
<div class="tool-call-diff-viewer">
<div innerHTML={props.html} />
</div>
)
}
type CacheHandle = { type CacheHandle = {
get<T>(): T | undefined get<T>(): T | undefined
params(): unknown params(): unknown
@@ -101,15 +116,20 @@ export function createDiffContentRenderer(params: {
</button> </button>
</div> </div>
</div> </div>
<ToolCallDiffViewer {cachedHtml ? (
diffText={payload.diffText} <CachedDiffMarkup html={cachedHtml} onRendered={handleDiffRendered} />
filePath={payload.filePath} ) : (
theme={themeKey} <Suspense fallback={<pre class="tool-call-diff-fallback">{payload.diffText}</pre>}>
mode={diffMode()} <LazyToolCallDiffViewer
cachedHtml={cachedHtml} diffText={payload.diffText}
cacheEntryParams={cacheEntryParams as any} filePath={payload.filePath}
onRendered={handleDiffRendered} theme={themeKey}
/> mode={diffMode()}
cacheEntryParams={cacheEntryParams as any}
onRendered={handleDiffRendered}
/>
</Suspense>
)}
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })} {params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
</div> </div>
) )

View File

@@ -43,7 +43,7 @@ export function createMarkdownContentRenderer(params: {
ref={registerRef} ref={registerRef}
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll} onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
> >
<pre class="whitespace-pre-wrap break-words text-sm font-mono">{options.content}</pre> <pre class="whitespace-pre-wrap break-words text-sm font-mono" dir="auto">{options.content}</pre>
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })} {params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
</div> </div>
) )

View File

@@ -1,5 +1,5 @@
import { isRenderableDiffText } from "../../lib/diff-utils" import { isRenderableDiffText } from "../../lib/diff-utils"
import { getLanguageFromPath } from "../../lib/markdown" import { getLanguageFromPath } from "../../lib/text-render-utils"
import type { ToolState } from "@opencode-ai/sdk/v2" import type { ToolState } from "@opencode-ai/sdk/v2"
import type { DiffPayload } from "./types" import type { DiffPayload } from "./types"
import { getLogger } from "../../lib/logger" import { getLogger } from "../../lib/logger"

View File

@@ -114,6 +114,10 @@ export const instanceMessages = {
"instanceShell.sessionChanges.filesChanged": "{count} files changed", "instanceShell.sessionChanges.filesChanged": "{count} files changed",
"instanceShell.sessionChanges.actions.show": "Show changes", "instanceShell.sessionChanges.actions.show": "Show changes",
"instanceShell.gitChanges.loading": "Loading git changes...",
"instanceShell.gitChanges.empty": "No git changes yet.",
"instanceShell.gitChanges.deleted": "Deleted",
"instanceShell.filesShell.fileListTitle": "File list", "instanceShell.filesShell.fileListTitle": "File list",
"instanceShell.filesShell.mobileSelectorLabel": "Select file", "instanceShell.filesShell.mobileSelectorLabel": "Select file",
"instanceShell.filesShell.mobileSelectorEmpty": "Select a file", "instanceShell.filesShell.mobileSelectorEmpty": "Select a file",

View File

@@ -90,6 +90,7 @@ export const instanceMessages = {
"instanceShell.rightPanel.title": "Panel de estado", "instanceShell.rightPanel.title": "Panel de estado",
"instanceShell.rightPanel.tabs.changes": "Cambios", "instanceShell.rightPanel.tabs.changes": "Cambios",
"instanceShell.rightPanel.tabs.gitChanges": "Cambios de Git",
"instanceShell.rightPanel.tabs.files": "Archivos", "instanceShell.rightPanel.tabs.files": "Archivos",
"instanceShell.rightPanel.tabs.status": "Estado", "instanceShell.rightPanel.tabs.status": "Estado",
"instanceShell.rightPanel.tabs.ariaLabel": "Pestañas del panel derecho", "instanceShell.rightPanel.tabs.ariaLabel": "Pestañas del panel derecho",
@@ -112,6 +113,10 @@ export const instanceMessages = {
"instanceShell.sessionChanges.filesChanged": "{count} archivos cambiados", "instanceShell.sessionChanges.filesChanged": "{count} archivos cambiados",
"instanceShell.sessionChanges.actions.show": "Mostrar cambios", "instanceShell.sessionChanges.actions.show": "Mostrar cambios",
"instanceShell.gitChanges.loading": "Cargando cambios de Git...",
"instanceShell.gitChanges.empty": "Aún no hay cambios de Git.",
"instanceShell.gitChanges.deleted": "Eliminado",
"instanceShell.filesShell.fileListTitle": "Lista de archivos", "instanceShell.filesShell.fileListTitle": "Lista de archivos",
"instanceShell.filesShell.mobileSelectorLabel": "Seleccionar archivo", "instanceShell.filesShell.mobileSelectorLabel": "Seleccionar archivo",
"instanceShell.filesShell.mobileSelectorEmpty": "Selecciona un archivo", "instanceShell.filesShell.mobileSelectorEmpty": "Selecciona un archivo",

View File

@@ -90,6 +90,7 @@ export const instanceMessages = {
"instanceShell.rightPanel.title": "Panneau d'état", "instanceShell.rightPanel.title": "Panneau d'état",
"instanceShell.rightPanel.tabs.changes": "Modifications", "instanceShell.rightPanel.tabs.changes": "Modifications",
"instanceShell.rightPanel.tabs.gitChanges": "Changements Git",
"instanceShell.rightPanel.tabs.files": "Fichiers", "instanceShell.rightPanel.tabs.files": "Fichiers",
"instanceShell.rightPanel.tabs.status": "Statut", "instanceShell.rightPanel.tabs.status": "Statut",
"instanceShell.rightPanel.tabs.ariaLabel": "Onglets du panneau droit", "instanceShell.rightPanel.tabs.ariaLabel": "Onglets du panneau droit",
@@ -112,6 +113,10 @@ export const instanceMessages = {
"instanceShell.sessionChanges.filesChanged": "{count} fichiers modifiés", "instanceShell.sessionChanges.filesChanged": "{count} fichiers modifiés",
"instanceShell.sessionChanges.actions.show": "Afficher les changements", "instanceShell.sessionChanges.actions.show": "Afficher les changements",
"instanceShell.gitChanges.loading": "Chargement des changements Git...",
"instanceShell.gitChanges.empty": "Aucun changement Git pour l'instant.",
"instanceShell.gitChanges.deleted": "Supprimé",
"instanceShell.filesShell.fileListTitle": "Liste des fichiers", "instanceShell.filesShell.fileListTitle": "Liste des fichiers",
"instanceShell.filesShell.mobileSelectorLabel": "Sélectionner un fichier", "instanceShell.filesShell.mobileSelectorLabel": "Sélectionner un fichier",
"instanceShell.filesShell.mobileSelectorEmpty": "Sélectionnez un fichier", "instanceShell.filesShell.mobileSelectorEmpty": "Sélectionnez un fichier",

View File

@@ -90,6 +90,7 @@ export const instanceMessages = {
"instanceShell.rightPanel.title": "ステータスパネル", "instanceShell.rightPanel.title": "ステータスパネル",
"instanceShell.rightPanel.tabs.changes": "変更", "instanceShell.rightPanel.tabs.changes": "変更",
"instanceShell.rightPanel.tabs.gitChanges": "Git 変更",
"instanceShell.rightPanel.tabs.files": "ファイル", "instanceShell.rightPanel.tabs.files": "ファイル",
"instanceShell.rightPanel.tabs.status": "ステータス", "instanceShell.rightPanel.tabs.status": "ステータス",
"instanceShell.rightPanel.tabs.ariaLabel": "右パネルのタブ", "instanceShell.rightPanel.tabs.ariaLabel": "右パネルのタブ",
@@ -112,6 +113,10 @@ export const instanceMessages = {
"instanceShell.sessionChanges.filesChanged": "{count} 個のファイルが変更されました", "instanceShell.sessionChanges.filesChanged": "{count} 個のファイルが変更されました",
"instanceShell.sessionChanges.actions.show": "変更を表示", "instanceShell.sessionChanges.actions.show": "変更を表示",
"instanceShell.gitChanges.loading": "Git の変更を読み込み中...",
"instanceShell.gitChanges.empty": "Git の変更はまだありません。",
"instanceShell.gitChanges.deleted": "削除済み",
"instanceShell.filesShell.fileListTitle": "ファイル一覧", "instanceShell.filesShell.fileListTitle": "ファイル一覧",
"instanceShell.filesShell.mobileSelectorLabel": "ファイルを選択", "instanceShell.filesShell.mobileSelectorLabel": "ファイルを選択",
"instanceShell.filesShell.mobileSelectorEmpty": "ファイルを選択してください", "instanceShell.filesShell.mobileSelectorEmpty": "ファイルを選択してください",

View File

@@ -90,6 +90,7 @@ export const instanceMessages = {
"instanceShell.rightPanel.title": "Панель состояния", "instanceShell.rightPanel.title": "Панель состояния",
"instanceShell.rightPanel.tabs.changes": "Изменения", "instanceShell.rightPanel.tabs.changes": "Изменения",
"instanceShell.rightPanel.tabs.gitChanges": "Изменения Git",
"instanceShell.rightPanel.tabs.files": "Файлы", "instanceShell.rightPanel.tabs.files": "Файлы",
"instanceShell.rightPanel.tabs.status": "Статус", "instanceShell.rightPanel.tabs.status": "Статус",
"instanceShell.rightPanel.tabs.ariaLabel": "Вкладки правой панели", "instanceShell.rightPanel.tabs.ariaLabel": "Вкладки правой панели",
@@ -112,6 +113,10 @@ export const instanceMessages = {
"instanceShell.sessionChanges.filesChanged": "Изменено файлов: {count}", "instanceShell.sessionChanges.filesChanged": "Изменено файлов: {count}",
"instanceShell.sessionChanges.actions.show": "Показать изменения", "instanceShell.sessionChanges.actions.show": "Показать изменения",
"instanceShell.gitChanges.loading": "Загрузка изменений Git...",
"instanceShell.gitChanges.empty": "Изменений Git пока нет.",
"instanceShell.gitChanges.deleted": "Удалено",
"instanceShell.filesShell.fileListTitle": "Список файлов", "instanceShell.filesShell.fileListTitle": "Список файлов",
"instanceShell.filesShell.mobileSelectorLabel": "Выбрать файл", "instanceShell.filesShell.mobileSelectorLabel": "Выбрать файл",
"instanceShell.filesShell.mobileSelectorEmpty": "Выберите файл", "instanceShell.filesShell.mobileSelectorEmpty": "Выберите файл",

View File

@@ -90,6 +90,7 @@ export const instanceMessages = {
"instanceShell.rightPanel.title": "状态面板", "instanceShell.rightPanel.title": "状态面板",
"instanceShell.rightPanel.tabs.changes": "更改", "instanceShell.rightPanel.tabs.changes": "更改",
"instanceShell.rightPanel.tabs.gitChanges": "Git 更改",
"instanceShell.rightPanel.tabs.files": "文件", "instanceShell.rightPanel.tabs.files": "文件",
"instanceShell.rightPanel.tabs.status": "状态", "instanceShell.rightPanel.tabs.status": "状态",
"instanceShell.rightPanel.tabs.ariaLabel": "右侧面板标签页", "instanceShell.rightPanel.tabs.ariaLabel": "右侧面板标签页",
@@ -112,6 +113,10 @@ export const instanceMessages = {
"instanceShell.sessionChanges.filesChanged": "已更改 {count} 个文件", "instanceShell.sessionChanges.filesChanged": "已更改 {count} 个文件",
"instanceShell.sessionChanges.actions.show": "显示更改", "instanceShell.sessionChanges.actions.show": "显示更改",
"instanceShell.gitChanges.loading": "正在加载 Git 更改...",
"instanceShell.gitChanges.empty": "暂无 Git 更改。",
"instanceShell.gitChanges.deleted": "已删除",
"instanceShell.filesShell.fileListTitle": "文件列表", "instanceShell.filesShell.fileListTitle": "文件列表",
"instanceShell.filesShell.mobileSelectorLabel": "选择文件", "instanceShell.filesShell.mobileSelectorLabel": "选择文件",
"instanceShell.filesShell.mobileSelectorEmpty": "请选择文件", "instanceShell.filesShell.mobileSelectorEmpty": "请选择文件",

View File

@@ -1,7 +1,8 @@
import { marked } from "marked" import { marked } from "marked"
import { createHighlighter, type Highlighter, bundledLanguages } from "shiki/bundle/full"
import { getLogger } from "./logger" import { getLogger } from "./logger"
import { tGlobal } from "./i18n" import { tGlobal } from "./i18n"
import type { Highlighter } from "shiki/bundle/full"
import { decodeHtmlEntities, escapeHtml } from "./text-render-utils"
const log = getLogger("actions") const log = getLogger("actions")
@@ -11,43 +12,8 @@ let currentTheme: "light" | "dark" = "light"
let isInitialized = false let isInitialized = false
let highlightSuppressed = false let highlightSuppressed = false
let rendererSetup = false let rendererSetup = false
let shikiModulePromise: Promise<typeof import("shiki/bundle/full")> | null = null
const extensionToLanguage: Record<string, string> = { let bundledLanguagesCache: typeof import("shiki/bundle/full")["bundledLanguages"] | null = null
ts: "typescript",
tsx: "typescript",
js: "javascript",
jsx: "javascript",
py: "python",
sh: "bash",
bash: "bash",
json: "json",
html: "html",
css: "css",
md: "markdown",
yaml: "yaml",
yml: "yaml",
sql: "sql",
rs: "rust",
go: "go",
cpp: "cpp",
cc: "cpp",
cxx: "cpp",
hpp: "cpp",
h: "cpp",
c: "c",
java: "java",
cs: "csharp",
php: "php",
rb: "ruby",
swift: "swift",
kt: "kotlin",
}
export function getLanguageFromPath(path?: string | null): string | undefined {
if (!path) return undefined
const ext = path.split(".").pop()?.toLowerCase()
return ext ? extensionToLanguage[ext] : undefined
}
// Track loaded languages and queue for on-demand loading // Track loaded languages and queue for on-demand loading
const loadedLanguages = new Set<string>() const loadedLanguages = new Set<string>()
@@ -89,10 +55,15 @@ async function getOrCreateHighlighter() {
return highlighterPromise return highlighterPromise
} }
// Create highlighter with no preloaded languages highlighterPromise = (async () => {
highlighterPromise = createHighlighter({ const shiki = await loadShikiModule()
themes: ["github-light", "github-light-high-contrast", "github-dark"], return shiki.createHighlighter({
langs: [], themes: ["github-light", "github-light-high-contrast", "github-dark"],
langs: [],
})
})().catch((error) => {
highlighterPromise = null
throw error
}) })
highlighter = await highlighterPromise highlighter = await highlighterPromise
@@ -100,12 +71,37 @@ async function getOrCreateHighlighter() {
return highlighter return highlighter
} }
async function loadShikiModule() {
if (!shikiModulePromise) {
shikiModulePromise = import("shiki/bundle/full").then((module) => {
bundledLanguagesCache = module.bundledLanguages
return module
})
}
return shikiModulePromise
}
function queueHighlighterWarmup() {
if (highlighter || highlighterPromise) {
return
}
void getOrCreateHighlighter().catch((error) => {
log.warn("Failed to initialize markdown highlighter", error)
})
}
function normalizeLanguageToken(token: string): string { function normalizeLanguageToken(token: string): string {
return token.trim().toLowerCase() return token.trim().toLowerCase()
} }
function resolveLanguage(token: string): { canonical: string | null; raw: string } { function resolveLanguage(token: string): { canonical: string | null; raw: string } {
const normalized = normalizeLanguageToken(token) const normalized = normalizeLanguageToken(token)
const bundledLanguages = bundledLanguagesCache
if (!bundledLanguages) {
return { canonical: null, raw: normalized }
}
// Check if it's a direct key match // Check if it's a direct key match
if (normalized in bundledLanguages) { if (normalized in bundledLanguages) {
@@ -148,32 +144,43 @@ async function ensureLanguages(content: string) {
// Queue language loading tasks // Queue language loading tasks
for (const token of foundLanguages) { for (const token of foundLanguages) {
const { canonical, raw } = resolveLanguage(token) const rawToken = normalizeLanguageToken(token)
const langKey = canonical || raw if (!rawToken) {
continue
}
// Skip "text" and aliases since Shiki handles plain text already // Skip "text" and aliases since Shiki handles plain text already
if (langKey === "text" || raw === "text") { if (rawToken === "text") {
continue continue
} }
// Skip if already loaded or queued // Skip if already loaded or queued
if (loadedLanguages.has(langKey) || queuedLanguages.has(langKey)) { if (loadedLanguages.has(rawToken) || queuedLanguages.has(rawToken)) {
continue continue
} }
queuedLanguages.add(langKey) queuedLanguages.add(rawToken)
// Queue the language loading task // Queue the language loading task
languageLoadQueue.push(async () => { languageLoadQueue.push(async () => {
try { try {
await loadShikiModule()
const { canonical, raw } = resolveLanguage(token)
const langKey = canonical || raw
if (langKey === "text" || raw === "text") {
return
}
const h = await getOrCreateHighlighter() const h = await getOrCreateHighlighter()
await h.loadLanguage(langKey as never) await h.loadLanguage(langKey as never)
loadedLanguages.add(langKey) loadedLanguages.add(langKey)
loadedLanguages.add(raw)
triggerLanguageListeners() triggerLanguageListeners()
} catch { } catch {
// Quietly ignore errors // Quietly ignore errors
} finally { } finally {
queuedLanguages.delete(langKey) queuedLanguages.delete(rawToken)
} }
}) })
} }
@@ -184,52 +191,6 @@ async function ensureLanguages(content: string) {
} }
} }
export function decodeHtmlEntities(content: string): string {
if (!content.includes("&")) {
return content
}
const entityPattern = /&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]+);/g
const namedEntities: Record<string, string> = {
amp: "&",
lt: "<",
gt: ">",
quot: '"',
apos: "'",
nbsp: " ",
}
let result = content
let previous = ""
while (result.includes("&") && result !== previous) {
previous = result
result = result.replace(entityPattern, (match, entity) => {
if (!entity) {
return match
}
if (entity[0] === "#") {
const isHex = entity[1]?.toLowerCase() === "x"
const value = isHex ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10)
if (!Number.isNaN(value)) {
try {
return String.fromCodePoint(value)
} catch {
return match
}
}
return match
}
const decoded = namedEntities[entity.toLowerCase()]
return decoded !== undefined ? decoded : match
})
}
return result
}
async function runLanguageLoadQueue() { async function runLanguageLoadQueue() {
if (isQueueRunning || languageLoadQueue.length === 0) { if (isQueueRunning || languageLoadQueue.length === 0) {
return return
@@ -249,7 +210,6 @@ async function runLanguageLoadQueue() {
function setupRenderer(isDark: boolean) { function setupRenderer(isDark: boolean) {
currentTheme = isDark ? "dark" : "light" currentTheme = isDark ? "dark" : "light"
if (!highlighter) return
if (rendererSetup) return if (rendererSetup) return
marked.setOptions({ marked.setOptions({
@@ -330,8 +290,9 @@ function setupRenderer(isDark: boolean) {
} }
export async function initMarkdown(isDark: boolean) { export async function initMarkdown(isDark: boolean) {
await getOrCreateHighlighter()
setupRenderer(isDark) setupRenderer(isDark)
queueHighlighterWarmup()
await getOrCreateHighlighter()
isInitialized = true isInitialized = true
} }
@@ -350,15 +311,16 @@ export async function renderMarkdown(
}, },
): Promise<string> { ): Promise<string> {
if (!isInitialized) { if (!isInitialized) {
await initMarkdown(currentTheme === "dark") setupRenderer(currentTheme === "dark")
isInitialized = true
} }
const suppressHighlight = options?.suppressHighlight ?? false const suppressHighlight = options?.suppressHighlight ?? false
const decoded = decodeHtmlEntities(content) const decoded = decodeHtmlEntities(content)
if (!suppressHighlight) { if (!suppressHighlight) {
// Queue language loading but don't wait for it to complete queueHighlighterWarmup()
await ensureLanguages(decoded) void ensureLanguages(decoded)
} }
const previousSuppressed = highlightSuppressed const previousSuppressed = highlightSuppressed
@@ -375,13 +337,3 @@ export async function renderMarkdown(
export async function getSharedHighlighter(): Promise<Highlighter> { export async function getSharedHighlighter(): Promise<Highlighter> {
return getOrCreateHighlighter() return getOrCreateHighlighter()
} }
export function escapeHtml(text: string): string {
const map: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
'"': "&quot;",
"'": "&#039;",
}
return text.replace(/[&<"']/g, (m) => map[m])
}

View File

@@ -0,0 +1,92 @@
const extensionToLanguage: Record<string, string> = {
ts: "typescript",
tsx: "typescript",
js: "javascript",
jsx: "javascript",
py: "python",
sh: "bash",
bash: "bash",
json: "json",
html: "html",
css: "css",
md: "markdown",
yaml: "yaml",
yml: "yaml",
sql: "sql",
rs: "rust",
go: "go",
cpp: "cpp",
cc: "cpp",
cxx: "cpp",
hpp: "cpp",
h: "cpp",
c: "c",
java: "java",
cs: "csharp",
php: "php",
rb: "ruby",
swift: "swift",
kt: "kotlin",
}
export function getLanguageFromPath(path?: string | null): string | undefined {
if (!path) return undefined
const ext = path.split(".").pop()?.toLowerCase()
return ext ? extensionToLanguage[ext] : undefined
}
export function decodeHtmlEntities(content: string): string {
if (!content.includes("&")) {
return content
}
const entityPattern = /&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]+);/g
const namedEntities: Record<string, string> = {
amp: "&",
lt: "<",
gt: ">",
quot: '"',
apos: "'",
nbsp: " ",
}
let result = content
let previous = ""
while (result.includes("&") && result !== previous) {
previous = result
result = result.replace(entityPattern, (match, entity) => {
if (!entity) {
return match
}
if (entity[0] === "#") {
const isHex = entity[1]?.toLowerCase() === "x"
const value = isHex ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10)
if (!Number.isNaN(value)) {
try {
return String.fromCodePoint(value)
} catch {
return match
}
}
return match
}
const decoded = namedEntities[entity.toLowerCase()]
return decoded !== undefined ? decoded : match
})
}
return result
}
export function escapeHtml(text: string): string {
const map: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
'"': "&quot;",
"'": "&#039;",
}
return text.replace(/[&<"']/g, (match) => map[match])
}

View File

@@ -1,4 +1,4 @@
import { decodeHtmlEntities } from "../../lib/markdown" import { decodeHtmlEntities } from "../../lib/text-render-utils"
function decodeTextSegment(segment: any): any { function decodeTextSegment(segment: any): any {
if (typeof segment === "string") { if (typeof segment === "string") {

View File

@@ -24,6 +24,21 @@
color: inherit; color: inherit;
} }
/* Auto-detect text direction per block element for RTL language support (e.g. Hebrew, Arabic) */
.markdown-body p,
.markdown-body li,
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6,
.markdown-body blockquote,
.markdown-body td,
.markdown-body th {
unicode-bidi: plaintext;
}
.markdown-body h1, .markdown-body h1,
.markdown-body h2, .markdown-body h2,
.markdown-body h3, .markdown-body h3,
@@ -129,16 +144,19 @@
} }
.markdown-body blockquote { .markdown-body blockquote {
border-left: 3px solid var(--border-base); border-inline-start: 3px solid var(--border-base);
color: var(--text-secondary); color: var(--text-secondary);
background-color: var(--surface-muted); background-color: var(--surface-muted);
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border-radius: 0 8px 8px 0; border-start-start-radius: 0;
border-start-end-radius: 8px;
border-end-end-radius: 8px;
border-end-start-radius: 0;
} }
.markdown-body ul, .markdown-body ul,
.markdown-body ol { .markdown-body ol {
padding-left: 1.5rem; padding-inline-start: 1.5rem;
margin: 0.5rem 0; margin: 0.5rem 0;
} }
@@ -166,7 +184,7 @@
.markdown-body td { .markdown-body td {
border: 1px solid var(--border-base); border: 1px solid var(--border-base);
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
text-align: left; text-align: start;
color: var(--text-primary); color: var(--text-primary);
background-color: transparent; background-color: transparent;
} }
@@ -221,7 +239,7 @@
cursor: pointer; cursor: pointer;
color: var(--text-secondary); color: var(--text-secondary);
transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease; transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease;
margin-left: auto; margin-inline-start: auto;
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
} }

View File

@@ -132,6 +132,13 @@
margin-bottom: 0; margin-bottom: 0;
} }
.message-stream-block {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 2px;
}
.message-step-start { .message-step-start {
background-color: var(--message-assistant-bg); background-color: var(--message-assistant-bg);
border-left: 4px solid var(--message-assistant-border); border-left: 4px solid var(--message-assistant-border);

View File

@@ -134,6 +134,34 @@ export default defineConfig({
main: resolve(__dirname, "./src/renderer/index.html"), main: resolve(__dirname, "./src/renderer/index.html"),
loading: resolve(__dirname, "./src/renderer/loading.html"), loading: resolve(__dirname, "./src/renderer/loading.html"),
}, },
output: {
manualChunks(id) {
const normalizedId = id.replace(/\\/g, "/")
if (normalizedId.includes("/node_modules/@git-diff-view/")) {
return "git-diff-vendor"
}
if (normalizedId.includes("/node_modules/highlight.js/") || normalizedId.includes("/node_modules/lowlight/")) {
return "highlight-vendor"
}
if (normalizedId.includes("/node_modules/fast-diff/")) {
return "fast-diff-vendor"
}
if (normalizedId.includes("/node_modules/monaco-editor/")) {
return "monaco-vendor"
}
if (
normalizedId.includes("/src/components/file-viewer/") ||
normalizedId.includes("/src/lib/monaco/")
) {
return "monaco-viewer"
}
},
},
}, },
}, },
}) })