From fa6735915c19a169ddfbc245fef924dd3be28dcb Mon Sep 17 00:00:00 2001 From: Min Date: Mon, 1 Jun 2026 10:48:22 +0700 Subject: [PATCH 1/3] feat: temp ui DAP --- .DS_Store | Bin 6148 -> 6148 bytes .wolf/anatomy.md | 76 +- .wolf/cron-state.json | 10 +- .wolf/daemon.log | 4 + AGENTS.md | 2 +- CLAUDE.md | 2 +- debug.md | 167 +++++ src/app/app_state/mod.rs | 11 + src/app/app_state/palette.rs | 24 +- src/app/app_state/workspace.rs | 4 + src/app/command_palette.rs | 29 + src/app/event_loop/application.rs | 194 ++++- src/app/event_loop/async_results/mod.rs | 4 +- src/app/event_loop/async_results/system.rs | 37 + src/app/event_loop/commands.rs | 21 + src/app/event_loop/commands_dap.rs | 816 +++++++++++++++++++++ src/app/event_loop/commands_explorer.rs | 97 +++ src/app/event_loop/commands_palette.rs | 68 ++ src/app/event_loop/commands_terminal.rs | 42 +- src/app/event_loop/commands_tests.rs | 385 ++++++++++ src/app/event_loop/mod.rs | 5 + src/app/event_loop/setup.rs | 21 +- src/app/input/handler.rs | 10 +- src/app/input_map/focus.rs | 51 ++ src/app/resolved_keymap.rs | 117 +-- src/async_runtime/flutter_device.rs | 489 ++++++++++++ src/async_runtime/message.rs | 9 + src/async_runtime/mod.rs | 1 + src/async_runtime/scheduler/dispatch.rs | 52 ++ src/async_runtime/scheduler/runtime.rs | 4 + src/core/command_dispatch/mod.rs | 17 +- src/core/command_dispatch/palette.rs | 5 + src/core/command_dispatch/session.rs | 15 + src/core/command_ids.rs | 42 ++ src/core/commands.rs | 17 + src/dap/client.rs | 336 +++++++++ src/dap/launch_config.rs | 267 +++++++ src/dap/mod.rs | 8 + src/dap/session.rs | 320 ++++++++ src/dap/types.rs | 430 +++++++++++ src/lib.rs | 1 + src/render/renderer.rs | 3 + src/render/renderer/editor.rs | 2 +- src/render/renderer/editor/overlays.rs | 50 ++ src/render/renderer/editor/selections.rs | 17 +- src/render/renderer/editor/viewport.rs | 5 +- src/render/renderer/lifecycle.rs | 2 + src/render/renderer/ui/statusbar.rs | 40 + src/workbench/debug_state.rs | 398 +++++++--- src/workbench/inspector_panel.rs | 336 ++++++++- src/workbench/overlay_manager.rs | 12 +- src/workbench/panel_state.rs | 7 +- test_fvm_detection.sh | 44 ++ tests/lsp_fvm_detection.rs | 31 + 54 files changed, 4923 insertions(+), 234 deletions(-) create mode 100644 debug.md create mode 100644 src/app/event_loop/commands_dap.rs create mode 100644 src/async_runtime/flutter_device.rs create mode 100644 src/dap/client.rs create mode 100644 src/dap/launch_config.rs create mode 100644 src/dap/mod.rs create mode 100644 src/dap/session.rs create mode 100644 src/dap/types.rs create mode 100755 test_fvm_detection.sh diff --git a/.DS_Store b/.DS_Store index 7aa5ebb10cbbb3e8f9cd257aafbcf70ab2e8f001..490aefb1a9cc2288b811bb1beb677779c6be3600 100644 GIT binary patch delta 75 zcmZoMXfc@J&&W10U^gS%WFAICMuy3Dj53TZ#Ml^07!nzZ7}6P18A?)$lXH^t^K%$C RH#4@dO>7X}%+B$b9{}_B5g`Bo diff --git a/.wolf/anatomy.md b/.wolf/anatomy.md index 0aa21a33..eee2cc86 100644 --- a/.wolf/anatomy.md +++ b/.wolf/anatomy.md @@ -1,7 +1,7 @@ # anatomy.md -> Auto-maintained by OpenWolf. Last scanned: 2026-05-30T05:33:23.456Z -> Files: 374 tracked | Anatomy hits: 0 | Misses: 0 +> Auto-maintained by OpenWolf. Last scanned: 2026-05-30T11:00:00.789Z +> Files: 381 tracked | Anatomy hits: 0 | Misses: 0 ## ./ @@ -13,6 +13,7 @@ - `Cargo.toml` — Rust package manifest (~660 tok) - `CLAUDE.md` — OpenWolf (~1543 tok) - `Cross.toml` (~73 tok) +- `debug.md` — Kiến trúc Built-in Debugger: Hệ thống Generic DAP Client trong Netherize Editor (~2000 tok) - `DEPENDENCIES.md` — Netherize Editor - Runtime Dependencies (~1867 tok) - `README.md` — Project documentation (~6759 tok) - `test_fvm_detection.sh` — Test FVM detection logic (~311 tok) @@ -21,7 +22,7 @@ ## .claude/ - `settings.json` (~441 tok) -- `settings.local.json` (~1651 tok) +- `settings.local.json` (~1660 tok) ## .claude/rules/ @@ -138,7 +139,7 @@ ## .codegraph/ - `.gitignore` — Git ignore rules (~47 tok) -- `codegraph.db-shm` (~8739 tok) +- `codegraph.db-shm` (~8738 tok) ## .codex/ @@ -305,28 +306,28 @@ ## src/ - `editor_core.rs` — Editor core buffer tối thiểu cho single-file workflow. (~7620 tok) -- `lib.rs` — Phiên bản hiển thị trong UI (Welcome screen, status bar…). (~126 tok) +- `lib.rs` — Phiên bản hiển thị trong UI (Welcome screen, status bar…). (~130 tok) - `main.rs` (~42 tok) ## src/app/ - `async_bridge.rs` — BridgePumpStats: new, pump (~4589 tok) - `clipboard.rs` — SystemClipboard: new (~524 tok) -- `command_palette.rs` — File Picker (Space f f) — Box 800px, top-center, badges theo ext (~15545 tok) +- `command_palette.rs` — File Picker (Space f f) — Box 800px, top-center, badges theo ext (~15739 tok) - `file_picker.rs` — FilePickerEntry: open, close, append_query, backspace_query + 6 more (~1644 tok) - `match_ranges.rs` — [derive(Debug, Clone, Copy, PartialEq, Eq)] (~2128 tok) - `mod.rs` (~66 tok) - `persistence.rs` — [derive(Debug, Clone, Default, Serialize, Deserialize)] (~1310 tok) -- `resolved_keymap.rs` — Typed representation of a key specification parsed from a TOML string. (~15785 tok) +- `resolved_keymap.rs` — Typed representation of a key specification parsed from a TOML string. (~16174 tok) ## src/app/app_state/ - `buffers.rs` — open_file, save_file, reload_active_file_from_disk_discarding_local, new_empty_buffer + 12 more (~8570 tok) - `editor.rs` — insert_tab, insert_char, step_over_closing_char, insert_html_auto_close_tag + 17 more (~10839 tok) -- `mod.rs` — ExternalChangeReport: status_label, new, is_supported_image_path, new + 7 more (~23870 tok) +- `mod.rs` — ExternalChangeReport: status_label, new, is_supported_image_path, new + 7 more (~23976 tok) - `multi_cursor.rs` — Returns all virtual cursors (read-only, used by the renderer). (~9219 tok) - `overlays.rs` — Update an item's `detail` (signature) by label. Used to apply data filled in (~18409 tok) -- `palette.rs` — open_command_palette_mode, open_python_env_selector, open_python_env_selector_with_items, open_dart_env_selector + 23 more (~18273 tok) +- `palette.rs` — open_command_palette_mode, open_python_env_selector, open_python_env_selector_with_items, open_dart_env_selector + 24 more (~18413 tok) - `settings.rs` — [derive(Debug, Clone, PartialEq)] (~1971 tok) - `state.rs` — file_history_picker_items, build_file_history_diff_preview, begin_file_history_preview_session, preview_file_history_index + 9 more (~11571 tok) - `tests.rs` — [cfg(test)] (~19670 tok) @@ -334,22 +335,23 @@ ## src/app/event_loop/ -- `application.rs` — [cfg(target_os = "macos")] (~24745 tok) +- `application.rs` — [cfg(target_os = "macos")] (~25471 tok) - `commands_ai_chat.rs` — Like [`ai_slash_command_completion`] but selects the Nth matching command. (~11667 tok) - `commands_completion.rs` (~16231 tok) +- `commands_dap.rs` (~5676 tok) - `commands_editor.rs` (~5441 tok) -- `commands_explorer.rs` (~7738 tok) +- `commands_explorer.rs` (~9366 tok) - `commands_lsp.rs` (~9672 tok) -- `commands_palette.rs` (~7884 tok) +- `commands_palette.rs` (~8417 tok) - `commands_prompts.rs` (~7215 tok) - `commands_settings_helpers.rs` — Declares RESIZE_STEP_PX (~6809 tok) - `commands_settings.rs` (~4526 tok) - `commands_terminal.rs` (~6841 tok) - `commands_tests.rs` — [derive(Default)] (~28762 tok) -- `commands.rs` — [path = "commands_ai_chat.rs"] (~10302 tok) +- `commands.rs` — [path = "commands_ai_chat.rs"] (~10472 tok) - `helpers.rs` — Declares BRACKET_PAIRS (~18627 tok) -- `mod.rs` — Struct: AppShell (~6794 tok) -- `setup.rs` — new, new_for_tests (~21193 tok) +- `mod.rs` — Struct: AppShell (~6842 tok) +- `setup.rs` — new, new_for_tests (~21382 tok) - `welcome.rs` (~611 tok) ## src/app/event_loop/async_results/ @@ -364,13 +366,13 @@ - `preview.rs` (~993 tok) - `shell.rs` (~112 tok) - `syntax.rs` (~421 tok) -- `system.rs` (~1970 tok) +- `system.rs` (~2429 tok) - `terminal.rs` (~2954 tok) ## src/app/input/ -- `handler.rs` — InputHandler: new, update_modifiers, on_focus_changed, clear_pending_prefix + 3 more (~18617 tok) -- `helpers.rs` — Trả về true nếu đây là event modifier-only (ShiftLeft, ShiftRight, ControlLeft...). (~4349 tok) +- `handler.rs` — InputHandler: new, update_modifiers, on_focus_changed, clear_pending_prefix + 3 more (~19108 tok) +- `helpers.rs` — Trả về true nếu đây là event modifier-only (ShiftLeft, ShiftRight, ControlLeft...). (~4358 tok) - `mod.rs` (~69 tok) - `model.rs` — Dữ liệu key đã chuẩn hóa để tách phần "đọc winit event" (~764 tok) - `pending.rs` — [derive(Debug, Clone, PartialEq, Eq)] (~2421 tok) @@ -378,7 +380,7 @@ ## src/app/input_map/ -- `focus.rs` (~11317 tok) +- `focus.rs` (~11533 tok) - `helpers.rs` (~180 tok) - `mod.rs` — KeybindingContext: as_str, allows_leader, for_mode, for_mode_with_palette + 8 more (~5068 tok) - `tests.rs` (~16214 tok) @@ -386,8 +388,9 @@ ## src/async_runtime/ - `dart_env.rs` — [derive(Debug, Clone, Serialize, Deserialize)] (~1022 tok) -- `message.rs` — Trạng thái cài đặt của từng tool hệ thống. (~7171 tok) -- `mod.rs` (~22 tok) +- `flutter_device.rs` — [derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] (~869 tok) +- `message.rs` — Trạng thái cài đặt của từng tool hệ thống. (~7230 tok) +- `mod.rs` (~28 tok) - `python_env.rs` — [derive(Debug, Clone, Serialize, Deserialize)] (~948 tok) - `scheduler.rs` — Declares FULL_BUFFER_HIGHLIGHT_BYTE_THRESHOLD (~2480 tok) @@ -395,14 +398,14 @@ - `ai_jobs.rs` — Platform install command for opencode: (~3926 tok) - `ai.rs` (~1994 tok) -- `dispatch.rs` (~6293 tok) +- `dispatch.rs` (~6841 tok) - `emit.rs` (~458 tok) - `file_watch.rs` (~2469 tok) - `fzf.rs` — Declares MAX_FZF_RESULTS (~2494 tok) - `git.rs` (~1560 tok) - `local_history.rs` — Local history (disk persistence) has been removed. (~42 tok) - `lsp_io.rs` (~2423 tok) -- `lsp_parse.rs` — Declares MAX_COMPLETION_ITEMS (~14884 tok) +- `lsp_parse.rs` — Declares MAX_COMPLETION_ITEMS (~15053 tok) - `lsp.rs` (~7248 tok) - `pty.rs` (~3903 tok) - `runtime.rs` — Runtime wrapper duy nhất cho background jobs. (~839 tok) @@ -412,7 +415,7 @@ ## src/config/ - `ai_config.rs` — [derive(Debug, Clone, Deserialize, Serialize, Default)] (~1206 tok) -- `keymap_config.rs` — Root structure of a `.toml` keymap profile file. (~327 tok) +- `keymap_config.rs` — Root structure of a `.toml` keymap profile file. (~350 tok) - `keymap_loader.rs` — Load and layer keymap profiles, returning a flat list of validated bindings. (~1675 tok) - `mod.rs` (~35 tok) - `paths.rs` — user_config_root, legacy_app_state_root (~205 tok) @@ -428,8 +431,8 @@ ## src/core/ -- `command_ids.rs` — ── Editor movement & editing ──────────────────────────────────────────────── (~9872 tok) -- `commands.rs` — [derive(Debug, Clone, Copy, PartialEq, Eq)] (~5479 tok) +- `command_ids.rs` — ── Editor movement & editing ──────────────────────────────────────────────── (~10401 tok) +- `commands.rs` — [derive(Debug, Clone, Copy, PartialEq, Eq)] (~5587 tok) - `mod.rs` (~35 tok) - `mode.rs` — Các mode cốt lõi của editor. (~4298 tok) - `text_object.rs` — find_text_object_range (~1753 tok) @@ -439,12 +442,19 @@ - `common.rs` — Struct: DispatchReport (~1739 tok) - `editing.rs` (~10656 tok) -- `mod.rs` — Dispatcher là điểm duy nhất được phép apply `Command` vào `AppState`. (~4616 tok) +- `mod.rs` — Dispatcher là điểm duy nhất được phép apply `Command` vào `AppState`. (~4760 tok) - `navigation.rs` (~3287 tok) -- `palette.rs` (~6910 tok) -- `session.rs` (~4001 tok) +- `palette.rs` (~6977 tok) +- `session.rs` (~4145 tok) - `tests.rs` (~18328 tok) +## src/dap/ + +- `client.rs` — DapClient: launch, send_request (~2863 tok) +- `mod.rs` (~55 tok) +- `session.rs` — DapSession: new, initialize, launch_flutter, set_breakpoints + 9 more (~3582 tok) +- `types.rs` — [derive(Debug, Clone, Serialize, Deserialize)] (~3916 tok) + ## src/lsp/ - `capabilities.rs` — Bitmask nhỏ gọn của các tính năng mà LSP server khai báo trong `InitializeResult`. (~761 tok) @@ -488,7 +498,7 @@ - `buffers.rs` — clear_editor_overlays, update_references_buffer_content (~5424 tok) - `completion.rs` — Declares COMPLETION_KIND_TEXT (~1959 tok) - `extensions.rs` — update_extensions_manager_content (~8893 tok) -- `fuzzy.rs` — update_fuzzy_picker_buffer_content (~7853 tok) +- `fuzzy.rs` — update_fuzzy_picker_buffer_content (~7817 tok) - `help.rs` — update_help_buffer_content (~4176 tok) - `overlays.rs` — update_editor_overlays (~19555 tok) - `selections.rs` — indent_guide_quads, current_line_highlight_quad, visual_selection_quads, visual_block_selection_quads (~11054 tok) @@ -641,13 +651,13 @@ ## src/workbench/ -- `debug_state.rs` — [derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] (~1847 tok) +- `debug_state.rs` — [derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] (~4026 tok) - `focus_manager.rs` — FocusManager: label, current, set, ensure_valid + 3 more (~1406 tok) -- `inspector_panel.rs` — InspectorNode: title, leaf, branch, sync_from_debug_state + 6 more (~3078 tok) +- `inspector_panel.rs` — InspectorNode: title, leaf, branch, sync_from_debug_state + 6 more (~6251 tok) - `layout_engine.rs` — SplitHandle: label, contains, from_ui_theme, new + 1 more (~11133 tok) - `mod.rs` (~54 tok) - `overlay_manager.rs` — OverlaySurface: toggle_command_palette, is_command_palette_open, command_palette_query, push_command_palette_text + 3 more (~2247 tok) -- `panel_state.rs` — [derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] (~2406 tok) +- `panel_state.rs` — [derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] (~2404 tok) - `region_model.rs` — ID chuẩn hóa cho các vùng chính của Workbench. (~960 tok) - `text_coordinate_map.rs` — [derive(Debug, Clone, PartialEq)] (~1674 tok) diff --git a/.wolf/cron-state.json b/.wolf/cron-state.json index 15c409ab..d0b7b56b 100644 --- a/.wolf/cron-state.json +++ b/.wolf/cron-state.json @@ -1,6 +1,6 @@ { - "last_heartbeat": "2026-05-30T06:30:42.080Z", - "engine_status": "running", + "last_heartbeat": "2026-05-30T12:09:00.537Z", + "engine_status": "stopped", "execution_log": [ { "task_id": "anatomy-rescan", @@ -187,6 +187,12 @@ "status": "success", "timestamp": "2026-05-30T05:00:00.316Z", "duration_ms": 81 + }, + { + "task_id": "anatomy-rescan", + "status": "success", + "timestamp": "2026-05-30T11:00:00.790Z", + "duration_ms": 124 } ], "dead_letter_queue": [], diff --git a/.wolf/daemon.log b/.wolf/daemon.log index 507fb01f..f4c28a19 100644 --- a/.wolf/daemon.log +++ b/.wolf/daemon.log @@ -77,3 +77,7 @@ [2026-05-29T17:00:00.630Z] [INFO] Task Full anatomy rescan completed in 141ms [2026-05-30T05:00:00.235Z] [INFO] Executing task: Full anatomy rescan [2026-05-30T05:00:00.317Z] [INFO] Task Full anatomy rescan completed in 81ms +[2026-05-30T11:00:00.666Z] [INFO] Executing task: Full anatomy rescan +[2026-05-30T11:00:00.790Z] [INFO] Task Full anatomy rescan completed in 124ms +[2026-05-30T12:25:21.340Z] [INFO] Daemon shutting down... +[2026-05-30T12:25:21.341Z] [INFO] Daemon stopped diff --git a/AGENTS.md b/AGENTS.md index a70989cd..2850eec1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,7 +64,7 @@ For any input-to-action feature, you MUST follow this exact path: # GitNexus — Code Intelligence -This project is indexed by GitNexus as **netherize_editor** (7490 symbols, 19753 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **netherize_editor** (7835 symbols, 20595 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/CLAUDE.md b/CLAUDE.md index cd75758c..7376cf9a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,7 +71,7 @@ For any input-to-action feature, you MUST follow this exact path: # GitNexus — Code Intelligence -This project is indexed by GitNexus as **netherize_editor** (7490 symbols, 19753 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **netherize_editor** (7835 symbols, 20595 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/debug.md b/debug.md new file mode 100644 index 00000000..a85bda9e --- /dev/null +++ b/debug.md @@ -0,0 +1,167 @@ + +# Kiến trúc Built-in Debugger: Hệ thống Generic DAP Client trong Netherize Editor + +Để debugger không chỉ chạy riêng cho Flutter mà trở thành một hạ tầng cốt lõi (Built-in Subsystem) của Netherize Editor, phục vụ cho việc debug mọi ngôn ngữ sau này (Rust, C++, Go, Python...), chúng ta sẽ thiết kế hệ thống theo chuẩn **Debug Adapter Protocol (DAP)** của Microsoft. + +Kiến trúc này tách biệt hoàn toàn phần giao diện (UI), lõi Editor (Core) và phần giao thức kết nối với các trình gỡ lỗi đặc thù của từng ngôn ngữ (Debug Adapters). + +--- + +## 1. Sơ Đồ Kiến Trúc Hạ Tầng DAP Client + +```mermaid +graph TD + UI[Giao diện DAP UI & Editor Gutter] <--> |AppState / Command Dispatch| Core[Editor Core Engine] + Core <--> |DapSessionRegistry| DAP_Client[Generic DAP Client Engine /src/dap] + DAP_Client <--> |stdin / stdout JSON-RPC| Adapter[Language Debug Adapter Subprocess] + + subgraph Debug Adapters tương thích + Adapter_Flutter[flutter debug_adapter] + Adapter_Rust[codelldb / lldb-vscode] + Adapter_Python[debugpy] + end + + Adapter -.-> Adapter_Flutter + Adapter -.-> Adapter_Rust + Adapter -.-> Adapter_Python +``` + +### Nguyên tắc thiết kế: +1. **Generic DAP Client (`src/dap`)**: Viết một client JSON-RPC chuẩn hóa, truyền thông qua stdin/stdout (hoặc TCP port) của tiến trình con. Client này độc lập hoàn toàn với ngôn ngữ lập trình. +2. **Standardized State (`DapSession`)**: Lưu trữ trạng thái gỡ lỗi chuẩn hóa: danh sách threads, call stack, variables scopes, active frame, và trạng thái suspend (paused/running). +3. **Language Configuration**: Mỗi ngôn ngữ sẽ định nghĩa một cấu hình Adapter trong file cấu hình (ví dụ: lệnh khởi chạy adapter, tham số truyền vào). + * **Flutter/Dart**: Chạy `flutter debug_adapter` hoặc `dart debug_adapter`. + * **Rust**: Chạy `codelldb` hoặc `lldb-vscode`. + * **Python**: Chạy `python -m debugpy --adapter`. + +--- + +## 2. Thiết Kế Module `/src/dap` + +Chúng ta tạo thư mục mới `/src/dap/` cấu trúc tương tự `/src/lsp/`: + +### A. `src/dap/types.rs` (DAP Protocol Definitions) +Định nghĩa các Struct serialize/deserialize theo đặc tả của Microsoft DAP: +* **Requests**: `InitializeRequest`, `LaunchRequest`, `SetBreakpointsRequest`, `StackTraceRequest`, `ScopesRequest`, `VariablesRequest`, `ContinueRequest`, `NextRequest` (Step Over), `StepInRequest`, `StepOutRequest`, `DisconnectRequest`. +* **Events**: `InitializedEvent`, `StoppedEvent` (lý do: breakpoint/step/exception), `ContinuedEvent`, `OutputEvent` (log output), `TerminatedEvent`. +* **Custom Requests cho Flutter**: Chuẩn DAP của Dart/Flutter hỗ trợ gửi các request tùy biến để reload/restart: + * Hot Reload: `{ "command": "customRequest", "arguments": { "request": "hotReload" } }` + * Hot Restart: `{ "command": "customRequest", "arguments": { "request": "hotRestart" } }` + +### B. `src/dap/client.rs` (DAP Process Handler) +* Quản lý vòng đời tiến trình con của Debug Adapter (ví dụ: `flutter debug_adapter`). +* Chạy một luồng đọc (stdout reader) chuyên biệt để nhận các gói JSON-RPC phản hồi hoặc Event bất đồng bộ từ adapter. +* Đánh số thứ tự gói tin gửi đi (`seq`) và map phản hồi trả về qua kênh `oneshot::Sender`. + +### C. `src/dap/session.rs` (DAP Session State) +* Quản lý trạng thái hiện tại của phiên debug: + * Isolate/Thread ID nào đang bị paused. + * Danh sách Stack Frames hiện tại. + * Ánh xạ Variables của scope đang xem (để vẽ lên cây phân cấp biến cục bộ). +* Gửi kết quả cập nhật về Event Loop của IDE để vẽ lại giao diện. + +--- + +## 3. Các Phím Tắt & Luồng Điều Hướng Tương Tác + +### A. Định nghĩa phím tắt trong `config/keymaps/default.toml` +```toml +# ── PHÍM TẮT ĐIỀU HƯỚNG PANEL ── +[[bindings]] +key = "ctrl+f" +command = "app.focus_explorer" # Focus vào File Explorer (Panel trái) + +[[bindings]] +key = "space d b" +command = "app.focus_dap" # Focus vào DAP Debugger Panel (Panel trái) + +# ── PHÍM TẮT DEBUG TOÀN CỤC ── +[[bindings]] +key = "F5" +command = "dap.continue" # Chạy debug / Tiếp tục chạy khi dừng + +[[bindings]] +key = "F6" +command = "dap.hot_restart" # Hot Restart (qua customRequest của DAP) + +[[bindings]] +key = "Shift+F5" +command = "dap.stop" # Dừng debug + +[[bindings]] +key = "F10" +command = "dap.step_over" # Đi qua (Next) + +[[bindings]] +key = "F11" +command = "dap.step_into" # Đi vào (Step In) + +[[bindings]] +key = "Shift+F11" +command = "dap.step_out" # Đi ra (Step Out) + +# ── TRONG PANEL DAP ── +[[bindings]] +mode = "normal" +key = "z a" +command = "dap.toggle_expand" # Collapse/Expand section hoặc dòng biến số đang chọn +``` + +--- + +## 4. Thiết Kế Chi Tiết Giao Diện DAP & Debug Console + +### A. Giao diện DAP (Panel Trái) +Khi Panel trái active tab `PanelTabId::Inspector` (DAP), giao diện sẽ hiển thị 4 vùng dọc, tự động tính toán kích thước khi người dùng nhấn `za`: + +1. **Variables (Biến số)**: + * Hiển thị danh sách các biến cục bộ thu được từ DAP request `scopes` -> `variables`. + * Hỗ trợ dạng cây phân cấp (nhấn `za` hoặc `Enter` để expand các thuộc tính sâu hơn của object nhờ `variableReference` của DAP). +2. **Watch (Theo dõi)**: + * Cho phép thêm biểu thức cần theo dõi. Khi suspend, DAP Client tự động gọi `evaluate` cho từng biểu thức và hiển thị kết quả. +3. **Call Stack (Ngăn xếp)**: + * Hiển thị danh sách Stack Frames thu được từ request `stackTrace`. + * Dòng suspended hiện tại sẽ có dấu chấm màu đỏ nổi bật `●`. Nhấn `Enter` vào một frame sẽ nhảy thẳng tới file và dòng code tương ứng trong editor. +4. **Breakpoints (Điểm dừng)**: + * Liệt kê tất cả các file và dòng đang đặt breakpoint. Cho phép bật/tắt nhanh breakpoint. + +**Thuật toán chia chiều cao động với `za`**: +* Chiều cao mặc định của Section Header khi collapse: $H_{collapsed} = 26.0\text{ px}$. +* Chiều cao khả dụng cho các section đang mở: + $$H_{expanded} = \frac{H_{total} - (N_{collapsed} \times H_{collapsed}) - \text{Gaps}}{N_{expanded}}$$ + +### B. Debug Console (Panel Dưới) +* Khi tiến trình Debug Adapter gửi các event `OutputEvent` (logs, print statements, exception stack trace), DAP Client sẽ chuyển hướng dữ liệu này tới tab **Debug Console** (`PanelTabId::DebugConsole`). +* Render văn bản tuần tự kèm màu sắc tiêu chuẩn (màu đỏ cho lỗi/stderr, màu trắng cho log thường, màu xanh cho thông tin debug). + +--- + +## 5. Lộ Trình Triển Khai Kỹ Thuật (Step-by-Step Implementation) + +### Giai đoạn 1: Xây dựng Module lõi `src/dap/` (Generic DAP Protocol) +1. Tạo thư mục `src/dap/` và định nghĩa các struct của chuẩn DAP trong `types.rs`. +2. Tạo `client.rs` để xử lý việc khởi chạy Adapter process dưới dạng `tokio::process::Child`, quản lý luồng ghi vào stdin và đọc từ stdout. +3. Tạo `session.rs` để quản lý luồng dữ liệu state (threads, frames, variables, breakpoints). + +### Giai đoạn 2: Tích hợp Giao diện DAP UI (Panel Trái) & Console (Panel Dưới) +1. Thêm nhãn `"DAP"` cho `PanelTabId::Inspector`. +2. Triển khai thuật toán tính chiều cao động trong `src/workbench/inspector_panel.rs` cho 4 section của DAP. +3. Liên kết phím tắt `za` để gọi hàm `toggle_expand()` của section hoặc của biến số đang chọn. +4. Tạo giao diện render log cho tab **Debug Console** tại Panel dưới. + +### Giai đoạn 3: Hiện thực hóa Flutter Debug Adapter +1. Triển khai lệnh chạy ngầm `flutter debug_adapter`. +2. Khi khởi chạy, gửi request `initialize`, sau đó gửi request `launch` kèm thông số cấu hình: + ```json + { + "program": "lib/main.dart", + "toolArgs": ["-d", "active_device_id"] + } + ``` +3. Lắng nghe `OutputEvent` của Dart VM gửi về và đẩy vào tab Debug Console. +4. Gửi customRequest `hotReload` khi lưu file bất kỳ, hoặc `hotRestart` khi nhấn `F6`. + +### Giai đoạn 4: Đồng bộ Breakpoint & Highlight Trực Quan trong Editor +1. Khi người dùng click hoặc dùng phím tắt toggle breakpoint trong editor, gọi `DapSession::set_breakpoints` để đồng bộ vị trí dòng sang adapter thông qua request `setBreakpoints`. +2. Khi nhận event `stopped` từ adapter, highlight dòng suspended màu vàng trong editor và hiển thị giá trị biến inline ở cuối dòng. +3. Khi nhận event `continued`, xóa highlight và con trỏ dừng ở gutter. diff --git a/src/app/app_state/mod.rs b/src/app/app_state/mod.rs index 090ba886..85edba44 100644 --- a/src/app/app_state/mod.rs +++ b/src/app/app_state/mod.rs @@ -1983,6 +1983,7 @@ pub struct AppState { // ── Workspace symbol cache ──────────────────────────────────────────────── /// Pre-indexed workspace symbols for fast import suggestions. workspace_symbol_cache: Arc, + active_dart_device_id: Option, } impl AppState { @@ -2048,9 +2049,18 @@ impl AppState { auto_folded_long_lines: Vec::new(), cached_line_starts: None, workspace_symbol_cache: Arc::new(crate::lsp::WorkspaceSymbolCache::new()), + active_dart_device_id: None, } } + pub fn active_dart_device_id(&self) -> Option<&str> { + self.active_dart_device_id.as_deref() + } + + pub fn set_active_dart_device_id(&mut self, device_id: Option) { + self.active_dart_device_id = device_id; + } + pub fn from_text(default_save_path: PathBuf, text: &str) -> Self { Self { text: Rope::from(text), @@ -2111,6 +2121,7 @@ impl AppState { auto_folded_long_lines: Vec::new(), cached_line_starts: None, workspace_symbol_cache: Arc::new(crate::lsp::WorkspaceSymbolCache::new()), + active_dart_device_id: None, } } diff --git a/src/app/app_state/palette.rs b/src/app/app_state/palette.rs index 78937548..1ab25a2c 100644 --- a/src/app/app_state/palette.rs +++ b/src/app/app_state/palette.rs @@ -65,6 +65,21 @@ impl AppState { .open_with_items(CommandPaletteMode::DartEnvSelector, items); } + pub fn open_flutter_device_selector(&mut self) -> bool { + let workspace = self.workspace_model.as_ref(); + self.command_palette + .open(CommandPaletteMode::FlutterDevices, workspace) + > 0 + } + + pub fn open_flutter_device_selector_with_items( + &mut self, + items: Vec, + ) { + self.command_palette + .open_with_items(CommandPaletteMode::FlutterDevices, items); + } + /// Push current file+line+column onto the jump back stack before a jump (e.g. gd). /// Clears the forward stack since jumping starts a new branch. pub fn push_jump(&mut self) { @@ -235,6 +250,9 @@ impl AppState { } pub fn command_palette_mode(&self) -> Option { + if self.command_palette.is_visible { + return Some(self.command_palette.mode); + } if let Some(index) = self.active_buffer_index { if let Some(BufferEntry { content: BufferContent::FuzzyPicker(state), @@ -244,11 +262,7 @@ impl AppState { return Some(state.mode); } } - if self.command_palette.is_visible { - Some(self.command_palette.mode) - } else { - None - } + None } pub fn command_palette_query_text(&self) -> &str { diff --git a/src/app/app_state/workspace.rs b/src/app/app_state/workspace.rs index a0856f40..ab9a4383 100644 --- a/src/app/app_state/workspace.rs +++ b/src/app/app_state/workspace.rs @@ -8,6 +8,10 @@ impl AppState { Ok(()) } + pub fn detach_workspace(&mut self) { + self.workspace_model = None; + } + pub fn clear_workspace_session_state(&mut self) -> bool { let mut changed = false; diff --git a/src/app/command_palette.rs b/src/app/command_palette.rs index 3670ea0f..88774b5a 100644 --- a/src/app/command_palette.rs +++ b/src/app/command_palette.rs @@ -53,6 +53,10 @@ pub enum CommandPaletteMode { PythonEnvSelector, /// Dart environment selector — opened from the command palette. DartEnvSelector, + /// Flutter devices selector. + FlutterDevices, + /// Debug watch expression input. + DebugWatchInput, } impl CommandPaletteMode { @@ -79,6 +83,8 @@ impl CommandPaletteMode { Self::CodeAction => "action> ", Self::PythonEnvSelector => "python> ", Self::DartEnvSelector => "dart> ", + Self::FlutterDevices => "devices> ", + Self::DebugWatchInput => "watch> ", } } @@ -105,6 +111,8 @@ impl CommandPaletteMode { Self::CodeAction => "no code actions available", Self::PythonEnvSelector => "scanning Python environments...", Self::DartEnvSelector => "scanning Dart environments...", + Self::FlutterDevices => "scanning Flutter devices...", + Self::DebugWatchInput => "enter expression to watch...", } } @@ -131,6 +139,8 @@ impl CommandPaletteMode { Self::CodeAction => "ACTIONS", Self::PythonEnvSelector => "PYTHON ENV", Self::DartEnvSelector => "DART ENV", + Self::FlutterDevices => "DEVICES", + Self::DebugWatchInput => "WATCH", } } @@ -173,6 +183,11 @@ pub enum CommandPaletteAction { SelectPythonEnv(PathBuf), /// Chọn Dart/Flutter SDK path để restart LSP. SelectDartEnv(PathBuf), + SelectFlutterDevice { + device_id: String, + is_emulator: bool, + is_active: bool, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -582,6 +597,7 @@ impl CommandPalette { | CommandPaletteMode::CodeAction | CommandPaletteMode::PythonEnvSelector | CommandPaletteMode::DartEnvSelector + | CommandPaletteMode::FlutterDevices ) { self.results = self.static_items.clone(); if self.results.is_empty() { @@ -654,6 +670,8 @@ impl CommandPalette { CommandPaletteMode::CodeAction => unreachable!("handled above"), CommandPaletteMode::PythonEnvSelector => Vec::new(), CommandPaletteMode::DartEnvSelector => Vec::new(), + CommandPaletteMode::FlutterDevices => Vec::new(), + CommandPaletteMode::DebugWatchInput => Vec::new(), }; if self.results.is_empty() { @@ -1100,6 +1118,17 @@ fn command_palette_items(query: &str, max_results: usize) -> Vec for AppShell { } return; } + if let Key::Named(NamedKey::F5) = key_event.logical_key { + eprintln!("[DAP LOG] [F5 Application] WindowEvent::KeyboardInput F5 pressed: {:?}", key_event); + } let context = self.build_context(); let outcome = self.input_handler .translate_key_event(&key_event, &self.input_map, context); + if let Key::Named(NamedKey::F5) = key_event.logical_key { + eprintln!("[DAP LOG] [F5 Application] WindowEvent F5 translation outcome: {:?}", outcome); + } match outcome { Some(InputRouteOutcome::Dispatch(translated)) => { + if let Command::DebugStart = &translated.command { + eprintln!("[DAP LOG] [F5 Application] WindowEvent F5 translated to Command::DebugStart"); + } if self .handle_command_with_count(translated.command, translated.repeat_count) { @@ -987,12 +996,34 @@ impl AppShell { syntax_spans_to_styled(&effective_highlights, &text, &self.theme); styled_spans .extend(diagnostic_spans_to_styled(&self.app_state, &self.theme)); + let breakpoint_lines = if let Some(active_file) = self.app_state.active_file() { + self.breakpoints.get(active_file).cloned().unwrap_or_default() + } else { + Vec::new() + }; renderer.update_editor_content( &text, &self.app_state, center_bounds, &styled_spans, + &breakpoint_lines, ); + let (dap_active, dap_paused, should_clear) = if let Some(session) = &self.dap_session { + let state = session.state.try_lock(); + let terminated = state.as_ref().map(|s| s.terminated).unwrap_or(false); + if terminated { + (false, false, true) + } else { + (true, state.map(|s| s.paused).unwrap_or(false), false) + } + } else { + (false, false, false) + }; + if should_clear { + self.dap_session = None; + } + renderer.dap_is_active = dap_active; + renderer.dap_is_paused = dap_paused; renderer.update_editor_overlays(&self.app_state, center_bounds); } } @@ -1097,6 +1128,11 @@ impl AppShell { &self.theme, ), ); + let breakpoint_lines = if let Some(active_file) = self.app_state.active_file() { + self.breakpoints.get(active_file).cloned().unwrap_or_default() + } else { + Vec::new() + }; if breadcrumb_viewport_changed { let effective_highlights = crate::syntax::highlight::overlay_highlight_layers( @@ -1113,10 +1149,27 @@ impl AppShell { &self.app_state, center_bounds, &styled_spans, + &breakpoint_lines, ); } else { - renderer.update_editor_caret(&self.app_state, center_bounds); + renderer.update_editor_caret(&self.app_state, center_bounds, &breakpoint_lines); + } + let (dap_active, dap_paused, should_clear) = if let Some(session) = &self.dap_session { + let state = session.state.try_lock(); + let terminated = state.as_ref().map(|s| s.terminated).unwrap_or(false); + if terminated { + (false, false, true) + } else { + (true, state.map(|s| s.paused).unwrap_or(false), false) + } + } else { + (false, false, false) + }; + if should_clear { + self.dap_session = None; } + renderer.dap_is_active = dap_active; + renderer.dap_is_paused = dap_paused; renderer.update_editor_overlays(&self.app_state, center_bounds); } self.editor_caret_needs_layout = false; @@ -1230,13 +1283,50 @@ impl AppShell { } else { 0 }; - let sidebar_rows = build_sidebar_rows( - &self.explorer_snapshot.entries, - self.explorer_cursor, - &self.theme, - self.app_state.workspace_has_active_filter(), - sidebar_scroll_offset_rows, - ); + let sidebar_rows = if self.panel_state.left.active_tab_id() == Some(PanelTabId::Inspector) { + if let Some(session) = &self.dap_session { + if let Ok(debug_state) = session.state.try_lock() { + self.dap_panel_state.sync_from_debug_state(&debug_state); + } + } + let visible = self.dap_panel_state.visible_rows(); + let selected = self.dap_panel_state.selected_row; + visible.into_iter().enumerate().map(|(idx, r)| { + let arrow = self.theme.sidebar_arrow(r.expandable, r.expanded).to_string(); + let icon = if r.depth == 0 { + match r.section_index { + 0 => "󰏫", // Variables + 1 => "󰛢", // Watch + 2 => "󰢱", // CallStack + 3 => "󰔧", // Breakpoints + _ => "•", + }.to_string() + } else { + "•".to_string() + }; + SidebarRow { + path: None, + depth: r.depth, + arrow, + nerd_icon: icon, + icon_color: self.theme.icons.default_file.color.as_f32(), + label: r.label, + prefix_marker: None, + prefix_color: None, + git_marker: None, + git_color: None, + is_selected: idx == selected, + } + }).collect::>() + } else { + build_sidebar_rows( + &self.explorer_snapshot.entries, + self.explorer_cursor, + &self.theme, + self.app_state.workspace_has_active_filter(), + sidebar_scroll_offset_rows, + ) + }; if let Some(renderer) = self.renderer.as_mut() { if let Some(bounds) = sidebar_bounds { let sidebar_focused = self.focus_manager.current() == FocusTarget::LeftSidebar; @@ -1527,6 +1617,7 @@ impl AppShell { self.runtime_versions.python_version.as_deref(), self.runtime_versions.node_version.as_deref(), self.runtime_versions.go_version.as_deref(), + self.app_state.active_dart_device_id(), status_bounds, ); region_instances.extend(pill_quads); @@ -1575,18 +1666,53 @@ impl AppShell { .map(|renderer| { renderer.terminal_tab_bar_content_bounds( bottom_bounds, - self.terminal_tabs.len(), + self.panel_state.bottom.tabs.len(), ) }) .unwrap_or(bottom_bounds); + let active_tab = self.panel_state.bottom.active_tab_id(); + let is_debug_console = active_tab == Some(PanelTabId::DebugConsole); + if is_debug_console { + let messages = if let Some(session) = &self.dap_session { + if let Ok(state) = session.state.try_lock() { + state.console_messages.clone() + } else { + Vec::new() + } + } else { + Vec::new() + }; + + if messages.len() < self.dap_console_fed_count { + self.dap_console_grid.clear(); + self.dap_console_fed_count = 0; + } + + if self.dap_console_fed_count < messages.len() { + for msg in &messages[self.dap_console_fed_count..] { + self.dap_console_grid.feed_chunk(msg); + if !msg.ends_with('\n') { + self.dap_console_grid.feed_chunk("\n"); + } + } + self.dap_console_fed_count = messages.len(); + self.dap_console_grid.view_scroll_to_bottom(); + } + } + let bounds_changed = self.last_terminal_bounds != Some(bottom_bounds); let grid_changed = self.sync_terminal_layout(terminal_content_bounds); - if (self.terminal_needs_layout || bounds_changed || grid_changed) + if (self.terminal_needs_layout || bounds_changed || grid_changed || is_debug_console) && let Some(renderer) = self.renderer.as_mut() { + let grid = if is_debug_console { + &self.dap_console_grid + } else { + &self.terminal_tabs[self.active_terminal_tab].grid + }; renderer.update_terminal_content( - &self.terminal_tabs[self.active_terminal_tab].grid, + grid, terminal_content_bounds, self.app_state.current_mode(), ); @@ -1596,22 +1722,42 @@ impl AppShell { // Render tab bar after terminal content layout so tab labels are // appended after body glyphs in the shared terminal text pipeline. + // Compute tab info before mutable borrow of renderer. + let tab_labels_short: Vec<&str> = self + .panel_state + .bottom + .tabs + .iter() + .map(|t| match t { + PanelTabId::Terminal => "Term", + PanelTabId::DebugConsole => "Debug", + PanelTabId::Problems => "Probs", + _ => t.label(), + }) + .collect(); + let terminal_is_running = self + .active_terminal_tab() + .is_some_and(|tab| tab.status.is_running()); + let dap_is_active = self.dap_session.is_some(); + let tab_running: Vec = self + .panel_state + .bottom + .tabs + .iter() + .map(|t| match t { + PanelTabId::Terminal => terminal_is_running, + PanelTabId::DebugConsole => dap_is_active, + _ => false, + }) + .collect(); + let active_tab_idx = self.panel_state.bottom.active_tab; + let tab_bar_quads = if let Some(renderer) = self.renderer.as_mut() { - let labels: Vec<&str> = self - .terminal_tabs - .iter() - .map(|t| t.label.as_str()) - .collect(); - let running: Vec = self - .terminal_tabs - .iter() - .map(|t| t.status.is_running()) - .collect(); renderer .update_terminal_tab_bar( - &labels, - &running, - self.active_terminal_tab, + &tab_labels_short, + &tab_running, + active_tab_idx, bottom_bounds, ) .0 diff --git a/src/app/event_loop/async_results/mod.rs b/src/app/event_loop/async_results/mod.rs index 2925dbc9..e3c9479e 100644 --- a/src/app/event_loop/async_results/mod.rs +++ b/src/app/event_loop/async_results/mod.rs @@ -94,7 +94,9 @@ impl AsyncResultRouter for AppShell { WorkerResultPayload::SystemDepCheckResult { .. } | WorkerResultPayload::RuntimeVersionsDetected { .. } | WorkerResultPayload::PythonEnvironmentsDiscovered(_) - | WorkerResultPayload::DartEnvironmentsDiscovered(_) => { + | WorkerResultPayload::DartEnvironmentsDiscovered(_) + | WorkerResultPayload::FlutterDevicesDiscovered(_) + | WorkerResultPayload::FlutterEmulatorLaunched => { system::handle_system_result(self, result.payload); } WorkerResultPayload::FileCopyResult { .. } => { diff --git a/src/app/event_loop/async_results/system.rs b/src/app/event_loop/async_results/system.rs index 6ce5b333..f32233fa 100644 --- a/src/app/event_loop/async_results/system.rs +++ b/src/app/event_loop/async_results/system.rs @@ -91,6 +91,43 @@ pub(super) fn handle_system_result(app: &mut AppShell, payload: WorkerResultPayl app.app_state.open_dart_env_selector_with_items(items); app.request_redraw(); } + WorkerResultPayload::FlutterDevicesDiscovered(devices) => { + eprintln!("[AppShell] flutter devices discovered: {}", devices.len()); + let items: Vec = devices + .into_iter() + .map(|dev| { + let secondary = if dev.is_active { + Some(format!("active ({})", dev.platform)) + } else { + Some(format!("emulator offline ({})", dev.platform)) + }; + crate::app::command_palette::CommandPaletteItem { + label: dev.name, + secondary_label: secondary, + action: crate::app::command_palette::CommandPaletteAction::SelectFlutterDevice { + device_id: dev.id, + is_emulator: dev.emulator, + is_active: dev.is_active, + }, + tone: crate::app::command_palette::CommandPaletteItemTone::Default, + preview_colors: Vec::new(), + } + }) + .collect(); + app.app_state.open_flutter_device_selector_with_items(items); + app.request_redraw(); + } + WorkerResultPayload::FlutterEmulatorLaunched => { + app.show_transient_toast("Flutter emulator launched successfully.".to_string()); + let flutter_path = app.current_flutter_path(); + app.submit(crate::async_runtime::message::RequestSpec { + revision_id: 0, + topic: crate::async_runtime::message::RequestTopic::SystemTask, + payload: crate::async_runtime::message::WorkerRequestPayload::ScanFlutterDevices { + flutter_path: Some(flutter_path), + }, + }); + } _ => {} } } diff --git a/src/app/event_loop/commands.rs b/src/app/event_loop/commands.rs index e81238d1..1554d34e 100644 --- a/src/app/event_loop/commands.rs +++ b/src/app/event_loop/commands.rs @@ -18,6 +18,8 @@ mod commands_settings; mod commands_settings_helpers; #[path = "commands_terminal.rs"] mod commands_terminal; +#[path = "commands_dap.rs"] +mod commands_dap; #[cfg(test)] #[path = "commands_tests.rs"] mod tests; @@ -100,6 +102,12 @@ impl AppShell { Command::SaveFile => { self.submit_workspace_git_status_refresh(); self.submit_active_buffer_git_baseline_refresh(); + if let Some(session) = &self.dap_session { + let session_clone = session.clone(); + tokio::spawn(async move { + let _ = session_clone.hot_reload().await; + }); + } } _ => {} } @@ -330,6 +338,19 @@ impl AppShell { changed, ); } + if let Command::DebugStart = &command { + eprintln!("[DAP LOG] [F5 Commands] handle_command_with_count: Routing Command::DebugStart to handle_dap_command"); + } + if let Some(changed) = self.handle_dap_command(&command) { + if let Command::DebugStart = &command { + eprintln!("[DAP LOG] [F5 Commands] handle_command_with_count: handle_dap_command returned: {:?}", changed); + } + return self.finalize_post_command_hooks( + &command_for_post_hooks, + should_persist_history_after, + changed, + ); + } if let Some(changed) = self.handle_palette_and_open_command(&command, repeat_count, &command_for_post_hooks) { diff --git a/src/app/event_loop/commands_dap.rs b/src/app/event_loop/commands_dap.rs new file mode 100644 index 00000000..0dd9e85c --- /dev/null +++ b/src/app/event_loop/commands_dap.rs @@ -0,0 +1,816 @@ +use super::*; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use crate::core::commands::Command; +use crate::workbench::panel_state::PanelTabId; +use crate::workbench::focus_manager::FocusTarget; + +fn resolve_flutter_path(dart_path: &Path) -> PathBuf { + let dart_str = dart_path.to_string_lossy(); + if dart_str.contains("bin/cache/dart-sdk/bin/dart") { + let flutter_str = dart_str.replace("bin/cache/dart-sdk/bin/dart", "bin/flutter"); + let path = PathBuf::from(flutter_str); + if path.exists() { + return path; + } + } + if let Some(parent) = dart_path.parent() { + let flutter_same_dir = parent.join("flutter"); + if flutter_same_dir.exists() { + return flutter_same_dir; + } + if let Some(grandparent) = parent.parent() { + let flutter_grand = grandparent.join("flutter"); + if flutter_grand.exists() { + return flutter_grand; + } + let flutter_grand_bin = grandparent.join("bin").join("flutter"); + if flutter_grand_bin.exists() { + return flutter_grand_bin; + } + } + } + PathBuf::from("flutter") +} + +impl AppShell { + pub fn handle_dap_command(&mut self, command: &Command) -> Option { + match command { + Command::FocusDap => { + let mut changed = self.release_focus_mode_to_editor(); + if !self.panel_state.left.visible { + self.panel_state.left.visible = true; + changed = true; + self.sidebar_needs_layout = true; + } + if self.panel_state.left.active_tab_id() != Some(PanelTabId::Inspector) { + self.panel_state.left.switch_to_tab(PanelTabId::Inspector); + changed = true; + self.sidebar_needs_layout = true; + } + changed |= self.dismiss_initial_launch_welcome_if_active(); + let focus_changed = self.focus_manager.set(FocusTarget::LeftSidebar); + changed |= focus_changed; + if focus_changed { + self.input_handler.clear_pending_prefix(); + } + Some(changed) + } + Command::DapToggleExpand => { + if self.focus_manager.current() == FocusTarget::LeftSidebar + && self.panel_state.left.active_tab_id() == Some(PanelTabId::Inspector) + { + let changed = self.dap_panel_state.toggle_selected_expand(); + if changed { + self.sidebar_needs_layout = true; + } + Some(changed) + } else { + None + } + } + Command::DebugStart => { + eprintln!("[DAP LOG] [F5 Commands DAP] handle_dap_command: entered Command::DebugStart"); + let mut should_clear = false; + if let Some(session) = &self.dap_session { + let (paused, terminated) = { + let state = session.state.try_lock(); + state.map(|s| (s.paused, s.terminated)).unwrap_or((false, false)) + }; + eprintln!("[DAP LOG] [F5 Commands DAP] DebugStart with existing session, paused={}, terminated={}", paused, terminated); + if terminated { + should_clear = true; + } else { + if paused { + let session_clone = session.clone(); + let window_clone = self.window.clone(); + let _guard = self.scheduler.enter(); + tokio::spawn(async move { + let _ = session_clone.resume().await; + if let Some(w) = window_clone { + w.request_redraw(); + } + }); + return Some(true); + } + return Some(false); + } + } + if should_clear { + self.dap_session = None; + } + + // Launch new session — try launch.json first, then fallback to Flutter defaults + let workspace_root = self.app_state.workspace_root_path() + .map(PathBuf::from) + .or_else(|| { + self.app_state.active_file() + .and_then(|file| file.parent().map(PathBuf::from)) + }) + .unwrap_or_default(); + eprintln!("[DAP LOG] [F5 Commands DAP] workspace_root: {:?}", workspace_root); + + if workspace_root.as_os_str().is_empty() { + eprintln!("[DAP LOG] [F5 Commands DAP] No workspace_root found, returning false"); + self.show_transient_toast_kind("No active workspace or file parent folder", ToastKind::Error); + return Some(false); + } + + self.show_transient_toast("Starting debugger...".to_string()); + + // Try to load launch.json (.vscode/launch.json or .zed/debug.json) + let launch_config = crate::dap::launch_config::load_launch_json(&workspace_root) + .and_then(|cfg| { + eprintln!("[DebugStart] Found launch.json with {} configurations", cfg.configurations.len()); + cfg.configurations.into_iter().next() + }); + + if launch_config.is_none() { + eprintln!("[DebugStart] No launch.json found, using Flutter defaults"); + } + + let (program, adapter_cmd, adapter_args, adapter_id) = if let Some(ref config) = launch_config { + let resolved = config.resolve(&workspace_root); + let program = if resolved.program.is_empty() { + workspace_root.join("lib").join("main.dart").to_string_lossy().to_string() + } else { + resolved.program.clone() + }; + match config.config_type.as_str() { + "dart" | "flutter" => { + // Check if FVM is available - prefer fvm flutter over direct flutter + let fvm_path = Self::resolve_fvm_path(); + let has_local_fvm = workspace_root.join(".fvm").join("flutter_sdk").exists(); + + let (adapter_cmd, adapter_args) = if has_local_fvm { + // Use FVM wrapper for local FVM projects + if let Some(ref fvm) = fvm_path { + eprintln!("[DebugStart] Using FVM wrapper: {:?}", fvm); + ( + fvm.to_string_lossy().to_string(), + vec!["flutter".to_string(), "debug_adapter".to_string()], + ) + } else { + // FVM not found in PATH, fallback to direct flutter + let dart_bin = self.selected_dart_env.clone().or_else(|| { + let local_fvm = workspace_root.join(".fvm").join("flutter_sdk") + .join("bin").join("cache").join("dart-sdk") + .join("bin").join("dart"); + if local_fvm.try_exists().unwrap_or(false) { Some(local_fvm) } else { None } + }); + let flutter_path = dart_bin.as_ref() + .map(|d| resolve_flutter_path(d)) + .unwrap_or_else(|| PathBuf::from("flutter")); + ( + flutter_path.to_string_lossy().to_string(), + vec!["debug_adapter".to_string()], + ) + } + } else { + // No local FVM, use direct flutter + let dart_bin = self.selected_dart_env.clone().or_else(|| { + let local_fvm = workspace_root.join(".fvm").join("flutter_sdk") + .join("bin").join("cache").join("dart-sdk") + .join("bin").join("dart"); + if local_fvm.try_exists().unwrap_or(false) { Some(local_fvm) } else { None } + }); + let flutter_path = dart_bin.as_ref() + .map(|d| resolve_flutter_path(d)) + .unwrap_or_else(|| PathBuf::from("flutter")); + ( + flutter_path.to_string_lossy().to_string(), + vec!["debug_adapter".to_string()], + ) + }; + + ( + program, + adapter_cmd, + adapter_args, + "flutter".to_string(), + ) + } + "lldb" | "codelldb" => { + ( + program, + "codelldb".to_string(), + vec![], + "lldb".to_string(), + ) + } + "python" | "debugpy" => { + ( + program, + "python".to_string(), + vec!["-m".to_string(), "debugpy".to_string(), "--adapter".to_string()], + "debugpy".to_string(), + ) + } + _ => { + // Generic adapter — use config type as adapter ID + ( + program, + config.config_type.clone(), + vec![], + config.config_type.clone(), + ) + } + } + } else { + // No launch.json — fallback to Flutter defaults with FVM support + let fvm_path = Self::resolve_fvm_path(); + let has_local_fvm = workspace_root.join(".fvm").join("flutter_sdk").exists(); + + let (adapter_cmd, adapter_args) = if has_local_fvm { + // Use FVM wrapper for local FVM projects + if let Some(ref fvm) = fvm_path { + eprintln!("[DebugStart] Using FVM wrapper (no launch.json): {:?}", fvm); + ( + fvm.to_string_lossy().to_string(), + vec!["flutter".to_string(), "debug_adapter".to_string()], + ) + } else { + // FVM not found in PATH, fallback to direct flutter + let dart_bin = self.selected_dart_env.clone().or_else(|| { + let local_fvm = workspace_root.join(".fvm").join("flutter_sdk") + .join("bin").join("cache").join("dart-sdk") + .join("bin").join("dart"); + if local_fvm.try_exists().unwrap_or(false) { Some(local_fvm) } else { None } + }); + let flutter_path = dart_bin.as_ref() + .map(|d| resolve_flutter_path(d)) + .unwrap_or_else(|| PathBuf::from("flutter")); + ( + flutter_path.to_string_lossy().to_string(), + vec!["debug_adapter".to_string()], + ) + } + } else { + // No local FVM, use direct flutter + let dart_bin = self.selected_dart_env.clone().or_else(|| { + let local_fvm = workspace_root.join(".fvm").join("flutter_sdk") + .join("bin").join("cache").join("dart-sdk") + .join("bin").join("dart"); + if local_fvm.try_exists().unwrap_or(false) { Some(local_fvm) } else { None } + }); + let flutter_path = dart_bin.as_ref() + .map(|d| resolve_flutter_path(d)) + .unwrap_or_else(|| PathBuf::from("flutter")); + ( + flutter_path.to_string_lossy().to_string(), + vec!["debug_adapter".to_string()], + ) + }; + + let program = self.app_state.active_file() + .and_then(|id| { + if id.extension().map(|ext| ext == "dart").unwrap_or(false) { + Some(id.to_string_lossy().to_string()) + } else { + None + } + }) + .unwrap_or_else(|| { + let main_dart = workspace_root.join("lib").join("main.dart"); + if main_dart.exists() { + main_dart.to_string_lossy().to_string() + } else { + "lib/main.dart".to_string() + } + }); + ( + program, + adapter_cmd, + adapter_args, + "flutter".to_string(), + ) + }; + + eprintln!("[DebugStart] adapter_cmd={}, adapter_args={:?}, adapter_id={}, program={}", adapter_cmd, adapter_args, adapter_id, program); + + // Build FVM environment variables if using FVM + let fvm_env = if adapter_args.len() >= 2 && adapter_args[0] == "flutter" && adapter_args[1] == "debug_adapter" { + // Using FVM wrapper - set FLUTTER_ROOT to the local SDK + let flutter_sdk = workspace_root.join(".fvm").join("flutter_sdk"); + if flutter_sdk.exists() { + let mut env = std::collections::HashMap::new(); + env.insert("FLUTTER_ROOT".to_string(), flutter_sdk.to_string_lossy().to_string()); + // Also add flutter/bin to PATH + let flutter_bin = flutter_sdk.join("bin"); + if let Ok(path) = std::env::var("PATH") { + let new_path = format!("{}:{}", flutter_bin.display(), path); + env.insert("PATH".to_string(), new_path); + } + eprintln!("[DebugStart] Setting FVM env: FLUTTER_ROOT={}", flutter_sdk.display()); + Some(env) + } else { + None + } + } else { + None + }; + + let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); + let _guard = self.scheduler.enter(); + match crate::dap::client::DapClient::launch_with_env( + &adapter_cmd, + &adapter_args, + Some(workspace_root.clone()), + event_tx, + fvm_env, + ) { + Ok(client) => { + let session = Arc::new(crate::dap::DapSession::new(client)); + self.dap_session = Some(session.clone()); + + // Open Debug Console tab in bottom dock and focus it + self.panel_state.bottom.visible = true; + self.panel_state.bottom.switch_to_tab(PanelTabId::DebugConsole); + + // Open Left Sidebar and switch to DAP (Inspector) + if !self.panel_state.left.visible { + self.panel_state.left.visible = true; + } + if self.panel_state.left.active_tab_id() != Some(PanelTabId::Inspector) { + self.panel_state.left.switch_to_tab(PanelTabId::Inspector); + } + + self.sidebar_needs_layout = true; + + // Build launch arguments from config or defaults + let launch_args = if let Some(ref config) = launch_config { + let resolved = config.resolve(&workspace_root); + let program_to_use = if resolved.program.is_empty() { + program.clone() + } else { + resolved.program.clone() + }; + let mut args = serde_json::json!({ + "request": "launch", + "program": program_to_use, + "noDebug": false, + }); + if !resolved.args.is_empty() { + args["args"] = serde_json::json!(resolved.args); + } + if !resolved.tool_args.is_empty() { + args["toolArgs"] = serde_json::json!(resolved.tool_args); + } + if let Some(device_id) = &resolved.device_id { + args["deviceId"] = serde_json::json!(device_id); + } + if let Some(cwd) = &resolved.cwd { + args["cwd"] = serde_json::json!(cwd.to_string_lossy()); + } + if !resolved.env.is_empty() { + args["env"] = serde_json::json!(resolved.env); + } + // Merge custom/extra fields from the launch configuration to be fully compatible with VS Code + if let Some(obj) = args.as_object_mut() { + for (k, v) in &config.extra { + if let Some(k_str) = k.as_str() { + obj.insert(k_str.to_string(), v.clone()); + } + } + } + eprintln!("[DebugStart] Using launch.json config: {:?}", config.name); + args + } else { + let device_id = self.app_state.active_dart_device_id().unwrap_or("chrome").to_string(); + eprintln!("[DebugStart] Using Flutter defaults: program={}, device={}", program, device_id); + serde_json::json!({ + "request": "launch", + "program": program, + "toolArgs": ["-d", device_id], + "noDebug": false, + }) + }; + eprintln!("[DebugStart] launch_args: {}", launch_args); + + let adapter_id_clone = adapter_id.clone(); + let session_clone = session.clone(); + let window_clone = self.window.clone(); + tokio::spawn(async move { + if let Err(e) = session_clone.initialize(&adapter_id_clone).await { + eprintln!("DAP Init error: {}", e); + return; + } + if let Err(e) = session_clone.client.send_request("launch", Some(launch_args)).await { + eprintln!("DAP Launch error: {}", e); + return; + } + let _ = session_clone.client.send_request("configurationDone", None).await; + { + let mut state = session_clone.state.lock().await; + state.paused = false; + state.console_messages.push("Debug session started.".to_string()); + } + if let Some(w) = &window_clone { + w.request_redraw(); + } + }); + + // Sync existing breakpoints to session + let grouped_bps = self.breakpoints.clone(); + let session_bps_clone = session.clone(); + let _guard = self.scheduler.enter(); + tokio::spawn(async move { + for (path, lines) in grouped_bps { + let _ = session_bps_clone.set_breakpoints(&path, &lines).await; + } + }); + + let session_event_clone = session.clone(); + let window_event_clone = self.window.clone(); + tokio::spawn(async move { + while let Some(event) = event_rx.recv().await { + match event.event.as_str() { + "stopped" => { + let thread_id = event.body.as_ref() + .and_then(|b| b.get("threadId")) + .and_then(|t| t.as_i64()) + .unwrap_or(1); + let _ = session_event_clone.update_suspended_state(thread_id).await; + let _ = session_event_clone.evaluate_watch_expressions().await; + } + "continued" => { + let mut state_guard = session_event_clone.state.lock().await; + state_guard.paused = false; + state_guard.execution_location = None; + } + "output" => { + if let Some(body) = event.body { + if let Some(output) = body.get("output").and_then(|v| v.as_str()) { + let mut state_guard = session_event_clone.state.lock().await; + state_guard.console_messages.push(output.to_string()); + } + } + } + "terminated" => { + let mut state_guard = session_event_clone.state.lock().await; + state_guard.terminated = true; + state_guard.paused = true; + state_guard.execution_location = None; + break; + } + _ => {} + } + if let Some(w) = &window_event_clone { + w.request_redraw(); + } + } + }); + Some(true) + } + Err(err) => { + eprintln!("Failed to launch debug adapter: {}", err); + self.show_transient_toast_kind(format!("Failed to launch debug adapter: {}", err), ToastKind::Error); + Some(false) + } + } + } + Command::DebugStop => { + if let Some(session) = &self.dap_session { + let session_clone = session.clone(); + let window_clone = self.window.clone(); + let _guard = self.scheduler.enter(); + tokio::spawn(async move { + let _ = session_clone.stop().await; + if let Some(w) = window_clone { + w.request_redraw(); + } + }); + self.dap_session = None; + Some(true) + } else { + None + } + } + Command::DebugContinue => { + if let Some(session) = &self.dap_session { + let session_clone = session.clone(); + let window_clone = self.window.clone(); + let _guard = self.scheduler.enter(); + tokio::spawn(async move { + let _ = session_clone.resume().await; + if let Some(w) = window_clone { + w.request_redraw(); + } + }); + Some(true) + } else { + None + } + } + Command::DebugStepOver => { + if let Some(session) = &self.dap_session { + let session_clone = session.clone(); + let window_clone = self.window.clone(); + let _guard = self.scheduler.enter(); + tokio::spawn(async move { + let _ = session_clone.step_over().await; + if let Some(w) = window_clone { + w.request_redraw(); + } + }); + Some(true) + } else { + None + } + } + Command::DebugStepInto => { + if let Some(session) = &self.dap_session { + let session_clone = session.clone(); + let window_clone = self.window.clone(); + let _guard = self.scheduler.enter(); + tokio::spawn(async move { + let _ = session_clone.step_into().await; + if let Some(w) = window_clone { + w.request_redraw(); + } + }); + Some(true) + } else { + None + } + } + Command::DebugStepOut => { + if let Some(session) = &self.dap_session { + let session_clone = session.clone(); + let window_clone = self.window.clone(); + let _guard = self.scheduler.enter(); + tokio::spawn(async move { + let _ = session_clone.step_out().await; + if let Some(w) = window_clone { + w.request_redraw(); + } + }); + Some(true) + } else { + None + } + } + Command::DebugToggleBreakpoint => { + if let Some(active_file) = self.app_state.active_file() { + let (line, _) = self.app_state.cursor_line_col(); + let path = active_file.canonicalize().unwrap_or_else(|_| active_file.to_path_buf()); + // Toggle in our local breakpoints map + let is_empty = { + let lines = self.breakpoints.entry(path.clone()).or_default(); + if let Some(pos) = lines.iter().position(|&l| l == line) { + lines.remove(pos); + } else { + lines.push(line); + } + lines.is_empty() + }; + if is_empty { + self.breakpoints.remove(&path); + } + let lines_clone = self.breakpoints.get(&path).cloned().unwrap_or_default(); + self.save_breakpoints(); + + if let Some(session) = &self.dap_session { + if let Ok(mut state_guard) = session.state.try_lock() { + state_guard.toggle_breakpoint_at_line(&path, line); + } + let session_clone = session.clone(); + let _guard = self.scheduler.enter(); + tokio::spawn(async move { + let _ = session_clone.set_breakpoints(&path, &lines_clone).await; + }); + } + self.sidebar_needs_layout = true; + self.editor_needs_layout = true; + self.editor_caret_needs_layout = true; + if let Some(w) = &self.window { + w.request_redraw(); + } + Some(true) + } else { + None + } + } + Command::FlutterHotReload => { + if let Some(session) = &self.dap_session { + let session_clone = session.clone(); + let _guard = self.scheduler.enter(); + tokio::spawn(async move { + let _ = session_clone.hot_reload().await; + }); + Some(true) + } else { + None + } + } + Command::FlutterHotRestart => { + if let Some(session) = &self.dap_session { + let session_clone = session.clone(); + let _guard = self.scheduler.enter(); + tokio::spawn(async move { + let _ = session_clone.hot_restart().await; + }); + Some(true) + } else { + None + } + } + Command::DebugWatchAdd => { + if self.dap_session.is_some() { + let _ = self.app_state.open_command_palette_mode( + crate::app::command_palette::CommandPaletteMode::DebugWatchInput, + ); + Some(true) + } else { + None + } + } + Command::DebugWatchRemove => { + if let Some(session) = &self.dap_session { + let rows = self.dap_panel_state.visible_rows(); + if let Some(row) = rows.get(self.dap_panel_state.selected_row) { + if row.section_index == 1 { + // Watch section + if let Some(path) = &row.node_path { + if let Some(node_index) = path.first() { + if let Ok(mut state) = session.state.try_lock() { + if state.remove_watch_expression(*node_index) { + drop(state); + if let Ok(state_guard) = session.state.try_lock() { + self.dap_panel_state.sync_from_debug_state(&state_guard); + } + self.sidebar_needs_layout = true; + return Some(true); + } + } + } + } + } + } + Some(false) + } else { + None + } + } + Command::DebugGotoFrame => { + if let Some(session) = &self.dap_session { + let rows = self.dap_panel_state.visible_rows(); + if let Some(row) = rows.get(self.dap_panel_state.selected_row) { + if row.section_index == 2 { + // Call Stack section + if let Some(path) = &row.node_path { + if let Some(node_index) = path.first() { + if let Ok(state) = session.state.try_lock() { + if let Some(frame) = state.call_stack.get(*node_index) { + let file_path = frame.path.clone(); + let line = frame.location.line; + drop(state); + if !file_path.as_os_str().is_empty() { + let _ = self.app_state.open_file(file_path); + self.app_state.jump_to_line(line); + return Some(true); + } + } + } + } + } + } + } + Some(false) + } else { + None + } + } + Command::FlutterDevices => { + self.app_state.open_flutter_device_selector(); + let flutter_path = self.current_flutter_path(); + self.submit(crate::async_runtime::message::RequestSpec { + revision_id: 0, + topic: crate::async_runtime::message::RequestTopic::SystemTask, + payload: crate::async_runtime::message::WorkerRequestPayload::ScanFlutterDevices { + flutter_path: Some(flutter_path), + }, + }); + Some(true) + } + _ => None, + } + } + + pub(super) fn confirm_flutter_device_selection(&mut self) -> bool { + use crate::app::command_palette::CommandPaletteAction; + if let Some(CommandPaletteAction::SelectFlutterDevice { device_id, is_emulator, is_active }) = + self.app_state.command_palette_selected_action() + { + self.app_state.set_active_dart_device_id(Some(device_id.clone())); + if is_emulator && !is_active { + let flutter_path = self.current_flutter_path(); + self.show_transient_toast(format!("Launching emulator {}...", device_id)); + self.submit(crate::async_runtime::message::RequestSpec { + revision_id: 0, + topic: crate::async_runtime::message::RequestTopic::SystemTask, + payload: crate::async_runtime::message::WorkerRequestPayload::LaunchFlutterEmulator { + flutter_path: Some(flutter_path), + emulator_id: device_id.clone(), + }, + }); + } + true + } else { + false + } + } + + fn resolve_fvm_path() -> Option { + if let Ok(home) = std::env::var("HOME") { + for relative in &[ + "fvm/bin/fvm", + ".fvm/bin/fvm", + ".pub-cache/bin/fvm", + ] { + let p = PathBuf::from(&home).join(relative); + if p.exists() { + return Some(p); + } + } + } + for p in &["/opt/homebrew/bin/fvm", "/usr/local/bin/fvm", "/usr/bin/fvm"] { + let p_path = PathBuf::from(p); + if p_path.exists() { + return Some(p_path); + } + } + None + } + + pub fn current_flutter_path(&self) -> PathBuf { + let workspace_root = self.app_state.workspace_root_path().map(PathBuf::from).unwrap_or_default(); + let dart_bin = self.selected_dart_env.clone() + .or_else(|| { + let local_fvm = workspace_root + .join(".fvm") + .join("flutter_sdk") + .join("bin") + .join("cache") + .join("dart-sdk") + .join("bin") + .join("dart"); + if local_fvm.try_exists().unwrap_or(false) { + Some(local_fvm) + } else { + None + } + }); + + if let Some(d) = dart_bin.as_ref() { + resolve_flutter_path(d) + } else if let Some(fvm_path) = Self::resolve_fvm_path() { + fvm_path + } else { + PathBuf::from("flutter") + } + } + + pub fn load_breakpoints(&mut self) { + let workspace_root = self.app_state.workspace_root_path() + .map(PathBuf::from) + .or_else(|| { + self.app_state.active_file() + .and_then(|file| file.parent().map(PathBuf::from)) + }); + if let Some(root) = workspace_root { + let bp_path = root.join(".vscode").join("breakpoints.json"); + if bp_path.exists() { + if let Ok(content) = std::fs::read_to_string(&bp_path) { + if let Ok(bps) = serde_json::from_str::>>(&content) { + self.breakpoints = bps.into_iter().map(|(p, l)| { + (p.canonicalize().unwrap_or(p), l) + }).collect(); + eprintln!("[DAP LOG] [Breakpoint Persist] Loaded breakpoints: {:?}", self.breakpoints); + } + } + } + } + } + + pub fn save_breakpoints(&self) { + let workspace_root = self.app_state.workspace_root_path() + .map(PathBuf::from) + .or_else(|| { + self.app_state.active_file() + .and_then(|file| file.parent().map(PathBuf::from)) + }); + if let Some(root) = workspace_root { + let vscode_dir = root.join(".vscode"); + if !vscode_dir.exists() { + let _ = std::fs::create_dir_all(&vscode_dir); + } + let bp_path = vscode_dir.join("breakpoints.json"); + if let Ok(content) = serde_json::to_string_pretty(&self.breakpoints) { + if let Err(e) = std::fs::write(&bp_path, content) { + eprintln!("Failed to write breakpoints.json: {}", e); + } + } + } + } +} diff --git a/src/app/event_loop/commands_explorer.rs b/src/app/event_loop/commands_explorer.rs index 879e8b8d..0d8fa780 100644 --- a/src/app/event_loop/commands_explorer.rs +++ b/src/app/event_loop/commands_explorer.rs @@ -425,6 +425,13 @@ impl AppShell { Some(true) } Command::ExplorerMoveUp => { + if self.panel_state.left.active_tab_id() == Some(PanelTabId::Inspector) { + let changed = self.dap_panel_state.move_selection_prev(); + if changed { + self.sidebar_needs_layout = true; + } + return Some(changed); + } self.ensure_explorer_snapshot(); let entries_len = self.explorer_snapshot.entries.len(); if entries_len == 0 { @@ -444,6 +451,13 @@ impl AppShell { } } Command::ExplorerMoveDown => { + if self.panel_state.left.active_tab_id() == Some(PanelTabId::Inspector) { + let changed = self.dap_panel_state.move_selection_next(); + if changed { + self.sidebar_needs_layout = true; + } + return Some(changed); + } self.ensure_explorer_snapshot(); let entries_len = self.explorer_snapshot.entries.len(); if entries_len == 0 { @@ -612,6 +626,89 @@ impl AppShell { Command::ExplorerToggleOrOpen | Command::ExplorerExpandCollapse | Command::ExplorerOpenFile => { + if self.panel_state.left.active_tab_id() == Some(PanelTabId::Inspector) { + let rows = self.dap_panel_state.visible_rows(); + if let Some(row) = rows.get(self.dap_panel_state.selected_row) { + if row.section_index == 2 { + if let Some(path) = &row.node_path { + if !path.is_empty() { + let frame_idx = path[0]; + let jump_target = self.dap_session.as_ref().and_then(|session| { + if let Ok(debug_guard) = session.state.try_lock() { + debug_guard.call_stack.get(frame_idx).and_then(|frame| { + if frame.path.exists() { + Some((frame.path.clone(), frame.location.line)) + } else { + None + } + }) + } else { + None + } + }); + if let Some((target_path, target_line)) = jump_target { + let report = dispatch_command( + &mut self.app_state, + Command::OpenFile(target_path), + ); + if report.success { + self.app_state.jump_to_line_and_column(target_line, 0); + self.clear_highlight_layers(); + self.submit_active_buffer_git_baseline_refresh(); + self.submit_parse_for_active_buffer(true); + self.submit_lsp_did_open_for_active_file(); + self.editor_needs_layout = true; + self.editor_caret_needs_layout = true; + let _ = self.focus_manager.set(FocusTarget::CenterEditor); + return Some(true); + } + } + } + } + } else if row.section_index == 3 { + if let Some(path) = &row.node_path { + if !path.is_empty() { + let bp_idx = path[0]; + let jump_target = self.dap_session.as_ref().and_then(|session| { + if let Ok(debug_guard) = session.state.try_lock() { + debug_guard.breakpoints.get(bp_idx).and_then(|bp| { + if bp.path.exists() { + Some((bp.path.clone(), bp.location.line)) + } else { + None + } + }) + } else { + None + } + }); + if let Some((target_path, target_line)) = jump_target { + let report = dispatch_command( + &mut self.app_state, + Command::OpenFile(target_path), + ); + if report.success { + self.app_state.jump_to_line_and_column(target_line, 0); + self.clear_highlight_layers(); + self.submit_active_buffer_git_baseline_refresh(); + self.submit_parse_for_active_buffer(true); + self.submit_lsp_did_open_for_active_file(); + self.editor_needs_layout = true; + self.editor_caret_needs_layout = true; + let _ = self.focus_manager.set(FocusTarget::CenterEditor); + return Some(true); + } + } + } + } + } + } + let changed = self.dap_panel_state.toggle_selected_expand(); + if changed { + self.sidebar_needs_layout = true; + } + return Some(changed); + } self.ensure_explorer_snapshot(); if self.explorer_snapshot.entries.is_empty() { self.explorer_cursor = 0; diff --git a/src/app/event_loop/commands_palette.rs b/src/app/event_loop/commands_palette.rs index 587f65d9..0379d481 100644 --- a/src/app/event_loop/commands_palette.rs +++ b/src/app/event_loop/commands_palette.rs @@ -389,6 +389,43 @@ impl AppShell { return Some(self.confirm_lsp_rename_prompt()); } + if matches!(command, Command::FilePickerConfirmSelection) + && matches!( + self.app_state.command_palette_mode(), + Some(CommandPaletteMode::FlutterDevices) + ) + { + let changed = self.confirm_flutter_device_selection(); + let _ = dispatch_command(&mut self.app_state, Command::CloseFilePicker); + return Some(changed); + } + + if matches!(command, Command::FilePickerConfirmSelection) + && matches!( + self.app_state.command_palette_mode(), + Some(CommandPaletteMode::DebugWatchInput) + ) + { + let expr = self.app_state.command_palette_query_text().trim().to_string(); + let _ = dispatch_command(&mut self.app_state, Command::CloseFilePicker); + if !expr.is_empty() { + if let Some(session) = &self.dap_session { + if let Ok(mut state) = session.state.try_lock() { + state.add_watch_expression(expr.clone()); + } + let session_clone = session.clone(); + tokio::spawn(async move { + let _ = session_clone.evaluate_watch_expressions().await; + }); + if let Ok(state_guard) = session.state.try_lock() { + self.dap_panel_state.sync_from_debug_state(&state_guard); + } + self.sidebar_needs_layout = true; + } + } + return Some(true); + } + if matches!(command, Command::FilePickerConfirmSelection) && matches!( self.app_state.command_palette_mode(), @@ -445,6 +482,21 @@ impl AppShell { } else { None }; + // Capture the command ID before dispatch closes the palette. + let pending_execute_command_id: Option = + if matches!(command, Command::FilePickerConfirmSelection) + && palette_mode_before == Some(CommandPaletteMode::CommandPalette) + { + if let Some(CommandPaletteAction::ExecuteCommand(ref id)) = + self.app_state.command_palette_selected_action() + { + Some(id.clone()) + } else { + None + } + } else { + None + }; let confirmed_from_fuzzy_picker = matches!(command, Command::FilePickerConfirmSelection) && self.app_state.active_buffer_is_fuzzy_picker(); @@ -515,6 +567,22 @@ impl AppShell { return Some(true); } + // When ExecuteCommand dispatches FlutterDevices, trigger the device scan. + if report.success + && pending_execute_command_id.as_deref() == Some("flutter.devices") + { + self.app_state.open_flutter_device_selector(); + let flutter_path = self.current_flutter_path(); + self.submit(RequestSpec { + revision_id: 0, + topic: RequestTopic::SystemTask, + payload: WorkerRequestPayload::ScanFlutterDevices { + flutter_path: Some(flutter_path), + }, + }); + return Some(true); + } + let file_after = self.app_state.active_file().map(PathBuf::from); let file_changed = report.success && file_after != file_before; let mut parsed_after_file_change = false; diff --git a/src/app/event_loop/commands_terminal.rs b/src/app/event_loop/commands_terminal.rs index 09e8b80b..595b790f 100644 --- a/src/app/event_loop/commands_terminal.rs +++ b/src/app/event_loop/commands_terminal.rs @@ -446,16 +446,46 @@ impl AppShell { } fn handle_switch_terminal_tab(&mut self, idx: usize) -> bool { - if idx >= self.terminal_tabs.len() { + if idx >= self.panel_state.bottom.tabs.len() { return false; } - if self.active_terminal_tab == idx { - return false; + + let mut changed = false; + if self.panel_state.bottom.active_tab != idx { + self.panel_state.bottom.active_tab = idx; + changed = true; + } + + if !self.panel_state.bottom.visible { + self.panel_state.bottom.visible = true; + changed = true; + } + + // Focus the bottom panel + let focus_changed = self.focus_manager.set(FocusTarget::BottomPanel); + changed |= focus_changed; + + // If the switched tab is Terminal, apply ModeEvent::FocusTerminal. + // If the switched tab is DebugConsole, we should exit terminal focus mode (transition back to Normal). + if self.panel_state.bottom.tabs[idx] == PanelTabId::Terminal { + self.ensure_active_terminal_tab_spawned(); + if let Ok(result) = self.app_state.apply_mode_event(ModeEvent::FocusTerminal) { + changed |= result.changed; + } + } else { + // Switch away from terminal mode to Normal (exit terminal focus) + if matches!( + self.app_state.current_mode(), + EditorMode::TerminalFocus | EditorMode::TerminalNormal + ) { + if let Ok(result) = self.app_state.apply_mode_event(ModeEvent::ExitFocus) { + changed |= result.changed; + } + } } - self.active_terminal_tab = idx; + self.terminal_needs_layout = true; - self.ensure_active_terminal_tab_spawned(); - true + changed } /// Handle search commands when in Terminal Normal Mode. diff --git a/src/app/event_loop/commands_tests.rs b/src/app/event_loop/commands_tests.rs index b2c4790c..a91f74c7 100644 --- a/src/app/event_loop/commands_tests.rs +++ b/src/app/event_loop/commands_tests.rs @@ -2769,3 +2769,388 @@ fn resize_panel_and_ui_config_stay_in_sync() { shell.ui_config.docks.bottom.size_px ); } + +// ── DAP / Debugger command tests ──────────────────────────────────────────── + +#[test] +fn focus_dap_opens_left_sidebar_inspector_tab() { + let mut shell = AppShell::new_for_tests().expect("create app shell"); + shell.panel_state.left.visible = false; + + let changed = shell.handle_command(Command::FocusDap); + + assert!(changed); + assert!(shell.panel_state.left.visible); + assert_eq!( + shell.panel_state.left.active_tab_id(), + Some(PanelTabId::Inspector) + ); +} + +#[test] +fn focus_dap_sets_focus_to_left_sidebar() { + let mut shell = AppShell::new_for_tests().expect("create app shell"); + + shell.handle_command(Command::FocusDap); + + assert_eq!(shell.focus_manager.current(), FocusTarget::LeftSidebar); +} + +#[test] +fn focus_dap_idempotent_when_already_focused() { + let mut shell = AppShell::new_for_tests().expect("create app shell"); + shell.handle_command(Command::FocusDap); + let changed = shell.handle_command(Command::FocusDap); + // Should still report no change since already focused + assert!(!changed); +} + +#[test] +fn debug_start_without_session_returns_none_via_handler() { + let shell = AppShell::new_for_tests().expect("create app shell"); + // No DAP session — handler tries to launch but panics without tokio runtime. + // Test the handler directly: when there's no session, it enters the launch branch. + // We can't fully test launch without a tokio runtime, so verify the session is None. + assert!(shell.dap_session.is_none()); +} + +#[test] +fn debug_stop_without_session_returns_none() { + let mut shell = AppShell::new_for_tests().expect("create app shell"); + let result = shell.handle_dap_command(&Command::DebugStop); + assert!(result.is_none()); +} + +#[test] +fn debug_continue_without_session_returns_none() { + let mut shell = AppShell::new_for_tests().expect("create app shell"); + let result = shell.handle_dap_command(&Command::DebugContinue); + assert!(result.is_none()); +} + +#[test] +fn debug_step_over_without_session_returns_none() { + let mut shell = AppShell::new_for_tests().expect("create app shell"); + let result = shell.handle_dap_command(&Command::DebugStepOver); + assert!(result.is_none()); +} + +#[test] +fn debug_step_into_without_session_returns_none() { + let mut shell = AppShell::new_for_tests().expect("create app shell"); + let result = shell.handle_dap_command(&Command::DebugStepInto); + assert!(result.is_none()); +} + +#[test] +fn debug_step_out_without_session_returns_none() { + let mut shell = AppShell::new_for_tests().expect("create app shell"); + let result = shell.handle_dap_command(&Command::DebugStepOut); + assert!(result.is_none()); +} + +#[test] +fn debug_toggle_breakpoint_without_session_returns_none() { + let mut shell = AppShell::new_for_tests().expect("create app shell"); + let result = shell.handle_dap_command(&Command::DebugToggleBreakpoint); + assert!(result.is_none()); +} + +#[test] +fn debug_watch_add_without_session_returns_none() { + let mut shell = AppShell::new_for_tests().expect("create app shell"); + let result = shell.handle_dap_command(&Command::DebugWatchAdd); + assert!(result.is_none()); +} + +#[test] +fn debug_watch_remove_without_session_returns_none() { + let mut shell = AppShell::new_for_tests().expect("create app shell"); + let result = shell.handle_dap_command(&Command::DebugWatchRemove); + assert!(result.is_none()); +} + +#[test] +fn debug_goto_frame_without_session_returns_none() { + let mut shell = AppShell::new_for_tests().expect("create app shell"); + let result = shell.handle_dap_command(&Command::DebugGotoFrame); + assert!(result.is_none()); +} + +#[test] +fn dap_toggle_expand_works_on_inspector_panel() { + let mut shell = AppShell::new_for_tests().expect("create app shell"); + shell.handle_command(Command::FocusDap); + + // Variables section is expanded by default + let was_expanded = shell.dap_panel_state.sections[0].expanded; + let changed = shell.handle_command(Command::DapToggleExpand); + assert!(changed); + assert_eq!(shell.dap_panel_state.sections[0].expanded, !was_expanded); +} + +#[test] +fn dap_toggle_expand_noop_when_not_on_inspector() { + let mut shell = AppShell::new_for_tests().expect("create app shell"); + // Focus editor, not DAP panel + shell.handle_command(Command::FocusEditor); + + // DapToggleExpand returns None when not on inspector (falls through) + let result = shell.handle_dap_command(&Command::DapToggleExpand); + assert!(result.is_none()); +} + +#[test] +fn flutter_hot_reload_without_session_returns_none() { + let mut shell = AppShell::new_for_tests().expect("create app shell"); + // No DAP session — handler returns None + let result = shell.handle_dap_command(&Command::FlutterHotReload); + assert!(result.is_none()); +} + +#[test] +fn flutter_hot_restart_without_session_returns_none() { + let mut shell = AppShell::new_for_tests().expect("create app shell"); + let result = shell.handle_dap_command(&Command::FlutterHotRestart); + assert!(result.is_none()); +} + +#[test] +fn debug_toggle_breakpoint_noop_without_active_file() { + let mut shell = AppShell::new_for_tests().expect("create app shell"); + // No active file — toggle should return None + let result = shell.handle_dap_command(&Command::DebugToggleBreakpoint); + assert!(result.is_none()); +} + +#[test] +fn flutter_devices_in_command_palette() { + let mut shell = AppShell::new_for_tests().expect("create app shell"); + // Open command palette + shell.handle_command(Command::OpenCommandPalette); + assert!(shell.app_state.is_command_palette_visible()); + + // Verify flutter.devices is a valid command ID + use crate::core::command_ids; + assert!(command_ids::is_valid("flutter.devices")); + assert!(command_ids::is_valid("flutter.hot_reload")); + assert!(command_ids::is_valid("flutter.hot_restart")); + + // Verify command parsing works + assert!(command_ids::parse("flutter.devices", None).is_some()); + assert!(command_ids::parse("flutter.hot_reload", None).is_some()); + assert!(command_ids::parse("flutter.hot_restart", None).is_some()); +} + +struct TestTempDir { + path: PathBuf, +} + +impl TestTempDir { + fn new(prefix: &str) -> Self { + let path = std::env::temp_dir().join(format!("{}_{}", prefix, std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_micros())); + std::fs::create_dir_all(&path).unwrap(); + Self { path } + } +} + +impl Drop for TestTempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} + +struct PathGuard { + old_path: String, +} + +impl Drop for PathGuard { + fn drop(&mut self) { + unsafe { + std::env::set_var("PATH", &self.old_path); + } + } +} +static ENV_LOCK: once_cell::sync::Lazy> = once_cell::sync::Lazy::new(|| std::sync::Mutex::new(())); + +fn setup_dummy_executable(name: &str) -> (TestTempDir, PathGuard) { + let temp = TestTempDir::new(name); + let bin_path = temp.path.join(name); + std::fs::write(&bin_path, "#!/bin/sh\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&bin_path).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&bin_path, perms).unwrap(); + } + let old_path = std::env::var("PATH").unwrap_or_default(); + let new_path = format!("{}:{}", temp.path.display(), old_path); + unsafe { + std::env::set_var("PATH", new_path); + } + (temp, PathGuard { old_path }) +} + +#[test] +fn debug_start_fallback_to_flutter_defaults() { + let _lock = ENV_LOCK.lock().unwrap(); + let _temp_exe = setup_dummy_executable("flutter"); + let mut shell = AppShell::new_for_tests().expect("create app shell"); + let temp_workspace = TestTempDir::new("workspace"); + shell.app_state.attach_workspace(temp_workspace.path.clone()).unwrap(); + + // Create an active file that is a .dart file + let main_dart = temp_workspace.path.join("lib").join("main.dart"); + std::fs::create_dir_all(main_dart.parent().unwrap()).unwrap(); + std::fs::write(&main_dart, "void main() {}").unwrap(); + shell.app_state.open_file(main_dart).unwrap(); + + let result = shell.handle_command(Command::DebugStart); + assert!(result); + assert!(shell.dap_session.is_some()); +} + +#[test] +fn debug_start_reads_launch_json() { + let _lock = ENV_LOCK.lock().unwrap(); + let _temp_exe = setup_dummy_executable("custom_type"); + let mut shell = AppShell::new_for_tests().expect("create app shell"); + let temp_workspace = TestTempDir::new("workspace_launch_json"); + shell.app_state.attach_workspace(temp_workspace.path.clone()).unwrap(); + + let vscode_dir = temp_workspace.path.join(".vscode"); + std::fs::create_dir_all(&vscode_dir).unwrap(); + let launch_json_path = vscode_dir.join("launch.json"); + let launch_json_content = r#"{ + "version": "0.2.0", + "configurations": [ + { + "name": "My Custom Debug", + "type": "custom_type", + "request": "launch", + "program": "bin/debug_target" + } + ] + }"#; + std::fs::write(&launch_json_path, launch_json_content).unwrap(); + + let result = shell.handle_command(Command::DebugStart); + assert!(result); + assert!(shell.dap_session.is_some()); +} + +#[test] +fn debug_start_no_workspace_returns_false() { + let _lock = ENV_LOCK.lock().unwrap(); + let mut shell = AppShell::new_for_tests().expect("create app shell"); + shell.app_state.detach_workspace(); + // Explicitly make sure there is no active file or workspace root + assert!(shell.app_state.workspace_root_path().is_none()); + assert!(shell.app_state.active_file().is_none()); + + let result = shell.handle_command(Command::DebugStart); + assert!(!result); // Returns false + assert!(shell.transient_toast.is_some()); + let toast = shell.transient_toast.as_ref().unwrap(); + assert_eq!(toast.kind, crate::app::event_loop::ToastKind::Error); + assert!(toast.message.contains("No active workspace or file parent folder")); +} + +#[test] +fn debug_adapter_binary_not_found() { + let _lock = ENV_LOCK.lock().unwrap(); + let mut shell = AppShell::new_for_tests().expect("create app shell"); + let temp_workspace = TestTempDir::new("workspace_binary_not_found"); + shell.app_state.attach_workspace(temp_workspace.path.clone()).unwrap(); + + let vscode_dir = temp_workspace.path.join(".vscode"); + std::fs::create_dir_all(&vscode_dir).unwrap(); + let launch_json_path = vscode_dir.join("launch.json"); + let launch_json_content = r#"{ + "version": "0.2.0", + "configurations": [ + { + "name": "My Bad Custom Debug", + "type": "non_existent_adapter_binary", + "request": "launch", + "program": "bin/debug_target" + } + ] + }"#; + std::fs::write(&launch_json_path, launch_json_content).unwrap(); + + let result = shell.handle_command(Command::DebugStart); + assert!(!result); + assert!(shell.transient_toast.is_some()); + let toast = shell.transient_toast.as_ref().unwrap(); + assert_eq!(toast.kind, crate::app::event_loop::ToastKind::Error); + assert!(toast.message.contains("Binary 'non_existent_adapter_binary' not found")); +} + +#[test] +fn breakpoint_toggle_persists_across_session() { + let mut shell = AppShell::new_for_tests().expect("create app shell"); + let temp_workspace = TestTempDir::new("workspace_breakpoints_persist"); + shell.app_state.attach_workspace(temp_workspace.path.clone()).unwrap(); + + let main_dart = temp_workspace.path.join("lib").join("main.dart"); + std::fs::create_dir_all(main_dart.parent().unwrap()).unwrap(); + std::fs::write(&main_dart, "void main() {\n print('hello');\n}").unwrap(); + let main_dart = main_dart.canonicalize().unwrap(); + shell.app_state.open_file(main_dart.clone()).unwrap(); + + // Toggle breakpoint on line 1 + shell.app_state.jump_to_line_and_column(1, 0); + let result = shell.handle_command(Command::DebugToggleBreakpoint); + assert!(result); + assert_eq!(shell.breakpoints.get(&main_dart), Some(&vec![1])); + + // Now verify the file exists on disk + let bp_file = temp_workspace.path.join(".vscode").join("breakpoints.json"); + assert!(bp_file.exists()); + + // Create a new AppShell (simulating restart) and attach the same workspace + let mut new_shell = AppShell::new_for_tests().expect("create app shell"); + new_shell.app_state.attach_workspace(temp_workspace.path.clone()).unwrap(); + new_shell.load_breakpoints(); + + // Verify breakpoints are loaded + assert_eq!(new_shell.breakpoints.get(&main_dart), Some(&vec![1])); +} + +#[test] +fn test_breakpoint_and_cursor_movement() { + let mut shell = AppShell::new_for_tests().expect("create app shell"); + let temp_workspace = TestTempDir::new("workspace_breakpoints_movement"); + shell.app_state.attach_workspace(temp_workspace.path.clone()).unwrap(); + + let main_dart = temp_workspace.path.join("lib").join("main.dart"); + std::fs::create_dir_all(main_dart.parent().unwrap()).unwrap(); + std::fs::write(&main_dart, "void main() {\n print('hello');\n print('world');\n}").unwrap(); + let main_dart = main_dart.canonicalize().unwrap(); + shell.app_state.open_file(main_dart.clone()).unwrap(); + + // Toggle breakpoint on line 1 + shell.app_state.jump_to_line_and_column(1, 0); + let result = shell.handle_command(Command::DebugToggleBreakpoint); + assert!(result); + + // Verify breakpoint exists + let active_file = shell.app_state.active_file().unwrap(); + assert_eq!(active_file, main_dart); + let breakpoint_lines = shell.breakpoints.get(active_file).cloned().unwrap_or_default(); + assert_eq!(breakpoint_lines, vec![1]); + + // Move cursor down + let moved = shell.handle_command(Command::MoveDown); + assert!(moved); + + // Verify breakpoint STILL exists for the active file + let active_file_after = shell.app_state.active_file().unwrap(); + assert_eq!(active_file_after, main_dart); + let breakpoint_lines_after = shell.breakpoints.get(active_file_after).cloned().unwrap_or_default(); + assert_eq!(breakpoint_lines_after, vec![1]); +} + + diff --git a/src/app/event_loop/mod.rs b/src/app/event_loop/mod.rs index aa1f01c3..1d9cc597 100644 --- a/src/app/event_loop/mod.rs +++ b/src/app/event_loop/mod.rs @@ -130,6 +130,11 @@ pub struct AppShell { explorer_clipboard_path: Option, pending_confirmation: Option, workspace_git_branch: Option, + dap_session: Option>, + breakpoints: std::collections::HashMap>, + dap_panel_state: crate::workbench::inspector_panel::InspectorPanelState, + dap_console_grid: TerminalGrid, + dap_console_fed_count: usize, active_lsp_server: Option, pending_lsp_server: Option, lsp_completion_trigger_chars: Vec, diff --git a/src/app/event_loop/setup.rs b/src/app/event_loop/setup.rs index 1d655f4d..7f22f352 100644 --- a/src/app/event_loop/setup.rs +++ b/src/app/event_loop/setup.rs @@ -178,6 +178,15 @@ impl AppShell { explorer_clipboard_path: None, pending_confirmation: None, workspace_git_branch, + dap_session: None, + breakpoints: HashMap::new(), + dap_panel_state: crate::workbench::inspector_panel::InspectorPanelState::default(), + dap_console_grid: { + let mut g = TerminalGrid::new(120, 40); + g.highlight_colors = HighlightColors::from_theme(&theme); + g + }, + dap_console_fed_count: 0, active_lsp_server: None, pending_lsp_server: None, lsp_completion_trigger_chars: Vec::new(), @@ -274,6 +283,7 @@ impl AppShell { } pub(super) fn startup_subsystems(&mut self) { + self.load_breakpoints(); let cwd = std::env::current_dir().unwrap_or_default(); let workspace_root = self .app_state @@ -664,6 +674,9 @@ impl AppShell { } } } + if self.dap_console_grid.resize(cols, rows) { + grid_changed = true; + } if grid_changed { self.terminal_needs_layout = true; } @@ -732,7 +745,13 @@ impl AppShell { InputFocusContext::Welcome } else { match self.focus_manager.current() { - FocusTarget::LeftSidebar => InputFocusContext::Explorer, + FocusTarget::LeftSidebar => { + if self.panel_state.left.active_tab_id() == Some(PanelTabId::Inspector) { + InputFocusContext::Inspector + } else { + InputFocusContext::Explorer + } + } FocusTarget::RightSidebar => match self.panel_state.right.active_tab_id() { Some(PanelTabId::AiChat) => InputFocusContext::AiChat, Some(PanelTabId::MarkdownPreview) => InputFocusContext::MarkdownPreview, diff --git a/src/app/input/handler.rs b/src/app/input/handler.rs index 400fdaa2..b1d1b2a3 100644 --- a/src/app/input/handler.rs +++ b/src/app/input/handler.rs @@ -189,10 +189,18 @@ impl InputHandler { } let normalized = NormalizedInput::from_key_event(key_event, self.modifiers); + let is_f5 = normalized.named_key == Some(NamedKey::F5); + if is_f5 { + eprintln!("[DAP LOG] [F5 Input Handler] translate_key_event: normalized F5 key_event, modifiers: {:?}", self.modifiers); + } if key_event.repeat { return self.route_repeated_normalized_input(normalized, input_map, context); } - self.route_normalized_input(normalized, input_map, context, Instant::now()) + let outcome = self.route_normalized_input(normalized, input_map, context, Instant::now()); + if is_f5 { + eprintln!("[DAP LOG] [F5 Input Handler] translate_key_event F5 outcome: {:?}", outcome); + } + outcome } pub(crate) fn route_repeated_normalized_input( diff --git a/src/app/input_map/focus.rs b/src/app/input_map/focus.rs index 0de4a155..57c29b0a 100644 --- a/src/app/input_map/focus.rs +++ b/src/app/input_map/focus.rs @@ -488,6 +488,20 @@ impl InputMap { }); } + if !input.has_command_modifier() && input.named_key == Some(NamedKey::Enter) { + return Some(KeybindingMatch { + command: Command::DebugGotoFrame, + reason: "inspector: Enter -> DebugGotoFrame (jump to call stack frame)", + }); + } + + if let Some(command) = resolved_keymap::resolve_command(&self.keymap, input, "normal", &self.open_file_path) { + return Some(KeybindingMatch { + command, + reason: "inspector: keymap binding", + }); + } + resolved_keymap::resolve_global_command(&self.keymap, input, &self.open_file_path).map( |command| KeybindingMatch { command, @@ -1045,6 +1059,43 @@ impl InputMap { }); } + if let Some(command) = resolved_keymap::resolve_global_command( + &self.keymap, + input, + &self.open_file_path, + ) { + let is_allowed = match &command { + Command::FocusTerminal + | Command::ToggleBottomDock + | Command::FocusDap + | Command::FocusExplorer + | Command::FocusInspector + | Command::SwitchTerminalTab(_) + | Command::SaveFile + | Command::OpenFile(_) + | Command::OpenFilePicker + | Command::OpenCommandPalette + | Command::ToggleLeftDock + | Command::DebugStart + | Command::FlutterHotRestart + | Command::DebugStop + | Command::DebugStepOver + | Command::DebugStepInto + | Command::DebugStepOut => true, + Command::DebugToggleBreakpoint => { + // Only allow if NOT Shift+B + !(input.modifiers.shift_key() && !input.modifiers.control_key() && !input.modifiers.super_key() && !input.modifiers.alt_key()) + } + _ => false, + }; + if is_allowed { + return Some(KeybindingMatch { + command, + reason: "terminal focus: global keymap bypass", + }); + } + } + if mode == EditorMode::TerminalFocus && input.named_key == Some(NamedKey::Escape) { return Some(KeybindingMatch { command: Command::FocusBack, diff --git a/src/app/resolved_keymap.rs b/src/app/resolved_keymap.rs index 19c5576e..1c6eaf42 100644 --- a/src/app/resolved_keymap.rs +++ b/src/app/resolved_keymap.rs @@ -225,6 +225,17 @@ fn parse_non_leader_key(token: &str) -> Option { "arrowleft" => Some(NamedKey::ArrowLeft), "arrowright" => Some(NamedKey::ArrowRight), "tab" => Some(NamedKey::Tab), + "f1" => Some(NamedKey::F1), + "f2" => Some(NamedKey::F2), + "f3" => Some(NamedKey::F3), + "f4" => Some(NamedKey::F4), + "f5" => Some(NamedKey::F5), + "f6" => Some(NamedKey::F6), + "f7" => Some(NamedKey::F7), + "f8" => Some(NamedKey::F8), + "f9" => Some(NamedKey::F9), + "f10" => Some(NamedKey::F10), + "f11" => Some(NamedKey::F11), "f12" => Some(NamedKey::F12), _ => None, }; @@ -584,10 +595,32 @@ pub fn builtin_defaults() -> ResolvedKeymap { ); km.insert(None, cmd(KeyCode::KeyB), TOGGLE_LEFT_DOCK); km.insert(None, KeySpec::CtrlPlus(KeyCode::KeyF), FOCUS_EXPLORER); + km.insert(None, KeySpec::CtrlPlus(KeyCode::KeyB), FOCUS_DAP); km.insert(None, cmd(KeyCode::KeyR), FOCUS_INSPECTOR); km.insert(None, nk(NamedKey::F12), FOCUS_TERMINAL); km.insert(None, cmd(KeyCode::Backslash), TOGGLE_BOTTOM_DOCK); + // ── Global Tab switching ────────────────────────────────────────────────── + km.insert(None, cmd(KeyCode::Digit1), TERMINAL_TAB_SWITCH_1); + km.insert(None, cmd(KeyCode::Digit2), TERMINAL_TAB_SWITCH_2); + km.insert(None, cmd(KeyCode::Digit3), TERMINAL_TAB_SWITCH_3); + km.insert(None, cmd(KeyCode::Digit4), TERMINAL_TAB_SWITCH_4); + km.insert(None, cmd(KeyCode::Digit5), TERMINAL_TAB_SWITCH_5); + km.insert(None, cmd(KeyCode::Digit6), TERMINAL_TAB_SWITCH_6); + km.insert(None, cmd(KeyCode::Digit7), TERMINAL_TAB_SWITCH_7); + km.insert(None, cmd(KeyCode::Digit8), TERMINAL_TAB_SWITCH_8); + km.insert(None, cmd(KeyCode::Digit9), TERMINAL_TAB_SWITCH_9); + + // ── Debugger (DAP) function keys ───────────────────────────────────────── + km.insert(None, nk(NamedKey::F5), DEBUG_START); + km.insert(None, nk(NamedKey::F6), FLUTTER_HOT_RESTART); + km.insert(None, KeySpec::ShiftPlus(KeyCode::F5), DEBUG_STOP); + km.insert(None, nk(NamedKey::F10), DEBUG_STEP_OVER); + km.insert(None, nk(NamedKey::F11), DEBUG_STEP_INTO); + km.insert(None, KeySpec::ShiftPlus(KeyCode::F11), DEBUG_STEP_OUT); + km.insert(None, nk(NamedKey::F9), DEBUG_TOGGLE_BREAKPOINT); + km.insert(None, KeySpec::ShiftPlus(KeyCode::KeyB), DEBUG_TOGGLE_BREAKPOINT); + // ── Insert mode ─────────────────────────────────────────────────────────── km.insert(Some("insert"), nk(NamedKey::Escape), ENTER_NORMAL); km.insert(Some("insert"), nk(NamedKey::Backspace), BACKSPACE); @@ -738,15 +771,6 @@ pub fn builtin_defaults() -> ResolvedKeymap { km.insert(Some("terminal"), cmd(KeyCode::KeyV), TERMINAL_PASTE); km.insert(Some("terminal"), cmd(KeyCode::KeyT), TERMINAL_TAB_NEW); km.insert(Some("terminal"), cmd(KeyCode::KeyW), TERMINAL_TAB_CLOSE); - km.insert(Some("terminal"), cmd(KeyCode::Digit1), TERMINAL_TAB_SWITCH_1); - km.insert(Some("terminal"), cmd(KeyCode::Digit2), TERMINAL_TAB_SWITCH_2); - km.insert(Some("terminal"), cmd(KeyCode::Digit3), TERMINAL_TAB_SWITCH_3); - km.insert(Some("terminal"), cmd(KeyCode::Digit4), TERMINAL_TAB_SWITCH_4); - km.insert(Some("terminal"), cmd(KeyCode::Digit5), TERMINAL_TAB_SWITCH_5); - km.insert(Some("terminal"), cmd(KeyCode::Digit6), TERMINAL_TAB_SWITCH_6); - km.insert(Some("terminal"), cmd(KeyCode::Digit7), TERMINAL_TAB_SWITCH_7); - km.insert(Some("terminal"), cmd(KeyCode::Digit8), TERMINAL_TAB_SWITCH_8); - km.insert(Some("terminal"), cmd(KeyCode::Digit9), TERMINAL_TAB_SWITCH_9); // ── Terminal normal mode bindings (copy mode / virtual cursor) ────────── km.insert( @@ -803,51 +827,6 @@ pub fn builtin_defaults() -> ResolvedKeymap { cmd(KeyCode::KeyW), TERMINAL_TAB_CLOSE, ); - km.insert( - Some("terminal_normal"), - cmd(KeyCode::Digit1), - TERMINAL_TAB_SWITCH_1, - ); - km.insert( - Some("terminal_normal"), - cmd(KeyCode::Digit2), - TERMINAL_TAB_SWITCH_2, - ); - km.insert( - Some("terminal_normal"), - cmd(KeyCode::Digit3), - TERMINAL_TAB_SWITCH_3, - ); - km.insert( - Some("terminal_normal"), - cmd(KeyCode::Digit4), - TERMINAL_TAB_SWITCH_4, - ); - km.insert( - Some("terminal_normal"), - cmd(KeyCode::Digit5), - TERMINAL_TAB_SWITCH_5, - ); - km.insert( - Some("terminal_normal"), - cmd(KeyCode::Digit6), - TERMINAL_TAB_SWITCH_6, - ); - km.insert( - Some("terminal_normal"), - cmd(KeyCode::Digit7), - TERMINAL_TAB_SWITCH_7, - ); - km.insert( - Some("terminal_normal"), - cmd(KeyCode::Digit8), - TERMINAL_TAB_SWITCH_8, - ); - km.insert( - Some("terminal_normal"), - cmd(KeyCode::Digit9), - TERMINAL_TAB_SWITCH_9, - ); km.insert(Some("terminal_normal"), ch('/'), TERMINAL_SEARCH_OPEN); km.insert(Some("terminal_normal"), ch('n'), SEARCH_NEXT); km.insert(Some("terminal_normal"), ch('N'), SEARCH_PREV); @@ -1011,6 +990,36 @@ pub fn builtin_defaults() -> ResolvedKeymap { seq(&[KeySpec::Leader, ph(KeyCode::KeyI)]), FOCUS_INSPECTOR, ); + km.insert_sequence( + None, + seq(&[KeySpec::Leader, ph(KeyCode::KeyD), ph(KeyCode::KeyB)]), + FOCUS_DAP, + ); + km.insert_sequence( + Some("normal"), + seq(&[ph(KeyCode::KeyZ), ph(KeyCode::KeyA)]), + DAP_TOGGLE_EXPAND, + ); + km.insert_sequence( + None, + seq(&[KeySpec::Leader, ph(KeyCode::KeyF), ph(KeyCode::KeyD)]), + FLUTTER_DEVICES, + ); + km.insert_sequence( + None, + seq(&[KeySpec::Leader, ph(KeyCode::KeyF), ph(KeyCode::KeyH)]), + FLUTTER_HOT_RELOAD, + ); + km.insert_sequence( + None, + seq(&[KeySpec::Leader, ph(KeyCode::KeyF), ph(KeyCode::KeyR)]), + FLUTTER_HOT_RESTART, + ); + km.insert_sequence( + None, + seq(&[KeySpec::Leader, ph(KeyCode::KeyF), ph(KeyCode::KeyQ)]), + DEBUG_STOP, + ); km.insert_sequence( None, seq(&[KeySpec::Leader, ph(KeyCode::KeyF), ph(KeyCode::KeyF)]), diff --git a/src/async_runtime/flutter_device.rs b/src/async_runtime/flutter_device.rs new file mode 100644 index 00000000..f7b8e4af --- /dev/null +++ b/src/async_runtime/flutter_device.rs @@ -0,0 +1,489 @@ +use std::process::Stdio; +use serde::{Deserialize, Serialize}; +use tokio::process::Command; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FlutterDevice { + pub id: String, + pub name: String, + pub platform: String, + pub emulator: bool, + pub is_active: bool, +} + +fn resolve_sdk_dir() -> Option { + if let Ok(path) = std::env::var("ANDROID_HOME") { + let p = std::path::PathBuf::from(path); + if p.exists() { + return Some(p); + } + } + if let Ok(path) = std::env::var("ANDROID_SDK_ROOT") { + let p = std::path::PathBuf::from(path); + if p.exists() { + return Some(p); + } + } + if let Ok(home) = std::env::var("HOME") { + let p = std::path::Path::new(&home).join("Library").join("Android").join("sdk"); + if p.exists() { + return Some(p); + } + } + None +} + +fn resolve_adb_path() -> String { + if let Some(sdk_dir) = resolve_sdk_dir() { + let p = sdk_dir.join("platform-tools").join("adb"); + if p.exists() { + return p.to_string_lossy().to_string(); + } + } + for p in &["/opt/homebrew/bin/adb", "/usr/local/bin/adb", "/usr/bin/adb"] { + if std::path::Path::new(p).exists() { + return p.to_string().to_string(); + } + } + "adb".to_string() +} + +fn resolve_emulator_path() -> String { + if let Some(sdk_dir) = resolve_sdk_dir() { + let p = sdk_dir.join("emulator").join("emulator"); + if p.exists() { + return p.to_string_lossy().to_string(); + } + } + for p in &["/opt/homebrew/bin/emulator", "/usr/local/bin/emulator", "/usr/bin/emulator"] { + if std::path::Path::new(p).exists() { + return p.to_string().to_string(); + } + } + "emulator".to_string() +} + +fn make_flutter_command(flutter_path: &str) -> Command { + if flutter_path.ends_with("fvm") { + let mut cmd = Command::new(flutter_path); + cmd.arg("flutter"); + cmd + } else { + Command::new(flutter_path) + } +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct RawFlutterDevice { + name: String, + id: String, + target_platform: String, + emulator: bool, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct RawFlutterEmulator { + id: String, + name: String, + platform_type: String, +} + +#[derive(Deserialize, Debug)] +struct SimctlOutput { + devices: std::collections::HashMap>, +} + +#[derive(Deserialize, Debug)] +struct SimctlDevice { + name: String, + udid: String, + state: String, + #[serde(rename = "isAvailable")] + is_available: serde_json::Value, +} + +pub async fn scan_flutter_devices(flutter_path: Option) -> Vec { + eprintln!("[scan_flutter_devices] Starting parallel device scan..."); + let flutter_cmd = flutter_path + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| "flutter".to_string()); + + let timeout_duration = std::time::Duration::from_secs(5); + + // 1. Run flutter devices --machine + let f1_cmd = flutter_cmd.clone(); + let handle_flutter_devices = tokio::spawn(async move { + let mut list = Vec::new(); + eprintln!("[scan_flutter_devices] Spawning 'flutter devices --machine'..."); + let res = tokio::time::timeout(timeout_duration, make_flutter_command(&f1_cmd) + .args(&["devices", "--machine"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + ).await; + match res { + Ok(Ok(output)) if output.status.success() => { + if let Ok(raw_devices) = serde_json::from_slice::>(&output.stdout) { + eprintln!("[scan_flutter_devices] 'flutter devices' returned {} devices", raw_devices.len()); + for dev in raw_devices { + list.push(FlutterDevice { + id: dev.id, + name: dev.name, + platform: dev.target_platform, + emulator: dev.emulator, + is_active: true, + }); + } + } else { + eprintln!("[scan_flutter_devices] Failed to parse 'flutter devices' JSON"); + } + } + Ok(Ok(output)) => { + eprintln!("[scan_flutter_devices] 'flutter devices' failed with status: {:?}", output.status); + } + Ok(Err(e)) => { + eprintln!("[scan_flutter_devices] Failed to run 'flutter devices': {:?}", e); + } + Err(_) => { + eprintln!("[scan_flutter_devices] 'flutter devices' timed out after 5s"); + } + } + list + }); + + // 2. Run flutter emulators --machine + let f2_cmd = flutter_cmd.clone(); + let handle_flutter_emulators = tokio::spawn(async move { + let mut list = Vec::new(); + eprintln!("[scan_flutter_devices] Spawning 'flutter emulators --machine'..."); + let res = tokio::time::timeout(timeout_duration, make_flutter_command(&f2_cmd) + .args(&["emulators", "--machine"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + ).await; + match res { + Ok(Ok(output)) if output.status.success() => { + if let Ok(raw_emulators) = serde_json::from_slice::>(&output.stdout) { + eprintln!("[scan_flutter_devices] 'flutter emulators' returned {} emulators", raw_emulators.len()); + for emu in raw_emulators { + list.push(FlutterDevice { + id: emu.id, + name: emu.name, + platform: emu.platform_type, + emulator: true, + is_active: false, + }); + } + } else { + eprintln!("[scan_flutter_devices] Failed to parse 'flutter emulators' JSON"); + } + } + Ok(Ok(output)) => { + eprintln!("[scan_flutter_devices] 'flutter emulators' failed with status: {:?}", output.status); + } + Ok(Err(e)) => { + eprintln!("[scan_flutter_devices] Failed to run 'flutter emulators': {:?}", e); + } + Err(_) => { + eprintln!("[scan_flutter_devices] 'flutter emulators' timed out after 5s"); + } + } + list + }); + + // 3. Native iOS Simulator scan via xcrun simctl + let handle_simctl = tokio::spawn(async move { + let mut list = Vec::new(); + eprintln!("[scan_flutter_devices] Spawning 'xcrun simctl list devices --json'..."); + let res = tokio::time::timeout(timeout_duration, Command::new("xcrun") + .args(&["simctl", "list", "devices", "--json"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + ).await; + match res { + Ok(Ok(output)) if output.status.success() => { + if let Ok(simctl_data) = serde_json::from_slice::(&output.stdout) { + let mut count = 0; + for (_runtime, devs) in simctl_data.devices { + for dev in devs { + let is_avail = match &dev.is_available { + serde_json::Value::Bool(b) => *b, + serde_json::Value::String(s) => s.to_lowercase() == "yes" || s.to_lowercase() == "true", + _ => true, + }; + if is_avail { + count += 1; + let is_active = dev.state == "Booted"; + list.push(FlutterDevice { + id: dev.udid, + name: dev.name, + platform: "ios".to_string(), + emulator: true, + is_active, + }); + } + } + } + eprintln!("[scan_flutter_devices] 'xcrun simctl' returned {} available simulators", count); + } else { + eprintln!("[scan_flutter_devices] Failed to parse 'xcrun simctl' JSON"); + } + } + Ok(Ok(output)) => { + eprintln!("[scan_flutter_devices] 'xcrun simctl' failed with status: {:?}", output.status); + } + Ok(Err(e)) => { + eprintln!("[scan_flutter_devices] Failed to run 'xcrun simctl': {:?}", e); + } + Err(_) => { + eprintln!("[scan_flutter_devices] 'xcrun simctl' timed out after 5s"); + } + } + list + }); + + // 4. Native Android AVDs scan via emulator -list-avds + let emulator_cmd = resolve_emulator_path(); + let adb_cmd = resolve_adb_path(); + + let e_cmd = emulator_cmd.clone(); + let handle_emulator = tokio::spawn(async move { + let mut list = Vec::new(); + eprintln!("[scan_flutter_devices] Spawning '{} -list-avds'...", e_cmd); + let res = tokio::time::timeout(timeout_duration, Command::new(&e_cmd) + .arg("-list-avds") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + ).await; + match res { + Ok(Ok(output)) if output.status.success() => { + let stdout_str = String::from_utf8_lossy(&output.stdout); + let mut count = 0; + for line in stdout_str.lines() { + let avd_name = line.trim(); + if !avd_name.is_empty() { + count += 1; + list.push(FlutterDevice { + id: avd_name.to_string(), + name: avd_name.replace('_', " "), + platform: "android".to_string(), + emulator: true, + is_active: false, + }); + } + } + eprintln!("[scan_flutter_devices] Android emulator list returned {} AVDs", count); + } + Ok(Ok(output)) => { + eprintln!("[scan_flutter_devices] Android emulator list failed with status: {:?}", output.status); + } + Ok(Err(e)) => { + eprintln!("[scan_flutter_devices] Failed to run Android emulator list: {:?}", e); + } + Err(_) => { + eprintln!("[scan_flutter_devices] Android emulator list timed out after 5s"); + } + } + list + }); + + // 5. Native active Android devices scan via adb devices + let a_cmd = adb_cmd.clone(); + let handle_adb = tokio::spawn(async move { + let mut list = Vec::new(); + eprintln!("[scan_flutter_devices] Spawning '{} devices'...", a_cmd); + let res = tokio::time::timeout(timeout_duration, Command::new(&a_cmd) + .arg("devices") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + ).await; + match res { + Ok(Ok(output)) if output.status.success() => { + let stdout_str = String::from_utf8_lossy(&output.stdout); + let mut count = 0; + for line in stdout_str.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() == 2 && parts[1] == "device" { + let id = parts[0]; + count += 1; + list.push(FlutterDevice { + id: id.to_string(), + name: id.to_string(), + platform: "android".to_string(), + emulator: id.starts_with("emulator-"), + is_active: true, + }); + } + } + eprintln!("[scan_flutter_devices] adb devices returned {} active devices", count); + } + Ok(Ok(output)) => { + eprintln!("[scan_flutter_devices] adb devices failed with status: {:?}", output.status); + } + Ok(Err(e)) => { + eprintln!("[scan_flutter_devices] Failed to run adb devices: {:?}", e); + } + Err(_) => { + eprintln!("[scan_flutter_devices] adb devices timed out after 5s"); + } + } + list + }); + + // Wait for all branches to complete + let flutter_devs = handle_flutter_devices.await.unwrap_or_default(); + let flutter_emus = handle_flutter_emulators.await.unwrap_or_default(); + let simctl_devs = handle_simctl.await.unwrap_or_default(); + let avd_emus = handle_emulator.await.unwrap_or_default(); + let adb_devs = handle_adb.await.unwrap_or_default(); + + let mut devices = Vec::new(); + + // Populate flutter devices and emulators + for d in flutter_devs { + if !devices.iter().any(|existing: &FlutterDevice| existing.id == d.id) { + devices.push(d); + } + } + for d in flutter_emus { + if !devices.iter().any(|existing: &FlutterDevice| existing.id == d.id) { + devices.push(d); + } + } + + // Populate iOS simulators + for d in simctl_devs { + if !devices.iter().any(|existing: &FlutterDevice| existing.id == d.id) { + devices.push(d); + } + } + + // Populate Android AVDs + for d in avd_emus { + if !devices.iter().any(|existing: &FlutterDevice| existing.id == d.id) { + devices.push(d); + } + } + + // Sync active state from ADB list + for d in adb_devs { + let mut found = false; + for existing in &mut devices { + if existing.id == d.id || (d.id.starts_with("emulator-") && existing.platform == "android") { + existing.is_active = true; + found = true; + } + } + if !found { + devices.push(d); + } + } + + eprintln!("[scan_flutter_devices] Scan completed. Found {} total devices", devices.len()); + devices +} + +pub async fn launch_flutter_emulator( + flutter_path: Option, + emulator_id: &str, +) -> Result<(), String> { + // 1. iOS Simulator boot via xcrun simctl + let is_ios = emulator_id.contains('-') && emulator_id.len() >= 36; + if is_ios { + eprintln!("[launch_flutter_emulator] Booting iOS Simulator: {}", emulator_id); + let status = Command::new("xcrun") + .args(&["simctl", "boot", emulator_id]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await; + + let _ = Command::new("open") + .args(&["-a", "Simulator"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await; + + return match status { + Ok(s) if s.success() => { + eprintln!("[launch_flutter_emulator] iOS Simulator boot succeeded"); + Ok(()) + } + _ => { + eprintln!("[launch_flutter_emulator] iOS Simulator boot failed"); + Err("Failed to boot iOS simulator".to_string()) + } + }; + } + + // 2. Android Emulator boot + let flutter_cmd = flutter_path + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| "flutter".to_string()); + + let status = make_flutter_command(&flutter_cmd) + .args(&["emulators", "--launch", emulator_id]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await; + + match status { + Ok(s) if s.success() => { + eprintln!("[launch_flutter_emulator] Flutter emulator launch succeeded"); + Ok(()) + } + _ => { + // Fallback: spawn emulator -avd directly as background process + let mut emulator_cmd = "emulator".to_string(); + if let Ok(home) = std::env::var("HOME") { + let sdk_emulator = std::path::Path::new(&home) + .join("Library") + .join("Android") + .join("sdk") + .join("emulator") + .join("emulator"); + if sdk_emulator.exists() { + emulator_cmd = sdk_emulator.to_string_lossy().to_string(); + } + } + + eprintln!("[launch_flutter_emulator] Falling back to native: {} -avd {}", emulator_cmd, emulator_id); + let spawn_result = Command::new(&emulator_cmd) + .args(&["-avd", emulator_id]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + + match spawn_result { + Ok(_) => { + eprintln!("[launch_flutter_emulator] Fallback emulator spawn succeeded"); + Ok(()) + } + Err(e) => { + eprintln!("[launch_flutter_emulator] Fallback emulator spawn failed: {:?}", e); + Err(format!( + "Both flutter emulator launch and fallback emulator launch failed. Fallback error: {}", + e + )) + } + } + } + } +} diff --git a/src/async_runtime/message.rs b/src/async_runtime/message.rs index 69a8cb37..b290a4f7 100644 --- a/src/async_runtime/message.rs +++ b/src/async_runtime/message.rs @@ -382,6 +382,13 @@ pub enum WorkerRequestPayload { ScanDartEnvironments { workspace_root: PathBuf, }, + ScanFlutterDevices { + flutter_path: Option, + }, + LaunchFlutterEmulator { + flutter_path: Option, + emulator_id: String, + }, /// Detect Python / Node / Go runtime versions for statusbar display. /// `python_binary` is the interpreter path from the selected venv (or system). DetectRuntimeVersions { @@ -748,6 +755,8 @@ pub enum WorkerResultPayload { PythonEnvironmentsDiscovered(Vec), /// Kết quả scan Dart environments. DartEnvironmentsDiscovered(Vec), + FlutterDevicesDiscovered(Vec), + FlutterEmulatorLaunched, /// Runtime version strings for statusbar display. RuntimeVersionsDetected { python_version: Option, diff --git a/src/async_runtime/mod.rs b/src/async_runtime/mod.rs index 7d2ca344..70d32b69 100644 --- a/src/async_runtime/mod.rs +++ b/src/async_runtime/mod.rs @@ -2,3 +2,4 @@ pub mod message; pub mod python_env; pub mod dart_env; pub mod scheduler; +pub mod flutter_device; diff --git a/src/async_runtime/scheduler/dispatch.rs b/src/async_runtime/scheduler/dispatch.rs index dee6bfeb..28ed724a 100644 --- a/src/async_runtime/scheduler/dispatch.rs +++ b/src/async_runtime/scheduler/dispatch.rs @@ -380,6 +380,58 @@ pub(super) async fn dispatch_loop( continue; } + if matches!( + request.payload, + WorkerRequestPayload::ScanFlutterDevices { .. } + ) { + let flutter_path = match request.payload { + WorkerRequestPayload::ScanFlutterDevices { flutter_path } => flutter_path, + _ => unreachable!(), + }; + let worker_tx = result_tx.clone(); + let event_proxy = event_proxy.clone(); + tokio::spawn(async move { + let devices = crate::async_runtime::flutter_device::scan_flutter_devices(flutter_path).await; + emit_message_and_wake( + &worker_tx, + &event_proxy, + WorkerMessage::Result(WorkerResult { + request_id: request.request_id, + revision_id: request.revision_id, + topic: request.topic, + payload: WorkerResultPayload::FlutterDevicesDiscovered(devices), + }), + ); + }); + continue; + } + + if matches!( + request.payload, + WorkerRequestPayload::LaunchFlutterEmulator { .. } + ) { + let (flutter_path, emulator_id) = match request.payload { + WorkerRequestPayload::LaunchFlutterEmulator { flutter_path, emulator_id } => (flutter_path, emulator_id), + _ => unreachable!(), + }; + let worker_tx = result_tx.clone(); + let event_proxy = event_proxy.clone(); + tokio::spawn(async move { + let _ = crate::async_runtime::flutter_device::launch_flutter_emulator(flutter_path, &emulator_id).await; + emit_message_and_wake( + &worker_tx, + &event_proxy, + WorkerMessage::Result(WorkerResult { + request_id: request.request_id, + revision_id: request.revision_id, + topic: request.topic, + payload: WorkerResultPayload::FlutterEmulatorLaunched, + }), + ); + }); + continue; + } + if matches!( request.payload, WorkerRequestPayload::DetectRuntimeVersions { .. } diff --git a/src/async_runtime/scheduler/runtime.rs b/src/async_runtime/scheduler/runtime.rs index 1ed18041..4907eb19 100644 --- a/src/async_runtime/scheduler/runtime.rs +++ b/src/async_runtime/scheduler/runtime.rs @@ -82,6 +82,10 @@ impl AsyncScheduler { Ok(request) } + + pub fn enter(&self) -> tokio::runtime::EnterGuard<'_> { + self._runtime.handle().enter() + } } pub(super) fn build_worker_runtime() -> Result { diff --git a/src/core/command_dispatch/mod.rs b/src/core/command_dispatch/mod.rs index 58e4a9c1..9e71f6c7 100644 --- a/src/core/command_dispatch/mod.rs +++ b/src/core/command_dispatch/mod.rs @@ -333,7 +333,22 @@ fn dispatch_command_with_clipboard_once( format!("Dispatch: resize command {command:?} handled by event loop"), false, ), - Command::SaveFile + Command::DebugStart + | Command::DebugStop + | Command::DebugContinue + | Command::DebugStepOver + | Command::DebugStepInto + | Command::DebugStepOut + | Command::DebugToggleBreakpoint + | Command::DebugWatchAdd + | Command::DebugWatchRemove + | Command::DebugGotoFrame + | Command::FocusDap + | Command::DapToggleExpand + | Command::FlutterDevices + | Command::FlutterHotReload + | Command::FlutterHotRestart + | Command::SaveFile | Command::OpenFile(_) | Command::NewInstance | Command::OpenFolder diff --git a/src/core/command_dispatch/palette.rs b/src/core/command_dispatch/palette.rs index 50496cef..474c6623 100644 --- a/src/core/command_dispatch/palette.rs +++ b/src/core/command_dispatch/palette.rs @@ -573,6 +573,11 @@ fn confirm_selection(ctx: &mut DispatchCtx<'_, '_, '_>) -> DispatchReport { true, false, ), + CommandPaletteAction::SelectFlutterDevice { .. } => DispatchReport::success_with_flags( + "Dispatch: flutter device selected (handled by AppShell)".to_string(), + true, + false, + ), } } diff --git a/src/core/command_dispatch/session.rs b/src/core/command_dispatch/session.rs index 67152c6e..ad3abd92 100644 --- a/src/core/command_dispatch/session.rs +++ b/src/core/command_dispatch/session.rs @@ -81,6 +81,21 @@ pub(super) fn dispatch(ctx: &mut DispatchCtx<'_, '_, '_>, command: Command) -> D } Command::ToggleTerminal => toggle_terminal(ctx), Command::ToggleBottomDock + | Command::DebugStart + | Command::DebugStop + | Command::DebugContinue + | Command::DebugStepOver + | Command::DebugStepInto + | Command::DebugStepOut + | Command::DebugToggleBreakpoint + | Command::DebugWatchAdd + | Command::DebugWatchRemove + | Command::DebugGotoFrame + | Command::FocusDap + | Command::DapToggleExpand + | Command::FlutterDevices + | Command::FlutterHotReload + | Command::FlutterHotRestart | Command::ToggleLeftDock | Command::GitOpenLazygit | Command::GitOpenLazydocker diff --git a/src/core/command_ids.rs b/src/core/command_ids.rs index 93512afe..d19b5c5b 100644 --- a/src/core/command_ids.rs +++ b/src/core/command_ids.rs @@ -200,6 +200,20 @@ pub const FOCUS_EDITOR: &str = "app.focus_editor"; pub const FOCUS_EXPLORER: &str = "app.focus_explorer"; pub const FOCUS_TERMINAL: &str = "app.focus_terminal"; pub const FOCUS_INSPECTOR: &str = "app.focus_inspector"; +pub const FOCUS_DAP: &str = "app.focus_dap"; +pub const DAP_TOGGLE_EXPAND: &str = "dap.toggle_expand"; +pub const DEBUG_START: &str = "dap.continue"; +pub const DEBUG_STOP: &str = "dap.stop"; +pub const DEBUG_STEP_OVER: &str = "dap.step_over"; +pub const DEBUG_STEP_INTO: &str = "dap.step_into"; +pub const DEBUG_STEP_OUT: &str = "dap.step_out"; +pub const DEBUG_TOGGLE_BREAKPOINT: &str = "dap.toggle_breakpoint"; +pub const DEBUG_WATCH_ADD: &str = "dap.watch_add"; +pub const DEBUG_WATCH_REMOVE: &str = "dap.watch_remove"; +pub const DEBUG_GOTO_FRAME: &str = "dap.goto_frame"; +pub const FLUTTER_DEVICES: &str = "flutter.devices"; +pub const FLUTTER_HOT_RELOAD: &str = "flutter.hot_reload"; +pub const FLUTTER_HOT_RESTART: &str = "flutter.hot_restart"; pub const FOCUS_LEFT: &str = "app.focus_left"; pub const FOCUS_RIGHT: &str = "app.focus_right"; pub const FOCUS_UP: &str = "app.focus_up"; @@ -474,6 +488,20 @@ pub const ALL_IDS: &[&str] = &[ TOGGLE_MAXIMIZE_FOCUS, TOGGLE_FOLD, TOGGLE_FOLD_ALL, + FOCUS_DAP, + DAP_TOGGLE_EXPAND, + DEBUG_START, + DEBUG_STOP, + DEBUG_STEP_OVER, + DEBUG_STEP_INTO, + DEBUG_STEP_OUT, + DEBUG_TOGGLE_BREAKPOINT, + DEBUG_WATCH_ADD, + DEBUG_WATCH_REMOVE, + DEBUG_GOTO_FRAME, + FLUTTER_DEVICES, + FLUTTER_HOT_RELOAD, + FLUTTER_HOT_RESTART, ]; pub fn is_valid(id: &str) -> bool { @@ -629,6 +657,20 @@ pub fn parse(id: &str, open_file_path: Option<&std::path::Path>) -> Option Some(Command::FocusExplorer), FOCUS_TERMINAL => Some(Command::FocusTerminal), FOCUS_INSPECTOR => Some(Command::FocusInspector), + FOCUS_DAP => Some(Command::FocusDap), + DAP_TOGGLE_EXPAND => Some(Command::DapToggleExpand), + DEBUG_START => Some(Command::DebugStart), + DEBUG_STOP => Some(Command::DebugStop), + DEBUG_STEP_OVER => Some(Command::DebugStepOver), + DEBUG_STEP_INTO => Some(Command::DebugStepInto), + DEBUG_STEP_OUT => Some(Command::DebugStepOut), + DEBUG_TOGGLE_BREAKPOINT => Some(Command::DebugToggleBreakpoint), + DEBUG_WATCH_ADD => Some(Command::DebugWatchAdd), + DEBUG_WATCH_REMOVE => Some(Command::DebugWatchRemove), + DEBUG_GOTO_FRAME => Some(Command::DebugGotoFrame), + FLUTTER_DEVICES => Some(Command::FlutterDevices), + FLUTTER_HOT_RELOAD => Some(Command::FlutterHotReload), + FLUTTER_HOT_RESTART => Some(Command::FlutterHotRestart), FOCUS_LEFT => Some(Command::FocusLeft), FOCUS_RIGHT => Some(Command::FocusRight), FOCUS_UP => Some(Command::FocusUp), diff --git a/src/core/commands.rs b/src/core/commands.rs index 3306e5e0..16bd9a27 100644 --- a/src/core/commands.rs +++ b/src/core/commands.rs @@ -119,6 +119,23 @@ pub enum Command { SearchWordUnderCursor, ClearSearchHighlights, + // ── Debugger (DAP) & Flutter ─────────────────────────────────────────────── + DebugStart, + DebugStop, + DebugContinue, + DebugStepOver, + DebugStepInto, + DebugStepOut, + DebugToggleBreakpoint, + DebugWatchAdd, + DebugWatchRemove, + DebugGotoFrame, + FocusDap, + DapToggleExpand, + FlutterDevices, + FlutterHotReload, + FlutterHotRestart, + // ── File & palette ───────────────────────────────────────────────────────── SaveFile, OpenFile(PathBuf), diff --git a/src/dap/client.rs b/src/dap/client.rs new file mode 100644 index 00000000..6c2d4b4c --- /dev/null +++ b/src/dap/client.rs @@ -0,0 +1,336 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::process::Stdio; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, ChildStdin, Command}; +use tokio::sync::{mpsc, oneshot}; + +use crate::dap::types::{Request, Response, Event}; + +pub struct DapClient { + _child: Child, + stdin: tokio::sync::Mutex, + next_seq: AtomicU64, + pending_requests: Arc>>>, +} + +impl DapClient { + pub fn launch( + program: &str, + args: &[String], + working_dir: Option, + event_tx: mpsc::UnboundedSender, + ) -> Result, std::io::Error> { + Self::launch_with_env(program, args, working_dir, event_tx, None) + } + + pub fn launch_with_env( + program: &str, + args: &[String], + working_dir: Option, + event_tx: mpsc::UnboundedSender, + env_vars: Option>, + ) -> Result, std::io::Error> { + eprintln!("[DAP LOG] [DAP Client] DapClient::launch called for program: {}, args: {:?}", program, args); + let path_buf = std::path::Path::new(program); + let exists = if path_buf.is_absolute() || path_buf.components().count() > 1 { + path_buf.exists() + } else if let Ok(path) = std::env::var("PATH") { + let mut found = false; + for dir in std::env::split_paths(&path) { + let joined = dir.join(program); + eprintln!("[DAP LOG] exists check: program={}, checking joined={:?}, exists={}", program, joined, joined.exists()); + if joined.exists() { + found = true; + break; + } + } + found + } else { + false + }; + if !exists { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("Binary '{}' not found in PATH or at specified path", program), + )); + } + + let mut cmd = Command::new(program); + cmd.args(args); + cmd.stdin(Stdio::piped()); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::null()); + + // Set environment variables if provided (for FVM support) + if let Some(env) = env_vars { + for (key, value) in env { + cmd.env(&key, &value); + } + } + + if let Some(wd) = working_dir { + cmd.current_dir(wd); + } + + let mut child = cmd.spawn()?; + let stdin = child.stdin.take().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::Other, "Failed to capture stdin") + })?; + let stdout = child.stdout.take().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::Other, "Failed to capture stdout") + })?; + + let pending_requests: Arc>>> = Arc::new(Mutex::new(HashMap::new())); + let pending_requests_clone = pending_requests.clone(); + + // Spawn background stdout reader thread + tokio::spawn(async move { + let mut reader = BufReader::new(stdout); + let mut buffer = Vec::new(); + + loop { + match read_message(&mut reader, &mut buffer).await { + Ok(Some(raw_json)) => { + if let Ok(value) = serde_json::from_str::(&raw_json) { + let msg_type = value.get("type").and_then(|v| v.as_str()).unwrap_or(""); + match msg_type { + "response" => { + if let Ok(resp) = serde_json::from_str::(&raw_json) { + let mut guard = pending_requests_clone.lock().unwrap(); + if let Some(tx) = guard.remove(&resp.request_seq) { + let _ = tx.send(resp); + } + } + } + "event" => { + if let Ok(event) = serde_json::from_str::(&raw_json) { + let _ = event_tx.send(event); + } + } + _ => {} + } + } + } + Ok(None) => { + // EOF reached + break; + } + Err(err) => { + eprintln!("[DapClient] Error reading stdout message: {:?}", err); + break; + } + } + } + }); + + Ok(Arc::new(Self { + _child: child, + stdin: tokio::sync::Mutex::new(stdin), + next_seq: AtomicU64::new(1), + pending_requests, + })) + } + + pub async fn send_request( + &self, + command: &str, + arguments: Option, + ) -> Result { + let seq = self.next_seq.fetch_add(1, Ordering::SeqCst); + let req = Request { + seq, + message_type: "request".to_string(), + command: command.to_string(), + arguments, + }; + + let raw_json = serde_json::to_string(&req) + .map_err(|e| format!("Failed to serialize request: {}", e))?; + + let (tx, rx) = oneshot::channel(); + { + let mut guard = self.pending_requests.lock().unwrap(); + guard.insert(seq, tx); + } + + let payload = format!("Content-Length: {}\r\n\r\n{}", raw_json.len(), raw_json); + { + let mut stdin_guard = self.stdin.lock().await; + stdin_guard + .write_all(payload.as_bytes()) + .await + .map_err(|e| format!("Failed to write to stdin: {}", e))?; + stdin_guard + .flush() + .await + .map_err(|e| format!("Failed to flush stdin: {}", e))?; + } + + rx.await + .map_err(|_| "Debug adapter disconnected".to_string()) + } +} + +// ── Read DAP Protocol Header & Body ────────────────────────────────────────── + +async fn read_message( + reader: &mut R, + buffer: &mut Vec, +) -> std::io::Result> { + let mut content_length: Option = None; + + loop { + // Read header line by line + let mut line = String::new(); + let mut header_bytes = [0u8; 1]; + + loop { + let bytes_read = reader.read(&mut header_bytes).await?; + if bytes_read == 0 { + return Ok(None); // EOF + } + let c = header_bytes[0] as char; + line.push(c); + if line.ends_with("\r\n") { + break; + } + } + + if line == "\r\n" { + // End of headers, break to read body + break; + } + + if line.starts_with("Content-Length:") { + let parts: Vec<&str> = line.split(':').collect(); + if parts.len() == 2 { + if let Ok(len) = parts[1].trim().parse::() { + content_length = Some(len); + } + } + } + } + + let len = content_length.ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Missing Content-Length header", + ) + })?; + + buffer.resize(len, 0); + reader.read_exact(buffer).await?; + + let json_str = String::from_utf8(buffer.clone()).map_err(|e| { + std::io::Error::new(std::io::ErrorKind::InvalidData, format!("Invalid UTF-8: {}", e)) + })?; + + Ok(Some(json_str)) +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::BufReader; + + fn make_dap_message(json: &str) -> Vec { + let header = format!("Content-Length: {}\r\n\r\n", json.len()); + let mut bytes = header.into_bytes(); + bytes.extend_from_slice(json.as_bytes()); + bytes + } + + #[tokio::test] + async fn read_message_parses_valid_dap_frame() { + let json = r#"{"seq":1,"type":"event","event":"initialized"}"#; + let data = make_dap_message(json); + let mut reader = BufReader::new(&data[..]); + let mut buffer = Vec::new(); + + let result = read_message(&mut reader, &mut buffer).await.unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap(), json); + } + + #[tokio::test] + async fn read_message_returns_none_on_eof() { + let data: &[u8] = &[]; + let mut reader = BufReader::new(data); + let mut buffer = Vec::new(); + + let result = read_message(&mut reader, &mut buffer).await.unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn read_message_errors_on_missing_content_length() { + // Header with no Content-Length + let data = b"\r\n"; + let mut reader = BufReader::new(&data[..]); + let mut buffer = Vec::new(); + + let result = read_message(&mut reader, &mut buffer).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn read_message_parses_multiple_frames_sequentially() { + let json1 = r#"{"seq":1,"type":"event","event":"initialized"}"#; + let json2 = r#"{"seq":2,"type":"response","request_seq":0,"success":true,"command":"initialize"}"#; + let mut data = make_dap_message(json1); + data.extend_from_slice(&make_dap_message(json2)); + + let mut reader = BufReader::new(&data[..]); + let mut buffer = Vec::new(); + + let r1 = read_message(&mut reader, &mut buffer).await.unwrap().unwrap(); + assert_eq!(r1, json1); + + let r2 = read_message(&mut reader, &mut buffer).await.unwrap().unwrap(); + assert_eq!(r2, json2); + } + + #[tokio::test] + async fn read_message_handles_empty_body() { + let json = "{}"; + let data = make_dap_message(json); + let mut reader = BufReader::new(&data[..]); + let mut buffer = Vec::new(); + + let result = read_message(&mut reader, &mut buffer).await.unwrap(); + assert_eq!(result.unwrap(), "{}"); + } + + #[tokio::test] + async fn read_message_handles_unicode_content() { + let json = r#"{"output":"Xin chào thế giới 🌍"}"#; + let data = make_dap_message(json); + let mut reader = BufReader::new(&data[..]); + let mut buffer = Vec::new(); + + let result = read_message(&mut reader, &mut buffer).await.unwrap(); + assert_eq!(result.unwrap(), json); + } + + #[tokio::test] + async fn read_message_reuses_buffer_across_calls() { + let json1 = r#"{"short":true}"#; + let json2 = r#"{"a_much_longer_field_name":"with a longer value that exceeds the first buffer"}"#; + let mut data = make_dap_message(json1); + data.extend_from_slice(&make_dap_message(json2)); + + let mut reader = BufReader::new(&data[..]); + let mut buffer = Vec::new(); + + let r1 = read_message(&mut reader, &mut buffer).await.unwrap().unwrap(); + assert_eq!(r1, json1); + + let r2 = read_message(&mut reader, &mut buffer).await.unwrap().unwrap(); + assert_eq!(r2, json2); + // Buffer should have been resized for the larger message + assert!(buffer.len() >= json2.len()); + } +} diff --git a/src/dap/launch_config.rs b/src/dap/launch_config.rs new file mode 100644 index 00000000..3a3564a0 --- /dev/null +++ b/src/dap/launch_config.rs @@ -0,0 +1,267 @@ +use std::path::{Path, PathBuf}; +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LaunchJson { + pub version: Option, + #[serde(default)] + pub configurations: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LaunchConfiguration { + pub name: String, + #[serde(rename = "type")] + pub config_type: String, + pub request: String, + #[serde(default)] + pub program: Option, + #[serde(default)] + pub args: Option>, + #[serde(default)] + pub cwd: Option, + #[serde(default)] + pub env: Option>, + #[serde(default)] + pub tool_args: Option>, + #[serde(default)] + pub device_id: Option, + #[serde(default)] + pub flutter_mode: Option, + #[serde(default)] + pub dart_path: Option, + #[serde(flatten)] + pub extra: std::collections::HashMap, +} + +#[derive(Debug, Clone)] +pub struct ResolvedLaunchConfig { + pub name: String, + pub config_type: String, + pub request: String, + pub program: String, + pub args: Vec, + pub cwd: Option, + pub env: std::collections::HashMap, + pub tool_args: Vec, + pub device_id: Option, +} + +impl LaunchConfiguration { + pub fn resolve(&self, workspace_root: &Path) -> ResolvedLaunchConfig { + let program = self.program.clone().unwrap_or_default(); + let cwd = self.cwd.as_ref().map(|c| { + let p = PathBuf::from(c); + if p.is_absolute() { p } else { workspace_root.join(p) } + }); + ResolvedLaunchConfig { + name: self.name.clone(), + config_type: self.config_type.clone(), + request: self.request.clone(), + program, + args: self.args.clone().unwrap_or_default(), + cwd, + env: self.env.clone().unwrap_or_default(), + tool_args: self.tool_args.clone().unwrap_or_default(), + device_id: self.device_id.clone(), + } + } +} + +pub fn find_launch_json(workspace_root: &Path) -> Option { + let candidates = [ + workspace_root.join(".vscode").join("launch.json"), + workspace_root.join(".zed").join("debug.json"), + workspace_root.join(".cursor").join("launch.json"), + ]; + candidates.into_iter().find(|p| p.exists()) +} + +pub fn load_launch_json(workspace_root: &Path) -> Option { + let path = find_launch_json(workspace_root)?; + let content = std::fs::read_to_string(&path).ok()?; + // VS Code launch.json may contain comments (JSONC), strip them. + let cleaned = strip_json_comments(&content); + serde_json::from_str(&cleaned).ok() +} + +fn strip_json_comments(json: &str) -> String { + let mut result = String::with_capacity(json.len()); + let mut in_string = false; + let mut escape_next = false; + let chars: Vec = json.chars().collect(); + let mut i = 0; + + while i < chars.len() { + let c = chars[i]; + + if escape_next { + result.push(c); + escape_next = false; + i += 1; + continue; + } + + if in_string { + if c == '\\' { + escape_next = true; + } else if c == '"' { + in_string = false; + } + result.push(c); + i += 1; + continue; + } + + if c == '"' { + in_string = true; + result.push(c); + i += 1; + continue; + } + + if c == '/' && i + 1 < chars.len() { + if chars[i + 1] == '/' { + // Line comment - skip to end of line + while i < chars.len() && chars[i] != '\n' { + i += 1; + } + continue; + } else if chars[i + 1] == '*' { + // Block comment - skip to */ + i += 2; + while i + 1 < chars.len() { + if chars[i] == '*' && chars[i + 1] == '/' { + i += 2; + break; + } + i += 1; + } + continue; + } + } + + result.push(c); + i += 1; + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_flutter_launch_json() { + let json = r#"{ + "version": "0.2.0", + "configurations": [ + { + "name": "Flutter", + "type": "dart", + "request": "launch", + "program": "lib/main.dart", + "toolArgs": ["-d", "chrome"], + "deviceId": "chrome" + } + ] + }"#; + let config: LaunchJson = serde_json::from_str(json).unwrap(); + assert_eq!(config.configurations.len(), 1); + assert_eq!(config.configurations[0].name, "Flutter"); + assert_eq!(config.configurations[0].config_type, "dart"); + assert_eq!(config.configurations[0].request, "launch"); + assert_eq!( + config.configurations[0].program.as_deref(), + Some("lib/main.dart") + ); + } + + #[test] + fn parse_rust_launch_json() { + let json = r#"{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug", + "type": "lldb", + "request": "launch", + "program": "${workspaceFolder}/target/debug/myapp" + } + ] + }"#; + let config: LaunchJson = serde_json::from_str(json).unwrap(); + assert_eq!(config.configurations[0].config_type, "lldb"); + } + + #[test] + fn strip_single_line_comments() { + let json = r#"{ + // comment + "key": "value" + }"#; + let cleaned = strip_json_comments(json); + let parsed: serde_json::Value = serde_json::from_str(&cleaned).unwrap(); + assert_eq!(parsed["key"], "value"); + } + + #[test] + fn strip_block_comments() { + let json = r#"{ + /* block + comment */ + "key": "value" + }"#; + let cleaned = strip_json_comments(json); + let parsed: serde_json::Value = serde_json::from_str(&cleaned).unwrap(); + assert_eq!(parsed["key"], "value"); + } + + #[test] + fn strip_comments_preserves_strings() { + let json = r#"{ + "url": "http://example.com//not-a-comment" + }"#; + let cleaned = strip_json_comments(json); + let parsed: serde_json::Value = serde_json::from_str(&cleaned).unwrap(); + assert_eq!(parsed["url"], "http://example.com//not-a-comment"); + } + + #[test] + fn resolve_config_with_relative_cwd() { + let config = LaunchConfiguration { + name: "Test".to_string(), + config_type: "dart".to_string(), + request: "launch".to_string(), + program: Some("lib/main.dart".to_string()), + args: None, + cwd: Some("my_project".to_string()), + env: None, + tool_args: None, + device_id: None, + flutter_mode: None, + dart_path: None, + extra: std::collections::HashMap::new(), + }; + let resolved = config.resolve(Path::new("/workspace")); + assert_eq!(resolved.cwd, Some(PathBuf::from("/workspace/my_project"))); + } + + #[test] + fn multiple_configurations() { + let json = r#"{ + "version": "0.2.0", + "configurations": [ + { "name": "App", "type": "dart", "request": "launch", "program": "lib/main.dart" }, + { "name": "Tests", "type": "dart", "request": "launch", "program": "test/" }, + { "name": "Profile", "type": "dart", "request": "launch", "program": "lib/main.dart", "flutterMode": "profile" } + ] + }"#; + let config: LaunchJson = serde_json::from_str(json).unwrap(); + assert_eq!(config.configurations.len(), 3); + assert_eq!(config.configurations[2].name, "Profile"); + } +} diff --git a/src/dap/mod.rs b/src/dap/mod.rs new file mode 100644 index 00000000..b04d4748 --- /dev/null +++ b/src/dap/mod.rs @@ -0,0 +1,8 @@ +pub mod client; +pub mod types; +pub mod session; +pub mod launch_config; + +pub use client::DapClient; +pub use types::{Breakpoint, Event, Request, Response, StackFrame, Thread, Variable}; +pub use session::DapSession; diff --git a/src/dap/session.rs b/src/dap/session.rs new file mode 100644 index 00000000..b6419c98 --- /dev/null +++ b/src/dap/session.rs @@ -0,0 +1,320 @@ +use std::path::Path; +use std::sync::{Arc, Mutex}; +use serde_json::json; + +use crate::dap::client::DapClient; +use crate::dap::types::{Breakpoint as DapBreakpoint, StackFrame as DapStackFrame, Variable as DapVariable, Scope as DapScope, SourceBreakpoint}; +use crate::workbench::debug_state::{ + Breakpoint as EditorBreakpoint, DebugSharedState, DebugVariable, SourceLocation, + StackFrame as EditorStackFrame, +}; + +pub struct DapSession { + pub client: Arc, + pub state: Arc>, + pub active_thread_id: Arc>>, +} + +impl DapSession { + pub fn new(client: Arc) -> Self { + Self { + client, + state: Arc::new(tokio::sync::Mutex::new(DebugSharedState::default())), + active_thread_id: Arc::new(Mutex::new(None)), + } + } + + pub async fn initialize(&self, adapter_id: &str) -> Result<(), String> { + let args = json!({ + "clientID": "netherize", + "clientName": "Netherize Editor", + "adapterID": adapter_id, + "linesStartAt1": true, + "columnsStartAt1": true, + "pathFormat": "path" + }); + + self.client.send_request("initialize", Some(args)).await?; + Ok(()) + } + + pub async fn launch_flutter(&self, program: &str, device_id: &str) -> Result<(), String> { + let args = json!({ + "request": "launch", + "program": program, + "toolArgs": ["-d", device_id], + "noDebug": false + }); + + self.client.send_request("launch", Some(args)).await?; + // Standard configurationDone to let the adapter know initialization is finished + self.client.send_request("configurationDone", None).await?; + + let mut state_guard = self.state.lock().await; + state_guard.paused = false; + state_guard.console_messages.push("Flutter application launching...".to_string()); + + Ok(()) + } + + pub async fn set_breakpoints(&self, path: &Path, lines: &[usize]) -> Result<(), String> { + let source_path = path.to_string_lossy().to_string(); + let breakpoints: Vec = lines + .iter() + .map(|&l| SourceBreakpoint { + line: (l + 1) as i64, // Convert 0-indexed to 1-indexed + column: None, + }) + .collect(); + + let args = json!({ + "source": { + "name": path.file_name().map(|f| f.to_string_lossy().to_string()), + "path": source_path, + }, + "breakpoints": breakpoints + }); + + let resp = self.client.send_request("setBreakpoints", Some(args)).await?; + if resp.success { + if let Some(body) = resp.body { + if let Ok(dap_bps) = serde_json::from_value::>(body["breakpoints"].clone()) { + let mut state_guard = self.state.lock().await; + state_guard.breakpoints.clear(); + for (i, dap_bp) in dap_bps.into_iter().enumerate() { + if dap_bp.verified { + if let Some(line) = dap_bp.line { + state_guard.breakpoints.push(EditorBreakpoint { + id: dap_bp.id.unwrap_or(i as i64) as u64, + location: SourceLocation { + line: (line - 1) as usize, // 1-indexed to 0-indexed + column: 0, + }, + enabled: true, + path: path.canonicalize().unwrap_or_else(|_| path.to_path_buf()), + }); + } + } + } + } + } + } + Ok(()) + } + + pub async fn resume(&self) -> Result<(), String> { + let thread_id = { *self.active_thread_id.lock().unwrap() }; + if let Some(tid) = thread_id { + let args = json!({ "threadId": tid }); + self.client.send_request("continue", Some(args)).await?; + let mut state_guard = self.state.lock().await; + state_guard.paused = false; + state_guard.execution_location = None; + } + Ok(()) + } + + pub async fn step_over(&self) -> Result<(), String> { + let thread_id = { *self.active_thread_id.lock().unwrap() }; + if let Some(tid) = thread_id { + let args = json!({ "threadId": tid }); + self.client.send_request("next", Some(args)).await?; + } + Ok(()) + } + + pub async fn step_into(&self) -> Result<(), String> { + let thread_id = { *self.active_thread_id.lock().unwrap() }; + if let Some(tid) = thread_id { + let args = json!({ "threadId": tid }); + self.client.send_request("stepIn", Some(args)).await?; + } + Ok(()) + } + + pub async fn step_out(&self) -> Result<(), String> { + let thread_id = { *self.active_thread_id.lock().unwrap() }; + if let Some(tid) = thread_id { + let args = json!({ "threadId": tid }); + self.client.send_request("stepOut", Some(args)).await?; + } + Ok(()) + } + + pub async fn stop(&self) -> Result<(), String> { + self.client.send_request("disconnect", None).await?; + let mut state_guard = self.state.lock().await; + state_guard.paused = true; + state_guard.execution_location = None; + state_guard.console_messages.push("Debugger session stopped.".to_string()); + Ok(()) + } + + pub async fn hot_reload(&self) -> Result<(), String> { + let args = json!({ + "request": "hotReload" + }); + self.client.send_request("customRequest", Some(args)).await?; + let mut state_guard = self.state.lock().await; + state_guard.console_messages.push("⚡ Hot Reload triggered...".to_string()); + Ok(()) + } + + pub async fn hot_restart(&self) -> Result<(), String> { + let args = json!({ + "request": "hotRestart" + }); + self.client.send_request("customRequest", Some(args)).await?; + let mut state_guard = self.state.lock().await; + state_guard.console_messages.push("↻ Hot Restart triggered...".to_string()); + Ok(()) + } + + pub async fn evaluate_watch_expressions(&self) -> Result<(), String> { + let expressions: Vec = { + let state_guard = self.state.lock().await; + state_guard.watch.iter().map(|w| w.expression.clone()).collect() + }; + + let mut results = Vec::new(); + for expr in expressions { + let args = json!({ + "expression": expr, + "context": "watch" + }); + if let Ok(resp) = self.client.send_request("evaluate", Some(args)).await { + if resp.success { + if let Some(body) = resp.body { + let value = body.get("result") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + results.push((expr, value)); + } + } else { + results.push((expr, "".to_string())); + } + } + } + + let mut state_guard = self.state.lock().await; + state_guard.update_watch_values(results); + Ok(()) + } + + // ── Update suspended state when paused ───────────────────────────────────── + + pub async fn update_suspended_state(&self, thread_id: i64) -> Result<(), String> { + { + let mut tid_guard = self.active_thread_id.lock().unwrap(); + *tid_guard = Some(thread_id); + } + + // 1. Fetch Call Stack + let args = json!({ "threadId": thread_id }); + let resp = self.client.send_request("stackTrace", Some(args)).await?; + let mut call_stack = Vec::new(); + let mut current_loc = None; + let mut top_frame_id = None; + + if resp.success && let Some(body) = resp.body { + if let Ok(frames) = serde_json::from_value::>(body["stackFrames"].clone()) { + for (idx, frame) in frames.into_iter().enumerate() { + let line = (frame.line - 1).max(0) as usize; // 1-indexed to 0-indexed + let col = (frame.column - 1).max(0) as usize; + + let file_label = frame + .source + .as_ref() + .and_then(|s| s.path.as_ref()) + .and_then(|p| Path::new(p).file_name()) + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + let path = frame + .source + .as_ref() + .and_then(|s| s.path.as_ref()) + .map(Path::new) + .map(|p| p.to_path_buf()) + .unwrap_or_default(); + + if idx == 0 { + top_frame_id = Some(frame.id); + current_loc = Some(SourceLocation { line, column: col }); + } + + call_stack.push(EditorStackFrame { + function: format!("{} ({})", frame.name, file_label), + location: SourceLocation { line, column: col }, + path, + }); + } + } + } + + // 2. Fetch Variables for the Top Frame + let mut variables = Vec::new(); + if let Some(frame_id) = top_frame_id { + let args = json!({ "frameId": frame_id }); + let resp = self.client.send_request("scopes", Some(args)).await?; + if resp.success && let Some(body) = resp.body { + if let Ok(scopes) = serde_json::from_value::>(body["scopes"].clone()) { + // Iterate scopes (usually Local, Global) + for scope in scopes { + if scope.variables_reference > 0 { + let var_args = json!({ "variablesReference": scope.variables_reference }); + let var_resp = self.client.send_request("variables", Some(var_args)).await?; + if var_resp.success && let Some(var_body) = var_resp.body { + if let Ok(dap_vars) = serde_json::from_value::>(var_body["variables"].clone()) { + for d_var in dap_vars { + variables.push(self.fetch_variable_tree(d_var).await); + } + } + } + } + } + } + } + } + + // 3. Update active state + { + let mut state_guard = self.state.lock().await; + state_guard.paused = true; + state_guard.execution_location = current_loc; + state_guard.call_stack = call_stack; + state_guard.variables = variables; + } + + Ok(()) + } + + // Lazily evaluate/fetch nested object properties (children) + async fn fetch_variable_tree(&self, var: DapVariable) -> DebugVariable { + let mut children = Vec::new(); + if var.variables_reference > 0 { + let args = json!({ "variablesReference": var.variables_reference }); + if let Ok(resp) = self.client.send_request("variables", Some(args)).await { + if resp.success && let Some(body) = resp.body { + if let Ok(dap_vars) = serde_json::from_value::>(body["variables"].clone()) { + for d_var in dap_vars { + // Don't recursively fetch indefinitely (fetch 1 level down) + children.push(DebugVariable { + name: d_var.name, + value: d_var.value, + children: Vec::new(), + }); + } + } + } + } + } + + DebugVariable { + name: var.name, + value: var.value, + children, + } + } +} diff --git a/src/dap/types.rs b/src/dap/types.rs new file mode 100644 index 00000000..a55fe6bc --- /dev/null +++ b/src/dap/types.rs @@ -0,0 +1,430 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProtocolMessage { + pub seq: u64, + #[serde(rename = "type")] + pub message_type: String, // "request", "response", or "event" +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Request { + pub seq: u64, + #[serde(rename = "type")] + pub message_type: String, // "request" + pub command: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub arguments: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Response { + pub seq: u64, + #[serde(rename = "type")] + pub message_type: String, // "response" + pub request_seq: u64, + pub success: bool, + pub command: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Event { + pub seq: u64, + #[serde(rename = "type")] + pub message_type: String, // "event" + pub event: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, +} + +// ── Shared Protocol Objects ────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Source { + pub name: Option, + pub path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Thread { + pub id: i64, + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StackFrame { + pub id: i64, + pub name: String, + pub source: Option, + pub line: i64, + pub column: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Scope { + pub name: String, + #[serde(rename = "variablesReference")] + pub variables_reference: i64, + pub expensive: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Variable { + pub name: String, + pub value: String, + #[serde(rename = "variablesReference")] + pub variables_reference: i64, + pub evaluate_name: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Breakpoint { + pub id: Option, + pub verified: bool, + pub line: Option, + pub message: Option, +} + +// ── Specific Event Bodies ──────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoppedEventBody { + pub reason: String, + #[serde(rename = "threadId")] + pub thread_id: Option, + #[serde(rename = "allThreadsStopped")] + pub all_threads_stopped: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutputEventBody { + pub category: Option, + pub output: String, +} + +// ── Specific Request Arguments ─────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InitializeRequestArguments { + #[serde(rename = "clientID")] + pub client_id: String, + #[serde(rename = "clientName")] + pub client_name: String, + #[serde(rename = "adapterID")] + pub adapter_id: String, + #[serde(rename = "linesStartAt1")] + pub lines_start_at_1: bool, + #[serde(rename = "columnsStartAt1")] + pub columns_start_at_1: bool, + #[serde(rename = "pathFormat")] + pub path_format: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SourceBreakpoint { + pub line: i64, + pub column: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SetBreakpointsArguments { + pub source: Source, + pub breakpoints: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_serializes_with_type_field() { + let req = Request { + seq: 1, + message_type: "request".to_string(), + command: "initialize".to_string(), + arguments: Some(serde_json::json!({"adapterID": "flutter"})), + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains(r#""type":"request""#)); + assert!(json.contains(r#""command":"initialize""#)); + assert!(json.contains(r#""adapterID":"flutter""#)); + } + + #[test] + fn request_serializes_without_arguments_when_none() { + let req = Request { + seq: 2, + message_type: "request".to_string(), + command: "configurationDone".to_string(), + arguments: None, + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(!json.contains("arguments")); + } + + #[test] + fn response_deserializes_from_json() { + let json = r#"{ + "seq": 5, + "type": "response", + "request_seq": 1, + "success": true, + "command": "initialize", + "body": {"supportsConfigurationDoneRequest": true} + }"#; + let resp: Response = serde_json::from_str(json).unwrap(); + assert_eq!(resp.seq, 5); + assert_eq!(resp.request_seq, 1); + assert!(resp.success); + assert_eq!(resp.command, "initialize"); + assert!(resp.body.is_some()); + } + + #[test] + fn response_handles_failure_with_message() { + let json = r#"{ + "seq": 6, + "type": "response", + "request_seq": 2, + "success": false, + "command": "launch", + "message": "Failed to launch" + }"#; + let resp: Response = serde_json::from_str(json).unwrap(); + assert!(!resp.success); + assert_eq!(resp.message.as_deref(), Some("Failed to launch")); + } + + #[test] + fn event_deserializes_stopped_event() { + let json = r#"{ + "seq": 10, + "type": "event", + "event": "stopped", + "body": { + "reason": "breakpoint", + "threadId": 1, + "allThreadsStopped": true + } + }"#; + let event: Event = serde_json::from_str(json).unwrap(); + assert_eq!(event.event, "stopped"); + let body: StoppedEventBody = serde_json::from_value(event.body.unwrap()).unwrap(); + assert_eq!(body.reason, "breakpoint"); + assert_eq!(body.thread_id, Some(1)); + assert_eq!(body.all_threads_stopped, Some(true)); + } + + #[test] + fn event_deserializes_output_event() { + let json = r#"{ + "seq": 11, + "type": "event", + "event": "output", + "body": { + "category": "stdout", + "output": "Hello, world!\n" + } + }"#; + let event: Event = serde_json::from_str(json).unwrap(); + let body: OutputEventBody = serde_json::from_value(event.body.unwrap()).unwrap(); + assert_eq!(body.category.as_deref(), Some("stdout")); + assert_eq!(body.output, "Hello, world!\n"); + } + + #[test] + fn event_deserializes_terminated_event() { + let json = r#"{ + "seq": 12, + "type": "event", + "event": "terminated" + }"#; + let event: Event = serde_json::from_str(json).unwrap(); + assert_eq!(event.event, "terminated"); + assert!(event.body.is_none()); + } + + #[test] + fn thread_serializes_and_deserializes() { + let thread = Thread { + id: 1, + name: "main".to_string(), + }; + let json = serde_json::to_string(&thread).unwrap(); + let deserialized: Thread = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.id, 1); + assert_eq!(deserialized.name, "main"); + } + + #[test] + fn stack_frame_with_source_roundtrips() { + let frame = StackFrame { + id: 100, + name: "main".to_string(), + source: Some(Source { + name: Some("main.dart".to_string()), + path: Some("/lib/main.dart".to_string()), + }), + line: 42, + column: 8, + }; + let json = serde_json::to_string(&frame).unwrap(); + let deserialized: StackFrame = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.id, 100); + assert_eq!(deserialized.line, 42); + assert_eq!(deserialized.column, 8); + assert_eq!(deserialized.source.as_ref().unwrap().name.as_deref(), Some("main.dart")); + } + + #[test] + fn stack_frame_without_source_roundtrips() { + let frame = StackFrame { + id: 200, + name: "".to_string(), + source: None, + line: 1, + column: 1, + }; + let json = serde_json::to_string(&frame).unwrap(); + assert!(json.contains("null") || !json.contains("source")); + let deserialized: StackFrame = serde_json::from_str(&json).unwrap(); + assert!(deserialized.source.is_none()); + } + + #[test] + fn scope_serializes_with_variables_reference() { + let scope = Scope { + name: "Local".to_string(), + variables_reference: 1001, + expensive: false, + }; + let json = serde_json::to_string(&scope).unwrap(); + assert!(json.contains(r#""variablesReference":1001"#)); + let deserialized: Scope = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.name, "Local"); + assert_eq!(deserialized.variables_reference, 1001); + } + + #[test] + fn variable_with_nested_reference() { + let var = Variable { + name: "myList".to_string(), + value: "List(3)".to_string(), + variables_reference: 2001, + evaluate_name: Some("myList".to_string()), + }; + let json = serde_json::to_string(&var).unwrap(); + let deserialized: Variable = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.name, "myList"); + assert_eq!(deserialized.variables_reference, 2001); + assert_eq!(deserialized.evaluate_name.as_deref(), Some("myList")); + } + + #[test] + fn variable_without_evaluate_name() { + let var = Variable { + name: "x".to_string(), + value: "42".to_string(), + variables_reference: 0, + evaluate_name: None, + }; + let json = serde_json::to_string(&var).unwrap(); + let deserialized: Variable = serde_json::from_str(&json).unwrap(); + assert!(deserialized.evaluate_name.is_none()); + } + + #[test] + fn breakpoint_verified_with_line() { + let json = r#"{"id": 5, "verified": true, "line": 10}"#; + let bp: Breakpoint = serde_json::from_str(json).unwrap(); + assert_eq!(bp.id, Some(5)); + assert!(bp.verified); + assert_eq!(bp.line, Some(10)); + } + + #[test] + fn breakpoint_unverified_with_message() { + let json = r#"{"verified": false, "message": "Breakpoint not verified"}"#; + let bp: Breakpoint = serde_json::from_str(json).unwrap(); + assert!(!bp.verified); + assert!(bp.id.is_none()); + assert_eq!(bp.message.as_deref(), Some("Breakpoint not verified")); + } + + #[test] + fn source_breakpoint_with_column() { + let sbp = SourceBreakpoint { + line: 10, + column: Some(5), + }; + let json = serde_json::to_string(&sbp).unwrap(); + assert!(json.contains(r#""line":10"#)); + assert!(json.contains(r#""column":5"#)); + } + + #[test] + fn source_breakpoint_without_column() { + let sbp = SourceBreakpoint { + line: 20, + column: None, + }; + let json = serde_json::to_string(&sbp).unwrap(); + assert!(json.contains(r#""line":20"#)); + } + + #[test] + fn set_breakpoints_arguments_roundtrips() { + let args = SetBreakpointsArguments { + source: Source { + name: Some("main.dart".to_string()), + path: Some("/lib/main.dart".to_string()), + }, + breakpoints: vec![ + SourceBreakpoint { line: 10, column: None }, + SourceBreakpoint { line: 20, column: Some(5) }, + ], + }; + let json = serde_json::to_string(&args).unwrap(); + let deserialized: SetBreakpointsArguments = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.breakpoints.len(), 2); + assert_eq!(deserialized.source.path.as_deref(), Some("/lib/main.dart")); + } + + #[test] + fn initialize_request_arguments_roundtrips() { + let args = InitializeRequestArguments { + client_id: "netherize".to_string(), + client_name: "Netherize Editor".to_string(), + adapter_id: "flutter".to_string(), + lines_start_at_1: true, + columns_start_at_1: true, + path_format: "path".to_string(), + }; + let json = serde_json::to_string(&args).unwrap(); + assert!(json.contains(r#""clientID":"netherize""#)); + assert!(json.contains(r#""adapterID":"flutter""#)); + assert!(json.contains(r#""linesStartAt1":true"#)); + let deserialized: InitializeRequestArguments = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.adapter_id, "flutter"); + } + + #[test] + fn stopped_event_body_defaults_thread_id_to_none() { + let json = r#"{"reason": "step"}"#; + let body: StoppedEventBody = serde_json::from_str(json).unwrap(); + assert_eq!(body.reason, "step"); + assert!(body.thread_id.is_none()); + } + + #[test] + fn output_event_body_with_no_category() { + let json = r#"{"output": "raw output"}"#; + let body: OutputEventBody = serde_json::from_str(json).unwrap(); + assert!(body.category.is_none()); + assert_eq!(body.output, "raw output"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 02b24a36..04397179 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ pub mod app; pub mod async_runtime; pub mod config; pub mod core; +pub mod dap; pub mod editor_core; pub mod lsp; pub mod render; diff --git a/src/render/renderer.rs b/src/render/renderer.rs index ec7d798d..7daf9070 100644 --- a/src/render/renderer.rs +++ b/src/render/renderer.rs @@ -134,6 +134,7 @@ pub(super) struct StatusbarLayoutKey { pub(super) python_version: Option, pub(super) node_version: Option, pub(super) go_version: Option, + pub(super) active_device: Option, } #[derive(Debug, Clone, PartialEq)] @@ -184,6 +185,8 @@ pub struct Renderer { pub relative_numbers: bool, pub(super) last_editor_chrome_instances: Vec, pub(super) editor_breadcrumb_segments: Vec, + pub dap_is_active: bool, + pub dap_is_paused: bool, // ── Explorer sidebar ────────────────────────────────────────────────────── pub(super) sidebar_text_system: TextSystem, diff --git a/src/render/renderer/editor.rs b/src/render/renderer/editor.rs index 3e0aa1db..3839c389 100644 --- a/src/render/renderer/editor.rs +++ b/src/render/renderer/editor.rs @@ -209,7 +209,7 @@ pub(super) fn editor_viewport_geometry( let gutter_width = gutter_width_for_editor(gutter_digits, font_size, line_height); let scroll_x = app_state.scroll_column as f32 * (font_size * 0.6).max(1.0); let top_inset = EDITOR_BREADCRUMB_TOP_INSET; - let breadcrumb_height = if renderer.editor_breadcrumb_segments.is_empty() { + let breadcrumb_height = if renderer.editor_breadcrumb_segments.is_empty() && !renderer.dap_is_active { 0.0 } else { line_height + EDITOR_BREADCRUMB_PAD_Y * 2.0 + EDITOR_BREADCRUMB_GAP_Y diff --git a/src/render/renderer/editor/overlays.rs b/src/render/renderer/editor/overlays.rs index ea99a578..2701bb46 100644 --- a/src/render/renderer/editor/overlays.rs +++ b/src/render/renderer/editor/overlays.rs @@ -122,6 +122,56 @@ impl Renderer { x += separator_w; } } + + if self.dap_is_active { + let status_text = if self.dap_is_paused { "⏸ PAUSED " } else { "▶ RUNNING " }; + let status_color = if self.dap_is_paused { + self.theme.ui.warning.as_f32() + } else { + self.theme.ui.success.as_f32() + }; + + let controls_text = " F5 Cont | F6 Over | F7 Into | F8 Out | ⇧F5 Stop "; + let controls_color = self.theme.ui.fg_ghost.as_f32(); + + // Compute widths + let status_w = estimate_monospace_width(status_text, geometry.font_size); + let controls_w = estimate_monospace_width(controls_text, geometry.font_size); + + // Draw background pill for the active status bar + let pill_w = status_w + controls_w; + let pill_x = header_x + header_w - pill_w - self.editor_padding_x; + let pill_y = header_y + ((header_h - geometry.line_height).max(0.0) * 0.5); + + // Draw a sleek dark border/background for the debug control pill + let pill_bg = blend_rgba(header_bg, self.theme.ui.border_color.as_f32(), 0.3, 1.0); + chrome_quads.push( + RegionDrawInstance::new([pill_x - 4.0, pill_y - 2.0, pill_w + 8.0, geometry.line_height + 4.0], pill_bg) + .with_radius(4.0) + ); + + // Draw status + glyphs.extend(layout_panel_text_bold( + status_text, + &mut self.editor_overlay_text_system, + &mut self.atlas, + &self.queue, + pill_x, + pill_y, + status_color, + )); + + // Draw controls + glyphs.extend(layout_panel_text( + controls_text, + &mut self.editor_overlay_text_system, + &mut self.atlas, + &self.queue, + pill_x + status_w, + pill_y, + controls_color, + )); + } } if let Some(diagnostics) = active_diagnostics { diff --git a/src/render/renderer/editor/selections.rs b/src/render/renderer/editor/selections.rs index 19e930a2..5ea248ee 100644 --- a/src/render/renderer/editor/selections.rs +++ b/src/render/renderer/editor/selections.rs @@ -682,6 +682,7 @@ impl Renderer { font_size: f32, gutter_digits: usize, gutter_width: f32, + breakpoint_lines: &[usize], ) { let geometry = editor_viewport_geometry(self, app_state, center_bounds); let gutter_inset_left = self.editor_padding_x + 6.0; @@ -927,12 +928,26 @@ impl Renderer { gutter_text_color }; + if breakpoint_lines.contains(&abs_line) { + let dot_size = (run.line_height * 0.52).clamp(8.0, 12.0); + let dot_x = gutter_x + 3.0; + let dot_y = line_top_y + (run.line_height - dot_size) * 0.5; + let dot_color = self.theme.ui.error.as_f32(); + quads.push( + RegionDrawInstance::new( + [dot_x, dot_y, dot_size, dot_size], + dot_color, + ) + .with_radius(dot_size * 0.5), + ); + } + gutter_glyphs.extend(layout_panel_text( &label, &mut self.gutter_text_system, &mut self.atlas, &self.queue, - gutter_x, + gutter_x + 14.0, line_top_y, color, )); diff --git a/src/render/renderer/editor/viewport.rs b/src/render/renderer/editor/viewport.rs index d3f3f1f2..61382f1b 100644 --- a/src/render/renderer/editor/viewport.rs +++ b/src/render/renderer/editor/viewport.rs @@ -283,6 +283,7 @@ impl Renderer { app_state: &AppState, center_bounds: [f32; 4], spans: &[StyledTextSpan], + breakpoint_lines: &[usize], ) { self.caret_blink_visible = true; // Text/scratch/no-tab surfaces share the center viewport with image buffers. @@ -429,6 +430,7 @@ impl Renderer { .len() .max(3), geometry.gutter_width, + breakpoint_lines, ); } @@ -436,7 +438,7 @@ impl Renderer { /// /// Must honor the same mode → shape mapping as `update_editor_content`, otherwise /// h/j/k/l in Normal mode would collapse the block caret back to a thin bar. - pub fn update_editor_caret(&mut self, app_state: &AppState, center_bounds: [f32; 4]) { + pub fn update_editor_caret(&mut self, app_state: &AppState, center_bounds: [f32; 4], breakpoint_lines: &[usize]) { self.caret_blink_visible = true; self.image_pipeline.clear(); self.image_scissor = None; @@ -516,6 +518,7 @@ impl Renderer { .len() .max(3), geometry.gutter_width, + breakpoint_lines, ); } diff --git a/src/render/renderer/lifecycle.rs b/src/render/renderer/lifecycle.rs index 2651e530..3ee2b36c 100644 --- a/src/render/renderer/lifecycle.rs +++ b/src/render/renderer/lifecycle.rs @@ -211,6 +211,8 @@ impl Renderer { relative_numbers: false, last_editor_chrome_instances: Vec::new(), editor_breadcrumb_segments: Vec::new(), + dap_is_active: false, + dap_is_paused: false, sidebar_text_system, sidebar_text_pipeline, sidebar_glyph_instances: Vec::new(), diff --git a/src/render/renderer/ui/statusbar.rs b/src/render/renderer/ui/statusbar.rs index 4c07defe..46bcb825 100644 --- a/src/render/renderer/ui/statusbar.rs +++ b/src/render/renderer/ui/statusbar.rs @@ -43,6 +43,7 @@ impl Renderer { python_version: Option<&str>, node_version: Option<&str>, go_version: Option<&str>, + active_device: Option<&str>, bounds: [f32; 4], ) -> Vec { if bounds[2] < 1.0 || bounds[3] < 1.0 { @@ -75,6 +76,7 @@ impl Renderer { python_version: python_version.map(str::to_string), node_version: node_version.map(str::to_string), go_version: go_version.map(str::to_string), + active_device: active_device.map(str::to_string), }; if self.last_statusbar_layout_key.as_ref() == Some(&layout_key) { return self.statusbar_chrome_instances.clone(); @@ -177,6 +179,37 @@ impl Renderer { // ══════════════════════════════════════════════════════════════════════════ let mut left_x = pill_x + pill_width + item_gap; + // ── DAP Debug status badge ──────────────────────────────────────────────── + if self.dap_is_active { + let dbg_text = " DEBUG "; + let dbg_w = estimate_monospace_width(dbg_text, font_size); + let badge_bg = if self.dap_is_paused { + warning_fg + } else { + success_fg + }; + let badge_fg = [0.0, 0.0, 0.0, 1.0]; + + chrome.push( + RegionDrawInstance::new( + [left_x, pill_y, dbg_w, pill_height], + badge_bg, + ) + .with_radius(pill_radius), + ); + + glyphs.extend(layout_panel_text_bold( + dbg_text, + &mut self.statusbar_text_system, + &mut self.atlas, + &self.queue, + left_x, + origin_y, + badge_fg, + )); + left_x += dbg_w + item_gap; + } + // ── Git branch ──────────────────────────────────────────────────────────── let branch = git_branch.trim(); if !branch.is_empty() { @@ -299,6 +332,13 @@ impl Renderer { } } + if let Some(device) = active_device { + let trimmed = device.trim(); + if !trimmed.is_empty() { + right_items.push((format!("📱 {trimmed}"), success_fg)); + } + } + let right_text_w: f32 = right_items .iter() .map(|(t, _)| estimate_monospace_width(t, font_size)) diff --git a/src/workbench/debug_state.rs b/src/workbench/debug_state.rs index 214f4ee9..4fbf3661 100644 --- a/src/workbench/debug_state.rs +++ b/src/workbench/debug_state.rs @@ -9,6 +9,7 @@ pub struct Breakpoint { pub id: u64, pub location: SourceLocation, pub enabled: bool, + pub path: std::path::PathBuf, } #[derive(Debug, Clone, PartialEq)] @@ -28,6 +29,7 @@ pub struct WatchEntry { pub struct StackFrame { pub function: String, pub location: SourceLocation, + pub path: std::path::PathBuf, } #[derive(Debug, Clone, PartialEq)] @@ -46,93 +48,23 @@ pub struct DebugSharedState { pub call_stack: Vec, pub inline_values: Vec, pub console_messages: Vec, + pub terminated: bool, next_breakpoint_id: u64, } impl Default for DebugSharedState { fn default() -> Self { Self { - paused: true, - execution_location: Some(SourceLocation { line: 6, column: 8 }), - breakpoints: vec![ - Breakpoint { - id: 1, - location: SourceLocation { line: 3, column: 0 }, - enabled: true, - }, - Breakpoint { - id: 2, - location: SourceLocation { - line: 10, - column: 0, - }, - enabled: true, - }, - ], - variables: vec![ - DebugVariable { - name: "counter".to_string(), - value: "42".to_string(), - children: Vec::new(), - }, - DebugVariable { - name: "user".to_string(), - value: "User".to_string(), - children: vec![ - DebugVariable { - name: "id".to_string(), - value: "u_1024".to_string(), - children: Vec::new(), - }, - DebugVariable { - name: "active".to_string(), - value: "true".to_string(), - children: Vec::new(), - }, - ], - }, - ], - watch: vec![ - WatchEntry { - expression: "counter > max".to_string(), - value: "false".to_string(), - }, - WatchEntry { - expression: "items.len()".to_string(), - value: "128".to_string(), - }, - ], - call_stack: vec![ - StackFrame { - function: "process_event".to_string(), - location: SourceLocation { line: 6, column: 8 }, - }, - StackFrame { - function: "main".to_string(), - location: SourceLocation { - line: 42, - column: 2, - }, - }, - ], - inline_values: vec![ - InlineValue { - location: SourceLocation { - line: 6, - column: 14, - }, - text: "counter=42".to_string(), - }, - InlineValue { - location: SourceLocation { - line: 6, - column: 28, - }, - text: "active=true".to_string(), - }, - ], - console_messages: vec!["paused at breakpoint #1".to_string()], - next_breakpoint_id: 3, + paused: false, + execution_location: None, + breakpoints: Vec::new(), + variables: Vec::new(), + watch: Vec::new(), + call_stack: Vec::new(), + inline_values: Vec::new(), + console_messages: Vec::new(), + terminated: false, + next_breakpoint_id: 1, } } } @@ -171,18 +103,18 @@ impl DebugSharedState { true } - pub fn toggle_breakpoint_on_execution_line(&mut self) -> bool { + pub fn toggle_breakpoint_on_execution_line(&mut self, path: &std::path::Path) -> bool { let Some(loc) = self.execution_location else { return false; }; - self.toggle_breakpoint_at_line(loc.line) + self.toggle_breakpoint_at_line(path, loc.line) } - pub fn toggle_breakpoint_at_line(&mut self, line: usize) -> bool { + pub fn toggle_breakpoint_at_line(&mut self, path: &std::path::Path, line: usize) -> bool { if let Some(index) = self .breakpoints .iter() - .position(|bp| bp.location.line == line) + .position(|bp| bp.path == path && bp.location.line == line) { self.breakpoints.remove(index); self.console_messages @@ -194,25 +126,315 @@ impl DebugSharedState { id: self.next_breakpoint_id, location: SourceLocation { line, column: 0 }, enabled: true, + path: path.to_path_buf(), }); self.next_breakpoint_id += 1; self.console_messages .push(format!("breakpoint added at line {}", line + 1)); true } + + pub fn add_watch_expression(&mut self, expression: String) { + if self.watch.iter().any(|w| w.expression == expression) { + return; + } + self.watch.push(WatchEntry { + expression, + value: String::new(), + }); + } + + pub fn remove_watch_expression(&mut self, index: usize) -> bool { + if index < self.watch.len() { + self.watch.remove(index); + return true; + } + false + } + + pub fn update_watch_values(&mut self, values: Vec<(String, String)>) { + for (expr, value) in values { + if let Some(entry) = self.watch.iter_mut().find(|w| w.expression == expr) { + entry.value = value; + } + } + } } #[cfg(test)] mod tests { - use super::DebugSharedState; + use super::*; #[test] fn toggle_breakpoint_adds_and_removes_on_same_line() { let mut state = DebugSharedState::default(); let initial = state.breakpoints.len(); - state.toggle_breakpoint_at_line(25); + state.toggle_breakpoint_at_line(&std::path::PathBuf::new(), 25); assert_eq!(state.breakpoints.len(), initial + 1); - state.toggle_breakpoint_at_line(25); + state.toggle_breakpoint_at_line(&std::path::PathBuf::new(), 25); assert_eq!(state.breakpoints.len(), initial); } + + #[test] + fn default_state_is_empty() { + let state = DebugSharedState::default(); + assert!(!state.paused); + assert!(state.execution_location.is_none()); + assert!(state.breakpoints.is_empty()); + assert!(state.variables.is_empty()); + assert!(state.watch.is_empty()); + assert!(state.call_stack.is_empty()); + assert!(state.inline_values.is_empty()); + assert!(state.console_messages.is_empty()); + assert!(!state.terminated); + } + + #[test] + fn toggle_breakpoint_tracks_path_correctly() { + let mut state = DebugSharedState::default(); + let path_a = std::path::PathBuf::from("/src/main.dart"); + let path_b = std::path::PathBuf::from("/src/lib.rs"); + + state.toggle_breakpoint_at_line(&path_a, 10); + state.toggle_breakpoint_at_line(&path_b, 10); + assert_eq!(state.breakpoints.len(), 2); + + // Same line, different path -> both stay + state.toggle_breakpoint_at_line(&path_a, 10); + assert_eq!(state.breakpoints.len(), 1); + assert_eq!(state.breakpoints[0].path, path_b); + } + + #[test] + fn toggle_breakpoint_assigns_incrementing_ids() { + let mut state = DebugSharedState::default(); + let path = std::path::PathBuf::from("main.dart"); + + state.toggle_breakpoint_at_line(&path, 5); + state.toggle_breakpoint_at_line(&path, 10); + state.toggle_breakpoint_at_line(&path, 15); + + assert_eq!(state.breakpoints[0].id, 1); + assert_eq!(state.breakpoints[1].id, 2); + assert_eq!(state.breakpoints[2].id, 3); + } + + #[test] + fn toggle_breakpoint_on_execution_line() { + let mut state = DebugSharedState::default(); + state.execution_location = Some(SourceLocation { line: 42, column: 0 }); + let path = std::path::PathBuf::from("main.dart"); + + assert!(state.toggle_breakpoint_on_execution_line(&path)); + assert_eq!(state.breakpoints.len(), 1); + assert_eq!(state.breakpoints[0].location.line, 42); + } + + #[test] + fn toggle_breakpoint_on_execution_line_returns_false_when_no_location() { + let mut state = DebugSharedState::default(); + state.execution_location = None; + assert!(!state.toggle_breakpoint_on_execution_line(&std::path::PathBuf::new())); + } + + #[test] + fn add_watch_expression_adds_new() { + let mut state = DebugSharedState::default(); + state.add_watch_expression("x + 1".to_string()); + assert_eq!(state.watch.len(), 1); + assert_eq!(state.watch[0].expression, "x + 1"); + assert!(state.watch[0].value.is_empty()); + } + + #[test] + fn add_watch_expression_prevents_duplicates() { + let mut state = DebugSharedState::default(); + state.add_watch_expression("x".to_string()); + state.add_watch_expression("x".to_string()); + assert_eq!(state.watch.len(), 1); + } + + #[test] + fn add_watch_expression_allows_different_expressions() { + let mut state = DebugSharedState::default(); + state.add_watch_expression("x".to_string()); + state.add_watch_expression("y".to_string()); + state.add_watch_expression("x + y".to_string()); + assert_eq!(state.watch.len(), 3); + } + + #[test] + fn remove_watch_expression_by_index() { + let mut state = DebugSharedState::default(); + state.add_watch_expression("a".to_string()); + state.add_watch_expression("b".to_string()); + state.add_watch_expression("c".to_string()); + + assert!(state.remove_watch_expression(1)); + assert_eq!(state.watch.len(), 2); + assert_eq!(state.watch[0].expression, "a"); + assert_eq!(state.watch[1].expression, "c"); + } + + #[test] + fn remove_watch_expression_out_of_bounds_returns_false() { + let mut state = DebugSharedState::default(); + state.add_watch_expression("x".to_string()); + assert!(!state.remove_watch_expression(5)); + assert_eq!(state.watch.len(), 1); + } + + #[test] + fn remove_watch_expression_empty_list_returns_false() { + let mut state = DebugSharedState::default(); + assert!(!state.remove_watch_expression(0)); + } + + #[test] + fn update_watch_values_matches_by_expression() { + let mut state = DebugSharedState::default(); + state.add_watch_expression("x".to_string()); + state.add_watch_expression("y".to_string()); + + state.update_watch_values(vec![ + ("x".to_string(), "42".to_string()), + ("y".to_string(), "hello".to_string()), + ]); + + assert_eq!(state.watch[0].value, "42"); + assert_eq!(state.watch[1].value, "hello"); + } + + #[test] + fn update_watch_values_ignores_unknown_expressions() { + let mut state = DebugSharedState::default(); + state.add_watch_expression("x".to_string()); + + state.update_watch_values(vec![ + ("unknown".to_string(), "999".to_string()), + ]); + + assert!(state.watch[0].value.is_empty()); + } + + #[test] + fn update_watch_values_overwrites_existing() { + let mut state = DebugSharedState::default(); + state.add_watch_expression("x".to_string()); + + state.update_watch_values(vec![("x".to_string(), "first".to_string())]); + assert_eq!(state.watch[0].value, "first"); + + state.update_watch_values(vec![("x".to_string(), "second".to_string())]); + assert_eq!(state.watch[0].value, "second"); + } + + #[test] + fn move_execution_down_increments_line() { + let mut state = DebugSharedState::default(); + state.execution_location = Some(SourceLocation { line: 0, column: 0 }); + assert!(state.move_execution_down(10)); + assert_eq!(state.execution_location.unwrap().line, 1); + } + + #[test] + fn move_execution_down_stops_at_max() { + let mut state = DebugSharedState::default(); + state.execution_location = Some(SourceLocation { line: 9, column: 0 }); + assert!(!state.move_execution_down(10)); + assert_eq!(state.execution_location.unwrap().line, 9); + } + + #[test] + fn move_execution_up_decrements_line() { + let mut state = DebugSharedState::default(); + state.execution_location = Some(SourceLocation { line: 5, column: 0 }); + assert!(state.move_execution_up()); + assert_eq!(state.execution_location.unwrap().line, 4); + } + + #[test] + fn move_execution_up_stops_at_zero() { + let mut state = DebugSharedState::default(); + state.execution_location = Some(SourceLocation { line: 0, column: 0 }); + assert!(!state.move_execution_up()); + } + + #[test] + fn move_execution_returns_false_when_no_location() { + let mut state = DebugSharedState::default(); + assert!(!state.move_execution_down(10)); + assert!(!state.move_execution_up()); + } + + #[test] + fn toggle_paused_flips_state() { + let mut state = DebugSharedState::default(); + assert!(!state.paused); + state.toggle_paused(); + assert!(state.paused); + state.toggle_paused(); + assert!(!state.paused); + } + + #[test] + fn toggle_paused_pushes_console_messages() { + let mut state = DebugSharedState::default(); + state.toggle_paused(); + assert!(state.console_messages.iter().any(|m| m.contains("paused"))); + state.toggle_paused(); + assert!(state.console_messages.iter().any(|m| m.contains("resumed"))); + } + + #[test] + fn breakpoint_defaults_are_correct() { + let bp = Breakpoint { + id: 42, + location: SourceLocation { line: 10, column: 5 }, + enabled: true, + path: std::path::PathBuf::from("main.dart"), + }; + assert_eq!(bp.id, 42); + assert!(bp.enabled); + assert_eq!(bp.location.line, 10); + } + + #[test] + fn stack_frame_stores_location_and_path() { + let frame = StackFrame { + function: "main".to_string(), + location: SourceLocation { line: 42, column: 2 }, + path: std::path::PathBuf::from("/lib/main.dart"), + }; + assert_eq!(frame.function, "main"); + assert_eq!(frame.location.line, 42); + assert_eq!(frame.path.to_str(), Some("/lib/main.dart")); + } + + #[test] + fn debug_variable_supports_nested_children() { + let var = DebugVariable { + name: "list".to_string(), + value: "List(3)".to_string(), + children: vec![ + DebugVariable { + name: "[0]".to_string(), + value: "1".to_string(), + children: Vec::new(), + }, + DebugVariable { + name: "[1]".to_string(), + value: "2".to_string(), + children: vec![DebugVariable { + name: "nested".to_string(), + value: "deep".to_string(), + children: Vec::new(), + }], + }, + ], + }; + assert_eq!(var.children.len(), 2); + assert_eq!(var.children[1].children.len(), 1); + assert_eq!(var.children[1].children[0].name, "nested"); + } } diff --git a/src/workbench/inspector_panel.rs b/src/workbench/inspector_panel.rs index a963a06e..6cc2ae87 100644 --- a/src/workbench/inspector_panel.rs +++ b/src/workbench/inspector_panel.rs @@ -254,19 +254,34 @@ impl InspectorPanelState { } let header_gap = 6.0; + let collapsed_h = 26.0_f32; let total_gap = header_gap * (self.sections.len().saturating_sub(1) as f32); - let section_h = - ((sidebar_bounds.height - total_gap) / self.sections.len() as f32).max(20.0); + + let collapsed_count = self.sections.iter().filter(|s| !s.expanded).count(); + let expanded_count = self.sections.len() - collapsed_count; + + let collapsed_total_h = collapsed_count as f32 * collapsed_h; + let remaining_h = (sidebar_bounds.height - total_gap - collapsed_total_h).max(0.0); + + let expanded_h = if expanded_count > 0 { + (remaining_h / expanded_count as f32).max(20.0) + } else { + 0.0 + }; let mut surfaces = Vec::with_capacity(self.sections.len()); - for (index, section) in self.sections.iter().enumerate() { - let y = sidebar_bounds.y + index as f32 * (section_h + header_gap); + let mut current_y = sidebar_bounds.y; + + for section in &self.sections { + let section_h = if section.expanded { expanded_h } else { collapsed_h }; surfaces.push(InspectorSectionSurface { kind: section.kind, - bounds: RegionBounds::new(sidebar_bounds.x, y, sidebar_bounds.width, section_h), + bounds: RegionBounds::new(sidebar_bounds.x, current_y, sidebar_bounds.width, section_h), selected: selected_section.is_some_and(|kind| kind == section.kind), }); + current_y += section_h + header_gap; } + surfaces } @@ -334,9 +349,11 @@ fn node_mut_from_slice<'a>( #[cfg(test)] mod tests { - use crate::workbench::debug_state::DebugSharedState; + use crate::workbench::debug_state::{ + DebugSharedState, DebugVariable, SourceLocation, StackFrame, WatchEntry, + }; - use super::InspectorPanelState; + use super::*; #[test] fn inspector_rows_support_navigation_and_toggle() { @@ -348,4 +365,309 @@ mod tests { assert!(state.move_selection_next()); assert!(state.selected_row_label().is_some()); } + + #[test] + fn sync_populates_variables_section() { + let mut debug = DebugSharedState::default(); + debug.variables = vec![ + DebugVariable { + name: "x".to_string(), + value: "42".to_string(), + children: Vec::new(), + }, + DebugVariable { + name: "y".to_string(), + value: "hello".to_string(), + children: vec![DebugVariable { + name: "child".to_string(), + value: "nested".to_string(), + children: Vec::new(), + }], + }, + ]; + + let mut state = InspectorPanelState::default(); + state.sync_from_debug_state(&debug); + + let var_section = &state.sections[0]; + assert_eq!(var_section.nodes.len(), 2); + assert_eq!(var_section.nodes[0].label, "x = 42"); + assert!(var_section.nodes[0].children.is_empty()); + assert_eq!(var_section.nodes[1].label, "y = hello"); + assert_eq!(var_section.nodes[1].children.len(), 1); + assert!(var_section.nodes[1].expanded); // branch nodes default to expanded + } + + #[test] + fn sync_populates_watch_section() { + let mut debug = DebugSharedState::default(); + debug.watch = vec![ + WatchEntry { + expression: "x + 1".to_string(), + value: "43".to_string(), + }, + WatchEntry { + expression: "items.len()".to_string(), + value: "5".to_string(), + }, + ]; + + let mut state = InspectorPanelState::default(); + state.sync_from_debug_state(&debug); + + let watch_section = &state.sections[1]; + assert_eq!(watch_section.nodes.len(), 2); + assert_eq!(watch_section.nodes[0].label, "x + 1 = 43"); + assert_eq!(watch_section.nodes[1].label, "items.len() = 5"); + } + + #[test] + fn sync_populates_call_stack_section() { + let mut debug = DebugSharedState::default(); + debug.call_stack = vec![ + StackFrame { + function: "main".to_string(), + location: SourceLocation { line: 0, column: 0 }, + path: std::path::PathBuf::from("main.dart"), + }, + StackFrame { + function: "build".to_string(), + location: SourceLocation { line: 41, column: 8 }, + path: std::path::PathBuf::from("widget.dart"), + }, + ]; + + let mut state = InspectorPanelState::default(); + state.sync_from_debug_state(&debug); + + let stack_section = &state.sections[2]; + assert_eq!(stack_section.nodes.len(), 2); + assert!(stack_section.nodes[0].label.contains("main")); + assert!(stack_section.nodes[0].label.contains("1:1")); // 0-indexed + 1 + assert!(stack_section.nodes[1].label.contains("build")); + assert!(stack_section.nodes[1].label.contains("42:9")); // line+1, col+1 + } + + #[test] + fn sync_populates_breakpoints_section() { + let mut debug = DebugSharedState::default(); + debug.toggle_breakpoint_at_line(&std::path::PathBuf::from("main.dart"), 5); + debug.toggle_breakpoint_at_line(&std::path::PathBuf::from("main.dart"), 10); + + let mut state = InspectorPanelState::default(); + state.sync_from_debug_state(&debug); + + let bp_section = &state.sections[3]; + assert_eq!(bp_section.nodes.len(), 2); + assert!(bp_section.nodes[0].label.contains("line 6")); // 0-indexed + 1 + assert!(bp_section.nodes[1].label.contains("line 11")); + } + + #[test] + fn sync_clamps_selected_row_when_shrinking() { + let mut debug = DebugSharedState::default(); + debug.variables = vec![ + DebugVariable { + name: "a".to_string(), + value: "1".to_string(), + children: Vec::new(), + }, + DebugVariable { + name: "b".to_string(), + value: "2".to_string(), + children: Vec::new(), + }, + ]; + + let mut state = InspectorPanelState::default(); + state.sync_from_debug_state(&debug); + state.selected_row = 100; // Way out of bounds + + // Sync again with empty data + state.sync_from_debug_state(&DebugSharedState::default()); + let rows = state.visible_rows(); + assert!(state.selected_row < rows.len()); + } + + #[test] + fn visible_rows_include_section_headers_and_items() { + let mut debug = DebugSharedState::default(); + debug.variables = vec![DebugVariable { + name: "x".to_string(), + value: "1".to_string(), + children: Vec::new(), + }]; + + let mut state = InspectorPanelState::default(); + state.sync_from_debug_state(&debug); + + let rows = state.visible_rows(); + // At minimum: 4 section headers + 1 variable + assert!(rows.len() >= 5); + + // First row should be Variables header + assert_eq!(rows[0].label, "Variables"); + assert!(rows[0].node_path.is_none()); // Section header + assert_eq!(rows[0].depth, 0); + + // Second row should be the variable + assert_eq!(rows[1].label, "x = 1"); + assert!(rows[1].node_path.is_some()); + assert_eq!(rows[1].depth, 1); + } + + #[test] + fn collapsed_section_hides_children() { + let mut debug = DebugSharedState::default(); + debug.variables = vec![DebugVariable { + name: "x".to_string(), + value: "1".to_string(), + children: Vec::new(), + }]; + + let mut state = InspectorPanelState::default(); + state.sync_from_debug_state(&debug); + + let rows_expanded = state.visible_rows(); + let expanded_count = rows_expanded.len(); + + // Collapse the Variables section + state.sections[0].expanded = false; + let rows_collapsed = state.visible_rows(); + assert!(rows_collapsed.len() < expanded_count); + } + + #[test] + fn toggle_expand_on_section_header() { + let mut state = InspectorPanelState::default(); + state.sync_from_debug_state(&DebugSharedState::default()); + + let was_expanded = state.sections[0].expanded; + state.selected_row = 0; // First row is Variables header + assert!(state.toggle_selected_expand()); + assert_eq!(state.sections[0].expanded, !was_expanded); + } + + #[test] + fn toggle_expand_on_node_with_children() { + let mut debug = DebugSharedState::default(); + debug.variables = vec![DebugVariable { + name: "list".to_string(), + value: "List(2)".to_string(), + children: vec![ + DebugVariable { + name: "[0]".to_string(), + value: "a".to_string(), + children: Vec::new(), + }, + DebugVariable { + name: "[1]".to_string(), + value: "b".to_string(), + children: Vec::new(), + }, + ], + }]; + + let mut state = InspectorPanelState::default(); + state.sync_from_debug_state(&debug); + + // Find the row for "list = List(2)" + let rows = state.visible_rows(); + let list_row_idx = rows.iter().position(|r| r.label.contains("list")).unwrap(); + state.selected_row = list_row_idx; + + // Collapse it + assert!(state.toggle_selected_expand()); + let rows_after = state.visible_rows(); + // Should have fewer rows now (the children are hidden) + assert!(rows_after.len() < rows.len()); + } + + #[test] + fn toggle_expand_on_leaf_node_returns_false() { + let mut debug = DebugSharedState::default(); + debug.variables = vec![DebugVariable { + name: "x".to_string(), + value: "42".to_string(), + children: Vec::new(), + }]; + + let mut state = InspectorPanelState::default(); + state.sync_from_debug_state(&debug); + + // Find the leaf node row + let rows = state.visible_rows(); + let leaf_idx = rows.iter().position(|r| r.label.contains("x = 42")).unwrap(); + state.selected_row = leaf_idx; + + // Toggle on leaf should return false (no children) + assert!(!state.toggle_selected_expand()); + } + + #[test] + fn section_surfaces_compute_dynamic_height() { + let mut state = InspectorPanelState::default(); + state.sync_from_debug_state(&DebugSharedState::default()); + + let bounds = RegionBounds::new(0.0, 0.0, 300.0, 800.0); + let surfaces = state.section_surfaces(bounds, None); + + assert_eq!(surfaces.len(), 4); + // All sections are expanded by default, so each should get ~1/4 of the height + for surface in &surfaces { + assert!(surface.bounds.height > 0.0); + } + } + + #[test] + fn section_surfaces_collapsed_gets_fixed_height() { + let mut state = InspectorPanelState::default(); + state.sections[0].expanded = false; // Collapse Variables + + let bounds = RegionBounds::new(0.0, 0.0, 300.0, 800.0); + let surfaces = state.section_surfaces(bounds, None); + + // Collapsed section should have height ~26.0 + assert!((surfaces[0].bounds.height - 26.0).abs() < 1.0); + } + + #[test] + fn move_selection_wraps_correctly() { + let mut state = InspectorPanelState::default(); + state.sync_from_debug_state(&DebugSharedState::default()); + + let rows = state.visible_rows(); + let total = rows.len(); + + // Move to end + for _ in 0..total + 5 { + state.move_selection_next(); + } + assert_eq!(state.selected_row, total - 1); + + // Move back to start + for _ in 0..total + 5 { + state.move_selection_prev(); + } + assert_eq!(state.selected_row, 0); + } + + #[test] + fn selected_row_label_returns_current_row_text() { + let mut debug = DebugSharedState::default(); + debug.variables = vec![DebugVariable { + name: "test_var".to_string(), + value: "999".to_string(), + children: Vec::new(), + }]; + + let mut state = InspectorPanelState::default(); + state.sync_from_debug_state(&debug); + + // Navigate to the variable row (skip Variables header) + state.move_selection_next(); + let label = state.selected_row_label(); + assert!(label.is_some()); + assert!(label.unwrap().contains("test_var")); + } } diff --git a/src/workbench/overlay_manager.rs b/src/workbench/overlay_manager.rs index e1f9fff9..3dc76409 100644 --- a/src/workbench/overlay_manager.rs +++ b/src/workbench/overlay_manager.rs @@ -197,7 +197,8 @@ fn clamp_to_bounds(rect: RegionBounds, bounds: RegionBounds) -> RegionBounds { #[cfg(test)] mod tests { use crate::workbench::{ - debug_state::DebugSharedState, region_model::RegionBounds, + debug_state::{DebugSharedState, InlineValue, SourceLocation}, + region_model::RegionBounds, text_coordinate_map::TextCoordinateMapper, }; @@ -208,7 +209,14 @@ mod tests { let mut manager = OverlayManager::default(); manager.command_palette_open = true; manager.command_palette_query = "src/main".to_string(); - let debug_state = DebugSharedState::default(); + let mut debug_state = DebugSharedState::default(); + debug_state.execution_location = Some(SourceLocation { line: 2, column: 4 }); + debug_state.paused = true; + debug_state.inline_values = vec![InlineValue { + location: SourceLocation { line: 2, column: 10 }, + text: "x = 42".to_string(), + }]; + debug_state.toggle_breakpoint_at_line(&std::path::PathBuf::from("main.dart"), 0); let mapper = TextCoordinateMapper::from_text( "a\nb\nc\nd\ne\nf\ng\nh", RegionBounds::new(40.0, 120.0, 700.0, 420.0), diff --git a/src/workbench/panel_state.rs b/src/workbench/panel_state.rs index efdb4814..a576492a 100644 --- a/src/workbench/panel_state.rs +++ b/src/workbench/panel_state.rs @@ -16,7 +16,7 @@ impl PanelTabId { match self { Self::Explorer => "Explorer", Self::Search => "Search", - Self::Inspector => "Inspector", + Self::Inspector => "DAP", Self::Outline => "Outline", Self::Terminal => "Terminal", Self::DebugConsole => "Debug Console", @@ -188,15 +188,15 @@ pub struct WorkbenchPanelState { impl Default for WorkbenchPanelState { fn default() -> Self { Self { - left: PanelState::new(true, 240.0, vec![PanelTabId::Explorer, PanelTabId::Search]), + left: PanelState::new(true, 240.0, vec![PanelTabId::Explorer, PanelTabId::Inspector]), right: PanelState::new( false, 650.0, vec![ PanelTabId::Terminal, + PanelTabId::Search, PanelTabId::AiChat, PanelTabId::MarkdownPreview, - PanelTabId::Inspector, PanelTabId::Outline, ], ), @@ -206,7 +206,6 @@ impl Default for WorkbenchPanelState { vec![ PanelTabId::Terminal, PanelTabId::DebugConsole, - PanelTabId::Problems, ], ), overlay_visible: false, diff --git a/test_fvm_detection.sh b/test_fvm_detection.sh new file mode 100755 index 00000000..6267ab3b --- /dev/null +++ b/test_fvm_detection.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# Test FVM detection logic + +echo "=== Testing FVM Detection ===" +echo "" + +# Test workspace with local FVM +TEST_WORKSPACE="/Users/qc-bright/Project/mine_wallet" +echo "Test workspace: $TEST_WORKSPACE" +echo "" + +# Check if .fvm exists +if [ -d "$TEST_WORKSPACE/.fvm" ]; then + echo "✓ .fvm directory exists" +else + echo "✗ .fvm directory not found" + exit 1 +fi + +# Check if pubspec.yaml exists +if [ -f "$TEST_WORKSPACE/pubspec.yaml" ]; then + echo "✓ pubspec.yaml exists" +else + echo "✗ pubspec.yaml not found" + exit 1 +fi + +# Check if local FVM dart binary exists +LOCAL_DART="$TEST_WORKSPACE/.fvm/flutter_sdk/bin/cache/dart-sdk/bin/dart" +if [ -f "$LOCAL_DART" ]; then + echo "✓ Local FVM dart binary exists: $LOCAL_DART" + echo " Version: $($LOCAL_DART --version 2>&1 | head -1)" +else + echo "✗ Local FVM dart binary not found" + exit 1 +fi + +echo "" +echo "=== FVM Detection Test Passed ===" +echo "" +echo "Expected behavior:" +echo " When opening a .dart file in $TEST_WORKSPACE," +echo " the LSP server should use: $LOCAL_DART" +echo " instead of system dart" diff --git a/tests/lsp_fvm_detection.rs b/tests/lsp_fvm_detection.rs index 5997cc25..55c47eac 100644 --- a/tests/lsp_fvm_detection.rs +++ b/tests/lsp_fvm_detection.rs @@ -41,4 +41,35 @@ mod lsp_fvm_detection_tests { "Non-Dart workspace should not have pubspec.yaml" ); } + + #[tokio::test] + async fn test_real_dap_session_with_flutter() { + let flutter_bin = "/Users/qc-bright/Project/mine_wallet/.fvm/flutter_sdk/bin/flutter"; + if !std::path::Path::new(flutter_bin).exists() { + eprintln!("FVM flutter not found, skipping real DAP integration test"); + return; + } + + let (event_tx, _event_rx) = tokio::sync::mpsc::unbounded_channel(); + let client = netherize_editor::dap::DapClient::launch( + flutter_bin, + &["debug_adapter".to_string()], + Some(PathBuf::from("/Users/qc-bright/Project/mine_wallet")), + event_tx, + ).expect("launch flutter debug adapter"); + + // Send initialize request + let init_args = serde_json::json!({ + "adapterID": "flutter", + "clientID": "netherize_editor_test", + "linesStartAt1": true, + "columnsStartAt1": true, + "pathFormat": "path" + }); + + let init_resp = client.send_request("initialize", Some(init_args)).await + .expect("send initialize request"); + assert!(init_resp.success, "initialize request should be successful"); + assert!(init_resp.body.is_some(), "initialize response should have capabilities"); + } } From 7fbbd8efd641efbf8842cfd8bd710f0698a53366 Mon Sep 17 00:00:00 2001 From: Min Date: Fri, 12 Jun 2026 23:32:00 +0700 Subject: [PATCH 2/3] merge main into feature --- .agents/skills/generated/app-state/SKILL.md | 98 + .agents/skills/generated/app/SKILL.md | 98 + .agents/skills/generated/benches/SKILL.md | 92 + .../generated/command-dispatch/SKILL.md | 96 + .agents/skills/generated/config/SKILL.md | 93 + .agents/skills/generated/editor/SKILL.md | 91 + .agents/skills/generated/event-loop/SKILL.md | 98 + .agents/skills/generated/input-map/SKILL.md | 93 + .agents/skills/generated/input/SKILL.md | 86 + .agents/skills/generated/lsp/SKILL.md | 95 + .agents/skills/generated/palette/SKILL.md | 96 + .agents/skills/generated/render/SKILL.md | 68 + .agents/skills/generated/renderer/SKILL.md | 98 + .agents/skills/generated/scheduler/SKILL.md | 98 + .agents/skills/generated/syntax/SKILL.md | 91 + .agents/skills/generated/terminal/SKILL.md | 98 + .agents/skills/generated/text/SKILL.md | 97 + .../skills/generated/theme-config/SKILL.md | 94 + .agents/skills/generated/workbench/SKILL.md | 91 + .agents/skills/generated/workspace/SKILL.md | 86 + .agents/skills/gitnexus/gitnexus-cli/SKILL.md | 83 + .../gitnexus/gitnexus-debugging/SKILL.md | 89 + .../gitnexus/gitnexus-exploring/SKILL.md | 78 + .../skills/gitnexus/gitnexus-guide/SKILL.md | 64 + .../gitnexus-impact-analysis/SKILL.md | 97 + .../gitnexus/gitnexus-refactoring/SKILL.md | 121 + .codex/config.toml | 7 + .codex/hooks.json | 71 + .wolf/anatomy.md | 257 +- .wolf/buglog.json | 1430 +- .wolf/cerebrum.md | 61 + .wolf/cron-state.json | 2 +- .wolf/daemon.log | 15 +- .wolf/hooks/_session.json | 738 +- .wolf/memory.md | 622 + .wolf/token-ledger.json | 11586 +++++++++++++++- AGENTS.md | 2 +- BUILD.md | 8 +- CLAUDE.md | 2 +- Cargo.lock | 10 + Cargo.toml | 1 + config/ai.toml | 6 +- config/keymaps/default.toml | 54 +- config/ui/default.toml | 8 +- scripts/bundle_linux.sh | 2 +- scripts/bundle_macos.sh | 2 +- src/app/app_state/buffers.rs | 102 +- src/app/app_state/editor.rs | 35 + src/app/app_state/mod.rs | 289 +- src/app/app_state/overlays.rs | 17 +- src/app/app_state/palette.rs | 338 +- src/app/app_state/settings.rs | 65 +- src/app/app_state/state.rs | 151 +- src/app/app_state/tests.rs | 257 +- src/app/app_state/workspace.rs | 6 +- src/app/event_loop/application.rs | 593 +- src/app/event_loop/async_results/ai.rs | 200 +- src/app/event_loop/async_results/failure.rs | 30 + .../event_loop/async_results/filesystem.rs | 90 +- src/app/event_loop/async_results/lsp.rs | 7 + src/app/event_loop/async_results/mod.rs | 50 + src/app/event_loop/async_results/system.rs | 30 +- src/app/event_loop/async_results/terminal.rs | 11 +- src/app/event_loop/commands.rs | 78 +- src/app/event_loop/commands_ai_chat.rs | 20 +- src/app/event_loop/commands_completion.rs | 85 +- src/app/event_loop/commands_dap.rs | 385 +- src/app/event_loop/commands_editor.rs | 50 +- src/app/event_loop/commands_explorer.rs | 88 +- src/app/event_loop/commands_lsp.rs | 26 +- src/app/event_loop/commands_palette.rs | 26 +- src/app/event_loop/commands_prompts.rs | 58 +- src/app/event_loop/commands_settings.rs | 158 +- .../event_loop/commands_settings_helpers.rs | 54 +- src/app/event_loop/commands_terminal.rs | 58 +- src/app/event_loop/commands_tests.rs | 929 +- src/app/event_loop/helpers.rs | 93 +- src/app/event_loop/mod.rs | 34 +- src/app/event_loop/setup.rs | 259 +- src/app/input/handler.rs | 204 +- src/app/input/helpers.rs | 6 +- src/app/input/pending.rs | 11 + src/app/input/tests.rs | 275 +- src/app/input_map/focus.rs | 33 +- src/app/input_map/mod.rs | 68 +- src/app/input_map/tests.rs | 29 + src/app/match_ranges.rs | 4 +- src/app/persistence.rs | 12 +- src/app/resolved_keymap.rs | 242 +- src/async_runtime/dart_env.rs | 5 +- src/async_runtime/flutter_device.rs | 230 +- src/async_runtime/message.rs | 39 +- src/async_runtime/mod.rs | 4 +- src/async_runtime/scheduler.rs | 1 + src/async_runtime/scheduler/ai.rs | 9 +- src/async_runtime/scheduler/dispatch.rs | 92 +- src/async_runtime/scheduler/file_watch.rs | 180 +- src/async_runtime/scheduler/lsp_parse.rs | 10 +- src/async_runtime/scheduler/syntax_jobs.rs | 78 +- src/async_runtime/scheduler/tests.rs | 16 +- src/config/ai_config.rs | 6 + src/config/keymap_loader.rs | 18 + src/config/theme_config/loader.rs | 149 +- src/config/theme_config/model.rs | 130 +- src/config/ui_config.rs | 32 +- src/core/command_dispatch/common.rs | 27 +- src/core/command_dispatch/editing.rs | 29 +- src/core/command_dispatch/mod.rs | 3 + src/core/command_dispatch/navigation.rs | 42 + src/core/command_dispatch/palette.rs | 27 +- src/core/command_dispatch/session.rs | 2 + src/core/command_dispatch/tests.rs | 148 + src/core/command_ids.rs | 25 +- src/core/commands.rs | 10 + src/dap/client.rs | 53 +- src/dap/launch_config.rs | 8 +- src/dap/mod.rs | 6 +- src/dap/session.rs | 96 +- src/dap/types.rs | 15 +- src/lib.rs | 2 +- src/lsp/client.rs | 102 +- src/lsp/registry.rs | 12 +- src/lsp/symbol_cache.rs | 54 +- src/render/icon_pipeline.rs | 861 +- src/render/renderer.rs | 45 +- src/render/renderer/components.rs | 10 +- .../renderer/components/help_keycaps.rs | 11 +- .../renderer/components/prefix_icon_badge.rs | 11 +- src/render/renderer/editor.rs | 22 +- src/render/renderer/editor/buffers.rs | 147 +- .../renderer/editor/buffers/diagnostics.rs | 72 +- src/render/renderer/editor/extensions.rs | 467 +- src/render/renderer/editor/fuzzy.rs | 255 +- src/render/renderer/editor/help.rs | 95 +- src/render/renderer/editor/overlays.rs | 233 +- src/render/renderer/editor/selections.rs | 34 +- src/render/renderer/editor/settings.rs | 542 +- src/render/renderer/editor/viewport.rs | 53 +- src/render/renderer/helpers.rs | 7 +- src/render/renderer/lifecycle.rs | 22 +- src/render/renderer/lifecycle/frame.rs | 59 +- src/render/renderer/palette.rs | 27 +- src/render/renderer/palette/file_picker.rs | 23 +- .../renderer/palette/highlighted_label.rs | 4 +- src/render/renderer/palette/leap.rs | 14 +- src/render/renderer/palette/live_grep.rs | 19 +- src/render/renderer/palette/minimal.rs | 32 +- .../renderer/palette/recent_projects.rs | 50 +- src/render/renderer/ui.rs | 1 + src/render/renderer/ui/ai_chat.rs | 64 +- src/render/renderer/ui/sidebar.rs | 17 +- src/render/renderer/ui/statusbar.rs | 73 +- src/render/renderer/ui/terminal.rs | 20 +- src/render/renderer/ui/topbar.rs | 16 +- src/render/renderer/ui/welcome.rs | 288 +- src/render/renderer/ui/whichkey.rs | 195 + src/syntax/highlight/categories.rs | 78 +- src/syntax/highlight/engine.rs | 55 +- src/syntax/highlight/mod.rs | 55 +- src/syntax/highlight/normalize_tests.rs | 12 +- src/terminal/ansi_parser.rs | 8 +- src/terminal/cell_shapes.rs | 317 + src/terminal/grid.rs | 387 +- src/terminal/mod.rs | 1 + src/terminal/terminal_renderer.rs | 43 + src/text/atlas.rs | 31 + src/text/layout_sync.rs | 6 +- src/text/text_system.rs | 3 +- src/workbench/debug_state.rs | 19 +- src/workbench/inspector_panel.rs | 23 +- src/workbench/overlay_manager.rs | 5 +- src/workbench/panel_state.rs | 11 +- src/workspace/model.rs | 17 + src/workspace/scanner.rs | 27 +- tests/lsp_fvm_detection.rs | 12 +- tests/vietnamese_terminal_render.rs | 211 + 176 files changed, 26584 insertions(+), 3015 deletions(-) create mode 100644 .agents/skills/generated/app-state/SKILL.md create mode 100644 .agents/skills/generated/app/SKILL.md create mode 100644 .agents/skills/generated/benches/SKILL.md create mode 100644 .agents/skills/generated/command-dispatch/SKILL.md create mode 100644 .agents/skills/generated/config/SKILL.md create mode 100644 .agents/skills/generated/editor/SKILL.md create mode 100644 .agents/skills/generated/event-loop/SKILL.md create mode 100644 .agents/skills/generated/input-map/SKILL.md create mode 100644 .agents/skills/generated/input/SKILL.md create mode 100644 .agents/skills/generated/lsp/SKILL.md create mode 100644 .agents/skills/generated/palette/SKILL.md create mode 100644 .agents/skills/generated/render/SKILL.md create mode 100644 .agents/skills/generated/renderer/SKILL.md create mode 100644 .agents/skills/generated/scheduler/SKILL.md create mode 100644 .agents/skills/generated/syntax/SKILL.md create mode 100644 .agents/skills/generated/terminal/SKILL.md create mode 100644 .agents/skills/generated/text/SKILL.md create mode 100644 .agents/skills/generated/theme-config/SKILL.md create mode 100644 .agents/skills/generated/workbench/SKILL.md create mode 100644 .agents/skills/generated/workspace/SKILL.md create mode 100644 .agents/skills/gitnexus/gitnexus-cli/SKILL.md create mode 100644 .agents/skills/gitnexus/gitnexus-debugging/SKILL.md create mode 100644 .agents/skills/gitnexus/gitnexus-exploring/SKILL.md create mode 100644 .agents/skills/gitnexus/gitnexus-guide/SKILL.md create mode 100644 .agents/skills/gitnexus/gitnexus-impact-analysis/SKILL.md create mode 100644 .agents/skills/gitnexus/gitnexus-refactoring/SKILL.md create mode 100644 .codex/config.toml create mode 100644 .codex/hooks.json create mode 100644 src/render/renderer/ui/whichkey.rs create mode 100644 src/terminal/cell_shapes.rs create mode 100644 tests/vietnamese_terminal_render.rs diff --git a/.agents/skills/generated/app-state/SKILL.md b/.agents/skills/generated/app-state/SKILL.md new file mode 100644 index 00000000..716e69b6 --- /dev/null +++ b/.agents/skills/generated/app-state/SKILL.md @@ -0,0 +1,98 @@ +--- +name: app-state +description: "Skill for the App_state area of netherize_editor. 391 symbols across 33 files." +--- + +# App_state + +391 symbols | 33 files | Cohesion: 71% + +## When to Use + +- Working with code in `src/` +- Understanding how delete_current_line, find_text_object_range, len_chars work +- Modifying app_state-related functionality + +## Key Files + +| File | Symbols | +|------|---------| +| `src/app/app_state/overlays.rs` | len_chars, len_lines, preview, cursor_state, restore_cursor_state (+60) | +| `src/app/app_state/tests.rs` | text_object_delete_removes_inner, text_object_select_enters_visual_mode, unique_temp_path, text_edits_record_highlight_byte_deltas, backspace_between_empty_auto_pair_deletes_both_chars (+56) | +| `src/app/app_state/state.rs` | clipboard_record_kind_for_text, completion_prefix_info_at, search_highlights, byte_to_line_idx, line_start_byte_idx (+45) | +| `src/app/app_state/palette.rs` | active_buffer_is_fuzzy_picker, open_terminal_buffer, diagnostics_for_path, open_pending_references_buffer, command_palette_append_query (+37) | +| `src/app/app_state/editor.rs` | insert_tab, step_over_closing_char, insert_auto_pair, smart_insert_newline, backspace (+23) | +| `src/app/app_state/multi_cursor.rs` | multi_cursor_selection_ranges, char_range_to_vsr, virtual_cursors, multi_cursor_select_all_visual, multi_cursor_add_next (+18) | +| `src/app/app_state/buffers.rs` | text_object_text, delete_text_object, clear_visual_selection, delete_char_text_at_cursor, substitute_current_line_text (+17) | +| `src/app/app_state/mod.rs` | from_bindings, build_help_sections, build_help_lines, command_label_for_help, append_help_binding (+9) | +| `src/render/renderer/editor/selections.rs` | leading_indent_columns, indent_guide_quads, current_line_highlight_quad, visual_selection_quads, multi_cursor_selection_quads (+3) | +| `src/core/mode.rs` | default, new, apply_with_side_effects, normal_to_insert_is_allowed, visual_to_normal_by_escape_is_allowed (+3) | + +## Entry Points + +Start here when exploring this area: + +- **`delete_current_line`** (Function) — `src/editor_core.rs:449` +- **`find_text_object_range`** (Function) — `src/core/text_object.rs:3` +- **`len_chars`** (Function) — `src/app/app_state/overlays.rs:94` +- **`len_lines`** (Function) — `src/app/app_state/overlays.rs:98` +- **`preview`** (Function) — `src/app/app_state/overlays.rs:146` + +## Key Symbols + +| Symbol | Type | File | Line | +|--------|------|------|------| +| `delete_current_line` | Function | `src/editor_core.rs` | 449 | +| `find_text_object_range` | Function | `src/core/text_object.rs` | 3 | +| `len_chars` | Function | `src/app/app_state/overlays.rs` | 94 | +| `len_lines` | Function | `src/app/app_state/overlays.rs` | 98 | +| `preview` | Function | `src/app/app_state/overlays.rs` | 146 | +| `cursor_state` | Function | `src/app/app_state/overlays.rs` | 209 | +| `restore_cursor_state` | Function | `src/app/app_state/overlays.rs` | 216 | +| `snapshot_editor_view` | Function | `src/app/app_state/overlays.rs` | 224 | +| `restore_editor_view` | Function | `src/app/app_state/overlays.rs` | 237 | +| `ensure_current_transaction` | Function | `src/app/app_state/overlays.rs` | 249 | +| `apply_insert` | Function | `src/app/app_state/overlays.rs` | 258 | +| `apply_delete` | Function | `src/app/app_state/overlays.rs` | 270 | +| `apply_insert_raw` | Function | `src/app/app_state/overlays.rs` | 284 | +| `apply_delete_raw` | Function | `src/app/app_state/overlays.rs` | 294 | +| `char_range_text` | Function | `src/app/app_state/overlays.rs` | 310 | +| `linewise_text_for_range` | Function | `src/app/app_state/overlays.rs` | 323 | +| `delete_char_range_at_cursor` | Function | `src/app/app_state/overlays.rs` | 331 | +| `current_line_delete_range` | Function | `src/app/app_state/overlays.rs` | 360 | +| `delete_word_forward_range` | Function | `src/app/app_state/overlays.rs` | 384 | +| `yank_word_end_range` | Function | `src/app/app_state/overlays.rs` | 403 | + +## Execution Flows + +| Flow | Type | Steps | +|------|------|-------| +| `Save_file_preserves_cursor_and_selection_state → Find_node` | cross_community | 9 | +| `Self_save_modify_event_is_ignored_without_reloading_cursor → Find_node` | cross_community | 9 | +| `External_reload_clamps_cursor_and_selection_to_new_buffer_length → Find_node` | cross_community | 9 | +| `Update_markdown_preview_content → Normalize_modifier_alias` | cross_community | 8 | +| `Update_markdown_preview_content → New` | cross_community | 8 | +| `External_reload_error_does_not_abort_workspace_updates → Is_hidden_name` | cross_community | 8 | +| `File_picker_results_refresh_while_overlay_is_open_after_external_create → Is_hidden_name` | cross_community | 8 | +| `External_modify_reloads_when_clean_and_warns_when_dirty → Is_hidden_name` | cross_community | 8 | +| `Modify_event_on_missing_path_triggers_workspace_rescan_for_rename_like_flow → Is_hidden_name` | cross_community | 8 | +| `Workspace_and_file_picker_state_are_tracked → Is_hidden_name` | cross_community | 8 | + +## Connected Areas + +| Area | Connections | +|------|-------------| +| Command_dispatch | 61 calls | +| Event_loop | 16 calls | +| Benches | 14 calls | +| Workbench | 9 calls | +| Text | 7 calls | +| App | 4 calls | +| Palette | 3 calls | +| Renderer | 3 calls | + +## How to Explore + +1. `gitnexus_context({name: "delete_current_line"})` — see callers and callees +2. `gitnexus_query({query: "app_state"})` — find related execution flows +3. Read key files listed above for implementation details diff --git a/.agents/skills/generated/app/SKILL.md b/.agents/skills/generated/app/SKILL.md new file mode 100644 index 00000000..29a99c20 --- /dev/null +++ b/.agents/skills/generated/app/SKILL.md @@ -0,0 +1,98 @@ +--- +name: app +description: "Skill for the App area of netherize_editor. 108 symbols across 15 files." +--- + +# App + +108 symbols | 15 files | Cohesion: 76% + +## When to Use + +- Working with code in `src/` +- Understanding how parse_key_sequence, parse_key_spec, apply_overrides work +- Modifying app-related functionality + +## Key Files + +| File | Symbols | +|------|---------| +| `src/app/command_palette.rs` | command, open, append_query, backspace_query, selected_action (+33) | +| `src/app/resolved_keymap.rs` | parse_key_sequence, parse_key_spec, new, insert_sequence, apply_overrides (+16) | +| `src/app/file_picker.rs` | default, open, append_query, backspace_query, select_next (+6) | +| `src/app/persistence.rs` | legacy_state_dir, state_path, legacy_state_path, load_from_path, load (+3) | +| `src/app/match_ranges.rs` | compute_label_match_ranges, score_label_match, build_lowercase_byte_map, map_lower_range_to_original, push_match_range (+2) | +| `src/app/async_bridge.rs` | new, bridge_counts_failed_event_in_summary, lsp_diagnostics_bypass_stale_revision_filter, bridge_tracks_multiple_worker_failure_events, pump (+2) | +| `src/app/clipboard.rs` | new, ensure_initialized, clipboard_mut, get_text, set_text | +| `src/app/event_loop/setup.rs` | new, new_with_scheduler, pump_bridge | +| `src/app/event_loop/commands_prompts.rs` | confirm_explorer_prompt, resolve_explorer_creation_target | +| `src/app/event_loop/async_results.rs` | read_file_preview | + +## Entry Points + +Start here when exploring this area: + +- **`parse_key_sequence`** (Function) — `src/app/resolved_keymap.rs:143` +- **`parse_key_spec`** (Function) — `src/app/resolved_keymap.rs:154` +- **`apply_overrides`** (Function) — `src/app/resolved_keymap.rs:366` +- **`from_bindings`** (Function) — `src/app/resolved_keymap.rs:464` +- **`builtin_defaults`** (Function) — `src/app/resolved_keymap.rs:529` + +## Key Symbols + +| Symbol | Type | File | Line | +|--------|------|------|------| +| `parse_key_sequence` | Function | `src/app/resolved_keymap.rs` | 143 | +| `parse_key_spec` | Function | `src/app/resolved_keymap.rs` | 154 | +| `apply_overrides` | Function | `src/app/resolved_keymap.rs` | 366 | +| `from_bindings` | Function | `src/app/resolved_keymap.rs` | 464 | +| `builtin_defaults` | Function | `src/app/resolved_keymap.rs` | 529 | +| `build` | Function | `src/app/resolved_keymap.rs` | 982 | +| `open` | Function | `src/app/file_picker.rs` | 48 | +| `append_query` | Function | `src/app/file_picker.rs` | 65 | +| `backspace_query` | Function | `src/app/file_picker.rs` | 76 | +| `select_next` | Function | `src/app/file_picker.rs` | 87 | +| `select_prev` | Function | `src/app/file_picker.rs` | 97 | +| `selected_path` | Function | `src/app/file_picker.rs` | 107 | +| `refresh_from_workspace` | Function | `src/app/file_picker.rs` | 119 | +| `confirm_explorer_prompt` | Function | `src/app/event_loop/commands_prompts.rs` | 129 | +| `resolve_explorer_creation_target` | Function | `src/app/event_loop/commands_prompts.rs` | 229 | +| `load_image_buffer` | Function | `src/app/app_state/overlays.rs` | 1407 | +| `command` | Function | `src/app/command_palette.rs` | 224 | +| `open` | Function | `src/app/command_palette.rs` | 378 | +| `append_query` | Function | `src/app/command_palette.rs` | 454 | +| `backspace_query` | Function | `src/app/command_palette.rs` | 486 | + +## Execution Flows + +| Flow | Type | Steps | +|------|------|-------| +| `Save_file_preserves_cursor_and_selection_state → Find_node` | cross_community | 9 | +| `Self_save_modify_event_is_ignored_without_reloading_cursor → Find_node` | cross_community | 9 | +| `External_reload_clamps_cursor_and_selection_to_new_buffer_length → Find_node` | cross_community | 9 | +| `Scenario_insert_and_scroll → Find_node` | cross_community | 9 | +| `Update_markdown_preview_content → Normalize_modifier_alias` | cross_community | 8 | +| `Update_markdown_preview_content → New` | cross_community | 8 | +| `Picker_open_query_select_flow → Find_node` | cross_community | 7 | +| `Save_file_preserves_cursor_and_selection_state → Replace` | cross_community | 7 | +| `Save_file_preserves_cursor_and_selection_state → WorkspaceMatch` | cross_community | 7 | +| `Self_save_modify_event_is_ignored_without_reloading_cursor → Replace` | cross_community | 7 | + +## Connected Areas + +| Area | Connections | +|------|-------------| +| Event_loop | 5 calls | +| Theme_config | 5 calls | +| Config | 3 calls | +| Workbench | 3 calls | +| Command_dispatch | 3 calls | +| Workspace | 2 calls | +| Benches | 1 calls | +| App_state | 1 calls | + +## How to Explore + +1. `gitnexus_context({name: "parse_key_sequence"})` — see callers and callees +2. `gitnexus_query({query: "app"})` — find related execution flows +3. Read key files listed above for implementation details diff --git a/.agents/skills/generated/benches/SKILL.md b/.agents/skills/generated/benches/SKILL.md new file mode 100644 index 00000000..73c44c17 --- /dev/null +++ b/.agents/skills/generated/benches/SKILL.md @@ -0,0 +1,92 @@ +--- +name: benches +description: "Skill for the Benches area of netherize_editor. 31 symbols across 6 files." +--- + +# Benches + +31 symbols | 6 files | Cohesion: 70% + +## When to Use + +- Working with code in `benches/` +- Understanding how cursor_line_col, text_len_bytes, accept_inline_suggestion work +- Modifying benches-related functionality + +## Key Files + +| File | Symbols | +|------|---------| +| `benches/e2e_perf_runner.rs` | bench_scratch_path, ensure_50mb_log_file, new, record, avg_ms (+9) | +| `benches/editor_bench.rs` | bench_scratch_path, ensure_50mb_log_file, bench_large_file_load, bench_language_cases, line_col_for_byte (+2) | +| `src/app/app_state/state.rs` | cursor_line_col, text_len_bytes, accept_inline_suggestion, cursor_byte_idx, cursor_byte_in_line (+2) | +| `src/app/app_state/mod.rs` | is_supported_image_path | +| `src/app/app_state/editor.rs` | move_to_last_line | +| `src/app/app_state/buffers.rs` | open_file | + +## Entry Points + +Start here when exploring this area: + +- **`cursor_line_col`** (Function) — `src/app/app_state/state.rs:268` +- **`text_len_bytes`** (Function) — `src/app/app_state/state.rs:464` +- **`accept_inline_suggestion`** (Function) — `src/app/app_state/state.rs:760` +- **`is_supported_image_path`** (Function) — `src/app/app_state/mod.rs:161` +- **`move_to_last_line`** (Function) — `src/app/app_state/editor.rs:919` + +## Key Symbols + +| Symbol | Type | File | Line | +|--------|------|------|------| +| `cursor_line_col` | Function | `src/app/app_state/state.rs` | 268 | +| `text_len_bytes` | Function | `src/app/app_state/state.rs` | 464 | +| `accept_inline_suggestion` | Function | `src/app/app_state/state.rs` | 760 | +| `is_supported_image_path` | Function | `src/app/app_state/mod.rs` | 161 | +| `move_to_last_line` | Function | `src/app/app_state/editor.rs` | 919 | +| `open_file` | Function | `src/app/app_state/buffers.rs` | 4 | +| `cursor_byte_idx` | Function | `src/app/app_state/state.rs` | 279 | +| `cursor_byte_in_line` | Function | `src/app/app_state/state.rs` | 285 | +| `active_search_match_position` | Function | `src/app/app_state/state.rs` | 338 | +| `jump_to_line_and_column` | Function | `src/app/app_state/state.rs` | 405 | +| `bench_scratch_path` | Function | `benches/editor_bench.rs` | 30 | +| `ensure_50mb_log_file` | Function | `benches/editor_bench.rs` | 34 | +| `bench_large_file_load` | Function | `benches/editor_bench.rs` | 230 | +| `bench_scratch_path` | Function | `benches/e2e_perf_runner.rs` | 32 | +| `ensure_50mb_log_file` | Function | `benches/e2e_perf_runner.rs` | 36 | +| `new` | Function | `benches/e2e_perf_runner.rs` | 62 | +| `record` | Function | `benches/e2e_perf_runner.rs` | 68 | +| `avg_ms` | Function | `benches/e2e_perf_runner.rs` | 72 | +| `scenario_load_large_file` | Function | `benches/e2e_perf_runner.rs` | 98 | +| `scenario_jump_to_last_line` | Function | `benches/e2e_perf_runner.rs` | 113 | + +## Execution Flows + +| Flow | Type | Steps | +|------|------|-------| +| `Save_file_preserves_cursor_and_selection_state → Find_node` | cross_community | 9 | +| `Self_save_modify_event_is_ignored_without_reloading_cursor → Find_node` | cross_community | 9 | +| `External_reload_clamps_cursor_and_selection_to_new_buffer_length → Find_node` | cross_community | 9 | +| `Scenario_insert_and_scroll → Find_node` | cross_community | 9 | +| `Bench_edit_loop_latency → HelpEntry` | cross_community | 7 | +| `Bench_edit_loop_latency → Command_label_for_help` | cross_community | 7 | +| `Bench_edit_loop_latency → HelpSection` | cross_community | 7 | +| `Bench_edit_loop_latency → Find_profile_path` | cross_community | 7 | +| `Save_file_preserves_cursor_and_selection_state → Replace` | cross_community | 7 | +| `Save_file_preserves_cursor_and_selection_state → WorkspaceMatch` | cross_community | 7 | + +## Connected Areas + +| Area | Connections | +|------|-------------| +| Workbench | 5 calls | +| Syntax | 3 calls | +| Command_dispatch | 2 calls | +| App_state | 2 calls | +| App | 1 calls | +| Lsp | 1 calls | + +## How to Explore + +1. `gitnexus_context({name: "cursor_line_col"})` — see callers and callees +2. `gitnexus_query({query: "benches"})` — find related execution flows +3. Read key files listed above for implementation details diff --git a/.agents/skills/generated/command-dispatch/SKILL.md b/.agents/skills/generated/command-dispatch/SKILL.md new file mode 100644 index 00000000..a92b887b --- /dev/null +++ b/.agents/skills/generated/command-dispatch/SKILL.md @@ -0,0 +1,96 @@ +--- +name: command-dispatch +description: "Skill for the Command_dispatch area of netherize_editor. 134 symbols across 18 files." +--- + +# Command_dispatch + +134 symbols | 18 files | Cohesion: 83% + +## When to Use + +- Working with code in `src/` +- Understanding how supports_numeric_count, groups_repeated_edits_into_single_transaction, dispatch_command work +- Modifying command_dispatch-related functionality + +## Key Files + +| File | Symbols | +|------|---------| +| `src/core/command_dispatch/tests.rs` | unique_temp_path, insert_command_changes_state, insert_text_command_supports_combining_sequence, newline_command_inserts_line_break, insert_open_paren_auto_pairs_and_places_cursor_inside (+66) | +| `src/core/command_dispatch/mod.rs` | dispatch_command, dispatch_command_count, dispatch_command_with_clipboard, dispatch_command_with_clipboard_count, dispatch_command_with_terminal (+4) | +| `src/app/event_loop/commands_tests.rs` | palette_paste_uses_clipboard_provider, leap_generates_multi_char_labels_after_twenty_six_matches, leap_fast_jump_label_resolves_immediately, leap_prefix_label_filters_and_waits_for_second_key, visual_selection_adds_code_context_to_ai_chat (+3) | +| `src/core/command_dispatch/common.rs` | success, success_with_flags, failure, open_file, terminal_grid_mut (+3) | +| `src/app/app_state/buffers.rs` | new_empty_buffer, buffer_next, buffer_prev, goto_buffer_index, close_current_buffer (+2) | +| `src/app/app_state/palette.rs` | active_fuzzy_picker_buffer, open_python_env_selector, is_terminal_panel_open, set_terminal_panel_open, open_command_palette_mode (+1) | +| `src/app/event_loop/commands.rs` | dispatch_palette_overlay_command, handle_terminal_paste, forward_to_pty, forward_to_terminal_session, normalize_terminal_paste_text | +| `src/app/app_state/state.rs` | text_string, cancel_file_history_preview, clear_search_highlights | +| `src/core/command_dispatch/session.rs` | dispatch, dispatch_terminal_normal, toggle_terminal | +| `src/core/command_dispatch/palette.rs` | open_palette_mode, open_document_symbols, close_picker | + +## Entry Points + +Start here when exploring this area: + +- **`supports_numeric_count`** (Function) — `src/core/commands.rs:395` +- **`groups_repeated_edits_into_single_transaction`** (Function) — `src/core/commands.rs:436` +- **`dispatch_command`** (Function) — `src/core/command_dispatch/mod.rs:21` +- **`dispatch_command_count`** (Function) — `src/core/command_dispatch/mod.rs:25` +- **`dispatch_command_with_clipboard`** (Function) — `src/core/command_dispatch/mod.rs:33` + +## Key Symbols + +| Symbol | Type | File | Line | +|--------|------|------|------| +| `supports_numeric_count` | Function | `src/core/commands.rs` | 395 | +| `groups_repeated_edits_into_single_transaction` | Function | `src/core/commands.rs` | 436 | +| `dispatch_command` | Function | `src/core/command_dispatch/mod.rs` | 21 | +| `dispatch_command_count` | Function | `src/core/command_dispatch/mod.rs` | 25 | +| `dispatch_command_with_clipboard` | Function | `src/core/command_dispatch/mod.rs` | 33 | +| `dispatch_command_with_clipboard_count` | Function | `src/core/command_dispatch/mod.rs` | 41 | +| `dispatch_command_with_terminal` | Function | `src/core/command_dispatch/mod.rs` | 50 | +| `dispatch_command_count_with_terminal` | Function | `src/core/command_dispatch/mod.rs` | 58 | +| `dispatch_command_with_clipboard_and_terminal` | Function | `src/core/command_dispatch/mod.rs` | 67 | +| `dispatch_command_with_clipboard_count_with_terminal` | Function | `src/core/command_dispatch/mod.rs` | 76 | +| `handle_palette_and_open_command` | Function | `src/app/event_loop/commands_palette.rs` | 3 | +| `handle_insert_edit_command` | Function | `src/app/event_loop/commands_editor.rs` | 3 | +| `handle_generic_editor_command` | Function | `src/app/event_loop/commands_editor.rs` | 179 | +| `text_string` | Function | `src/app/app_state/state.rs` | 468 | +| `active_fuzzy_picker_buffer` | Function | `src/app/app_state/palette.rs` | 524 | +| `from_text` | Function | `src/app/app_state/mod.rs` | 1288 | +| `exit_normal_mode` | Function | `src/terminal/grid.rs` | 522 | +| `begin_selection` | Function | `src/terminal/grid.rs` | 528 | +| `dispatch` | Function | `src/core/command_dispatch/session.rs` | 7 | +| `success` | Function | `src/core/command_dispatch/common.rs` | 20 | + +## Execution Flows + +| Flow | Type | Steps | +|------|------|-------| +| `External_reload_error_does_not_abort_workspace_updates → Is_hidden_name` | cross_community | 8 | +| `File_picker_results_refresh_while_overlay_is_open_after_external_create → Is_hidden_name` | cross_community | 8 | +| `External_modify_reloads_when_clean_and_warns_when_dirty → Is_hidden_name` | cross_community | 8 | +| `Modify_event_on_missing_path_triggers_workspace_rescan_for_rename_like_flow → Is_hidden_name` | cross_community | 8 | +| `Workspace_and_file_picker_state_are_tracked → Is_hidden_name` | cross_community | 8 | +| `Handle_terminal_and_focus_command → Len_chars` | cross_community | 7 | +| `Bench_edit_loop_latency → HelpEntry` | cross_community | 7 | +| `Bench_edit_loop_latency → Command_label_for_help` | cross_community | 7 | +| `Bench_edit_loop_latency → HelpSection` | cross_community | 7 | +| `Bench_edit_loop_latency → Find_profile_path` | cross_community | 7 | + +## Connected Areas + +| Area | Connections | +|------|-------------| +| App_state | 18 calls | +| Event_loop | 11 calls | +| Terminal | 5 calls | +| Renderer | 2 calls | +| Benches | 1 calls | +| Workspace | 1 calls | + +## How to Explore + +1. `gitnexus_context({name: "supports_numeric_count"})` — see callers and callees +2. `gitnexus_query({query: "command_dispatch"})` — find related execution flows +3. Read key files listed above for implementation details diff --git a/.agents/skills/generated/config/SKILL.md b/.agents/skills/generated/config/SKILL.md new file mode 100644 index 00000000..7e7ee5d3 --- /dev/null +++ b/.agents/skills/generated/config/SKILL.md @@ -0,0 +1,93 @@ +--- +name: config +description: "Skill for the Config area of netherize_editor. 41 symbols across 9 files." +--- + +# Config + +41 symbols | 9 files | Cohesion: 74% + +## When to Use + +- Working with code in `src/` +- Understanding how user_config_root, legacy_app_state_root, load work +- Modifying config-related functionality + +## Key Files + +| File | Symbols | +|------|---------| +| `src/config/ui_config.rs` | default, builtin, from_raw, validate, parse_positive_f32 (+11) | +| `src/config/keymap_loader.rs` | default_user_overrides_path, load, find_profile_path, load_validated_file, invalid_toml_returns_none (+3) | +| `src/config/ai_config.rs` | load, candidate_paths, debounce_ms, prefix_chars, suffix_chars (+1) | +| `src/config/paths.rs` | home_dir, user_config_root, legacy_app_state_root | +| `src/config/theme_config/loader.rs` | find_profile_path, theme_search_dirs | +| `src/app/event_loop/setup.rs` | flush_pending_ai_inline_completion, next_ai_inline_flush_deadline | +| `src/app/input_map/mod.rs` | new, default | +| `src/app/persistence.rs` | state_dir | +| `src/core/command_ids.rs` | is_valid | + +## Entry Points + +Start here when exploring this area: + +- **`user_config_root`** (Function) — `src/config/paths.rs:18` +- **`legacy_app_state_root`** (Function) — `src/config/paths.rs:30` +- **`load`** (Function) — `src/config/ai_config.rs:30` +- **`builtin`** (Function) — `src/config/ui_config.rs:177` +- **`validate`** (Function) — `src/config/ui_config.rs:661` + +## Key Symbols + +| Symbol | Type | File | Line | +|--------|------|------|------| +| `user_config_root` | Function | `src/config/paths.rs` | 18 | +| `legacy_app_state_root` | Function | `src/config/paths.rs` | 30 | +| `load` | Function | `src/config/ai_config.rs` | 30 | +| `builtin` | Function | `src/config/ui_config.rs` | 177 | +| `validate` | Function | `src/config/ui_config.rs` | 661 | +| `is_valid` | Function | `src/core/command_ids.rs` | 428 | +| `load` | Function | `src/config/keymap_loader.rs` | 35 | +| `debounce_ms` | Function | `src/config/ai_config.rs` | 49 | +| `prefix_chars` | Function | `src/config/ai_config.rs` | 53 | +| `suffix_chars` | Function | `src/config/ai_config.rs` | 57 | +| `max_tokens` | Function | `src/config/ai_config.rs` | 61 | +| `flush_pending_ai_inline_completion` | Function | `src/app/event_loop/setup.rs` | 1028 | +| `next_ai_inline_flush_deadline` | Function | `src/app/event_loop/setup.rs` | 1079 | +| `active_profile` | Function | `src/config/ui_config.rs` | 143 | +| `load_active` | Function | `src/config/ui_config.rs` | 147 | +| `load` | Function | `src/config/ui_config.rs` | 162 | +| `load_from_path` | Function | `src/config/ui_config.rs` | 168 | +| `load_user_editor_overrides` | Function | `src/config/ui_config.rs` | 628 | +| `user_override_path` | Function | `src/config/ui_config.rs` | 643 | +| `save_user_override` | Function | `src/config/ui_config.rs` | 647 | + +## Execution Flows + +| Flow | Type | Steps | +|------|------|-------| +| `Bench_edit_loop_latency → Find_profile_path` | cross_community | 7 | +| `Bench_edit_loop_latency → Active_profile` | cross_community | 6 | +| `Update_markdown_preview_content → From_str` | cross_community | 6 | +| `Update_markdown_preview_content → Is_valid` | cross_community | 6 | +| `Load → Home_dir` | cross_community | 6 | +| `Terminal_normal_selection_yanks_terminal_grid_and_returns_to_typing_mode → Active_profile` | cross_community | 6 | +| `Terminal_normal_selection_yanks_terminal_grid_and_returns_to_typing_mode → Find_profile_path` | cross_community | 6 | +| `Update_ai_chat_content → Find_profile_path` | cross_community | 5 | +| `Update_markdown_preview_content → Find_profile_path` | cross_community | 5 | +| `Load_active → WindowUiConfig` | cross_community | 5 | + +## Connected Areas + +| Area | Connections | +|------|-------------| +| Scheduler | 2 calls | +| Workbench | 1 calls | +| App | 1 calls | +| Event_loop | 1 calls | + +## How to Explore + +1. `gitnexus_context({name: "user_config_root"})` — see callers and callees +2. `gitnexus_query({query: "config"})` — find related execution flows +3. Read key files listed above for implementation details diff --git a/.agents/skills/generated/editor/SKILL.md b/.agents/skills/generated/editor/SKILL.md new file mode 100644 index 00000000..6d9c36ae --- /dev/null +++ b/.agents/skills/generated/editor/SKILL.md @@ -0,0 +1,91 @@ +--- +name: editor +description: "Skill for the Editor area of netherize_editor. 17 symbols across 6 files." +--- + +# Editor + +17 symbols | 6 files | Cohesion: 55% + +## When to Use + +- Working with code in `src/` +- Understanding how wrap_text_lines, current_overlays, completion work +- Modifying editor-related functionality + +## Key Files + +| File | Symbols | +|------|---------| +| `src/render/renderer/editor/settings.rs` | label, section, description, display_value, with_alpha (+3) | +| `src/render/renderer/editor/overlays.rs` | update_editor_overlays, blend_rgba, strip_markdown_inline | +| `src/render/renderer/editor.rs` | wrap_text_lines, wrap_text_lines_keeps_output_non_empty | +| `src/app/app_state/state.rs` | current_overlays, completion | +| `src/app/app_state/overlays.rs` | is_completion_loading | +| `src/render/renderer/editor/completion.rs` | completion_label_spans | + +## Entry Points + +Start here when exploring this area: + +- **`wrap_text_lines`** (Function) — `src/render/renderer/editor.rs:41` +- **`current_overlays`** (Function) — `src/app/app_state/state.rs:705` +- **`completion`** (Function) — `src/app/app_state/state.rs:709` +- **`is_completion_loading`** (Function) — `src/app/app_state/overlays.rs:62` +- **`update_editor_overlays`** (Function) — `src/render/renderer/editor/overlays.rs:33` + +## Key Symbols + +| Symbol | Type | File | Line | +|--------|------|------|------| +| `wrap_text_lines` | Function | `src/render/renderer/editor.rs` | 41 | +| `current_overlays` | Function | `src/app/app_state/state.rs` | 705 | +| `completion` | Function | `src/app/app_state/state.rs` | 709 | +| `is_completion_loading` | Function | `src/app/app_state/overlays.rs` | 62 | +| `update_editor_overlays` | Function | `src/render/renderer/editor/overlays.rs` | 33 | +| `completion_label_spans` | Function | `src/render/renderer/editor/completion.rs` | 26 | +| `update_settings_buffer_content` | Function | `src/render/renderer/editor/settings.rs` | 268 | +| `wrap_text_lines_keeps_output_non_empty` | Function | `src/render/renderer/editor.rs` | 116 | +| `blend_rgba` | Function | `src/render/renderer/editor/overlays.rs` | 929 | +| `strip_markdown_inline` | Function | `src/render/renderer/editor/overlays.rs` | 940 | +| `label` | Function | `src/render/renderer/editor/settings.rs` | 22 | +| `section` | Function | `src/render/renderer/editor/settings.rs` | 34 | +| `description` | Function | `src/render/renderer/editor/settings.rs` | 52 | +| `display_value` | Function | `src/render/renderer/editor/settings.rs` | 121 | +| `with_alpha` | Function | `src/render/renderer/editor/settings.rs` | 156 | +| `current_row_value` | Function | `src/render/renderer/editor/settings.rs` | 161 | +| `settings_preview_lines` | Function | `src/render/renderer/editor/settings.rs` | 184 | + +## Execution Flows + +| Flow | Type | Steps | +|------|------|-------| +| `Update_settings_buffer_content → From_rgba_u8` | cross_community | 6 | +| `Update_settings_buffer_content → F32_channel_to_u8` | cross_community | 5 | +| `Update_settings_buffer_content → Linear_to_srgb` | cross_community | 5 | +| `Update_settings_buffer_content → VisibleGlyph` | cross_community | 5 | +| `Update_settings_buffer_content → Rasterize_cache_key` | cross_community | 5 | +| `Update_settings_buffer_content → Extract_alpha_from_image_data` | cross_community | 5 | +| `Update_settings_buffer_content → RasterizedGlyph` | cross_community | 5 | +| `Update_settings_buffer_content → Get` | cross_community | 5 | +| `Update_settings_buffer_content → PendingGlyphUpload` | cross_community | 5 | +| `Update_settings_buffer_content → AtlasEntry` | cross_community | 5 | + +## Connected Areas + +| Area | Connections | +|------|-------------| +| Palette | 7 calls | +| Renderer | 6 calls | +| App_state | 6 calls | +| Ui | 1 calls | +| Text | 1 calls | +| Benches | 1 calls | +| Event_loop | 1 calls | +| Workbench | 1 calls | + +## How to Explore + +1. `gitnexus_context({name: "wrap_text_lines"})` — see callers and callees +2. `gitnexus_query({query: "editor"})` — find related execution flows +3. Read key files listed above for implementation details diff --git a/.agents/skills/generated/event-loop/SKILL.md b/.agents/skills/generated/event-loop/SKILL.md new file mode 100644 index 00000000..5f6231aa --- /dev/null +++ b/.agents/skills/generated/event-loop/SKILL.md @@ -0,0 +1,98 @@ +--- +name: event-loop +description: "Skill for the Event_loop area of netherize_editor. 289 symbols across 46 files." +--- + +# Event_loop + +289 symbols | 46 files | Cohesion: 76% + +## When to Use + +- Working with code in `src/` +- Understanding how set_metrics, from_theme, editor_chrome_instances work +- Modifying event_loop-related functionality + +## Key Files + +| File | Symbols | +|------|---------| +| `src/app/event_loop/setup.rs` | startup_subsystems, submit, submit_workspace_git_status_refresh, submit_active_buffer_git_baseline_refresh, flush_lsp_retry_if_due (+41) | +| `src/app/event_loop/helpers.rs` | build_sidebar_rows, region_color, byte_inside_any_span, rainbow_bracket_spans, render_markdown_node (+33) | +| `src/app/event_loop/commands_lsp.rs` | handle_lsp_and_diagnostics_command, open_lazygit_buffer, open_lazydocker_buffer, submit_git_blame_line, select_next_reference_item (+20) | +| `src/app/event_loop/async_results.rs` | on_worker_result, lsp_uri_to_path, active_fuzzy_preview_target, active_references_preview_target, active_diagnostics_preview_target (+12) | +| `src/app/event_loop/commands.rs` | dismiss_system_dep_guide, accept_system_dep_guide, clear_expired_transient_toast, should_persist_history_after, finalize_post_command_hooks (+9) | +| `src/app/event_loop/commands_tests.rs` | move_to_first_line_uses_viewport_layout_path, move_to_last_line_uses_viewport_layout_path, center_cursor_line_uses_viewport_layout_path, scroll_half_page_down_uses_viewport_layout_path, fuzzy_picker_selection_clears_stale_preview_lines (+8) | +| `src/app/event_loop/commands_ai_chat.rs` | ai_slash_command_completion_at, slash_command_suggestion_count, clean_ai_file_ref_token, ai_models_help, ai_agent_help (+7) | +| `src/app/event_loop/application.rs` | redraw, focus_target_region_id, window_event, handle_explorer_filter_ime_commit, handle_explorer_filter_key_event (+6) | +| `src/app/event_loop/mod.rs` | is_running, active_terminal_grid_mut, focused_terminal_grid_mut, focused_terminal_session_id, active_terminal_tab (+5) | +| `src/app/event_loop/commands_terminal.rs` | default_terminal_working_dir, spawn_shell_for_terminal_tab, ensure_active_terminal_tab_spawned, map_directional_focus_command, handle_terminal_and_focus_command (+5) | + +## Entry Points + +Start here when exploring this area: + +- **`set_metrics`** (Function) — `src/text/text_system.rs:127` +- **`from_theme`** (Function) — `src/terminal/grid.rs:128` +- **`editor_chrome_instances`** (Function) — `src/render/renderer.rs:311` +- **`clear_palette`** (Function) — `src/render/renderer/palette.rs:43` +- **`reconfigure_surface`** (Function) — `src/render/renderer/lifecycle.rs:463` + +## Key Symbols + +| Symbol | Type | File | Line | +|--------|------|------|------| +| `set_metrics` | Function | `src/text/text_system.rs` | 127 | +| `from_theme` | Function | `src/terminal/grid.rs` | 128 | +| `editor_chrome_instances` | Function | `src/render/renderer.rs` | 311 | +| `clear_palette` | Function | `src/render/renderer/palette.rs` | 43 | +| `reconfigure_surface` | Function | `src/render/renderer/lifecycle.rs` | 463 | +| `draw_text_region` | Function | `src/render/renderer/helpers.rs` | 141 | +| `as_f32` | Function | `src/config/theme_config/model.rs` | 83 | +| `sidebar_arrow` | Function | `src/config/theme_config/model.rs` | 277 | +| `get_icon_for_file` | Function | `src/config/theme_config/model.rs` | 368 | +| `is_running` | Function | `src/app/event_loop/mod.rs` | 365 | +| `build_sidebar_rows` | Function | `src/app/event_loop/helpers.rs` | 1414 | +| `region_color` | Function | `src/app/event_loop/helpers.rs` | 1475 | +| `confirm_theme_selection` | Function | `src/app/event_loop/commands_prompts.rs` | 336 | +| `switch_workspace_to` | Function | `src/app/event_loop/commands_explorer.rs` | 118 | +| `dismiss_system_dep_guide` | Function | `src/app/event_loop/commands.rs` | 449 | +| `accept_system_dep_guide` | Function | `src/app/event_loop/commands.rs` | 457 | +| `clear_expired_transient_toast` | Function | `src/app/event_loop/commands.rs` | 504 | +| `status_label` | Function | `src/app/app_state/mod.rs` | 81 | +| `is_dirty` | Function | `src/app/app_state/mod.rs` | 1111 | +| `update_terminal_content` | Function | `src/render/renderer/ui/terminal.rs` | 25 | + +## Execution Flows + +| Flow | Type | Steps | +|------|------|-------| +| `Handle_terminal_and_focus_command → Len_chars` | cross_community | 7 | +| `Handle_terminal_and_focus_command → Success` | cross_community | 6 | +| `Handle_terminal_and_focus_command → Open_python_env_selector` | cross_community | 6 | +| `Handle_terminal_and_focus_command → Success_with_flags` | cross_community | 6 | +| `Startup_subsystems → Find_node` | cross_community | 6 | +| `Handle_explorer_and_workspace_command → Login_shell_path_cache` | cross_community | 6 | +| `Handle_explorer_and_workspace_command → Probe_path_from_login_shell` | cross_community | 6 | +| `Handle_explorer_and_workspace_command → Resolve_nvm_bin` | cross_community | 6 | +| `Handle_explorer_and_workspace_command → Success` | cross_community | 6 | +| `Handle_explorer_and_workspace_command → Open_python_env_selector` | cross_community | 6 | + +## Connected Areas + +| Area | Connections | +|------|-------------| +| App_state | 23 calls | +| Renderer | 15 calls | +| Command_dispatch | 12 calls | +| Palette | 10 calls | +| Workbench | 9 calls | +| Syntax | 9 calls | +| Text | 7 calls | +| Theme_config | 4 calls | + +## How to Explore + +1. `gitnexus_context({name: "set_metrics"})` — see callers and callees +2. `gitnexus_query({query: "event_loop"})` — find related execution flows +3. Read key files listed above for implementation details diff --git a/.agents/skills/generated/input-map/SKILL.md b/.agents/skills/generated/input-map/SKILL.md new file mode 100644 index 00000000..043d707f --- /dev/null +++ b/.agents/skills/generated/input-map/SKILL.md @@ -0,0 +1,93 @@ +--- +name: input-map +description: "Skill for the Input_map area of netherize_editor. 79 symbols across 9 files." +--- + +# Input_map + +79 symbols | 9 files | Cohesion: 70% + +## When to Use + +- Working with code in `src/` +- Understanding how matches, is_leader_input, lookup_mode_only work +- Modifying input_map-related functionality + +## Key Files + +| File | Symbols | +|------|---------| +| `src/app/input_map/tests.rs` | make_default_profile_map, input_from_named, input_from_physical, default_profile_leader_f_m_routes_to_lsp_format_document, default_profile_leader_m_f_routes_to_focus_markdown_preview (+26) | +| `src/app/resolved_keymap.rs` | matches, is_leader_input, lookup_mode_only, lookup_global, input_to_specs (+9) | +| `src/app/input_map/focus.rs` | resolve_settings_focus, resolve_diagnostics_focus, resolve_references_focus, resolve_explorer_focus, resolve_inspector_focus (+6) | +| `src/app/input_map/mod.rs` | resolve_sequence_start, resolve_sequence_next, resolve, translate, allows_leader (+5) | +| `src/app/input/helpers.rs` | numeric_count_digit_from_input, should_start_replace_pending, should_start_yank_pending, inner_or_around_from_input, text_object_kind_from_input (+2) | +| `src/app/input_map/helpers.rs` | palette_query_from_text, insert_command_from_text | +| `src/app/input/tests.rs` | ime_commit_is_redirected_to_file_picker_when_palette_is_open, ime_commit_is_redirected_to_ai_chat_text | +| `src/app/input/model.rs` | has_command_modifier | +| `src/app/input/handler.rs` | translate_ime_commit | + +## Entry Points + +Start here when exploring this area: + +- **`matches`** (Function) — `src/app/resolved_keymap.rs:33` +- **`is_leader_input`** (Function) — `src/app/resolved_keymap.rs:284` +- **`lookup_mode_only`** (Function) — `src/app/resolved_keymap.rs:395` +- **`lookup_global`** (Function) — `src/app/resolved_keymap.rs:410` +- **`resolve_command_mode_only`** (Function) — `src/app/resolved_keymap.rs:1002` + +## Key Symbols + +| Symbol | Type | File | Line | +|--------|------|------|------| +| `matches` | Function | `src/app/resolved_keymap.rs` | 33 | +| `is_leader_input` | Function | `src/app/resolved_keymap.rs` | 284 | +| `lookup_mode_only` | Function | `src/app/resolved_keymap.rs` | 395 | +| `lookup_global` | Function | `src/app/resolved_keymap.rs` | 410 | +| `resolve_command_mode_only` | Function | `src/app/resolved_keymap.rs` | 1002 | +| `resolve_global_command` | Function | `src/app/resolved_keymap.rs` | 1012 | +| `palette_query_from_text` | Function | `src/app/input_map/helpers.rs` | 13 | +| `resolve_settings_focus` | Function | `src/app/input_map/focus.rs` | 6 | +| `resolve_diagnostics_focus` | Function | `src/app/input_map/focus.rs` | 121 | +| `resolve_references_focus` | Function | `src/app/input_map/focus.rs` | 175 | +| `resolve_explorer_focus` | Function | `src/app/input_map/focus.rs` | 229 | +| `resolve_inspector_focus` | Function | `src/app/input_map/focus.rs` | 312 | +| `resolve_markdown_preview_focus` | Function | `src/app/input_map/focus.rs` | 364 | +| `resolve_help_focus` | Function | `src/app/input_map/focus.rs` | 445 | +| `resolve_bottom_panel_focus` | Function | `src/app/input_map/focus.rs` | 501 | +| `resolve_palette_focus` | Function | `src/app/input_map/focus.rs` | 557 | +| `resolve_fuzzy_picker_focus` | Function | `src/app/input_map/focus.rs` | 715 | +| `has_command_modifier` | Function | `src/app/input/model.rs` | 42 | +| `numeric_count_digit_from_input` | Function | `src/app/input/helpers.rs` | 55 | +| `should_start_replace_pending` | Function | `src/app/input/helpers.rs` | 104 | + +## Execution Flows + +| Flow | Type | Steps | +|------|------|-------| +| `Table_driven_keybinding_resolution → SequenceBindingKey` | cross_community | 5 | +| `Resolve_settings_focus → Has_command_modifier` | intra_community | 5 | +| `Resolve_explorer_focus → Has_command_modifier` | intra_community | 5 | +| `Resolve_palette_focus → Has_command_modifier` | intra_community | 5 | +| `Resolve_fuzzy_picker_focus → Has_command_modifier` | intra_community | 5 | +| `Default_profile_leader_f_m_routes_to_lsp_format_document → From_str` | cross_community | 5 | +| `Default_profile_leader_f_m_routes_to_lsp_format_document → Is_valid` | cross_community | 5 | +| `Default_profile_leader_f_m_routes_to_lsp_format_document → New` | cross_community | 5 | +| `Default_profile_leader_f_m_routes_to_lsp_format_document → Allows_leader` | cross_community | 5 | +| `Default_profile_leader_f_m_routes_to_lsp_format_document → Editor_mode_str` | cross_community | 5 | + +## Connected Areas + +| Area | Connections | +|------|-------------| +| Input | 22 calls | +| App | 5 calls | +| Syntax | 4 calls | +| Config | 1 calls | + +## How to Explore + +1. `gitnexus_context({name: "matches"})` — see callers and callees +2. `gitnexus_query({query: "input_map"})` — find related execution flows +3. Read key files listed above for implementation details diff --git a/.agents/skills/generated/input/SKILL.md b/.agents/skills/generated/input/SKILL.md new file mode 100644 index 00000000..110d3f29 --- /dev/null +++ b/.agents/skills/generated/input/SKILL.md @@ -0,0 +1,86 @@ +--- +name: input +description: "Skill for the Input area of netherize_editor. 101 symbols across 9 files." +--- + +# Input + +101 symbols | 9 files | Cohesion: 91% + +## When to Use + +- Working with code in `src/` +- Understanding how supports_press_and_hold_repeat, for_mode, with_focus work +- Modifying input-related functionality + +## Key Files + +| File | Symbols | +|------|---------| +| `src/app/input/tests.rs` | char_input, ctrl_input, named_input, shift_named_input, completion_context (+58) | +| `src/app/input/handler.rs` | accumulate_pending_count, clear_pending_counts, consume_repeat_count_for_command, translate_dispatch, translate_key_event (+10) | +| `src/app/input/pending.rs` | classify_pending_state, uses_operator_count, generate_leap_labels, generate_leap_labels_returns_single_chars_up_to_twenty_six, generate_leap_labels_uses_hybrid_boundary_after_fast_group (+2) | +| `src/app/input/helpers.rs` | is_modifier_only_key, replace_char_from_input, printable_char_from_input, terminal_input_payload, shifted_letter_payload (+1) | +| `src/app/input_map/mod.rs` | for_mode, with_focus, with_keymap | +| `src/app/event_loop/commands_editor.rs` | handle_leap_command, normalize_leap_target, generate_editor_leap_state | +| `src/app/input/model.rs` | from_key_event, debug_label | +| `src/core/commands.rs` | supports_press_and_hold_repeat | +| `src/app/input_map/tests.rs` | table_driven_keybinding_resolution | + +## Entry Points + +Start here when exploring this area: + +- **`supports_press_and_hold_repeat`** (Function) — `src/core/commands.rs:449` +- **`for_mode`** (Function) — `src/app/input_map/mod.rs:101` +- **`with_focus`** (Function) — `src/app/input_map/mod.rs:126` +- **`with_keymap`** (Function) — `src/app/input_map/mod.rs:171` +- **`classify_pending_state`** (Function) — `src/app/input/pending.rs:97` + +## Key Symbols + +| Symbol | Type | File | Line | +|--------|------|------|------| +| `supports_press_and_hold_repeat` | Function | `src/core/commands.rs` | 449 | +| `for_mode` | Function | `src/app/input_map/mod.rs` | 101 | +| `with_focus` | Function | `src/app/input_map/mod.rs` | 126 | +| `with_keymap` | Function | `src/app/input_map/mod.rs` | 171 | +| `classify_pending_state` | Function | `src/app/input/pending.rs` | 97 | +| `uses_operator_count` | Function | `src/app/input/pending.rs` | 117 | +| `from_key_event` | Function | `src/app/input/model.rs` | 18 | +| `debug_label` | Function | `src/app/input/model.rs` | 46 | +| `is_modifier_only_key` | Function | `src/app/input/helpers.rs` | 13 | +| `replace_char_from_input` | Function | `src/app/input/helpers.rs` | 272 | +| `printable_char_from_input` | Function | `src/app/input/helpers.rs` | 288 | +| `terminal_input_payload` | Function | `src/app/input/helpers.rs` | 299 | +| `translate_key_event` | Function | `src/app/input/handler.rs` | 180 | +| `route_repeated_normalized_input` | Function | `src/app/input/handler.rs` | 197 | +| `route_normalized_input` | Function | `src/app/input/handler.rs` | 260 | +| `generate_leap_labels` | Function | `src/app/input/pending.rs` | 28 | +| `handle_leap_command` | Function | `src/app/event_loop/commands_editor.rs` | 96 | +| `normalize_leap_target` | Function | `src/app/event_loop/commands_editor.rs` | 312 | +| `generate_editor_leap_state` | Function | `src/app/event_loop/commands_editor.rs` | 320 | +| `on_focus_changed` | Function | `src/app/input/handler.rs` | 51 | + +## Execution Flows + +| Flow | Type | Steps | +|------|------|-------| +| `Table_driven_keybinding_resolution → SequenceBindingKey` | cross_community | 5 | +| `Table_driven_keybinding_resolution → New` | cross_community | 4 | + +## Connected Areas + +| Area | Connections | +|------|-------------| +| Input_map | 17 calls | +| App | 2 calls | +| Config | 1 calls | +| Command_dispatch | 1 calls | +| Workbench | 1 calls | + +## How to Explore + +1. `gitnexus_context({name: "supports_press_and_hold_repeat"})` — see callers and callees +2. `gitnexus_query({query: "input"})` — find related execution flows +3. Read key files listed above for implementation details diff --git a/.agents/skills/generated/lsp/SKILL.md b/.agents/skills/generated/lsp/SKILL.md new file mode 100644 index 00000000..97e766d7 --- /dev/null +++ b/.agents/skills/generated/lsp/SKILL.md @@ -0,0 +1,95 @@ +--- +name: lsp +description: "Skill for the Lsp area of netherize_editor. 106 symbols across 12 files." +--- + +# Lsp + +106 symbols | 12 files | Cohesion: 74% + +## When to Use + +- Working with code in `src/` +- Understanding how language_profile_for_language_id, is_document_open, mark_document_open work +- Modifying lsp-related functionality + +## Key Files + +| File | Symbols | +|------|---------| +| `src/lsp/client.rs` | is_document_open, mark_document_open, mark_document_closed, build_did_open_notification, build_did_change_notification (+67) | +| `src/lsp/registry.rs` | language_profile_for_language_id, language_profile_for_path, language_profile_detects_dockerfile_by_filename, language_profile_detects_dockerfile_variants, language_profile_detects_sql_by_extension (+9) | +| `src/async_runtime/scheduler.rs` | get_by_binary, get_handle, get_handle_by_uri, take_any, drain_all (+1) | +| `src/async_runtime/scheduler/lsp_parse.rs` | parse_locations, handle_lsp_definition, handle_lsp_references, lsp_request_response, lsp_cancellable_request_response | +| `src/async_runtime/scheduler/lsp_io.rs` | spawn_lsp_stderr_logger, spawn_lsp_stdout_reader | +| `src/async_runtime/scheduler/lsp.rs` | execute_lsp_request | +| `src/syntax/parser.rs` | language_id_for_path | +| `src/app/event_loop/setup.rs` | submit_lsp_check_for_path | +| `src/app/event_loop/helpers.rs` | language_id_for_path | +| `src/app/app_state/overlays.rs` | register_open_text_buffer | + +## Entry Points + +Start here when exploring this area: + +- **`language_profile_for_language_id`** (Function) — `src/lsp/registry.rs:258` +- **`is_document_open`** (Function) — `src/lsp/client.rs:539` +- **`mark_document_open`** (Function) — `src/lsp/client.rs:546` +- **`mark_document_closed`** (Function) — `src/lsp/client.rs:552` +- **`build_did_open_notification`** (Function) — `src/lsp/client.rs:1014` + +## Key Symbols + +| Symbol | Type | File | Line | +|--------|------|------|------| +| `language_profile_for_language_id` | Function | `src/lsp/registry.rs` | 258 | +| `is_document_open` | Function | `src/lsp/client.rs` | 539 | +| `mark_document_open` | Function | `src/lsp/client.rs` | 546 | +| `mark_document_closed` | Function | `src/lsp/client.rs` | 552 | +| `build_did_open_notification` | Function | `src/lsp/client.rs` | 1014 | +| `build_did_change_notification` | Function | `src/lsp/client.rs` | 1030 | +| `build_did_close_notification` | Function | `src/lsp/client.rs` | 1042 | +| `get_by_binary` | Function | `src/async_runtime/scheduler.rs` | 110 | +| `get_handle` | Function | `src/async_runtime/scheduler.rs` | 124 | +| `get_handle_by_uri` | Function | `src/async_runtime/scheduler.rs` | 135 | +| `take_any` | Function | `src/async_runtime/scheduler.rs` | 146 | +| `drain_all` | Function | `src/async_runtime/scheduler.rs` | 174 | +| `handle_lsp_definition` | Function | `src/async_runtime/scheduler/lsp_parse.rs` | 458 | +| `handle_lsp_references` | Function | `src/async_runtime/scheduler/lsp_parse.rs` | 524 | +| `spawn_lsp_stderr_logger` | Function | `src/async_runtime/scheduler/lsp_io.rs` | 185 | +| `swap_inflight` | Function | `src/lsp/client.rs` | 387 | +| `clear_inflight_if_matches` | Function | `src/lsp/client.rs` | 394 | +| `send_cancel_request` | Function | `src/lsp/client.rs` | 405 | +| `send_notification` | Function | `src/lsp/client.rs` | 458 | +| `allocate_request_id` | Function | `src/lsp/client.rs` | 484 | + +## Execution Flows + +| Flow | Type | Steps | +|------|------|-------| +| `Spawn_lsp_server → Find_node` | cross_community | 7 | +| `Spawn_lsp_server → FlatRegion` | cross_community | 7 | +| `Run_pty_request → FlatRegion` | cross_community | 7 | +| `Startup_subsystems → Find_node` | cross_community | 6 | +| `Handle_explorer_and_workspace_command → Login_shell_path_cache` | cross_community | 6 | +| `Handle_explorer_and_workspace_command → Probe_path_from_login_shell` | cross_community | 6 | +| `Handle_explorer_and_workspace_command → Resolve_nvm_bin` | cross_community | 6 | +| `Run_lsp_request → All_language_profiles` | cross_community | 6 | +| `Run_lsp_request → Find_node` | cross_community | 6 | +| `Run_lsp_request → Login_shell_path_cache` | cross_community | 6 | + +## Connected Areas + +| Area | Connections | +|------|-------------| +| Scheduler | 12 calls | +| Workbench | 10 calls | +| Event_loop | 2 calls | +| Terminal | 1 calls | +| App_state | 1 calls | + +## How to Explore + +1. `gitnexus_context({name: "language_profile_for_language_id"})` — see callers and callees +2. `gitnexus_query({query: "lsp"})` — find related execution flows +3. Read key files listed above for implementation details diff --git a/.agents/skills/generated/palette/SKILL.md b/.agents/skills/generated/palette/SKILL.md new file mode 100644 index 00000000..cd1bec4e --- /dev/null +++ b/.agents/skills/generated/palette/SKILL.md @@ -0,0 +1,96 @@ +--- +name: palette +description: "Skill for the Palette area of netherize_editor. 30 symbols across 14 files." +--- + +# Palette + +30 symbols | 14 files | Cohesion: 54% + +## When to Use + +- Working with code in `src/` +- Understanding how used_rows, debug_dump, upload_instances work +- Modifying palette-related functionality + +## Key Files + +| File | Symbols | +|------|---------| +| `src/render/renderer/helpers.rs` | rect_to_scissor, layout_panel_text, layout_panel_rich_text, color_f32_to_u8, clamp_monospace_text | +| `src/render/renderer/ui/terminal.rs` | inset_bounds, update_buffer_terminal_content, render_terminal_region, update_terminal_tab_bar | +| `src/render/renderer/palette/file_picker.rs` | render_file_picker_complex, split_symbol_badge, shift_ranges_after_badge, file_picker_tone_color | +| `src/terminal/grid.rs` | is_visually_empty, used_rows, debug_dump | +| `src/render/text_pipeline.rs` | upload_instances, ensure_instance_capacity | +| `src/render/renderer/palette/minimal.rs` | render_command_palette_minimalist, palette_tone_color | +| `src/render/renderer/palette/highlighted_label.rs` | render_highlighted_label, sanitize_label_range | +| `src/render/renderer/editor/help.rs` | cheat_sheet_logo_rgba, update_help_buffer_content | +| `src/render/renderer/ui/welcome.rs` | update_welcome_logo_content | +| `src/render/renderer/palette/recent_projects.rs` | render_recent_projects | + +## Entry Points + +Start here when exploring this area: + +- **`used_rows`** (Function) — `src/terminal/grid.rs:800` +- **`debug_dump`** (Function) — `src/terminal/grid.rs:812` +- **`upload_instances`** (Function) — `src/render/text_pipeline.rs:194` +- **`rect_to_scissor`** (Function) — `src/render/renderer/helpers.rs:20` +- **`layout_panel_text`** (Function) — `src/render/renderer/helpers.rs:32` + +## Key Symbols + +| Symbol | Type | File | Line | +|--------|------|------|------| +| `used_rows` | Function | `src/terminal/grid.rs` | 800 | +| `debug_dump` | Function | `src/terminal/grid.rs` | 812 | +| `upload_instances` | Function | `src/render/text_pipeline.rs` | 194 | +| `rect_to_scissor` | Function | `src/render/renderer/helpers.rs` | 20 | +| `layout_panel_text` | Function | `src/render/renderer/helpers.rs` | 32 | +| `layout_panel_rich_text` | Function | `src/render/renderer/helpers.rs` | 46 | +| `clamp_monospace_text` | Function | `src/render/renderer/helpers.rs` | 171 | +| `update_welcome_logo_content` | Function | `src/render/renderer/ui/welcome.rs` | 476 | +| `update_buffer_terminal_content` | Function | `src/render/renderer/ui/terminal.rs` | 65 | +| `update_terminal_tab_bar` | Function | `src/render/renderer/ui/terminal.rs` | 395 | +| `render_recent_projects` | Function | `src/render/renderer/palette/recent_projects.rs` | 15 | +| `render_command_palette_minimalist` | Function | `src/render/renderer/palette/minimal.rs` | 270 | +| `render_live_grep_picker` | Function | `src/render/renderer/palette/live_grep.rs` | 15 | +| `render_highlighted_label` | Function | `src/render/renderer/palette/highlighted_label.rs` | 19 | +| `sanitize_label_range` | Function | `src/render/renderer/palette/highlighted_label.rs` | 73 | +| `update_help_buffer_content` | Function | `src/render/renderer/editor/help.rs` | 47 | +| `update_fuzzy_picker_buffer_content` | Function | `src/render/renderer/editor/fuzzy.rs` | 28 | +| `update_references_buffer_content` | Function | `src/render/renderer/editor/buffers.rs` | 36 | +| `update_diagnostics_buffer_content` | Function | `src/render/renderer/editor/buffers/diagnostics.rs` | 26 | +| `render_file_picker_complex` | Function | `src/render/renderer/palette/file_picker.rs` | 15 | + +## Execution Flows + +| Flow | Type | Steps | +|------|------|-------| +| `Update_settings_buffer_content → From_rgba_u8` | cross_community | 6 | +| `Layout_shortcut_hint → From_rgba_u8` | cross_community | 6 | +| `Update_ai_chat_content → F32_channel_to_u8` | cross_community | 5 | +| `Update_ai_chat_content → Linear_to_srgb` | cross_community | 5 | +| `Update_ai_chat_content → VisibleGlyph` | cross_community | 5 | +| `Update_ai_chat_content → Rasterize_cache_key` | cross_community | 5 | +| `Update_ai_chat_content → Extract_alpha_from_image_data` | cross_community | 5 | +| `Render_file_picker_complex → F32_channel_to_u8` | cross_community | 5 | +| `Render_file_picker_complex → Linear_to_srgb` | cross_community | 5 | +| `Render_file_picker_complex → VisibleGlyph` | cross_community | 5 | + +## Connected Areas + +| Area | Connections | +|------|-------------| +| Renderer | 13 calls | +| Text | 8 calls | +| Event_loop | 5 calls | +| Terminal | 2 calls | +| Theme_config | 2 calls | +| Workbench | 2 calls | + +## How to Explore + +1. `gitnexus_context({name: "used_rows"})` — see callers and callees +2. `gitnexus_query({query: "palette"})` — find related execution flows +3. Read key files listed above for implementation details diff --git a/.agents/skills/generated/render/SKILL.md b/.agents/skills/generated/render/SKILL.md new file mode 100644 index 00000000..db2a970a --- /dev/null +++ b/.agents/skills/generated/render/SKILL.md @@ -0,0 +1,68 @@ +--- +name: render +description: "Skill for the Render area of netherize_editor. 26 symbols across 8 files." +--- + +# Render + +26 symbols | 8 files | Cohesion: 94% + +## When to Use + +- Working with code in `src/` +- Understanding how new, new, new work +- Modifying render-related functionality + +## Key Files + +| File | Symbols | +|------|---------| +| `src/render/caret.rs` | new, new, update_screen_size, from_rect, upload_caret (+2) | +| `src/render/text_pipeline.rs` | new, new, update_screen_size, draw, draw_range | +| `src/render/region_pipeline.rs` | layout, new, upload_instances, ensure_instance_capacity | +| `src/render/image_pipeline.rs` | layout, new, clear, upload_rgba | +| `src/render/pipeline.rs` | layout, new | +| `src/text/atlas.rs` | view, sampler | +| `src/render/color_space.rs` | srgb_color_target_state | +| `src/render/renderer.rs` | update_caret_visibility | + +## Entry Points + +Start here when exploring this area: + +- **`new`** (Function) — `src/render/region_pipeline.rs:99` +- **`new`** (Function) — `src/render/pipeline.rs:51` +- **`new`** (Function) — `src/render/image_pipeline.rs:38` +- **`srgb_color_target_state`** (Function) — `src/render/color_space.rs:13` +- **`new`** (Function) — `src/render/caret.rs:134` + +## Key Symbols + +| Symbol | Type | File | Line | +|--------|------|------|------| +| `new` | Function | `src/render/region_pipeline.rs` | 99 | +| `new` | Function | `src/render/pipeline.rs` | 51 | +| `new` | Function | `src/render/image_pipeline.rs` | 38 | +| `srgb_color_target_state` | Function | `src/render/color_space.rs` | 13 | +| `new` | Function | `src/render/caret.rs` | 134 | +| `update_screen_size` | Function | `src/render/caret.rs` | 234 | +| `view` | Function | `src/text/atlas.rs` | 89 | +| `sampler` | Function | `src/text/atlas.rs` | 93 | +| `new` | Function | `src/render/text_pipeline.rs` | 51 | +| `update_screen_size` | Function | `src/render/text_pipeline.rs` | 189 | +| `update_caret_visibility` | Function | `src/render/renderer.rs` | 317 | +| `upload_caret` | Function | `src/render/caret.rs` | 240 | +| `upload_carets` | Function | `src/render/caret.rs` | 252 | +| `set_caret_visible` | Function | `src/render/caret.rs` | 270 | +| `draw` | Function | `src/render/text_pipeline.rs` | 210 | +| `draw_range` | Function | `src/render/text_pipeline.rs` | 220 | +| `upload_instances` | Function | `src/render/region_pipeline.rs` | 213 | +| `clear` | Function | `src/render/image_pipeline.rs` | 117 | +| `upload_rgba` | Function | `src/render/image_pipeline.rs` | 123 | +| `layout` | Function | `src/render/region_pipeline.rs` | 35 | + +## How to Explore + +1. `gitnexus_context({name: "new"})` — see callers and callees +2. `gitnexus_query({query: "render"})` — find related execution flows +3. Read key files listed above for implementation details diff --git a/.agents/skills/generated/renderer/SKILL.md b/.agents/skills/generated/renderer/SKILL.md new file mode 100644 index 00000000..4957bb5e --- /dev/null +++ b/.agents/skills/generated/renderer/SKILL.md @@ -0,0 +1,98 @@ +--- +name: renderer +description: "Skill for the Renderer area of netherize_editor. 67 symbols across 14 files." +--- + +# Renderer + +67 symbols | 14 files | Cohesion: 53% + +## When to Use + +- Working with code in `src/` +- Understanding how set_size, with_radius, layout_panel_text_bold work +- Modifying renderer-related functionality + +## Key Files + +| File | Symbols | +|------|---------| +| `src/render/renderer/ui_render.rs` | bundled_ai_chat_logo, slash_command_suggestions, current_at_token, ai_chat_input_suggestions, strip_ansi (+19) | +| `src/render/renderer/helpers.rs` | layout_panel_text_bold, estimate_monospace_width, layout_clamp, clamp_popup_width, clamp_x_in_bounds (+3) | +| `src/render/renderer/components.rs` | push_centered_highlight_chip, centered_text_origin_x, centered_text_origin_y, layout_shortcut_hint, mix (+3) | +| `src/render/renderer/ui/topbar.rs` | bundled_app_logo, inset_scissor_rect, topbar_tab_text_scissor, with_alpha, update_topbar_content | +| `src/app/app_state/mod.rs` | default, new, new, new, new | +| `src/render/renderer/ui/welcome.rs` | bundled_logo, update_welcome_screen_content, clear_welcome_logo | +| `src/config/theme_config/model.rs` | linear_to_srgb, linear_rgba_to_srgb_u8, f32_channel_to_u8 | +| `src/app/event_loop/application.rs` | focus_ring_instances, focus_ring_keeps_outline_and_panel_fill | +| `src/render/renderer/ui/popups.rs` | update_lsp_guide_popup, update_system_dep_popup | +| `src/render/renderer/editor/overlays/diagnostic_hover.rs` | diagnostic_popup_width_handles_narrow_viewport_without_panicking, diagnostic_popup_width_uses_half_of_editor_viewport_when_space_allows | + +## Entry Points + +Start here when exploring this area: + +- **`set_size`** (Function) — `src/text/text_system.rs:123` +- **`with_radius`** (Function) — `src/render/region_pipeline.rs:76` +- **`layout_panel_text_bold`** (Function) — `src/render/renderer/helpers.rs:62` +- **`estimate_monospace_width`** (Function) — `src/render/renderer/helpers.rs:167` +- **`layout_clamp`** (Function) — `src/render/renderer/helpers.rs:242` + +## Key Symbols + +| Symbol | Type | File | Line | +|--------|------|------|------| +| `set_size` | Function | `src/text/text_system.rs` | 123 | +| `with_radius` | Function | `src/render/region_pipeline.rs` | 76 | +| `layout_panel_text_bold` | Function | `src/render/renderer/helpers.rs` | 62 | +| `estimate_monospace_width` | Function | `src/render/renderer/helpers.rs` | 167 | +| `layout_clamp` | Function | `src/render/renderer/helpers.rs` | 242 | +| `push_centered_highlight_chip` | Function | `src/render/renderer/components.rs` | 23 | +| `layout_shortcut_hint` | Function | `src/render/renderer/components.rs` | 60 | +| `help_keycap_palette` | Function | `src/render/renderer/components.rs` | 205 | +| `layout_help_keycaps` | Function | `src/render/renderer/components.rs` | 250 | +| `estimate_help_keycaps_width` | Function | `src/render/renderer/components.rs` | 372 | +| `update_welcome_screen_content` | Function | `src/render/renderer/ui/welcome.rs` | 42 | +| `clear_welcome_logo` | Function | `src/render/renderer/ui/welcome.rs` | 531 | +| `update_topbar_content` | Function | `src/render/renderer/ui/topbar.rs` | 56 | +| `new` | Function | `src/app/app_state/mod.rs` | 289 | +| `new` | Function | `src/app/app_state/mod.rs` | 1008 | +| `new` | Function | `src/app/app_state/mod.rs` | 1236 | +| `update_ai_chat_content` | Function | `src/render/renderer/ui_render.rs` | 742 | +| `linear_to_srgb` | Function | `src/config/theme_config/model.rs` | 97 | +| `linear_rgba_to_srgb_u8` | Function | `src/config/theme_config/model.rs` | 115 | +| `right_sidebar_background_quads` | Function | `src/render/renderer/ui_render.rs` | 344 | + +## Execution Flows + +| Flow | Type | Steps | +|------|------|-------| +| `Update_markdown_preview_content → Normalize_modifier_alias` | cross_community | 8 | +| `Update_markdown_preview_content → New` | cross_community | 8 | +| `Bench_edit_loop_latency → HelpEntry` | cross_community | 7 | +| `Bench_edit_loop_latency → Command_label_for_help` | cross_community | 7 | +| `Bench_edit_loop_latency → HelpSection` | cross_community | 7 | +| `Bench_edit_loop_latency → Find_profile_path` | cross_community | 7 | +| `Update_markdown_preview_content → Named_key_display` | cross_community | 7 | +| `Update_markdown_preview_content → Physical_key_display` | cross_community | 7 | +| `Bench_edit_loop_latency → Active_profile` | cross_community | 6 | +| `Update_markdown_preview_content → From_str` | cross_community | 6 | + +## Connected Areas + +| Area | Connections | +|------|-------------| +| Palette | 20 calls | +| Text | 8 calls | +| Ui | 3 calls | +| Workbench | 2 calls | +| Scheduler | 1 calls | +| Syntax | 1 calls | +| Event_loop | 1 calls | +| App_state | 1 calls | + +## How to Explore + +1. `gitnexus_context({name: "set_size"})` — see callers and callees +2. `gitnexus_query({query: "renderer"})` — find related execution flows +3. Read key files listed above for implementation details diff --git a/.agents/skills/generated/scheduler/SKILL.md b/.agents/skills/generated/scheduler/SKILL.md new file mode 100644 index 00000000..7539d50e --- /dev/null +++ b/.agents/skills/generated/scheduler/SKILL.md @@ -0,0 +1,98 @@ +--- +name: scheduler +description: "Skill for the Scheduler area of netherize_editor. 83 symbols across 21 files." +--- + +# Scheduler + +83 symbols | 21 files | Cohesion: 79% + +## When to Use + +- Working with code in `src/` +- Understanding how try_wait_status, language_profile_for_binary, scan_python_environments work +- Modifying scheduler-related functionality + +## Key Files + +| File | Symbols | +|------|---------| +| `src/async_runtime/scheduler/lsp_parse.rs` | parse_hover_content, parse_completion_items, parse_text_edits, handle_lsp_hover, parse_hover_doc_blocks (+14) | +| `src/async_runtime/scheduler/fzf.rs` | run_fzf_request, execute_fzf_async, fzf_live_grep, build_fzf_live_grep_script, build_ripgrep_ignore_glob_args (+5) | +| `src/editor_core.rs` | from_str, move_right, move_to_last_line, move_right_allows_eof_on_last_line_without_newline, move_right_crosses_to_next_line (+4) | +| `src/async_runtime/scheduler/tests.rs` | extend_unique_file_events_deduplicates_burst_entries, normalize_create_event_maps_to_internal_create, normalize_rename_event_maps_old_and_new_paths, normalize_single_path_rename_still_maps_to_rename, fzf_live_grep_script_uses_ripgrep_and_ignore_globs (+2) | +| `src/async_runtime/scheduler/ai_jobs.rs` | strip_ansi_sequences, should_skip_opencode_line, sanitize_opencode_line, build_prompt_with_file_context, resolve_opencode_binary (+2) | +| `src/async_runtime/scheduler/file_watch.rs` | run_file_watch_request, execute_file_watch_loop, extend_unique_file_events, filter_file_watch_events, normalize_notify_event (+1) | +| `src/async_runtime/scheduler/emit.rs` | emit_message, emit_message_and_wake, failure_from_join_error, panic_payload_to_string | +| `src/async_runtime/scheduler/dispatch.rs` | detect_python_version, detect_command_version, dispatch_loop | +| `src/async_runtime/scheduler/runtime.rs` | new, new_for_tests, build_worker_runtime | +| `src/async_runtime/scheduler/syntax_jobs.rs` | resolve_system_path, run_system_dep_install | + +## Entry Points + +Start here when exploring this area: + +- **`try_wait_status`** (Function) — `src/terminal/pty.rs:134` +- **`language_profile_for_binary`** (Function) — `src/lsp/registry.rs:264` +- **`scan_python_environments`** (Function) — `src/async_runtime/python_env.rs:18` +- **`resolve_system_path`** (Function) — `src/async_runtime/scheduler/syntax_jobs.rs:417` +- **`run_system_dep_install`** (Function) — `src/async_runtime/scheduler/syntax_jobs.rs:451` + +## Key Symbols + +| Symbol | Type | File | Line | +|--------|------|------|------| +| `try_wait_status` | Function | `src/terminal/pty.rs` | 134 | +| `language_profile_for_binary` | Function | `src/lsp/registry.rs` | 264 | +| `scan_python_environments` | Function | `src/async_runtime/python_env.rs` | 18 | +| `resolve_system_path` | Function | `src/async_runtime/scheduler/syntax_jobs.rs` | 417 | +| `run_system_dep_install` | Function | `src/async_runtime/scheduler/syntax_jobs.rs` | 451 | +| `run_lsp_request` | Function | `src/async_runtime/scheduler/lsp.rs` | 30 | +| `run_fzf_request` | Function | `src/async_runtime/scheduler/fzf.rs` | 18 | +| `run_file_watch_request` | Function | `src/async_runtime/scheduler/file_watch.rs` | 18 | +| `extend_unique_file_events` | Function | `src/async_runtime/scheduler/file_watch.rs` | 196 | +| `emit_message` | Function | `src/async_runtime/scheduler/emit.rs` | 10 | +| `emit_message_and_wake` | Function | `src/async_runtime/scheduler/emit.rs` | 16 | +| `failure_from_join_error` | Function | `src/async_runtime/scheduler/emit.rs` | 25 | +| `panic_payload_to_string` | Function | `src/async_runtime/scheduler/emit.rs` | 41 | +| `dispatch_loop` | Function | `src/async_runtime/scheduler/dispatch.rs` | 59 | +| `resolve_opencode_binary` | Function | `src/async_runtime/scheduler/ai_jobs.rs` | 111 | +| `run_ai_chat_stream` | Function | `src/async_runtime/scheduler/ai_jobs.rs` | 135 | +| `run_opencode_install` | Function | `src/async_runtime/scheduler/ai_jobs.rs` | 301 | +| `as_str` | Function | `src/syntax/highlight.rs` | 55 | +| `handle_lsp_hover` | Function | `src/async_runtime/scheduler/lsp_parse.rs` | 240 | +| `handle_lsp_formatting` | Function | `src/async_runtime/scheduler/lsp_parse.rs` | 489 | + +## Execution Flows + +| Flow | Type | Steps | +|------|------|-------| +| `Update_markdown_preview_content → From_str` | cross_community | 6 | +| `Run_lsp_request → All_language_profiles` | cross_community | 6 | +| `Run_lsp_request → Find_node` | cross_community | 6 | +| `Run_lsp_request → Login_shell_path_cache` | cross_community | 6 | +| `Run_fzf_request → Ignored_directory_names` | cross_community | 6 | +| `Run_lsp_request → Is_header_separator` | cross_community | 5 | +| `Run_lsp_request → Parse_header_line` | cross_community | 5 | +| `Run_lsp_request → Parse_content_length` | cross_community | 5 | +| `Run_fzf_request → Is_empty_fzf_status` | cross_community | 5 | +| `Run_fzf_request → FzfResultItem` | cross_community | 5 | + +## Connected Areas + +| Area | Connections | +|------|-------------| +| Lsp | 8 calls | +| Workbench | 3 calls | +| App_state | 2 calls | +| Syntax | 2 calls | +| Cluster_3 | 1 calls | +| Cluster_4 | 1 calls | +| Theme_config | 1 calls | +| Workspace | 1 calls | + +## How to Explore + +1. `gitnexus_context({name: "try_wait_status"})` — see callers and callees +2. `gitnexus_query({query: "scheduler"})` — find related execution flows +3. Read key files listed above for implementation details diff --git a/.agents/skills/generated/syntax/SKILL.md b/.agents/skills/generated/syntax/SKILL.md new file mode 100644 index 00000000..8e8f5508 --- /dev/null +++ b/.agents/skills/generated/syntax/SKILL.md @@ -0,0 +1,91 @@ +--- +name: syntax +description: "Skill for the Syntax area of netherize_editor. 103 symbols across 12 files." +--- + +# Syntax + +103 symbols | 12 files | Cohesion: 75% + +## When to Use + +- Working with code in `src/` +- Understanding how parse, as_str, root_node work +- Modifying syntax-related functionality + +## Key Files + +| File | Symbols | +|------|---------| +| `src/syntax/highlight.rs` | generate_highlight_spans, generate_dotenv_highlight_spans, should_highlight_inline, highlight_snippet, markdown_inline_highlight_query (+53) | +| `src/syntax/syntax_engine.rs` | as_str, root_node, new, new_rust, parse_source (+15) | +| `src/async_runtime/scheduler/syntax_jobs.rs` | execute_virtual_job, byte_range_for_line_window, highlight_byte_window, should_highlight_full_buffer, cpu_burn_checksum | +| `src/async_runtime/scheduler/git.rs` | run_git_blame_line, parse_git_blame_summary, run_workspace_git_status, parse_git_file_status, run_fetch_git_baseline | +| `src/syntax/parser.rs` | language_id_for_extension, tree_sitter_markdown_inline_language, tree_sitter_language | +| `src/async_runtime/scheduler/tests.rs` | file_preview_lines_center_around_target_line, file_preview_lines_without_target_use_file_start, parse_git_blame_summary_extracts_author_and_relative_time | +| `src/app/event_loop/helpers.rs` | build_preview_render_data, parse_markdown_preview_blocks, fallback_markdown_preview | +| `src/app/event_loop/commands.rs` | reconcile_highlight_spans_with_pending_edits, close_current_buffer_now | +| `benches/editor_bench.rs` | bench_incremental_parse | +| `src/core/command_ids.rs` | parse | + +## Entry Points + +Start here when exploring this area: + +- **`parse`** (Function) — `src/core/command_ids.rs:432` +- **`as_str`** (Function) — `src/syntax/syntax_engine.rs:30` +- **`root_node`** (Function) — `src/syntax/syntax_engine.rs:75` +- **`new`** (Function) — `src/syntax/syntax_engine.rs:98` +- **`new_rust`** (Function) — `src/syntax/syntax_engine.rs:121` + +## Key Symbols + +| Symbol | Type | File | Line | +|--------|------|------|------| +| `parse` | Function | `src/core/command_ids.rs` | 432 | +| `as_str` | Function | `src/syntax/syntax_engine.rs` | 30 | +| `root_node` | Function | `src/syntax/syntax_engine.rs` | 75 | +| `new` | Function | `src/syntax/syntax_engine.rs` | 98 | +| `new_rust` | Function | `src/syntax/syntax_engine.rs` | 121 | +| `parse_source` | Function | `src/syntax/syntax_engine.rs` | 127 | +| `parse_incremental` | Function | `src/syntax/syntax_engine.rs` | 151 | +| `current_tree` | Function | `src/syntax/syntax_engine.rs` | 205 | +| `language_id_for_extension` | Function | `src/syntax/parser.rs` | 10 | +| `tree_sitter_markdown_inline_language` | Function | `src/syntax/parser.rs` | 38 | +| `generate_highlight_spans` | Function | `src/syntax/highlight.rs` | 313 | +| `generate_dotenv_highlight_spans` | Function | `src/syntax/highlight.rs` | 347 | +| `should_highlight_inline` | Function | `src/syntax/highlight.rs` | 426 | +| `highlight_snippet` | Function | `src/syntax/highlight.rs` | 435 | +| `highlight_markdown_inline` | Function | `src/syntax/highlight.rs` | 668 | +| `execute_virtual_job` | Function | `src/async_runtime/scheduler/syntax_jobs.rs` | 23 | +| `run_git_blame_line` | Function | `src/async_runtime/scheduler/git.rs` | 2 | +| `parse_git_blame_summary` | Function | `src/async_runtime/scheduler/git.rs` | 33 | +| `run_workspace_git_status` | Function | `src/async_runtime/scheduler/git.rs` | 87 | +| `run_fetch_git_baseline` | Function | `src/async_runtime/scheduler/git.rs` | 135 | + +## Execution Flows + +| Flow | Type | Steps | +|------|------|-------| +| `Handle_command_with_count → Root_node` | cross_community | 5 | +| `Execute_virtual_job → Parse` | intra_community | 4 | +| `Execute_virtual_job → New` | cross_community | 4 | +| `Execute_virtual_job → Sanitize_byte_range` | cross_community | 4 | + +## Connected Areas + +| Area | Connections | +|------|-------------| +| Workbench | 5 calls | +| Event_loop | 5 calls | +| Benches | 1 calls | +| Lsp | 1 calls | +| Scheduler | 1 calls | +| App | 1 calls | +| Command_dispatch | 1 calls | + +## How to Explore + +1. `gitnexus_context({name: "parse"})` — see callers and callees +2. `gitnexus_query({query: "syntax"})` — find related execution flows +3. Read key files listed above for implementation details diff --git a/.agents/skills/generated/terminal/SKILL.md b/.agents/skills/generated/terminal/SKILL.md new file mode 100644 index 00000000..da8d6435 --- /dev/null +++ b/.agents/skills/generated/terminal/SKILL.md @@ -0,0 +1,98 @@ +--- +name: terminal +description: "Skill for the Terminal area of netherize_editor. 140 symbols across 11 files." +--- + +# Terminal + +140 symbols | 11 files | Cohesion: 83% + +## When to Use + +- Working with code in `src/` +- Understanding how new, feed_chunk, cell_at work +- Modifying terminal-related functionality + +## Key Files + +| File | Symbols | +|------|---------| +| `src/terminal/grid.rs` | new, feed_chunk, cell_at, apply_regex_highlights, set_visible_row_style_fg (+77) | +| `src/terminal/ansi_parser.rs` | collect_events, plain_text_emits_print_chars, newline_and_cr, sgr_reset, sgr_empty_is_reset (+31) | +| `src/terminal/pty.rs` | spawn_shell, spawn_command, new, write_input, resolve_shell_program (+2) | +| `src/terminal/terminal_renderer.rs` | new, default_monospace, cell_rect, cell_rect_calculation, default_renderer_has_positive_cell_size | +| `src/async_runtime/scheduler.rs` | alloc_session_id, async_trace_enabled | +| `src/render/renderer/lifecycle.rs` | make_text_pipeline, new | +| `src/app/event_loop/commands_terminal.rs` | handle_terminal_search_command, word_at_virtual_cursor | +| `src/core/command_dispatch/navigation.rs` | dispatch_terminal_normal | +| `src/async_runtime/scheduler/pty.rs` | execute_pty_request | +| `src/render/renderer/ui/terminal.rs` | append_terminal_overlay_quads | + +## Entry Points + +Start here when exploring this area: + +- **`new`** (Function) — `src/terminal/grid.rs:187` +- **`feed_chunk`** (Function) — `src/terminal/grid.rs:210` +- **`cell_at`** (Function) — `src/terminal/grid.rs:757` +- **`apply_regex_highlights`** (Function) — `src/terminal/grid.rs:865` +- **`total_rows`** (Function) — `src/terminal/grid.rs:502` + +## Key Symbols + +| Symbol | Type | File | Line | +|--------|------|------|------| +| `new` | Function | `src/terminal/grid.rs` | 187 | +| `feed_chunk` | Function | `src/terminal/grid.rs` | 210 | +| `cell_at` | Function | `src/terminal/grid.rs` | 757 | +| `apply_regex_highlights` | Function | `src/terminal/grid.rs` | 865 | +| `total_rows` | Function | `src/terminal/grid.rs` | 502 | +| `live_cursor_absolute_position` | Function | `src/terminal/grid.rs` | 506 | +| `enter_normal_mode` | Function | `src/terminal/grid.rs` | 513 | +| `move_virtual_left` | Function | `src/terminal/grid.rs` | 541 | +| `move_virtual_right` | Function | `src/terminal/grid.rs` | 551 | +| `move_virtual_up` | Function | `src/terminal/grid.rs` | 561 | +| `move_virtual_down` | Function | `src/terminal/grid.rs` | 571 | +| `move_virtual_word_forward` | Function | `src/terminal/grid.rs` | 582 | +| `move_virtual_word_backward` | Function | `src/terminal/grid.rs` | 589 | +| `move_virtual_word_end` | Function | `src/terminal/grid.rs` | 596 | +| `move_virtual_to_line_start` | Function | `src/terminal/grid.rs` | 603 | +| `move_virtual_to_line_end` | Function | `src/terminal/grid.rs` | 610 | +| `move_virtual_to_first_non_whitespace` | Function | `src/terminal/grid.rs` | 617 | +| `move_virtual_to_first_line` | Function | `src/terminal/grid.rs` | 624 | +| `move_virtual_to_last_line` | Function | `src/terminal/grid.rs` | 631 | +| `move_virtual_half_page_up` | Function | `src/terminal/grid.rs` | 638 | + +## Execution Flows + +| Flow | Type | Steps | +|------|------|-------| +| `Dispatch → Total_rows` | cross_community | 7 | +| `Dispatch → TerminalPoint` | cross_community | 6 | +| `Dispatch_terminal_normal → Total_rows` | intra_community | 6 | +| `Handle_command_with_count → Total_rows` | cross_community | 6 | +| `Dispatch → Point_leq` | cross_community | 5 | +| `New → From_rgba_u8` | cross_community | 5 | +| `Terminal_normal_selection_yanks_terminal_grid_and_returns_to_typing_mode → Clamp_add` | cross_community | 5 | +| `Terminal_normal_selection_yanks_terminal_grid_and_returns_to_typing_mode → Blank` | cross_community | 5 | +| `Terminal_normal_selection_yanks_terminal_grid_and_returns_to_typing_mode → Total_rows` | cross_community | 5 | +| `Terminal_normal_selection_yanks_terminal_grid_and_returns_to_typing_mode → TerminalPoint` | cross_community | 5 | + +## Connected Areas + +| Area | Connections | +|------|-------------| +| Command_dispatch | 3 calls | +| Workbench | 2 calls | +| Palette | 2 calls | +| Theme_config | 2 calls | +| Scheduler | 2 calls | +| App_state | 1 calls | +| Text | 1 calls | +| Event_loop | 1 calls | + +## How to Explore + +1. `gitnexus_context({name: "new"})` — see callers and callees +2. `gitnexus_query({query: "terminal"})` — find related execution flows +3. Read key files listed above for implementation details diff --git a/.agents/skills/generated/text/SKILL.md b/.agents/skills/generated/text/SKILL.md new file mode 100644 index 00000000..8608a948 --- /dev/null +++ b/.agents/skills/generated/text/SKILL.md @@ -0,0 +1,97 @@ +--- +name: text +description: "Skill for the Text area of netherize_editor. 49 symbols across 13 files." +--- + +# Text + +49 symbols | 13 files | Cohesion: 72% + +## When to Use + +- Working with code in `src/` +- Understanding how set_font_family, set_text, rasterize_cache_key work +- Modifying text-related functionality + +## Key Files + +| File | Symbols | +|------|---------| +| `src/text/text_system.rs` | set_font_family, set_text, rasterize_cache_key, collect_visible_glyphs, unbounded_height_shapes_deep_lines (+12) | +| `src/text/atlas.rs` | get, get_or_reserve, flush_pending, uv_min_max, allocate_region (+4) | +| `src/text/raster.rs` | rasterize_glyph_alpha, extract_alpha_from_image_data, mask_alpha_conversion_truncates_to_expected_pixel_count, color_alpha_conversion_reads_only_alpha_channel, subpixel_alpha_conversion_uses_max_channel_per_pixel (+1) | +| `src/text/layout_sync.rs` | rebuild_layout_projection, compute_cursor_overlay, compute_caret_layout, caret_uses_line_relative_byte_offset_for_second_line_start, compute_caret_layout_at | +| `src/terminal/ansi_parser.rs` | to_rgba_f32, to_rgba_f32_with_defaults, xterm256_to_rgb | +| `src/app/event_loop/helpers.rs` | clip_styled_span_to_line, heading_spans | +| `src/terminal/terminal_renderer.rs` | build_instances | +| `src/terminal/grid.rs` | iter_visible_cells | +| `src/render/renderer/lifecycle.rs` | make_text_system | +| `src/render/renderer/helpers.rs` | collect_instances | + +## Entry Points + +Start here when exploring this area: + +- **`set_font_family`** (Function) — `src/text/text_system.rs:98` +- **`set_text`** (Function) — `src/text/text_system.rs:131` +- **`rasterize_cache_key`** (Function) — `src/text/text_system.rs:278` +- **`collect_visible_glyphs`** (Function) — `src/text/text_system.rs:294` +- **`rasterize_glyph_alpha`** (Function) — `src/text/raster.rs:19` + +## Key Symbols + +| Symbol | Type | File | Line | +|--------|------|------|------| +| `set_font_family` | Function | `src/text/text_system.rs` | 98 | +| `set_text` | Function | `src/text/text_system.rs` | 131 | +| `rasterize_cache_key` | Function | `src/text/text_system.rs` | 278 | +| `collect_visible_glyphs` | Function | `src/text/text_system.rs` | 294 | +| `rasterize_glyph_alpha` | Function | `src/text/raster.rs` | 19 | +| `rebuild_layout_projection` | Function | `src/text/layout_sync.rs` | 33 | +| `compute_cursor_overlay` | Function | `src/text/layout_sync.rs` | 123 | +| `compute_caret_layout` | Function | `src/text/layout_sync.rs` | 206 | +| `get` | Function | `src/text/atlas.rs` | 97 | +| `get_or_reserve` | Function | `src/text/atlas.rs` | 104 | +| `flush_pending` | Function | `src/text/atlas.rs` | 139 | +| `uv_min_max` | Function | `src/text/atlas.rs` | 171 | +| `build_instances` | Function | `src/terminal/terminal_renderer.rs` | 80 | +| `iter_visible_cells` | Function | `src/terminal/grid.rs` | 768 | +| `to_rgba_f32` | Function | `src/terminal/ansi_parser.rs` | 32 | +| `to_rgba_f32_with_defaults` | Function | `src/terminal/ansi_parser.rs` | 40 | +| `xterm256_to_rgb` | Function | `src/terminal/ansi_parser.rs` | 568 | +| `inline_suggestion` | Function | `src/app/app_state/state.rs` | 735 | +| `collect_inline_suggestion_glyphs` | Function | `src/render/renderer/editor/viewport.rs` | 337 | +| `set_text_with_color` | Function | `src/text/text_system.rs` | 139 | + +## Execution Flows + +| Flow | Type | Steps | +|------|------|-------| +| `Update_settings_buffer_content → From_rgba_u8` | cross_community | 6 | +| `Layout_shortcut_hint → From_rgba_u8` | cross_community | 6 | +| `Update_ai_chat_content → VisibleGlyph` | cross_community | 5 | +| `Update_ai_chat_content → Rasterize_cache_key` | cross_community | 5 | +| `Update_ai_chat_content → Extract_alpha_from_image_data` | cross_community | 5 | +| `Render_file_picker_complex → VisibleGlyph` | cross_community | 5 | +| `Render_file_picker_complex → Rasterize_cache_key` | cross_community | 5 | +| `Render_file_picker_complex → Extract_alpha_from_image_data` | cross_community | 5 | +| `Render_file_picker_complex → RasterizedGlyph` | cross_community | 5 | +| `Render_file_picker_complex → Get` | cross_community | 5 | + +## Connected Areas + +| Area | Connections | +|------|-------------| +| Renderer | 5 calls | +| Theme_config | 2 calls | +| Command_dispatch | 2 calls | +| Workbench | 1 calls | +| Terminal | 1 calls | +| Event_loop | 1 calls | +| Ui | 1 calls | + +## How to Explore + +1. `gitnexus_context({name: "set_font_family"})` — see callers and callees +2. `gitnexus_query({query: "text"})` — find related execution flows +3. Read key files listed above for implementation details diff --git a/.agents/skills/generated/theme-config/SKILL.md b/.agents/skills/generated/theme-config/SKILL.md new file mode 100644 index 00000000..91ddf036 --- /dev/null +++ b/.agents/skills/generated/theme-config/SKILL.md @@ -0,0 +1,94 @@ +--- +name: theme-config +description: "Skill for the Theme_config area of netherize_editor. 55 symbols across 9 files." +--- + +# Theme_config + +55 symbols | 9 files | Cohesion: 76% + +## When to Use + +- Working with code in `src/` +- Understanding how from_rgba_u8, builtin_dark, default_profile work +- Modifying theme_config-related functionality + +## Key Files + +| File | Symbols | +|------|---------| +| `src/config/theme_config/loader.rs` | from_raw, parse_extension_file_icons, parse_exact_file_icons, parse_editor, parse_ui (+17) | +| `src/config/theme_config/model.rs` | from_rgba_u8, file_icon_lookup_prefers_dir_then_exact_then_extension_then_default, new, to_wgpu, as_srgb_f32 (+12) | +| `src/config/theme_config/builtin.rs` | builtin_dark, builtin_editor_tokens, builtin_ui_tokens, builtin_syntax_tokens, builtin_git_tokens (+4) | +| `src/render/renderer/helpers.rs` | theme_color_to_wgpu, ext_icon_dot | +| `src/app/command_palette.rs` | theme | +| `src/core/command_dispatch/palette.rs` | open_theme_selector | +| `src/app/app_state/palette.rs` | open_theme_selector_palette | +| `src/render/renderer/lifecycle.rs` | apply_theme | +| `src/text/text_system.rs` | rgba_f32_from_color | + +## Entry Points + +Start here when exploring this area: + +- **`from_rgba_u8`** (Function) — `src/config/theme_config/model.rs:35` +- **`builtin_dark`** (Function) — `src/config/theme_config/builtin.rs:8` +- **`default_profile`** (Function) — `src/config/theme_config/loader.rs:23` +- **`resolved_profile`** (Function) — `src/config/theme_config/loader.rs:27` +- **`active_profile`** (Function) — `src/config/theme_config/loader.rs:41` + +## Key Symbols + +| Symbol | Type | File | Line | +|--------|------|------|------| +| `from_rgba_u8` | Function | `src/config/theme_config/model.rs` | 35 | +| `builtin_dark` | Function | `src/config/theme_config/builtin.rs` | 8 | +| `default_profile` | Function | `src/config/theme_config/loader.rs` | 23 | +| `resolved_profile` | Function | `src/config/theme_config/loader.rs` | 27 | +| `active_profile` | Function | `src/config/theme_config/loader.rs` | 41 | +| `load_active` | Function | `src/config/theme_config/loader.rs` | 70 | +| `load_preferred` | Function | `src/config/theme_config/loader.rs` | 74 | +| `load` | Function | `src/config/theme_config/loader.rs` | 89 | +| `load_from_path` | Function | `src/config/theme_config/loader.rs` | 95 | +| `theme` | Function | `src/app/command_palette.rs` | 233 | +| `list_available_themes` | Function | `src/config/theme_config/loader.rs` | 45 | +| `list_available_theme_entries` | Function | `src/config/theme_config/loader.rs` | 52 | +| `open_theme_selector_palette` | Function | `src/app/app_state/palette.rs` | 110 | +| `apply_theme` | Function | `src/render/renderer/lifecycle.rs` | 305 | +| `theme_color_to_wgpu` | Function | `src/render/renderer/helpers.rs` | 379 | +| `new` | Function | `src/config/theme_config/model.rs` | 16 | +| `to_wgpu` | Function | `src/config/theme_config/model.rs` | 24 | +| `as_srgb_f32` | Function | `src/config/theme_config/model.rs` | 69 | +| `as_linear` | Function | `src/config/theme_config/model.rs` | 78 | +| `ext_icon_dot` | Function | `src/render/renderer/helpers.rs` | 386 | + +## Execution Flows + +| Flow | Type | Steps | +|------|------|-------| +| `Update_settings_buffer_content → From_rgba_u8` | cross_community | 6 | +| `Layout_shortcut_hint → From_rgba_u8` | cross_community | 6 | +| `Update_statusbar_content → As_srgb_f32` | cross_community | 5 | +| `Update_statusbar_content → New` | cross_community | 5 | +| `Update_statusbar_content → Srgb_to_linear` | cross_community | 5 | +| `New → From_rgba_u8` | cross_community | 5 | +| `Render_live_grep_picker → As_array` | cross_community | 4 | +| `New → UiThemeTokens` | cross_community | 4 | +| `New → EditorThemeTokens` | cross_community | 4 | +| `New → GitThemeTokens` | cross_community | 4 | + +## Connected Areas + +| Area | Connections | +|------|-------------| +| Event_loop | 2 calls | +| App_state | 2 calls | +| Command_dispatch | 2 calls | +| Config | 2 calls | +| Scheduler | 1 calls | + +## How to Explore + +1. `gitnexus_context({name: "from_rgba_u8"})` — see callers and callees +2. `gitnexus_query({query: "theme_config"})` — find related execution flows +3. Read key files listed above for implementation details diff --git a/.agents/skills/generated/workbench/SKILL.md b/.agents/skills/generated/workbench/SKILL.md new file mode 100644 index 00000000..075932f8 --- /dev/null +++ b/.agents/skills/generated/workbench/SKILL.md @@ -0,0 +1,91 @@ +--- +name: workbench +description: "Skill for the Workbench area of netherize_editor. 108 symbols across 17 files." +--- + +# Workbench + +108 symbols | 17 files | Cohesion: 87% + +## When to Use + +- Working with code in `src/` +- Understanding how flatten, find, from_ui_theme work +- Modifying workbench-related functionality + +## Key Files + +| File | Symbols | +|------|---------| +| `src/workbench/layout_engine.rs` | default, from_ui_theme, new, status_bar_top_gap, compute (+23) | +| `src/workbench/inspector_panel.rs` | title, visible_rows, move_selection_next, toggle_selected_expand, selected_row_label (+10) | +| `src/workbench/panel_state.rs` | new, switch_to_prev_tab, default, switch_bottom_prev_tab, switch_left_prev_tab (+10) | +| `src/workbench/text_coordinate_map.rs` | from_text, map_line_column_to_rect, map_source_location_rect, gutter_marker_rect, maps_line_column_to_stable_pixel_rect (+7) | +| `src/workbench/focus_manager.rs` | default, set, ensure_valid, cycle_next, cycle_prev (+5) | +| `src/workbench/region_model.rs` | flatten, flatten_node, find, find_node, new (+2) | +| `src/workbench/overlay_manager.rs` | default, build_overlays, clamp_to_bounds, builds_both_window_and_editor_relative_overlays | +| `src/workbench/debug_state.rs` | default, toggle_breakpoint_on_execution_line, toggle_breakpoint_at_line, toggle_breakpoint_adds_and_removes_on_same_line | +| `src/app/event_loop/helpers.rs` | preserve_markdown_blank_lines, should_preserve_markdown_blank_line, markdown_line_indent_level | +| `src/app/event_loop/commands_tests.rs` | explorer_filter_commands_update_workspace_state, leap_uses_editor_targets_even_when_explorer_is_focused | + +## Entry Points + +Start here when exploring this area: + +- **`flatten`** (Function) — `src/workbench/region_model.rs:103` +- **`find`** (Function) — `src/workbench/region_model.rs:120` +- **`from_ui_theme`** (Function) — `src/workbench/layout_engine.rs:87` +- **`new`** (Function) — `src/workbench/layout_engine.rs:114` +- **`compute`** (Function) — `src/workbench/layout_engine.rs:125` + +## Key Symbols + +| Symbol | Type | File | Line | +|--------|------|------|------| +| `flatten` | Function | `src/workbench/region_model.rs` | 103 | +| `find` | Function | `src/workbench/region_model.rs` | 120 | +| `from_ui_theme` | Function | `src/workbench/layout_engine.rs` | 87 | +| `new` | Function | `src/workbench/layout_engine.rs` | 114 | +| `compute` | Function | `src/workbench/layout_engine.rs` | 125 | +| `apply_handle_drag` | Function | `src/workbench/layout_engine.rs` | 420 | +| `language_profile_for_extension` | Function | `src/lsp/registry.rs` | 225 | +| `lsp_entry_for_extension` | Function | `src/lsp/client.rs` | 42 | +| `new` | Function | `src/render/surface.rs` | 13 | +| `set` | Function | `src/workbench/focus_manager.rs` | 48 | +| `ensure_valid` | Function | `src/workbench/focus_manager.rs` | 56 | +| `cycle_next` | Function | `src/workbench/focus_manager.rs` | 68 | +| `cycle_prev` | Function | `src/workbench/focus_manager.rs` | 72 | +| `set_from_click` | Function | `src/workbench/focus_manager.rs` | 98 | +| `from_text` | Function | `src/workbench/text_coordinate_map.rs` | 13 | +| `map_line_column_to_rect` | Function | `src/workbench/text_coordinate_map.rs` | 75 | +| `map_source_location_rect` | Function | `src/workbench/text_coordinate_map.rs` | 97 | +| `gutter_marker_rect` | Function | `src/workbench/text_coordinate_map.rs` | 101 | +| `new` | Function | `src/workbench/region_model.rs` | 42 | +| `build_overlays` | Function | `src/workbench/overlay_manager.rs` | 83 | + +## Execution Flows + +| Flow | Type | Steps | +|------|------|-------| +| `Save_file_preserves_cursor_and_selection_state → Find_node` | cross_community | 9 | +| `Self_save_modify_event_is_ignored_without_reloading_cursor → Find_node` | cross_community | 9 | +| `External_reload_clamps_cursor_and_selection_to_new_buffer_length → Find_node` | cross_community | 9 | +| `Scenario_insert_and_scroll → Find_node` | cross_community | 9 | +| `Spawn_lsp_server → Find_node` | cross_community | 7 | +| `Spawn_lsp_server → FlatRegion` | cross_community | 7 | +| `Picker_open_query_select_flow → Find_node` | cross_community | 7 | +| `Run_pty_request → FlatRegion` | cross_community | 7 | +| `Startup_subsystems → Find_node` | cross_community | 6 | +| `Run_lsp_request → Find_node` | cross_community | 6 | + +## Connected Areas + +| Area | Connections | +|------|-------------| +| Command_dispatch | 2 calls | + +## How to Explore + +1. `gitnexus_context({name: "flatten"})` — see callers and callees +2. `gitnexus_query({query: "workbench"})` — find related execution flows +3. Read key files listed above for implementation details diff --git a/.agents/skills/generated/workspace/SKILL.md b/.agents/skills/generated/workspace/SKILL.md new file mode 100644 index 00000000..e2adcd76 --- /dev/null +++ b/.agents/skills/generated/workspace/SKILL.md @@ -0,0 +1,86 @@ +--- +name: workspace +description: "Skill for the Workspace area of netherize_editor. 66 symbols across 4 files." +--- + +# Workspace + +66 symbols | 4 files | Cohesion: 90% + +## When to Use + +- Working with code in `src/` +- Understanding how new, scan, should_ignore_dir work +- Modifying workspace-related functionality + +## Key Files + +| File | Symbols | +|------|---------| +| `src/workspace/model.rs` | should_ignore_dir, select_path, expand_path, collapse_path, collapse_path_and_descendants (+28) | +| `src/workspace/scanner.rs` | default, new, scan, scan_dir_recursive, build_gitignore_matcher (+11) | +| `src/app/app_state/workspace.rs` | workspace_select_path, workspace_expand_path, workspace_collapse_path, workspace_collapse_path_and_descendants, workspace_expand_path_and_descendants (+7) | +| `src/workspace/fuzzy.rs` | find_file_matches, score_candidate, unique_temp_dir, fuzzy_matches_rank_substring_hits_higher, empty_query_returns_first_files_only | + +## Entry Points + +Start here when exploring this area: + +- **`new`** (Function) — `src/workspace/scanner.rs:30` +- **`scan`** (Function) — `src/workspace/scanner.rs:37` +- **`should_ignore_dir`** (Function) — `src/workspace/model.rs:63` +- **`select_path`** (Function) — `src/workspace/model.rs:280` +- **`expand_path`** (Function) — `src/workspace/model.rs:292` + +## Key Symbols + +| Symbol | Type | File | Line | +|--------|------|------|------| +| `new` | Function | `src/workspace/scanner.rs` | 30 | +| `scan` | Function | `src/workspace/scanner.rs` | 37 | +| `should_ignore_dir` | Function | `src/workspace/model.rs` | 63 | +| `select_path` | Function | `src/workspace/model.rs` | 280 | +| `expand_path` | Function | `src/workspace/model.rs` | 292 | +| `collapse_path` | Function | `src/workspace/model.rs` | 300 | +| `collapse_path_and_descendants` | Function | `src/workspace/model.rs` | 308 | +| `expand_path_and_descendants` | Function | `src/workspace/model.rs` | 328 | +| `reveal_path` | Function | `src/workspace/model.rs` | 343 | +| `expand_to_path` | Function | `src/workspace/model.rs` | 378 | +| `workspace_select_path` | Function | `src/app/app_state/workspace.rs` | 283 | +| `workspace_expand_path` | Function | `src/app/app_state/workspace.rs` | 289 | +| `workspace_collapse_path` | Function | `src/app/app_state/workspace.rs` | 295 | +| `workspace_collapse_path_and_descendants` | Function | `src/app/app_state/workspace.rs` | 301 | +| `workspace_expand_path_and_descendants` | Function | `src/app/app_state/workspace.rs` | 307 | +| `workspace_expand_to_path` | Function | `src/app/app_state/workspace.rs` | 313 | +| `workspace_reveal_path` | Function | `src/app/app_state/workspace.rs` | 319 | +| `new` | Function | `src/workspace/model.rs` | 48 | +| `load_with_rules` | Function | `src/workspace/model.rs` | 130 | +| `find_file_matches` | Function | `src/workspace/fuzzy.rs` | 11 | + +## Execution Flows + +| Flow | Type | Steps | +|------|------|-------| +| `Save_file_preserves_cursor_and_selection_state → Find_node` | cross_community | 9 | +| `Self_save_modify_event_is_ignored_without_reloading_cursor → Find_node` | cross_community | 9 | +| `External_reload_clamps_cursor_and_selection_to_new_buffer_length → Find_node` | cross_community | 9 | +| `Scenario_insert_and_scroll → Find_node` | cross_community | 9 | +| `External_reload_error_does_not_abort_workspace_updates → Is_hidden_name` | cross_community | 8 | +| `File_picker_results_refresh_while_overlay_is_open_after_external_create → Is_hidden_name` | cross_community | 8 | +| `External_modify_reloads_when_clean_and_warns_when_dirty → Is_hidden_name` | cross_community | 8 | +| `Modify_event_on_missing_path_triggers_workspace_rescan_for_rename_like_flow → Is_hidden_name` | cross_community | 8 | +| `Workspace_and_file_picker_state_are_tracked → Is_hidden_name` | cross_community | 8 | +| `Picker_open_query_select_flow → Find_node` | cross_community | 7 | + +## Connected Areas + +| Area | Connections | +|------|-------------| +| App_state | 1 calls | +| Workbench | 1 calls | + +## How to Explore + +1. `gitnexus_context({name: "new"})` — see callers and callees +2. `gitnexus_query({query: "workspace"})` — find related execution flows +3. Read key files listed above for implementation details diff --git a/.agents/skills/gitnexus/gitnexus-cli/SKILL.md b/.agents/skills/gitnexus/gitnexus-cli/SKILL.md new file mode 100644 index 00000000..ee40c41d --- /dev/null +++ b/.agents/skills/gitnexus/gitnexus-cli/SKILL.md @@ -0,0 +1,83 @@ +--- +name: gitnexus-cli +description: "Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: \"Index this repo\", \"Reanalyze the codebase\", \"Generate a wiki\"" +--- + +# GitNexus CLI Commands + +All commands work via `npx` — no global install required. + +## Commands + +### analyze — Build or refresh the index + +```bash +npx gitnexus analyze +``` + +Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates AGENTS.md / AGENTS.md context files. + +| Flag | Effect | +| -------------- | ---------------------------------------------------------------- | +| `--force` | Force full re-index even if up to date | +| `--embeddings` | Enable embedding generation for semantic search (off by default) | +| `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. | + +**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Codex, a PostToolUse hook runs `analyze` automatically after `git commit` and `git merge`, preserving embeddings if previously generated. + +### status — Check index freshness + +```bash +npx gitnexus status +``` + +Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed. + +### clean — Delete the index + +```bash +npx gitnexus clean +``` + +Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project. + +| Flag | Effect | +| --------- | ------------------------------------------------- | +| `--force` | Skip confirmation prompt | +| `--all` | Clean all indexed repos, not just the current one | + +### wiki — Generate documentation from the graph + +```bash +npx gitnexus wiki +``` + +Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use). + +| Flag | Effect | +| ------------------- | ----------------------------------------- | +| `--force` | Force full regeneration | +| `--model ` | LLM model (default: minimax/minimax-m2.5) | +| `--base-url ` | LLM API base URL | +| `--api-key ` | LLM API key | +| `--concurrency ` | Parallel LLM calls (default: 3) | +| `--gist` | Publish wiki as a public GitHub Gist | + +### list — Show all indexed repos + +```bash +npx gitnexus list +``` + +Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information. + +## After Indexing + +1. **Read `gitnexus://repo/{name}/context`** to verify the index loaded +2. Use the other GitNexus skills (`exploring`, `debugging`, `impact-analysis`, `refactoring`) for your task + +## Troubleshooting + +- **"Not inside a git repository"**: Run from a directory inside a git repo +- **Index is stale after re-analyzing**: Restart Codex to reload the MCP server +- **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding diff --git a/.agents/skills/gitnexus/gitnexus-debugging/SKILL.md b/.agents/skills/gitnexus/gitnexus-debugging/SKILL.md new file mode 100644 index 00000000..9510b97a --- /dev/null +++ b/.agents/skills/gitnexus/gitnexus-debugging/SKILL.md @@ -0,0 +1,89 @@ +--- +name: gitnexus-debugging +description: "Use when the user is debugging a bug, tracing an error, or asking why something fails. Examples: \"Why is X failing?\", \"Where does this error come from?\", \"Trace this bug\"" +--- + +# Debugging with GitNexus + +## When to Use + +- "Why is this function failing?" +- "Trace where this error comes from" +- "Who calls this method?" +- "This endpoint returns 500" +- Investigating bugs, errors, or unexpected behavior + +## Workflow + +``` +1. gitnexus_query({query: ""}) → Find related execution flows +2. gitnexus_context({name: ""}) → See callers/callees/processes +3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow +4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed +``` + +> If "Index is stale" → run `npx gitnexus analyze` in terminal. + +## Checklist + +``` +- [ ] Understand the symptom (error message, unexpected behavior) +- [ ] gitnexus_query for error text or related code +- [ ] Identify the suspect function from returned processes +- [ ] gitnexus_context to see callers and callees +- [ ] Trace execution flow via process resource if applicable +- [ ] gitnexus_cypher for custom call chain traces if needed +- [ ] Read source files to confirm root cause +``` + +## Debugging Patterns + +| Symptom | GitNexus Approach | +| -------------------- | ---------------------------------------------------------- | +| Error message | `gitnexus_query` for error text → `context` on throw sites | +| Wrong return value | `context` on the function → trace callees for data flow | +| Intermittent failure | `context` → look for external calls, async deps | +| Performance issue | `context` → find symbols with many callers (hot paths) | +| Recent regression | `detect_changes` to see what your changes affect | + +## Tools + +**gitnexus_query** — find code related to error: + +``` +gitnexus_query({query: "payment validation error"}) +→ Processes: CheckoutFlow, ErrorHandling +→ Symbols: validatePayment, handlePaymentError, PaymentException +``` + +**gitnexus_context** — full context for a suspect: + +``` +gitnexus_context({name: "validatePayment"}) +→ Incoming calls: processCheckout, webhookHandler +→ Outgoing calls: verifyCard, fetchRates (external API!) +→ Processes: CheckoutFlow (step 3/7) +``` + +**gitnexus_cypher** — custom call chain traces: + +```cypher +MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"}) +RETURN [n IN nodes(path) | n.name] AS chain +``` + +## Example: "Payment endpoint returns 500 intermittently" + +``` +1. gitnexus_query({query: "payment error handling"}) + → Processes: CheckoutFlow, ErrorHandling + → Symbols: validatePayment, handlePaymentError + +2. gitnexus_context({name: "validatePayment"}) + → Outgoing calls: verifyCard, fetchRates (external API!) + +3. READ gitnexus://repo/my-app/process/CheckoutFlow + → Step 3: validatePayment → calls fetchRates (external) + +4. Root cause: fetchRates calls external API without proper timeout +``` diff --git a/.agents/skills/gitnexus/gitnexus-exploring/SKILL.md b/.agents/skills/gitnexus/gitnexus-exploring/SKILL.md new file mode 100644 index 00000000..927a4e4b --- /dev/null +++ b/.agents/skills/gitnexus/gitnexus-exploring/SKILL.md @@ -0,0 +1,78 @@ +--- +name: gitnexus-exploring +description: "Use when the user asks how code works, wants to understand architecture, trace execution flows, or explore unfamiliar parts of the codebase. Examples: \"How does X work?\", \"What calls this function?\", \"Show me the auth flow\"" +--- + +# Exploring Codebases with GitNexus + +## When to Use + +- "How does authentication work?" +- "What's the project structure?" +- "Show me the main components" +- "Where is the database logic?" +- Understanding code you haven't seen before + +## Workflow + +``` +1. READ gitnexus://repos → Discover indexed repos +2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness +3. gitnexus_query({query: ""}) → Find related execution flows +4. gitnexus_context({name: ""}) → Deep dive on specific symbol +5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow +``` + +> If step 2 says "Index is stale" → run `npx gitnexus analyze` in terminal. + +## Checklist + +``` +- [ ] READ gitnexus://repo/{name}/context +- [ ] gitnexus_query for the concept you want to understand +- [ ] Review returned processes (execution flows) +- [ ] gitnexus_context on key symbols for callers/callees +- [ ] READ process resource for full execution traces +- [ ] Read source files for implementation details +``` + +## Resources + +| Resource | What you get | +| --------------------------------------- | ------------------------------------------------------- | +| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) | +| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) | +| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) | +| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) | + +## Tools + +**gitnexus_query** — find execution flows related to a concept: + +``` +gitnexus_query({query: "payment processing"}) +→ Processes: CheckoutFlow, RefundFlow, WebhookHandler +→ Symbols grouped by flow with file locations +``` + +**gitnexus_context** — 360-degree view of a symbol: + +``` +gitnexus_context({name: "validateUser"}) +→ Incoming calls: loginHandler, apiMiddleware +→ Outgoing calls: checkToken, getUserById +→ Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3) +``` + +## Example: "How does payment processing work?" + +``` +1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes +2. gitnexus_query({query: "payment processing"}) + → CheckoutFlow: processPayment → validateCard → chargeStripe + → RefundFlow: initiateRefund → calculateRefund → processRefund +3. gitnexus_context({name: "processPayment"}) + → Incoming: checkoutHandler, webhookHandler + → Outgoing: validateCard, chargeStripe, saveTransaction +4. Read src/payments/processor.ts for implementation details +``` diff --git a/.agents/skills/gitnexus/gitnexus-guide/SKILL.md b/.agents/skills/gitnexus/gitnexus-guide/SKILL.md new file mode 100644 index 00000000..937ac73d --- /dev/null +++ b/.agents/skills/gitnexus/gitnexus-guide/SKILL.md @@ -0,0 +1,64 @@ +--- +name: gitnexus-guide +description: "Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: \"What GitNexus tools are available?\", \"How do I use GitNexus?\"" +--- + +# GitNexus Guide + +Quick reference for all GitNexus MCP tools, resources, and the knowledge graph schema. + +## Always Start Here + +For any task involving code understanding, debugging, impact analysis, or refactoring: + +1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness +2. **Match your task to a skill below** and **read that skill file** +3. **Follow the skill's workflow and checklist** + +> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first. + +## Skills + +| Task | Skill to read | +| -------------------------------------------- | ------------------- | +| Understand architecture / "How does X work?" | `gitnexus-exploring` | +| Blast radius / "What breaks if I change X?" | `gitnexus-impact-analysis` | +| Trace bugs / "Why is X failing?" | `gitnexus-debugging` | +| Rename / extract / split / refactor | `gitnexus-refactoring` | +| Tools, resources, schema reference | `gitnexus-guide` (this file) | +| Index, status, clean, wiki CLI commands | `gitnexus-cli` | + +## Tools Reference + +| Tool | What it gives you | +| ---------------- | ------------------------------------------------------------------------ | +| `query` | Process-grouped code intelligence — execution flows related to a concept | +| `context` | 360-degree symbol view — categorized refs, processes it participates in | +| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence | +| `detect_changes` | Git-diff impact — what do your current changes affect | +| `rename` | Multi-file coordinated rename with confidence-tagged edits | +| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) | +| `list_repos` | Discover indexed repos | + +## Resources Reference + +Lightweight reads (~100-500 tokens) for navigation: + +| Resource | Content | +| ---------------------------------------------- | ----------------------------------------- | +| `gitnexus://repo/{name}/context` | Stats, staleness check | +| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores | +| `gitnexus://repo/{name}/cluster/{clusterName}` | Area members | +| `gitnexus://repo/{name}/processes` | All execution flows | +| `gitnexus://repo/{name}/process/{processName}` | Step-by-step trace | +| `gitnexus://repo/{name}/schema` | Graph schema for Cypher | + +## Graph Schema + +**Nodes:** File, Function, Class, Interface, Method, Community, Process +**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS + +```cypher +MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"}) +RETURN caller.name, caller.filePath +``` diff --git a/.agents/skills/gitnexus/gitnexus-impact-analysis/SKILL.md b/.agents/skills/gitnexus/gitnexus-impact-analysis/SKILL.md new file mode 100644 index 00000000..e19af280 --- /dev/null +++ b/.agents/skills/gitnexus/gitnexus-impact-analysis/SKILL.md @@ -0,0 +1,97 @@ +--- +name: gitnexus-impact-analysis +description: "Use when the user wants to know what will break if they change something, or needs safety analysis before editing code. Examples: \"Is it safe to change X?\", \"What depends on this?\", \"What will break?\"" +--- + +# Impact Analysis with GitNexus + +## When to Use + +- "Is it safe to change this function?" +- "What will break if I modify X?" +- "Show me the blast radius" +- "Who uses this code?" +- Before making non-trivial code changes +- Before committing — to understand what your changes affect + +## Workflow + +``` +1. gitnexus_impact({target: "X", direction: "upstream"}) → What depends on this +2. READ gitnexus://repo/{name}/processes → Check affected execution flows +3. gitnexus_detect_changes() → Map current git changes to affected flows +4. Assess risk and report to user +``` + +> If "Index is stale" → run `npx gitnexus analyze` in terminal. + +## Checklist + +``` +- [ ] gitnexus_impact({target, direction: "upstream"}) to find dependents +- [ ] Review d=1 items first (these WILL BREAK) +- [ ] Check high-confidence (>0.8) dependencies +- [ ] READ processes to check affected execution flows +- [ ] gitnexus_detect_changes() for pre-commit check +- [ ] Assess risk level and report to user +``` + +## Understanding Output + +| Depth | Risk Level | Meaning | +| ----- | ---------------- | ------------------------ | +| d=1 | **WILL BREAK** | Direct callers/importers | +| d=2 | LIKELY AFFECTED | Indirect dependencies | +| d=3 | MAY NEED TESTING | Transitive effects | + +## Risk Assessment + +| Affected | Risk | +| ------------------------------ | -------- | +| <5 symbols, few processes | LOW | +| 5-15 symbols, 2-5 processes | MEDIUM | +| >15 symbols or many processes | HIGH | +| Critical path (auth, payments) | CRITICAL | + +## Tools + +**gitnexus_impact** — the primary tool for symbol blast radius: + +``` +gitnexus_impact({ + target: "validateUser", + direction: "upstream", + minConfidence: 0.8, + maxDepth: 3 +}) + +→ d=1 (WILL BREAK): + - loginHandler (src/auth/login.ts:42) [CALLS, 100%] + - apiMiddleware (src/api/middleware.ts:15) [CALLS, 100%] + +→ d=2 (LIKELY AFFECTED): + - authRouter (src/routes/auth.ts:22) [CALLS, 95%] +``` + +**gitnexus_detect_changes** — git-diff based impact analysis: + +``` +gitnexus_detect_changes({scope: "staged"}) + +→ Changed: 5 symbols in 3 files +→ Affected: LoginFlow, TokenRefresh, APIMiddlewarePipeline +→ Risk: MEDIUM +``` + +## Example: "What breaks if I change validateUser?" + +``` +1. gitnexus_impact({target: "validateUser", direction: "upstream"}) + → d=1: loginHandler, apiMiddleware (WILL BREAK) + → d=2: authRouter, sessionManager (LIKELY AFFECTED) + +2. READ gitnexus://repo/my-app/processes + → LoginFlow and TokenRefresh touch validateUser + +3. Risk: 2 direct callers, 2 processes = MEDIUM +``` diff --git a/.agents/skills/gitnexus/gitnexus-refactoring/SKILL.md b/.agents/skills/gitnexus/gitnexus-refactoring/SKILL.md new file mode 100644 index 00000000..f48cc01b --- /dev/null +++ b/.agents/skills/gitnexus/gitnexus-refactoring/SKILL.md @@ -0,0 +1,121 @@ +--- +name: gitnexus-refactoring +description: "Use when the user wants to rename, extract, split, move, or restructure code safely. Examples: \"Rename this function\", \"Extract this into a module\", \"Refactor this class\", \"Move this to a separate file\"" +--- + +# Refactoring with GitNexus + +## When to Use + +- "Rename this function safely" +- "Extract this into a module" +- "Split this service" +- "Move this to a new file" +- Any task involving renaming, extracting, splitting, or restructuring code + +## Workflow + +``` +1. gitnexus_impact({target: "X", direction: "upstream"}) → Map all dependents +2. gitnexus_query({query: "X"}) → Find execution flows involving X +3. gitnexus_context({name: "X"}) → See all incoming/outgoing refs +4. Plan update order: interfaces → implementations → callers → tests +``` + +> If "Index is stale" → run `npx gitnexus analyze` in terminal. + +## Checklists + +### Rename Symbol + +``` +- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits +- [ ] Review graph edits (high confidence) and ast_search edits (review carefully) +- [ ] If satisfied: gitnexus_rename({..., dry_run: false}) — apply edits +- [ ] gitnexus_detect_changes() — verify only expected files changed +- [ ] Run tests for affected processes +``` + +### Extract Module + +``` +- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs +- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers +- [ ] Define new module interface +- [ ] Extract code, update imports +- [ ] gitnexus_detect_changes() — verify affected scope +- [ ] Run tests for affected processes +``` + +### Split Function/Service + +``` +- [ ] gitnexus_context({name: target}) — understand all callees +- [ ] Group callees by responsibility +- [ ] gitnexus_impact({target, direction: "upstream"}) — map callers to update +- [ ] Create new functions/services +- [ ] Update callers +- [ ] gitnexus_detect_changes() — verify affected scope +- [ ] Run tests for affected processes +``` + +## Tools + +**gitnexus_rename** — automated multi-file rename: + +``` +gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true}) +→ 12 edits across 8 files +→ 10 graph edits (high confidence), 2 ast_search edits (review) +→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}] +``` + +**gitnexus_impact** — map all dependents first: + +``` +gitnexus_impact({target: "validateUser", direction: "upstream"}) +→ d=1: loginHandler, apiMiddleware, testUtils +→ Affected Processes: LoginFlow, TokenRefresh +``` + +**gitnexus_detect_changes** — verify your changes after refactoring: + +``` +gitnexus_detect_changes({scope: "all"}) +→ Changed: 8 files, 12 symbols +→ Affected processes: LoginFlow, TokenRefresh +→ Risk: MEDIUM +``` + +**gitnexus_cypher** — custom reference queries: + +```cypher +MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"}) +RETURN caller.name, caller.filePath ORDER BY caller.filePath +``` + +## Risk Rules + +| Risk Factor | Mitigation | +| ------------------- | ----------------------------------------- | +| Many callers (>5) | Use gitnexus_rename for automated updates | +| Cross-area refs | Use detect_changes after to verify scope | +| String/dynamic refs | gitnexus_query to find them | +| External/public API | Version and deprecate properly | + +## Example: Rename `validateUser` to `authenticateUser` + +``` +1. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true}) + → 12 edits: 10 graph (safe), 2 ast_search (review) + → Files: validator.ts, login.ts, middleware.ts, config.json... + +2. Review ast_search edits (config.json: dynamic reference!) + +3. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false}) + → Applied 12 edits across 8 files + +4. gitnexus_detect_changes({scope: "all"}) + → Affected: LoginFlow, TokenRefresh + → Risk: MEDIUM — run tests for these flows +``` diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 00000000..54086ca8 --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,7 @@ +[mcp_servers.gitnexus] +command = "npx" +args = [ + "-y", + "gitnexus@latest", + "mcp", +] diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 00000000..91f3efc3 --- /dev/null +++ b/.codex/hooks.json @@ -0,0 +1,71 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Read", + "hooks": [ + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR/.wolf/hooks/pre-read.js\"", + "timeout": 5 + } + ] + }, + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR/.wolf/hooks/pre-write.js\"", + "timeout": 5 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Read", + "hooks": [ + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR/.wolf/hooks/post-read.js\"", + "timeout": 5 + } + ] + }, + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR/.wolf/hooks/post-write.js\"", + "timeout": 10 + } + ] + } + ], + "SessionStart": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR/.wolf/hooks/session-start.js\"", + "timeout": 5 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR/.wolf/hooks/stop.js\"", + "timeout": 10 + } + ] + } + ] + } +} diff --git a/.wolf/anatomy.md b/.wolf/anatomy.md index eee2cc86..368ebc8b 100644 --- a/.wolf/anatomy.md +++ b/.wolf/anatomy.md @@ -1,7 +1,21 @@ # anatomy.md -> Auto-maintained by OpenWolf. Last scanned: 2026-05-30T11:00:00.789Z -> Files: 381 tracked | Anatomy hits: 0 | Misses: 0 +> Auto-maintained by OpenWolf. Last scanned: 2026-06-12T15:38:50.645Z +> Files: 382 tracked | Anatomy hits: 0 | Misses: 0 + +## ../../../../tmp/ + +- `codestral_ctx_test.py` — average, ask (~1020 tok) +- `multiline_test.py` — ask, show, process (~588 tok) +- `sanitize_check.py` — Mirror of src/app/event_loop/async_results/ai.rs::sanitize_inline_suggestion (~490 tok) + +## ../../.claude/projects/-Users-qc-bright-Project-netherize-editor/memory/ + +- `onboarding-roadmap.md` — Declares blocks (~497 tok) + +## ../../.config/netherize/ + +- `ui.toml` (~139 tok) ## ./ @@ -9,20 +23,20 @@ - `.DS_Store` (~1640 tok) - `.gitignore` — Git ignore rules (~32 tok) - `AGENTS.md` — Project Rules Precedence (~1490 tok) +- `AI_INLINE_COMPLETION_STATUS_REPORT.md` — AI inline completion audit/status report (~1600 tok) - `BUILD.md` — Build Instructions (~915 tok) -- `Cargo.toml` — Rust package manifest (~660 tok) +- `Cargo.toml` — Rust package manifest (~669 tok) - `CLAUDE.md` — OpenWolf (~1543 tok) - `Cross.toml` (~73 tok) - `debug.md` — Kiến trúc Built-in Debugger: Hệ thống Generic DAP Client trong Netherize Editor (~2000 tok) - `DEPENDENCIES.md` — Netherize Editor - Runtime Dependencies (~1867 tok) - `README.md` — Project documentation (~6759 tok) -- `test_fvm_detection.sh` — Test FVM detection logic (~311 tok) - `update_themes.sh` — Script to update all theme files with vibrant syntax colors (~410 tok) ## .claude/ - `settings.json` (~441 tok) -- `settings.local.json` (~1660 tok) +- `settings.local.json` (~1672 tok) ## .claude/rules/ @@ -191,11 +205,11 @@ ## config/ - `.DS_Store` (~1640 tok) -- `ai.toml` (~79 tok) +- `ai.toml` (~219 tok) ## config/keymaps/ -- `default.toml` — Netherize Editor — Unified Neovim Profile (Final Clean) (~5308 tok) +- `default.toml` — Netherize Editor — Unified Neovim Profile (Final Clean) (~5194 tok) ## config/themes/ @@ -285,7 +299,7 @@ ## config/ui/ -- `default.toml` (~377 tok) +- `default.toml` (~391 tok) ## docs/ @@ -315,124 +329,122 @@ - `clipboard.rs` — SystemClipboard: new (~524 tok) - `command_palette.rs` — File Picker (Space f f) — Box 800px, top-center, badges theo ext (~15739 tok) - `file_picker.rs` — FilePickerEntry: open, close, append_query, backspace_query + 6 more (~1644 tok) -- `match_ranges.rs` — [derive(Debug, Clone, Copy, PartialEq, Eq)] (~2128 tok) +- `match_ranges.rs` — [derive(Debug, Clone, Copy, PartialEq, Eq)] (~2135 tok) - `mod.rs` (~66 tok) -- `persistence.rs` — [derive(Debug, Clone, Default, Serialize, Deserialize)] (~1310 tok) -- `resolved_keymap.rs` — Typed representation of a key specification parsed from a TOML string. (~16174 tok) +- `persistence.rs` — True after the one-time first-run key-hint toast was shown. (~1263 tok) +- `resolved_keymap.rs` — Typed representation of a key specification parsed from a TOML string. (~16007 tok) ## src/app/app_state/ -- `buffers.rs` — open_file, save_file, reload_active_file_from_disk_discarding_local, new_empty_buffer + 12 more (~8570 tok) -- `editor.rs` — insert_tab, insert_char, step_over_closing_char, insert_html_auto_close_tag + 17 more (~10839 tok) -- `mod.rs` — ExternalChangeReport: status_label, new, is_supported_image_path, new + 7 more (~23976 tok) +- `buffers.rs` — open_file, save_file, reload_active_file_from_disk_discarding_local, new_empty_buffer + 12 more (~8220 tok) +- `editor.rs` — insert_tab, insert_char, step_over_closing_char, insert_html_auto_close_tag, insert_auto_pair (~10520 tok) +- `mod.rs` — ExternalChangeReport: status_label, new, is_supported_image_path, new (~23243 tok) - `multi_cursor.rs` — Returns all virtual cursors (read-only, used by the renderer). (~9219 tok) -- `overlays.rs` — Update an item's `detail` (signature) by label. Used to apply data filled in (~18409 tok) -- `palette.rs` — open_command_palette_mode, open_python_env_selector, open_python_env_selector_with_items, open_dart_env_selector + 24 more (~18413 tok) -- `settings.rs` — [derive(Debug, Clone, PartialEq)] (~1971 tok) -- `state.rs` — file_history_picker_items, build_file_history_diff_preview, begin_file_history_preview_session, preview_file_history_index + 9 more (~11571 tok) -- `tests.rs` — [cfg(test)] (~19670 tok) -- `workspace.rs` — attach_workspace, clear_workspace_session_state, workspace_root_path, workspace_nodes + 22 more (~4385 tok) +- `overlays.rs` — Update an item's `detail` (signature) by label. Used to apply data filled in (~18369 tok) +- `palette.rs` — open_command_palette_mode, open_python_env_selector, open_python_env_selector_with_items, open_dart_ (~17907 tok) +- `settings.rs` — window.scale_factor_override — None = follow the display ("Auto"). (~1990 tok) +- `state.rs` — file_history_picker_items, build_file_history_diff_preview, begin_file_history_preview_session, prev (~12339 tok) +- `tests.rs` — [cfg(test)] (~21632 tok) +- `workspace.rs` — attach_workspace, clear_workspace_session_state, workspace_root_path, workspace_nodes + 22 more (~4384 tok) ## src/app/event_loop/ -- `application.rs` — [cfg(target_os = "macos")] (~25471 tok) -- `commands_ai_chat.rs` — Like [`ai_slash_command_completion`] but selects the Nth matching command. (~11667 tok) -- `commands_completion.rs` (~16231 tok) -- `commands_dap.rs` (~5676 tok) -- `commands_editor.rs` (~5441 tok) -- `commands_explorer.rs` (~9366 tok) -- `commands_lsp.rs` (~9672 tok) -- `commands_palette.rs` (~8417 tok) -- `commands_prompts.rs` (~7215 tok) -- `commands_settings_helpers.rs` — Declares RESIZE_STEP_PX (~6809 tok) -- `commands_settings.rs` (~4526 tok) -- `commands_terminal.rs` (~6841 tok) -- `commands_tests.rs` — [derive(Default)] (~28762 tok) -- `commands.rs` — [path = "commands_ai_chat.rs"] (~10472 tok) -- `helpers.rs` — Declares BRACKET_PAIRS (~18627 tok) -- `mod.rs` — Struct: AppShell (~6842 tok) -- `setup.rs` — new, new_for_tests (~21382 tok) +- `application.rs` — Delay before the which-key overlay appears for a pending chord — long (~27029 tok) +- `commands_ai_chat.rs` — Like [`ai_slash_command_completion`] but selects the Nth matching command. (~11819 tok) +- `commands_completion.rs` (~15455 tok) +- `commands_editor.rs` (~5335 tok) +- `commands_explorer.rs` (~7819 tok) +- `commands_lsp.rs` (~9141 tok) +- `commands_palette.rs` (~7900 tok) +- `commands_prompts.rs` (~7222 tok) +- `commands_settings_helpers.rs` — Declares RESIZE_STEP_PX (~7036 tok) +- `commands_settings.rs` (~5021 tok) +- `commands_terminal.rs` (~6896 tok) +- `commands_tests.rs` — [derive(Default)] (~29998 tok) +- `commands.rs` — [path = "commands_ai_chat.rs"] (~10265 tok) +- `helpers.rs` — Declares BRACKET_PAIRS (~18582 tok) +- `mod.rs` — Struct: AppShell (~6788 tok) +- `setup.rs` — new, new_for_tests (~21919 tok) - `welcome.rs` (~611 tok) ## src/app/event_loop/async_results/ -- `ai.rs` (~938 tok) -- `failure.rs` (~1255 tok) -- `filesystem.rs` (~929 tok) +- `ai.rs` — Chars of buffer text after the caret used to trim suggestion/suffix overlap. (~2878 tok) +- `failure.rs` — Consecutive inline-completion failures before the user is told once and (~1604 tok) +- `filesystem.rs` (~1750 tok) - `fzf.rs` (~596 tok) - `git.rs` (~865 tok) -- `lsp.rs` (~9836 tok) -- `mod.rs` (~9176 tok) +- `lsp.rs` (~9284 tok) +- `mod.rs` (~9705 tok) - `preview.rs` (~993 tok) - `shell.rs` (~112 tok) - `syntax.rs` (~421 tok) -- `system.rs` (~2429 tok) -- `terminal.rs` (~2954 tok) +- `system.rs` (~1916 tok) +- `terminal.rs` (~3021 tok) ## src/app/input/ -- `handler.rs` — InputHandler: new, update_modifiers, on_focus_changed, clear_pending_prefix + 3 more (~19108 tok) -- `helpers.rs` — Trả về true nếu đây là event modifier-only (ShiftLeft, ShiftRight, ControlLeft...). (~4358 tok) +- `handler.rs` — InputHandler: new, update_modifiers, on_focus_changed, clear_pending_prefix (~20114 tok) +- `helpers.rs` — Trả về true nếu đây là event modifier-only (ShiftLeft, ShiftRight, ControlLeft...). (~4366 tok) - `mod.rs` (~69 tok) - `model.rs` — Dữ liệu key đã chuẩn hóa để tách phần "đọc winit event" (~764 tok) -- `pending.rs` — [derive(Debug, Clone, PartialEq, Eq)] (~2421 tok) -- `tests.rs` (~17166 tok) +- `pending.rs` — [derive(Debug, Clone, PartialEq, Eq)] (~2406 tok) +- `tests.rs` (~18491 tok) ## src/app/input_map/ -- `focus.rs` (~11533 tok) +- `focus.rs` (~11494 tok) - `helpers.rs` (~180 tok) -- `mod.rs` — KeybindingContext: as_str, allows_leader, for_mode, for_mode_with_palette + 8 more (~5068 tok) +- `mod.rs` — KeybindingContext: as_str, allows_leader, for_mode, for_mode_with_palette (~5397 tok) - `tests.rs` (~16214 tok) ## src/async_runtime/ -- `dart_env.rs` — [derive(Debug, Clone, Serialize, Deserialize)] (~1022 tok) -- `flutter_device.rs` — [derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] (~869 tok) -- `message.rs` — Trạng thái cài đặt của từng tool hệ thống. (~7230 tok) -- `mod.rs` (~28 tok) +- `dart_env.rs` — [derive(Debug, Clone, Serialize, Deserialize)] (~1030 tok) +- `message.rs` — Trạng thái cài đặt của từng tool hệ thống. (~7100 tok) +- `mod.rs` (~22 tok) - `python_env.rs` — [derive(Debug, Clone, Serialize, Deserialize)] (~948 tok) -- `scheduler.rs` — Declares FULL_BUFFER_HIGHLIGHT_BYTE_THRESHOLD (~2480 tok) +- `scheduler.rs` — Declares FULL_BUFFER_HIGHLIGHT_BYTE_THRESHOLD (~2328 tok) ## src/async_runtime/scheduler/ - `ai_jobs.rs` — Platform install command for opencode: (~3926 tok) -- `ai.rs` (~1994 tok) -- `dispatch.rs` (~6841 tok) +- `ai.rs` (~1916 tok) +- `dispatch.rs` (~7139 tok) - `emit.rs` (~458 tok) -- `file_watch.rs` (~2469 tok) +- `file_watch.rs` — Declares FILE_WATCH_DEGRADED_THRESHOLD (~3241 tok) - `fzf.rs` — Declares MAX_FZF_RESULTS (~2494 tok) - `git.rs` (~1560 tok) - `local_history.rs` — Local history (disk persistence) has been removed. (~42 tok) - `lsp_io.rs` (~2423 tok) -- `lsp_parse.rs` — Declares MAX_COMPLETION_ITEMS (~15053 tok) +- `lsp_parse.rs` — Declares MAX_COMPLETION_ITEMS (~15075 tok) - `lsp.rs` (~7248 tok) - `pty.rs` (~3903 tok) - `runtime.rs` — Runtime wrapper duy nhất cho background jobs. (~839 tok) -- `syntax_jobs.rs` (~7032 tok) -- `tests.rs` (~1752 tok) +- `syntax_jobs.rs` (~7423 tok) +- `tests.rs` (~1915 tok) ## src/config/ - `ai_config.rs` — [derive(Debug, Clone, Deserialize, Serialize, Default)] (~1206 tok) - `keymap_config.rs` — Root structure of a `.toml` keymap profile file. (~350 tok) -- `keymap_loader.rs` — Load and layer keymap profiles, returning a flat list of validated bindings. (~1675 tok) +- `keymap_loader.rs` — Load and layer keymap profiles, returning a flat list of validated bindings. (~1764 tok) - `mod.rs` (~35 tok) - `paths.rs` — user_config_root, legacy_app_state_root (~205 tok) - `theme_config.rs` — Theme configuration entrypoint. (~182 tok) -- `ui_config.rs` — [derive(Debug, Clone, Copy, PartialEq, Eq)] (~9203 tok) +- `ui_config.rs` — [derive(Debug, Clone, Copy, PartialEq, Eq)] (~8633 tok) ## src/config/theme_config/ - `builtin.rs` — builtin_dark (~2263 tok) -- `loader.rs` — ThemeProfileEntry: default_profile, resolved_profile, active_profile, list_available_themes + 5 more (~11810 tok) -- `model.rs` — [derive(Debug, Clone, Copy, PartialEq, Eq)] (~9281 tok) +- `loader.rs` — ThemeProfileEntry: default_profile, resolved_profile, active_profile, list_available_themes + 5 more (~12169 tok) +- `model.rs` — [derive(Debug, Clone, Copy, PartialEq, Eq)] (~9510 tok) - `raw.rs` — [derive(Debug, Clone, Deserialize)] (~3449 tok) ## src/core/ -- `command_ids.rs` — ── Editor movement & editing ──────────────────────────────────────────────── (~10401 tok) -- `commands.rs` — [derive(Debug, Clone, Copy, PartialEq, Eq)] (~5587 tok) +- `command_ids.rs` — ── Editor movement & editing ──────────────────────────────────────────────── (~9448 tok) +- `commands.rs` — [derive(Debug, Clone, Copy, PartialEq, Eq)] (~5259 tok) - `mod.rs` (~35 tok) - `mode.rs` — Các mode cốt lõi của editor. (~4298 tok) - `text_object.rs` — find_text_object_range (~1753 tok) @@ -440,13 +452,13 @@ ## src/core/command_dispatch/ -- `common.rs` — Struct: DispatchReport (~1739 tok) -- `editing.rs` (~10656 tok) -- `mod.rs` — Dispatcher là điểm duy nhất được phép apply `Command` vào `AppState`. (~4760 tok) -- `navigation.rs` (~3287 tok) -- `palette.rs` (~6977 tok) -- `session.rs` (~4145 tok) -- `tests.rs` (~18328 tok) +- `common.rs` — Struct: DispatchReport (~1802 tok) +- `editing.rs` (~10629 tok) +- `mod.rs` — Dispatcher là điểm duy nhất được phép apply `Command` vào `AppState`. (~4342 tok) +- `navigation.rs` — Chụp lại (file, line, col) hiện tại trước một jump-motion (gg/G/{}/n/N). (~3534 tok) +- `palette.rs` (~6718 tok) +- `session.rs` (~4027 tok) +- `tests.rs` (~18631 tok) ## src/dap/ @@ -458,56 +470,56 @@ ## src/lsp/ - `capabilities.rs` — Bitmask nhỏ gọn của các tính năng mà LSP server khai báo trong `InitializeResult`. (~761 tok) -- `client.rs` — LspEntry: lsp_entry_for_extension, refresh_patched_env_path, patched_env_path, check_lsp_installed (~16369 tok) +- `client.rs` — LspEntry: lsp_entry_for_extension, refresh_patched_env_path, patched_env_path (~17032 tok) - `mod.rs` (~62 tok) -- `registry.rs` — LanguageProfile: all_language_profiles, language_profile_for_extension, language_profile_for_path, language_profile_for_language_id + 2 more (~4698 tok) -- `symbol_cache.rs` — A workspace-wide symbol cache for fast import/completion suggestions. (~19246 tok) +- `registry.rs` — LanguageProfile: all_language_profiles, language_profile_for_extension, language_profile_for_path, language_profile_for_language_id + 2 more (~4689 tok) +- `symbol_cache.rs` — A workspace-wide symbol cache for fast import/completion suggestions. (~19293 tok) ## src/render/ - `caret.rs` — [derive(Debug, Clone, Copy)] (~2993 tok) - `color_space.rs` — srgb_color_target_state (~236 tok) - `glyph_instance.rs` — Vertex tĩnh của một quad đơn vị (0..1). (~844 tok) -- `icon_pipeline.rs` — [repr(C)] (~10500 tok) +- `icon_pipeline.rs` — [repr(C)] (~12235 tok) - `image_pipeline.rs` — [repr(C)] (~2249 tok) - `mod.rs` (~60 tok) - `pipeline.rs` — Mỗi vertex của quad chỉ cần vị trí 2D (NDC). (~1222 tok) - `region_pipeline.rs` — RegionDrawInstance: new, with_radius, new, update_screen_size + 3 more (~3035 tok) -- `renderer.rs` — Core renderer types and module layout. (~4514 tok) +- `renderer.rs` — Core renderer types and module layout. (~4637 tok) - `surface.rs` — SurfaceState giữ toàn bộ thông tin liên quan đến swapchain/surface. (~797 tok) - `text_pipeline.rs` — TextPipeline: new, update_screen_size, upload_instances, draw + 1 more (~2779 tok) ## src/render/renderer/ -- `components.rs` (~124 tok) -- `editor.rs` — Editor viewport rendering modules. (~2282 tok) -- `helpers.rs` — Pure free-standing helper functions shared across renderer submodules. (~3453 tok) -- `lifecycle.rs` — Renderer lifecycle: GPU bootstrap, theme/config application, resize handling, (~6978 tok) -- `palette.rs` — Overlay rendering: Command Palette, File Picker, Recent Projects, Leap labels. (~3116 tok) -- `ui.rs` — Panel UI rendering modules. (~42 tok) +- `components.rs` (~123 tok) +- `editor.rs` — Editor viewport rendering modules. (~2289 tok) +- `helpers.rs` — Pure free-standing helper functions shared across renderer submodules. (~3458 tok) +- `lifecycle.rs` — Renderer lifecycle: GPU bootstrap, theme/config application, resize handling, (~6729 tok) +- `palette.rs` — Overlay rendering: Command Palette, File Picker, Recent Projects, Leap labels. (~3160 tok) +- `ui.rs` — Panel UI rendering modules. (~43 tok) ## src/render/renderer/components/ -- `help_keycaps.rs` — HelpKeycapPalette: help_keycap_palette, layout_help_keycaps, estimate_help_keycaps_width (~2000 tok) +- `help_keycaps.rs` — HelpKeycapPalette: help_keycap_palette, layout_help_keycaps, estimate_help_keycaps_width (~2003 tok) - `highlight_chip.rs` — [derive(Clone, Copy)] (~298 tok) -- `prefix_icon_badge.rs` — PrefixIconBadge: layout_prefix_icon_badge (~736 tok) +- `prefix_icon_badge.rs` — PrefixIconBadge: layout_prefix_icon_badge (~754 tok) - `shortcut_hint.rs` — Enum: ShortcutHintSegment (61 variants) (~1644 tok) ## src/render/renderer/editor/ -- `buffers.rs` — clear_editor_overlays, update_references_buffer_content (~5424 tok) +- `buffers.rs` — clear_editor_overlays, update_references_buffer_content (~5570 tok) - `completion.rs` — Declares COMPLETION_KIND_TEXT (~1959 tok) -- `extensions.rs` — update_extensions_manager_content (~8893 tok) -- `fuzzy.rs` — update_fuzzy_picker_buffer_content (~7817 tok) -- `help.rs` — update_help_buffer_content (~4176 tok) -- `overlays.rs` — update_editor_overlays (~19555 tok) -- `selections.rs` — indent_guide_quads, current_line_highlight_quad, visual_selection_quads, visual_block_selection_quads (~11054 tok) -- `settings.rs` — update_settings_buffer_content (~10502 tok) -- `viewport.rs` — clear_editor_content, update_image_content, update_editor_content (~7464 tok) +- `extensions.rs` — update_extensions_manager_content (~9544 tok) +- `fuzzy.rs` — update_fuzzy_picker_buffer_content (~7928 tok) +- `help.rs` — update_help_buffer_content (~3996 tok) +- `overlays.rs` — update_editor_overlays (~19960 tok) +- `selections.rs` — indent_guide_quads, current_line_highlight_quad, visual_selection_quads, visual_block_selection_quads (~11283 tok) +- `settings.rs` — update_settings_buffer_content (~10926 tok) +- `viewport.rs` — clear_editor_content, update_image_content, update_editor_content (~7848 tok) ## src/render/renderer/editor/buffers/ -- `diagnostics.rs` — update_diagnostics_buffer_content (~5853 tok) +- `diagnostics.rs` — update_diagnostics_buffer_content (~6058 tok) ## src/render/renderer/editor/overlays/ @@ -515,27 +527,28 @@ ## src/render/renderer/lifecycle/ -- `frame.rs` — render (~9400 tok) +- `frame.rs` — render (~8953 tok) ## src/render/renderer/palette/ -- `file_picker.rs` — Declares to (~3781 tok) +- `file_picker.rs` — Declares to (~3796 tok) - `highlighted_label.rs` — Render a label string with fuzzy-match character highlights. (~833 tok) -- `leap.rs` — update_editor_leap_labels, clear_leap_labels (~1928 tok) -- `live_grep.rs` — Declares to (~2602 tok) -- `minimal.rs` (~4681 tok) -- `recent_projects.rs` (~5184 tok) +- `leap.rs` — update_editor_leap_labels, clear_leap_labels (~1951 tok) +- `live_grep.rs` — Declares to (~2616 tok) +- `minimal.rs` (~4731 tok) +- `recent_projects.rs` (~5250 tok) ## src/render/renderer/ui/ -- `ai_chat.rs` — Right-sidebar AI chat and markdown preview text rendering. (~19532 tok) +- `ai_chat.rs` — Right-sidebar AI chat and markdown preview text rendering. (~19536 tok) - `popups.rs` — update_lsp_guide_popup, clear_lsp_guide_popup, update_system_dep_popup (~6724 tok) -- `sidebar.rs` — update_sidebar_content, clear_sidebar (~3076 tok) -- `statusbar.rs` — Render the three-zone status bar. (~5546 tok) -- `terminal.rs` — update_terminal_content, update_right_terminal_content, clear_right_terminal, update_buffer_terminal_content (~7189 tok) -- `topbar.rs` — update_topbar_content (~4592 tok) +- `sidebar.rs` — update_sidebar_content, clear_sidebar (~3120 tok) +- `statusbar.rs` — Render the three-zone status bar. (~5692 tok) +- `terminal.rs` — update_terminal_content, update_right_terminal_content, clear_right_terminal, update_buffer_terminal_content (~7134 tok) +- `topbar.rs` — update_topbar_content (~4633 tok) - `utils.rs` — Declares BORDER (~2418 tok) -- `welcome.rs` — update_welcome_screen_content (~8855 tok) +- `welcome.rs` — update_welcome_screen_content (~9261 tok) +- `whichkey.rs` — Which-key overlay: while a chord is pending (e.g. Space …), show every (~1932 tok) ## src/render/shaders/ @@ -555,10 +568,10 @@ ## src/syntax/highlight/ -- `categories.rs` — [derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] (~2844 tok) -- `engine.rs` (~5247 tok) -- `mod.rs` — generate_highlight_spans, generate_highlight_spans_with_cache, generate_dotenv_highlight_spans, generate_plaintext_highlight_spans + 3 more (~7537 tok) -- `normalize_tests.rs` (~646 tok) +- `categories.rs` — [derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] (~2812 tok) +- `engine.rs` (~5276 tok) +- `mod.rs` — generate_highlight_spans, generate_highlight_spans_with_cache, generate_dotenv_highlight_spans, generate_plaintext_highlight_spans + 3 more (~7538 tok) +- `normalize_tests.rs` (~632 tok) - `queries.rs` — highlight_markdown_inline (~3252 tok) - `spans.rs` — [derive(Debug, Clone, PartialEq, Eq)] (~1642 tok) @@ -634,20 +647,21 @@ ## src/terminal/ -- `ansi_parser.rs` — ANSI escape sequence parser — Phase 9b. (~7473 tok) -- `grid.rs` — Terminal Grid — Phase 9b. (~16817 tok) +- `ansi_parser.rs` — ANSI escape sequence parser — Phase 9b. (~7470 tok) +- `cell_shapes.rs` — Procedural cell shapes — vẽ Block Elements (U+2580–U+259F) và Box Drawing (~3155 tok) +- `grid.rs` — Terminal Grid — Phase 9b. (~19554 tok) - `highlighter.rs` — Terminal output highlighter — pre-compiled regex patterns for syntax coloring. (~787 tok) -- `mod.rs` (~28 tok) +- `mod.rs` (~34 tok) - `pty.rs` — Wrapper cấp project quanh crate `portable-pty`. (~1812 tok) -- `terminal_renderer.rs` — Terminal View Renderer — Phase 9b. (~3221 tok) +- `terminal_renderer.rs` — Terminal View Renderer — Phase 9b. (~3746 tok) ## src/text/ -- `atlas.rs` — [derive(Debug, Clone, Copy)] (~2633 tok) -- `layout_sync.rs` — Width of the glyph under the cursor. Used for block-style caret (~4691 tok) +- `atlas.rs` — [derive(Debug, Clone, Copy)] (~2952 tok) +- `layout_sync.rs` — Width of the glyph under the cursor. Used for block-style caret (~4706 tok) - `mod.rs` (~21 tok) - `raster.rs` — Bitmap coverage của một glyph sau bước rasterize. (~892 tok) -- `text_system.rs` — FontFaceSummary: new, set_font_family, locale, face_count + 16 more (~6296 tok) +- `text_system.rs` — FontFaceSummary: new, set_font_family, locale, face_count + 16 more (~6295 tok) ## src/workbench/ @@ -665,9 +679,10 @@ - `fuzzy.rs` — [derive(Debug, Clone, PartialEq, Eq)] (~1466 tok) - `mod.rs` (~14 tok) -- `model.rs` — WorkspaceNode: new, new, should_ignore_dir, should_ignore_path + 33 more (~8366 tok) -- `scanner.rs` — [derive(Debug, Clone, Copy, PartialEq, Eq)] (~4318 tok) +- `model.rs` — WorkspaceNode: new, new, should_ignore_dir, should_ignore_path + 35 more (~8547 tok) +- `scanner.rs` — [derive(Debug, Clone, Copy, PartialEq, Eq)] (~4386 tok) ## tests/ - `lsp_fvm_detection.rs` — [cfg(test)] (~352 tok) +- `vietnamese_terminal_render.rs` — Regression test for bug-047: Vietnamese diacritics lost when rendering (~2153 tok) diff --git a/.wolf/buglog.json b/.wolf/buglog.json index 94d55148..8c9f3107 100644 --- a/.wolf/buglog.json +++ b/.wolf/buglog.json @@ -696,6 +696,1434 @@ "related_bugs": [], "occurrences": 3, "last_seen": "2026-05-30T04:10:28.116Z" + }, + { + "id": "bug-043", + "timestamp": "2026-06-11T00:00:00Z", + "error_message": "In Zen Mode, pressing Esc on a maximized non-editor panel jumped focus back to the (hidden) main editor: AI chat tab lost focus, and terminal stopped accepting input until F12 was pressed again.", + "file": "src/app/input/handler.rs, src/app/event_loop/commands_ai_chat.rs", + "root_cause": "Esc routing was not Zen-aware. AiChat focus -> route_ai_chat_input mapped Esc unconditionally to Command::AiChatUnfocus, whose dispatch always set focus to CenterEditor. Terminal focus: terminal_input_payload returns None for Esc, so Esc fell through to the terminal resolver -> FocusBack -> release_focus_mode_to_editor() ExitFocus, leaving terminal focus. In Zen Mode the editor is hidden, so leaving panel focus strands the user.", + "fix": "1) handler.rs route_normalized_input: in Zen Mode, when focus==Terminal && mode==TerminalFocus && key==Escape, dispatch Command::TerminalWriteInput(\"\\u{1b}\") so raw ESC reaches the PTY (Vim etc.) and focus is preserved. 2) commands_ai_chat.rs AiChatUnfocus arm: if panel_state.maximized_region.is_some() return Some(false) (no-op) instead of moving focus to CenterEditor. Added tests zen_mode_active_forwards_escape_to_terminal_pty and non_zen_terminal_escape_does_not_forward_raw_pty.", + "tags": [ + "zen-mode", + "focus", + "terminal", + "ai-chat", + "escape", + "ux", + "rs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-11T00:00:00Z" + }, + { + "id": "bug-044", + "timestamp": "2026-06-11T04:07:43.393Z", + "error_message": "Significant refactor of ", + "file": "src/app/event_loop/commands_terminal.rs", + "root_cause": "20 lines replaced/restructured", + "fix": "Rewrote 26→6 lines (20 removed) | Also: /// Flag the layout of whichever terminal surface ; /// new scroll position is re-rendered on the next", + "tags": [ + "auto-detected", + "refactor", + "rs" + ], + "related_bugs": [], + "occurrences": 2, + "last_seen": "2026-06-11T04:09:09.996Z" + }, + { + "id": "bug-045", + "timestamp": "2026-06-11T00:00:00Z", + "error_message": "Can't scroll up/down in the right-sidebar opencode chat (PanelTabId::Terminal). User asked to bind Ctrl+U/Ctrl+D to scroll the chat there.", + "file": "src/app/event_loop/mod.rs, src/app/input/handler.rs, src/app/event_loop/commands_terminal.rs, src/app/event_loop/commands.rs, src/core/commands.rs", + "root_cause": "focused_terminal_grid_mut() only handled CenterEditor and BottomPanel, not RightSidebar, so TerminalScrollUp/Down returned None for the right opencode terminal (mark_focused_terminal_layout_dirty also lacked the RightSidebar branch). Additionally, in TerminalFocus the input handler forwards Ctrl+U/D as raw control bytes to the PTY, so they never reached a scroll command.", + "fix": "1) focused_terminal_grid_mut() + mark_focused_terminal_layout_dirty() now handle FocusTarget::RightSidebar -> right_terminal_grid / right_terminal_needs_layout. 2) Added commands TerminalScrollHalfPageUp/Down (routed in command_dispatch session.rs & mod.rs, repeatable via supports_press_and_hold_repeat) executed by scroll_focused_terminal(up, half_page). 3) New KeybindingContext field right_sidebar_terminal (set in build_context) scopes a new input interception (InputHandler::right_chat_scroll_command) that maps Ctrl+U/D -> half-page scroll only for the right chat, in both route_normalized_input and route_repeated_normalized_input. Bottom-panel shell keeps raw ^U/^D. Tests: right_sidebar_terminal_ctrl_u_d_scroll_half_page, bottom_terminal_ctrl_u_d_still_forward_raw_to_pty.", + "tags": [ + "terminal", + "scroll", + "right-sidebar", + "opencode", + "ai-chat", + "keybinding", + "ux", + "rs" + ], + "related_bugs": [ + "bug-043" + ], + "occurrences": 1, + "last_seen": "2026-06-11T00:00:00Z" + }, + { + "id": "bug-046", + "timestamp": "2026-06-11T04:23:57.320Z", + "error_message": "Significant refactor of ", + "file": "src/terminal/grid.rs", + "root_cause": "3 lines replaced/restructured", + "fix": "Rewrote 7→20 lines (3 removed)", + "tags": [ + "auto-detected", + "refactor", + "rs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-11T04:23:57.320Z" + }, + { + "id": "bug-047", + "timestamp": "2026-06-11T00:00:00Z", + "error_message": "Vietnamese tone marks dropped when rendering opencode output in the terminal. Base vowels with circumflex (ô ê â) showed, but tone marks were lost: 'bạn'->'ban', 'cụ thể'->'cu thê', 'kiểu'->'kiêu'. User suspected line-height clipping.", + "file": "src/terminal/grid.rs, Cargo.toml", + "root_cause": "NOT a line-height issue (panel_line_height 22 vs panel_font_size 14 is generous). The terminal grid stores one Unicode scalar per cell: print_char_at_cursor always wrote the char and advanced the cursor, with no handling for zero-width combining marks. The ANSI parser emits each codepoint as a separate PrintChar with no normalization. So a combining tone mark (e.g. precomposed 'ô' U+00F4 followed by combining dot-below U+0323) landed in its own cell, advancing the cursor; the lone mark shaped in isolation rendered clipped/invisible and the base lost its tone.", + "fix": "Added unicode-normalization dep. In print_char_at_cursor, detect combining marks via unicode_normalization::char::is_combining_mark; instead of consuming a cell, fold the mark into the previous cell's base char by NFC-normalizing base+mark (grapheme.nfc()). NFC reorders marks canonically first, so it composes even when the stream sends marks out of canonical order (pairwise char::compose alone misses 'ô'+U+0323 -> 'ộ' because ộ decomposes to ọ+circumflex). Only adopt the result if it collapses to a single scalar; otherwise keep base and drop mark to avoid misaligning the fixed-width grid. Cursor not advanced (zero-width). Tests: combining_marks_fold_into_base_cell, stacked_combining_marks_compose_incrementally, precomposed_vietnamese_passes_through, leading_combining_mark_is_dropped_not_misaligned.", + "tags": [ + "terminal", + "unicode", + "vietnamese", + "combining-marks", + "nfc", + "rendering", + "user-reported", + "rs" + ], + "related_bugs": [ + "bug-048" + ], + "occurrences": 2, + "last_seen": "2026-06-12T00:15:00+07:00" + }, + { + "id": "bug-048", + "timestamp": "2026-06-11T06:27:06.641Z", + "error_message": "Significant refactor of ", + "file": "src/async_runtime/scheduler/file_watch.rs", + "root_cause": "7 lines replaced/restructured", + "fix": "Rewrote 100→130 lines (7 removed)", + "tags": [ + "auto-detected", + "refactor", + "rs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-11T06:27:06.641Z" + }, + { + "id": "bug-049", + "timestamp": "2026-06-11T06:30:51.232Z", + "error_message": "Significant refactor of ", + "file": "src/app/event_loop/async_results/filesystem.rs", + "root_cause": "2 lines replaced/restructured", + "fix": "Rewrote 5→3 lines (2 removed)", + "tags": [ + "auto-detected", + "refactor", + "rs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-11T06:30:51.232Z" + }, + { + "id": "bug-050", + "timestamp": "2026-06-11T06:31:09.795Z", + "error_message": "Significant refactor of ", + "file": "src/app/app_state/palette.rs", + "root_cause": "4 lines replaced/restructured", + "fix": "Rewrote 9→5 lines (4 removed) | Also: eprintln!(\"[FileWatch] Inactive file {:?} is dirty; eprintln!(\"[FileWatch] Reloading inactive file fro", + "tags": [ + "auto-detected", + "refactor", + "rs" + ], + "related_bugs": [], + "occurrences": 2, + "last_seen": "2026-06-11T06:31:27.328Z" + }, + { + "id": "bug-051", + "timestamp": "2026-06-11T06:35:28.681Z", + "error_message": "Significant refactor of ", + "file": "src/app/app_state/tests.rs", + "root_cause": "6 lines replaced/restructured", + "fix": "Rewrote 22→47 lines (6 removed)", + "tags": [ + "auto-detected", + "refactor", + "rs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-11T06:35:28.681Z" + }, + { + "id": "bug-052", + "timestamp": "2026-06-11T00:00:00.000Z", + "error_message": "External file changes (create/delete/rename + content edits) do not update the IDE: open file, edit it elsewhere, IDE does not refresh at all", + "file": "src/async_runtime/scheduler/file_watch.rs", + "root_cause": "The notify file-watch worker emitted FileSystemEvents via emit_message (no wake) and dispatch.rs never passed event_proxy into run_file_watch_request. With ControlFlow::Wait the winit loop stayed asleep, so FileSystemEvents sat unconsumed in the mpsc channel until an unrelated event (keypress/mouse/terminal/1s poll tick) woke it. The whole notify path (workspace rescan, file-picker refresh, conflict detection, rename/delete handling) was effectively dead; only the 1s mtime poll over OPEN buffers worked, so tree-level changes never appeared.", + "fix": "Passed event_proxy into run_file_watch_request and switched the FileSystemEvents emit to emit_message_and_wake (#1). Also: auto-restart watcher with backoff on failure (#4); non-recursive watch for files opened outside workspace root with dedup set (#5, added recursive flag to StartFileWatch payload); poll mtime compare > -> != to catch git checkout restoring older mtime (#2); self-save ignore now content-based via active_disk_content_matches_memory so genuine external edits within the 2s window still reload (#3); reduced safety-net poll 1s -> 3s (#6); removed debug eprintln noise (#7).", + "tags": [ + "file-watcher", + "notify", + "event-loop", + "winit", + "wake", + "external-reload", + "rust" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-11T00:00:00.000Z" + }, + { + "id": "bug-053", + "timestamp": "2026-06-11T16:37:02Z", + "error_message": "Ctrl-U/Ctrl-D still do not scroll opencode output when the right dock is focused; user asked whether mouse scroll/click should be allowed only in the right dock.", + "file": "src/app/input/handler.rs, src/app/event_loop/application.rs, src/app/event_loop/commands_ai_chat.rs", + "root_cause": "Right-dock Ctrl-U/D was still gated by TerminalFocus/Terminal input context, so a focused right terminal with mode drift could miss the interception and fall through. Mouse wheel/input events were also ignored globally, so the right-dock terminal had no mouse scroll or xterm mouse click path.", + "fix": "Scoped Ctrl-U/D interception to KeybindingContext.right_sidebar_terminal regardless of mode drift, made AiChatFocus always re-enter TerminalFocus, and added right-dock-only mouse wheel scrolling plus SGR mouse click forwarding through TerminalWriteInput.", + "tags": [ + "right-sidebar", + "opencode", + "terminal", + "scroll", + "mouse", + "ctrl-u", + "ctrl-d" + ], + "related_bugs": [ + "bug-052" + ], + "occurrences": 1, + "last_seen": "2026-06-11T16:37:02Z" + }, + { + "id": "bug-054", + "timestamp": "2026-06-11T16:37:02Z", + "error_message": "cargo test rejected two test-name filters passed as positional arguments: unexpected argument 'sgr_mouse_sequence_uses_press_and_release_suffixes'", + "file": "verification command", + "root_cause": "Cargo accepts only one TESTNAME filter before the '--' separator; passing two separate test names is invalid.", + "fix": "Reran the two focused tests as separate cargo test commands.", + "tags": [ + "verification", + "cargo-test", + "command-syntax" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-11T16:37:02Z" + }, + { + "id": "bug-055", + "timestamp": "2026-06-12T00:00:00Z", + "error_message": "Terminal regex highlight (log level / strings / numbers / timestamps) is lost on soft-wrapped lines: every time a long log line wraps to the next visual row, the wrapped portion loses its color.", + "file": "src/terminal/grid.rs", + "root_cause": "apply_regex_highlights ran the regexes per physical grid row (cols-wide), independently. When a logical log line wraps across rows, the continuation row has no log-level prefix (so it lost the base line color) and a token split across the wrap boundary (e.g. a quoted string \"solo_quest...t_backend\") doesn't match on the second row because its opening quote is on the first row. The grid never recorded which rows were soft-wrap continuations.", + "fix": "Added wrap_continued flag to TerminalCell, set on the first cell of a row when print_char_at_cursor auto-wraps (cursor_col >= cols, no newline). Rewrote apply_regex_highlights to walk the buffer one LOGICAL line at a time (maximal run of rows where each after the first has wrap_continued), join their text, run regexes on the joined text, and map char index i back to physical (row start + i/cols, col i%cols). Two-phase (collect (row,col,color) under immutable borrow, then apply) to avoid borrow conflicts. Data-type matches pushed lowest-precedence-first so later writes win, preserving original string>number>bool>null>time>base-color precedence. Removed set_visible_row_style_fg; added row_is_wrap_continued + set_style_fg_absolute. Tests: apply_regex_highlights_string_spanning_soft_wrap_keeps_color_on_continuation, apply_regex_highlights_log_level_spans_soft_wrap.", + "tags": [ + "terminal", + "highlight", + "regex", + "soft-wrap", + "line-wrap", + "grid", + "rendering", + "user-reported", + "rs" + ], + "related_bugs": [ + "bug-047" + ], + "occurrences": 1, + "last_seen": "2026-06-12T00:00:00Z" + }, + { + "id": "bug-056", + "timestamp": "2026-06-11T17:52:30.730Z", + "error_message": "Wrong reference: grid should be cell_shapes", + "file": "src/terminal/mod.rs", + "root_cause": "Used \"grid\" instead of \"cell_shapes\"", + "fix": "Changed grid → cell_shapes", + "tags": [ + "auto-detected", + "wrong-reference", + "rs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-11T17:52:30.730Z" + }, + { + "id": "bug-048", + "timestamp": "2026-06-12T00:45:00+07:00", + "error_message": "Right dock (opencode) renders 'đứt nét': block-element logo (▀▄█) has gaps between cells and vertical borders (┃│) appear as broken dashes. User initially attributed Vietnamese mark loss to this.", + "file": "src/terminal/cell_shapes.rs, src/terminal/terminal_renderer.rs, src/text/atlas.rs", + "root_cause": "Terminal cells are panel_line_height tall (22px) but block/box-drawing glyphs from the font only cover the font's natural line box (~17px at 14px font). Each row leaves a ~5px uncovered strip, so vertical lines and block art show seams. Fonts can never fill an arbitrary cell height; real terminal emulators (kitty, alacritty) draw these characters procedurally.", + "fix": "Added src/terminal/cell_shapes.rs: solid_cell_rects() maps Block Elements (U+2580-259F incl. shades/quadrants) and Box Drawing (light/heavy/double lines, corners, tees, half-lines; rounded mapped to square; doubles approximated as heavy) to cell-filling rectangles. GlyphAtlas::solid_entry() lazily reserves a 4x4 white texel region (entry points at inset 2x2 core to avoid Nearest-sampling bleed). build_instances() emits these as GlyphInstance quads with fg color (alpha scaled for ░▒▓) and skips font shaping for those chars. Dashed/diagonal chars still use font glyphs.", + "tags": [ + "terminal", + "rendering", + "box-drawing", + "block-elements", + "right-dock", + "opencode", + "user-reported", + "rs" + ], + "related_bugs": [ + "bug-047" + ], + "occurrences": 1, + "last_seen": "2026-06-12T00:45:00+07:00" + }, + { + "id": "bug-058", + "timestamp": "2026-06-11T18:07:05.078Z", + "error_message": "Significant refactor of ", + "file": "src/app/input/handler.rs", + "root_cause": "3 lines replaced/restructured", + "fix": "Rewrote 21→25 lines (3 removed)", + "tags": [ + "auto-detected", + "refactor", + "rs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-11T18:07:05.078Z" + }, + { + "id": "bug-059", + "timestamp": "2026-06-11T18:07:25.689Z", + "error_message": "Significant refactor of ", + "file": "src/app/event_loop/application.rs", + "root_cause": "5 lines replaced/restructured", + "fix": "Rewrote 11→21 lines (5 removed)", + "tags": [ + "auto-detected", + "refactor", + "rs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-11T18:07:25.689Z" + }, + { + "id": "bug-060", + "timestamp": "2026-06-11T18:07:46.469Z", + "error_message": "Significant refactor of ", + "file": "src/app/input/tests.rs", + "root_cause": "2 lines replaced/restructured", + "fix": "Rewrote 16→25 lines (2 removed) | Also: matches!(translated.command, Command::TerminalWrit; \"bottom terminal Ctrl+U should forward raw, got {:", + "tags": [ + "auto-detected", + "refactor", + "rs" + ], + "related_bugs": [], + "occurrences": 2, + "last_seen": "2026-06-11T18:08:08.050Z" + }, + { + "id": "bug-057", + "timestamp": "2026-06-12T00:00:00+07:00", + "error_message": "Ctrl-U/D in the focused right dock still don't scroll the opencode chat content even after bug-053's interception fix; user asked whether to allow mouse (scroll/click) in the right dock instead.", + "file": "src/app/input/handler.rs, src/app/event_loop/application.rs", + "root_cause": "The bug-045/053 plumbing was correct but scrolled the WRONG surface: Ctrl-U/D mapped to TerminalScrollHalfPageUp/Down and mouse wheel to TerminalScrollUp/Down, which only move our grid's scrollback view. opencode is a full-screen TUI that repaints in place via cursor positioning + erase - it never emits newlines at the bottom row, so its chat history NEVER enters our scrollback (scrollback stays empty/stale frames). The history lives inside opencode's own viewport; no amount of local scrollback scrolling can reveal it.", + "fix": "Forward scroll intent INTO opencode instead of scrolling locally. right_chat_scroll_command now maps Ctrl-U/D to TerminalWriteInput(\"\\x1b\\x15\" / \"\\x1b\\x04\") - opencode's default messages_half_page_up/down keybinds (ctrl+alt+u/d, legacy ESC-prefix encoding). handle_right_terminal_mouse_wheel now writes SGR wheel events (sgr_wheel_sequence: button 64 up / 65 down, press-only 'M') at the hovered cell (grid-center fallback) via TerminalWriteInput; opencode's mouse capture is on by default so it scrolls its message list itself. Clicks were already SGR-forwarded. Updated tests incl. new sgr_wheel_sequence_is_press_only_with_wheel_button_codes; full suite 705 passed.", + "tags": [ + "right-sidebar", + "opencode", + "terminal", + "scroll", + "mouse", + "ctrl-u", + "ctrl-d", + "tui-viewport", + "sgr-mouse", + "user-reported" + ], + "related_bugs": [ + "bug-045", + "bug-053" + ], + "occurrences": 1, + "last_seen": "2026-06-12T00:00:00+07:00" + }, + { + "id": "bug-062", + "timestamp": "2026-06-11T18:23:21.657Z", + "error_message": "Significant refactor of ", + "file": "src/async_runtime/scheduler/file_watch.rs", + "root_cause": "4 lines replaced/restructured", + "fix": "Rewrote 4→11 lines (4 removed) | Also: extend_unique_file_events(; extend_unique_file_events(", + "tags": [ + "auto-detected", + "refactor", + "rs" + ], + "related_bugs": [], + "occurrences": 2, + "last_seen": "2026-06-11T18:23:53.555Z" + }, + { + "id": "bug-063", + "timestamp": "2026-06-11T18:27:17.342Z", + "error_message": "Wrong reference: normalize_notify_event should be file_watch_restart_backoff", + "file": "src/async_runtime/scheduler/tests.rs", + "root_cause": "Used \"normalize_notify_event\" instead of \"file_watch_restart_backoff\"", + "fix": "Changed normalize_notify_event → file_watch_restart_backoff", + "tags": [ + "auto-detected", + "wrong-reference", + "rs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-11T18:27:17.342Z" + }, + { + "id": "bug-058", + "timestamp": "2026-06-12T00:00:00+07:00", + "error_message": "File-watcher audit (user-requested): external edits by Claude Code/other IDEs could still leave the editor showing stale code in 3 cases.", + "file": "src/app/app_state/palette.rs, src/app/event_loop/setup.rs, src/async_runtime/scheduler/file_watch.rs, src/app/event_loop/async_results/{filesystem,failure}.rs, src/app/event_loop/application.rs, src/app/app_state/{mod,buffers}.rs", + "root_cause": "(1) macOS FSEvents splits renames into two single-path events (new_path=None); atomic saves (write temp + rename over) therefore arrived as Rename{new_path:None} and BOTH the active and inactive branches of apply_external_file_events skipped them entirely - reload only happened via the 3s poll. (2) Inactive-buffer reloads updated the in-memory rope but never told the LSP server; the stale didOpen overlay SHADOWS new on-disk content, so cross-file diagnostics in the active file were computed against old code until the user revisited the buffer. (3) The watcher gave up permanently after 5 restarts, silently - tree-level changes (new files created by agents) stopped appearing forever.", + "fix": "(1) Normalize Rename{new_path:None, path still exists} to Modify at the top of apply_external_file_events (rescan decision still made on raw events first). (2) Added ExternalChangeReport.inactive_reloaded_paths + AppState::buffer_text_for_path + AppShell::submit_lsp_sync_for_externally_reloaded_path (sends LspDidOpen which the worker turns into didOpen-or-didChange; never starts a server for inactive buffers); called from both the notify path (filesystem.rs) and the 3s poll (application.rs). (3) Watcher now restarts forever with exponential backoff 2s->30s cap, resets after a 60s stable run, and emits Failed exactly once past 5 restarts -> failure.rs shows a 'File watcher degraded' toast. Bonus: HashSet dedup for event batches (was O(n^2) Vec::contains). Tests: external_rename_without_new_path_on_existing_file_reloads_like_modify, external_modify_of_inactive_buffer_is_reported_for_lsp_sync, file_watch_restart_backoff_grows_exponentially_and_caps_at_30s. Full suite 708 passed.", + "tags": [ + "file-watcher", + "fsevents", + "rename", + "atomic-save", + "lsp-overlay", + "didchange", + "stale-content", + "watcher-restart", + "audit-found" + ], + "related_bugs": [ + "bug-052" + ], + "occurrences": 1, + "last_seen": "2026-06-12T00:00:00+07:00" + }, + { + "id": "bug-065", + "timestamp": "2026-06-11T19:29:42.291Z", + "error_message": "Function not marked async", + "file": "src/async_runtime/scheduler/dispatch.rs", + "root_cause": "Function uses await but wasn't declared async", + "fix": "Added async modifier", + "tags": [ + "auto-detected", + "async-fix", + "rs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-11T19:29:42.291Z" + }, + { + "id": "bug-066", + "timestamp": "2026-06-11T19:31:26.843Z", + "error_message": "Significant refactor of ", + "file": "src/app/app_state/palette.rs", + "root_cause": "11 lines replaced/restructured", + "fix": "Rewrote 17→8 lines (11 removed) | Also: // Check if this is a self-save event by comparing; let current_active_path = event.path.clone(); | Also: match std::fs::read_to_string(&buffer.path) {; Ok(content) => { | Also: if let Ok(content) = std::fs::read_to_string(new_p; buffer.in_memory_text = Some(Rope::from_str(&conte", + "tags": [ + "auto-detected", + "refactor", + "rs" + ], + "related_bugs": [], + "occurrences": 4, + "last_seen": "2026-06-11T19:32:01.838Z" + }, + { + "id": "bug-067", + "timestamp": "2026-06-11T19:32:24.247Z", + "error_message": "Function not marked async", + "file": "src/app/app_state/palette.rs", + "root_cause": "Function uses await but wasn't declared async", + "fix": "Added async modifier", + "tags": [ + "auto-detected", + "async-fix", + "rs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-11T19:32:24.247Z" + }, + { + "id": "bug-068", + "timestamp": "2026-06-11T19:33:46.239Z", + "error_message": "Significant refactor of ", + "file": "src/app/app_state/buffers.rs", + "root_cause": "37 lines replaced/restructured", + "fix": "Rewrote 94→56 lines (37 removed)", + "tags": [ + "auto-detected", + "refactor", + "rs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-11T19:33:46.239Z" + }, + { + "id": "bug-069", + "timestamp": "2026-06-11T19:34:04.082Z", + "error_message": "Wrong reference: reloaded_paths should be changed_paths", + "file": "src/app/app_state/buffers.rs", + "root_cause": "Used \"reloaded_paths\" instead of \"changed_paths\"", + "fix": "Changed reloaded_paths → changed_paths", + "tags": [ + "auto-detected", + "wrong-reference", + "rs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-11T19:34:04.082Z" + }, + { + "id": "bug-070", + "timestamp": "2026-06-11T19:35:52.285Z", + "error_message": "Function not marked async", + "file": "src/app/event_loop/async_results/filesystem.rs", + "root_cause": "Function uses await but wasn't declared async", + "fix": "Added async modifier", + "tags": [ + "auto-detected", + "async-fix", + "rs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-11T19:35:52.285Z" + }, + { + "id": "bug-071", + "timestamp": "2026-06-11T19:36:02.574Z", + "error_message": "Function not marked async", + "file": "src/app/event_loop/setup.rs", + "root_cause": "Function uses await but wasn't declared async", + "fix": "Added async modifier", + "tags": [ + "auto-detected", + "async-fix", + "rs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-11T19:36:02.574Z" + }, + { + "id": "bug-072", + "timestamp": "2026-06-11T19:41:00.620Z", + "error_message": "Significant refactor of ", + "file": "src/terminal/grid.rs", + "root_cause": "3 lines replaced/restructured", + "fix": "Rewrote 10→14 lines (3 removed)", + "tags": [ + "auto-detected", + "refactor", + "rs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-11T19:41:00.620Z" + }, + { + "id": "bug-059", + "timestamp": "2026-06-12T00:00:00+07:00", + "error_message": "Main-thread blocking I/O in external-change pipeline (#4 from file-watcher audit) + terminal regex highlight re-processed entire scrollback on every PTY chunk (quadratic).", + "file": "src/app/app_state/{palette,buffers,mod,overlays}.rs, src/async_runtime/{message.rs,scheduler/dispatch.rs}, src/app/event_loop/{setup.rs,application.rs,async_results/{filesystem,mod,terminal}.rs}, src/workspace/model.rs, src/terminal/grid.rs", + "root_cause": "(a) apply_external_file_events did std::fs::read_to_string per buffer + workspace.rescan() (full tree walk) synchronously in the winit event loop; the 3s poll also read files inline. Mass external changes (git checkout, agent bulk edits) caused UI hitches. (b) apply_regex_highlights ran 8 regexes over total_rows() = scrollback + live on EVERY PtyOutput chunk; busy terminals became O(scrollback x chunks) on the UI thread.", + "fix": "(a) Two-phase pipeline: phase 1 (apply_external_file_events) only triages - pending_reload_paths + workspace_rescan_needed flags in ExternalChangeReport; new worker payloads ReadExternalFiles (tokio::fs reads -> ExternalFilesRead) and RescanWorkspace (spawn_blocking tree walk -> WorkspaceRescanned, dropped on scan error to avoid swapping in an empty tree); phase 2 apply_external_file_contents re-validates dirty state at apply time (in-flight user edits become conflicts), detects self-save echo by content compare (replaced the last_saved_at/SELF_SAVE_IGNORE_WINDOW disk-read heuristic, now deleted), and apply_workspace_rescan swaps nodes + refreshes picker. Poll path renamed collect_externally_modified_open_buffers - stat-only, submits ReadExternalFiles. Tests use apply_external_file_events_for_test driver simulating the worker synchronously. (b) apply_regex_highlights_incremental(rows_scrolled): only re-highlights live grid + rows just pushed to scrollback, walking back over wrap_continued to logical line start; full-pass apply_regex_highlights() kept for tests. Note: feed_chunk tests must use \\r\\n - bare \\n leaves cursor_col unchanged (no implicit CR). Full suite 710 passed.", + "tags": [ + "performance", + "main-thread-io", + "file-watcher", + "two-phase", + "worker", + "terminal", + "regex-highlight", + "scrollback", + "quadratic", + "incremental" + ], + "related_bugs": [ + "bug-058", + "bug-052", + "bug-055" + ], + "occurrences": 1, + "last_seen": "2026-06-12T00:00:00+07:00" + }, + { + "id": "bug-074", + "timestamp": "2026-06-12T05:11:36.277Z", + "error_message": "Wrong reference: bounds should be ai_status", + "file": "src/render/renderer/ui/statusbar.rs", + "root_cause": "Used \"bounds\" instead of \"ai_status\"", + "fix": "Changed bounds → ai_status", + "tags": [ + "auto-detected", + "wrong-reference", + "rs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-12T05:11:36.277Z" + }, + { + "id": "bug-075", + "timestamp": "2026-06-12T00:00:00Z", + "error_message": "AI inline completion ghost text never appears: provider returns empty content with finish_reason=length", + "file": "config/ai.toml, src/async_runtime/scheduler/ai.rs", + "root_cause": "All models on the localhost:20128 proxy are reasoning models. With max_tokens=96 the entire budget is consumed by thinking tokens (reasoning_tokens=95/96), so choices[0].message.content comes back empty. mimo-v2.5-pro only accepts reasoning_effort low|medium|high (cannot fully disable thinking).", + "fix": "Added optional provider.reasoning_effort config (ai_config.rs -> WorkerRequestPayload -> request body) and raised max_tokens to 384 so the budget covers thinking + content. Verified: reasoning_effort=low yields content 'a + b' in ~5s. Streaming is safe: reasoning arrives as delta.reasoning_content which extract_streaming_delta ignores.", + "tags": [ + "ai-inline-completion", + "reasoning-model", + "empty-content", + "max_tokens", + "provider" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-12T00:00:00Z" + }, + { + "id": "bug-076", + "timestamp": "2026-06-12T05:36:22.419Z", + "error_message": "Wrong reference: last_ai_inline_queue_at should be last_ai_inline_submit_at", + "file": "src/app/event_loop/mod.rs", + "root_cause": "Used \"last_ai_inline_queue_at\" instead of \"last_ai_inline_submit_at\"", + "fix": "Changed last_ai_inline_queue_at → last_ai_inline_submit_at", + "tags": [ + "auto-detected", + "wrong-reference", + "rs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-12T05:36:22.419Z" + }, + { + "id": "bug-077", + "timestamp": "2026-06-12T05:36:24.314Z", + "error_message": "Wrong reference: last_ai_inline_queue_at should be last_ai_inline_submit_at", + "file": "src/app/event_loop/setup.rs", + "root_cause": "Used \"last_ai_inline_queue_at\" instead of \"last_ai_inline_submit_at\"", + "fix": "Changed last_ai_inline_queue_at → last_ai_inline_submit_at", + "tags": [ + "auto-detected", + "wrong-reference", + "rs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-12T05:36:24.314Z" + }, + { + "id": "bug-078", + "timestamp": "2026-06-12T12:40:00Z", + "error_message": "User: typing 'logg' or text inside console.log(\"\") never shows AI ghost text suggestion", + "file": "src/app/event_loop/setup.rs", + "root_cause": "should_queue_ai_inline_completion only queued when the char before cursor was a trigger char (space, '.', '(', ...). Normal letters/quotes fell to the 'idle trigger' branch which compared now against last_ai_inline_queue_at — reset on EVERY keystroke — so fast typing always failed the check, and after the user stopped typing no event ever re-queued. Net effect: word typing never produced a request (no AiInlineCompletion failures in logs = nothing submitted).", + "fix": "Queue on every typing edit and let the 150ms debounce in flush_pending_ai_inline_completion coalesce keystrokes. Moved min_interval rate limiting from queue time (dropping) to flush time (delaying, with next_ai_inline_flush_deadline extended to max(debounce, min_interval) so the event loop wakes to retry). Removed dead last_ai_inline_queue_at field; trigger_chars/idle_trigger_ms config now unused by the queue path.", + "tags": [ + "ai-inline-completion", + "trigger", + "debounce", + "user-reported", + "ghost-text" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-12T12:40:00Z" + }, + { + "id": "bug-INLINE-EMPTY-001", + "timestamp": "2026-06-12T14:30:00Z", + "error_message": "No ghost text when typing inside console.log(\"\") — user thought string context blocked it", + "file": "config/ai.toml", + "root_cause": "Not a string/trigger issue: should_queue_ai_inline_completion only checks min_prefix_chars + suppress_in_middle_of_word, so requests fire fine inside strings. Real cause: configured model xmtp/mimo-v2.5 is a reasoning model that consumes the ENTIRE max_tokens budget on hidden reasoning (finish_reason=length, reasoning_tokens==completion_tokens) and returns empty content. Editor drops the empty suggestion -> no ghost text. Verified on proxy localhost:20128: mimo/qwen3.6-plus/deepseek-v4-flash all reasoning-only (empty/slow); ds/deepseek-* = 402 Insufficient Balance; cc/claude-* = 403 OAuth blocked.", + "fix": "Switched config/ai.toml model to mistral/mistral-tiny (proxy -> ministral-8b-latest): non-reasoning, ~0.5s, finish_reason=stop, streams clean content via delta.content. Removed reasoning_effort line; lowered max_tokens 384 -> 96.", + "tags": [ + "ai-inline-completion", + "reasoning-model", + "empty-completion", + "model-config", + "user-reported", + "mistral" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-12T14:30:00Z" + }, + { + "id": "bug-080", + "timestamp": "2026-06-12T08:17:11.582Z", + "error_message": "Significant refactor of ", + "file": "src/app/input/handler.rs", + "root_cause": "25 lines replaced/restructured", + "fix": "Rewrote 27→5 lines (25 removed)", + "tags": [ + "auto-detected", + "refactor", + "rs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-12T08:17:11.582Z" + }, + { + "id": "bug-INLINE-OVERLAP-001", + "timestamp": "2026-06-12T15:15:00Z", + "error_message": "Ghost text overlaps LSP completion popup (hard to read); Tab had duplicate accept behavior — user wants Ctrl+j only for ghost text", + "file": "src/app/input/handler.rs, src/app/resolved_keymap.rs, src/app/event_loop/async_results/ai.rs, src/app/event_loop/async_results/lsp.rs", + "root_cause": "Two problems. (1) Tab accepted ghost text via TWO paths: a hardcoded intercept in handler.rs (ran before input_map.resolve) AND resolved_keymap.rs:595 Tab->AI_ACCEPT_INLINE — duplicate behavior. (2) AI ghost text (inline_suggestion) and the LSP completion menu (has_completion) are independent overlays that both trigger on typing; LSP is fast, AI ~0.6s, so both end up visible and render stacked on top of each other plus the code below.", + "fix": "(a) Removed the hardcoded Tab->AiAcceptInline intercept in handler.rs; changed resolved_keymap.rs:595 Tab->INSERT_TAB. Ghost text now accepted ONLY by Ctrl+j (full) / Ctrl+l (word). Tab inserts indentation; the separate completion-menu Tab->CompletionAccept intercept (handler.rs:1271, guarded by completion_visible) is untouched. (b) Ghost text wins: handle_ai_result now calls dismiss_completion_for_ghost_text (clear_completion + set_completion_loading(false)) when a suggestion becomes visible, and lsp.rs completion handler returns early (no menu) when inline_suggestion().is_some(). The two overlays can no longer be visible simultaneously.", + "tags": [ + "ai-inline-completion", + "ghost-text", + "lsp-completion", + "overlap", + "keybinding", + "tab", + "user-reported" + ], + "related_bugs": [ + "bug-INLINE-EMPTY-001" + ], + "occurrences": 1, + "last_seen": "2026-06-12T15:15:00Z" + }, + { + "id": "bug-082", + "timestamp": "2026-06-12T08:27:44.488Z", + "error_message": "Wrong return value", + "file": "config/ai.toml", + "root_cause": "Was returning: empty content — avoid.", + "fix": "Now returns: empty content.", + "tags": [ + "auto-detected", + "return-value", + "toml" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-12T08:27:44.488Z" + }, + { + "id": "bug-083", + "timestamp": "2026-06-12T08:31:50.178Z", + "error_message": "Significant refactor of ", + "file": "src/app/event_loop/async_results/ai.rs", + "root_cause": "4 lines replaced/restructured", + "fix": "Rewrote 9→12 lines (4 removed)", + "tags": [ + "auto-detected", + "refactor", + "rs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-12T08:31:50.178Z" + }, + { + "id": "bug-084", + "timestamp": "2026-06-12T09:35:47.940Z", + "error_message": "Significant refactor of ", + "file": "src/app/event_loop/async_results/ai.rs", + "root_cause": "2 lines replaced/restructured", + "fix": "Rewrote 8→18 lines (2 removed)", + "tags": [ + "auto-detected", + "refactor", + "rs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-12T09:35:47.940Z" + }, + { + "id": "bug-INLINE-INDENT-001", + "timestamp": "2026-06-12T16:10:00Z", + "error_message": "Accepting a multi-line ghost text fills with wrong indent / extra tabs and cursor looks like it's not at the end; single-line is fine", + "file": "src/app/event_loop/async_results/ai.rs", + "root_cause": "sanitize_inline_suggestion only stripped an echoed line-prefix when its trim_start() was non-empty. On a fresh indented line the line_prefix is whitespace-only (e.g. 8 spaces or tabs), so the guard skipped stripping. Codestral re-emits that leading whitespace (verified: caret after 8 spaces -> output starts with 8 spaces), so accept inserted it AFTER the caret's existing indentation = double indent on line 1. Single-line completions don't hit this because the caret usually sits mid-expression (non-whitespace prefix). The buffer/cursor math itself is correct (proven by new accept_multiline_preserves_text_and_cursor_at_end test); the 'cursor not at end' perception was the double-indent shifting the caret right of where the ghost preview ended.", + "fix": "Rewrote the prefix-strip in sanitize_inline_suggestion: if line_prefix is non-empty, FIRST try strip_prefix(full line_prefix) (covers whitespace-only echo), else fall back to the trim_start() remainder. Deeper-than-caret indentation is preserved (only the echoed caret-level whitespace is removed). Added tests strips_echoed_indentation_on_fresh_line, keeps_deeper_indentation_relative_to_caret, and accept_multiline_preserves_text_and_cursor_at_end. Known minor: during streaming the ghost-text PREVIEW shows the un-sanitized chunks (echoed indent) until the final result re-sanitizes; accept always uses the sanitized value.", + "tags": [ + "ai-inline-completion", + "ghost-text", + "indentation", + "multiline", + "sanitizer", + "user-reported" + ], + "related_bugs": [ + "bug-INLINE-OVERLAP-001", + "bug-INLINE-EMPTY-001" + ], + "occurrences": 1, + "last_seen": "2026-06-12T16:10:00Z" + }, + { + "id": "bug-086", + "timestamp": "2026-06-12T00:00:00+07:00", + "error_message": "UI zoomed far too large on external Full HD (1920x1080) monitor; looks correct on MacBook retina", + "file": "src/app/event_loop/application.rs, src/app/event_loop/setup.rs", + "root_cause": "ScaleFactorChanged handler recomputes runtime scaling with STALE physical window_size from previous monitor (application.rs:472). With sf 2.0->1.0 and retina physical size still cached, logical size doubles -> content_scale jumps to ~1.575 (clamped <=2.0 by max_content_scale) -> UI zooms ~1.6x. Additionally auto_scale conflates logical window size with DPI (1080p@1x has more logical px than retina MacBook but lower physical DPI), and scale_factor_override escape hatch is commented out.", + "fix": "FIXED: (1) re-enabled window.scale_factor_override end-to-end (ui_config.rs struct/builtin/from_raw/with_user_overrides/RawWindow/UserUiWindow + setup.rs usage); (2) ScaleFactorChanged now refreshes self.window_size from window.inner_size() before recomputing runtime scaling (application.rs); (3) find_profile_path now also checks ~/.config/netherize/config/ui/ between CWD and exe dir. Root confusion: ~/.config/netherize/config/ui/default.toml was NEVER read by the app, and scale_factor_override in TOML was a dead option (code commented out).", + "tags": [ + "ui-scaling", + "dpi", + "hidpi", + "winit", + "scale_factor", + "monitor" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-12" + }, + { + "id": "bug-087", + "timestamp": "2026-06-12T11:00:58.062Z", + "error_message": "Significant refactor of ", + "file": "src/render/renderer/ui/welcome.rs", + "root_cause": "6 lines replaced/restructured", + "fix": "Rewrote 6→4 lines (6 removed)", + "tags": [ + "auto-detected", + "refactor", + "rs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-12T11:00:58.062Z" + }, + { + "id": "bug-088", + "timestamp": "2026-06-12T11:01:17.044Z", + "error_message": "Significant refactor of ", + "file": "config/ui/default.toml", + "root_cause": "2 lines replaced/restructured", + "fix": "Rewrote 6→8 lines (2 removed)", + "tags": [ + "auto-detected", + "refactor", + "toml" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-12T11:01:17.044Z" + }, + { + "id": "bug-089", + "timestamp": "2026-06-12T11:01:36.272Z", + "error_message": "Wrong reference: true should be false", + "file": "../../.config/netherize/ui.toml", + "root_cause": "Used \"true\" instead of \"false\"", + "fix": "Changed true → false", + "tags": [ + "auto-detected", + "wrong-reference", + "toml" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-12T11:01:36.272Z" + }, + { + "id": "bug-090", + "timestamp": "2026-06-12T00:00:00+07:00", + "error_message": "UI proportions unstable ('bi bop') across monitors — welcome screen squeezed on Full HD vs MacBook", + "file": "src/render/renderer/ui/welcome.rs", + "root_cause": "welcome_scale = (welcome_card_max_width/560) * (font_size/14): both factors already carry runtime_scale -> quadratic scaling (rs^2). MacBook rs=2 -> 4x (clamped 3.0), Full HD rs=1 -> 1x, so cross-monitor ratio was 3:1 instead of 2:1.", + "fix": "welcome_scale = layout_clamp(welcome_card_max_width / 560.0, 0.5, 3.0) — single scale source. Also set auto_scale=false by default (config/ui/default.toml + user ui.toml) so UI size is logical-points x OS scale factor only, like other IDEs.", + "tags": [ + "ui-scaling", + "welcome-screen", + "double-scaling", + "dpi" + ], + "related_bugs": [ + "bug-086" + ], + "occurrences": 1, + "last_seen": "2026-06-12" + }, + { + "id": "bug-091", + "timestamp": "2026-06-12T00:00:00+07:00", + "error_message": "Extensions manager: LSP/tool installed-state detection wrong ('lsp bat khong dung')", + "file": "src/app/app_state/mod.rs, src/async_runtime/scheduler/syntax_jobs.rs", + "root_cause": "1) installed flags hardcoded in default_extension_items (author machine state: pylsp/rust-analyzer/yaml/json/bash=true) shown until async which-check returns; 2) resolve_system_path misses ~/.pub-cache/bin, fvm, ~/.asdf/shims, and uses non-standard ~/.nvm/versions/node/current/bin (nvm has no 'current' dir by default) so nvm-installed node LSPs report missing; 3) Dart LSP absent from registry despite editor Dart/FVM support; 4) SystemDepCheckResult only applied if extensions buffer active (system.rs:8)", + "fix": "FIXED: resolve_system_path now scans ~/.nvm/versions/node/*/bin (newest 3), adds ~/.pub-cache/bin, fvm default bin, ~/.asdf/shims, ~/.bun/bin, ~/.deno/bin; installed flags no longer hardcoded (all false + deps_checked flag, renderer shows '… Checking'); SystemDepCheckResult/install results applied via any_extensions_manager_buffer_mut even when buffer inactive; Dart LSP added to registry + which-check list.", + "tags": [ + "extensions-manager", + "lsp", + "detection", + "PATH", + "nvm", + "fvm" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-12" + }, + { + "id": "bug-092", + "timestamp": "2026-06-12T00:00:00+07:00", + "error_message": "Extensions manager: install popup 'sometimes does not work' / disappears", + "file": "src/app/event_loop/commands_settings.rs, src/app/input_map/focus.rs", + "root_cause": "Esc unconditionally maps to ExtensionsCancelFilter (focus.rs:14) which clears state.command even while install is running (commands_settings.rs:115-118) -> popup vanishes mid-install, logs dropped; also 'i' while filter focused types into filter instead of installing; no guard/confirm, no brew-missing pre-check for new users", + "fix": "FIXED: Esc no longer clears state.command while running (only dismisses finished popup); second install/uninstall blocked with warning toast while one runs; popup helper text updated.", + "tags": [ + "extensions-manager", + "install-popup", + "keybinding", + "ux" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-12" + }, + { + "id": "bug-093", + "timestamp": "2026-06-12T12:36:01.196Z", + "error_message": "Significant refactor of ", + "file": "src/render/renderer/editor/extensions.rs", + "root_cause": "4 lines replaced/restructured", + "fix": "Rewrote 8→16 lines (4 removed) | Also: [popup_x - 1.5, popup_y - 1.5, popup_w + 3.0, popu; .with_radius(6.0), | Also: [inner_x + 12.0, detail_y, inner_w - 24.0, detail_; .with_radius(6.0), | Also: let status_color = if item.installed { success } e; let status = if item.installed {", + "tags": [ + "auto-detected", + "refactor", + "rs" + ], + "related_bugs": [], + "occurrences": 4, + "last_seen": "2026-06-12T12:42:53.611Z" + }, + { + "id": "bug-094", + "timestamp": "2026-06-12T12:38:25.007Z", + "error_message": "Wrong reference: scroll_y should be s", + "file": "src/render/renderer/editor/extensions.rs", + "root_cause": "Used \"scroll_y\" instead of \"s\"", + "fix": "Changed scroll_y → s", + "tags": [ + "auto-detected", + "wrong-reference", + "rs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-12T12:38:25.007Z" + }, + { + "id": "bug-095", + "timestamp": "2026-06-12T12:40:31.209Z", + "error_message": "Significant refactor of ", + "file": "src/async_runtime/scheduler/syntax_jobs.rs", + "root_cause": "4 lines replaced/restructured", + "fix": "Rewrote 6→7 lines (4 removed)", + "tags": [ + "auto-detected", + "refactor", + "rs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-12T12:40:31.209Z" + }, + { + "id": "bug-096", + "timestamp": "2026-06-12T12:41:58.128Z", + "error_message": "Wrong reference: active_extensions_manager_buffer_mut should be any_extensions_manager_buffer_mut", + "file": "src/app/event_loop/async_results/system.rs", + "root_cause": "Used \"active_extensions_manager_buffer_mut\" instead of \"any_extensions_manager_buffer_mut\"", + "fix": "Changed active_extensions_manager_buffer_mut → any_extensions_manager_buffer_mut", + "tags": [ + "auto-detected", + "wrong-reference", + "rs" + ], + "related_bugs": [], + "occurrences": 3, + "last_seen": "2026-06-12T12:42:02.188Z" + }, + { + "id": "bug-097", + "timestamp": "2026-06-12T12:42:11.712Z", + "error_message": "Function not marked async", + "file": "src/app/app_state/mod.rs", + "root_cause": "Function uses await but wasn't declared async", + "fix": "Added async modifier", + "tags": [ + "auto-detected", + "async-fix", + "rs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-12T12:42:11.712Z" + }, + { + "id": "bug-098", + "timestamp": "2026-06-12T12:42:32.551Z", + "error_message": "Wrong reference: true should be false", + "file": "src/app/app_state/mod.rs", + "root_cause": "Used \"true\" instead of \"false\"", + "fix": "Changed true → false", + "tags": [ + "auto-detected", + "wrong-reference", + "rs" + ], + "related_bugs": [], + "occurrences": 10, + "last_seen": "2026-06-12T12:42:43.289Z" + }, + { + "id": "bug-099", + "timestamp": "2026-06-12T00:00:00+07:00", + "error_message": "Cheatsheet 'chua hoat dong' — no way to open help by key", + "file": "config/keymaps/default.toml", + "root_cause": "Command app.open_help existed with full dispatch+render path but had NO keybinding; only :help vim command triggered it, which new users never discover", + "fix": "Added ? (Space ?) binding in normal mode to app.open_help", + "tags": [ + "cheatsheet", + "help", + "keybinding", + "onboarding" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-12" + }, + { + "id": "bug-100", + "timestamp": "2026-06-12T00:00:00+07:00", + "error_message": "[keymap] unknown command 'app.open_help' (key='?') — skipped; also C, {, }, ctrl+v, cmd+shift+n, ai.chat_stop silently dead", + "file": "src/core/command_ids.rs, config/keymaps/default.toml", + "root_cause": "command_ids::is_valid only checked ALL_IDS array; 11 ids (NEW_INSTANCE, ENTER_VISUAL_BLOCK, CHANGE_TO_LINE_END, DELETE_TO_LINE_END, JOIN_LINES, MOVE_PARAGRAPH_UP/DOWN, AI_CHAT_STOP, OPEN_HELP, MARKDOWN_PREVIEW_SCROLL_TOP/BOTTOM) existed in parse() but were missing from ALL_IDS, so keymap loader skipped their bindings silently. Plus default.toml referenced nonexistent 'editor.insert_newline' (correct: editor.newline).", + "fix": "Added missing ids to ALL_IDS; is_valid now falls back to parse(id, None).is_some() so future drift cannot kill bindings; fixed editor.insert_newline->editor.newline; added regression test default_keymap_has_no_unknown_commands", + "tags": [ + "keymap", + "command-ids", + "registry-drift", + "cheatsheet", + "silent-failure" + ], + "related_bugs": [ + "bug-093" + ], + "occurrences": 1, + "last_seen": "2026-06-12" + }, + { + "id": "bug-101", + "timestamp": "2026-06-12T13:11:05.681Z", + "error_message": "Significant refactor of ", + "file": "src/render/renderer/editor/settings.rs", + "root_cause": "9 lines replaced/restructured", + "fix": "Rewrote 22→34 lines (9 removed) | Also: let key_col_w = 400.0;; let value_col_w = 300.0;", + "tags": [ + "auto-detected", + "refactor", + "rs" + ], + "related_bugs": [], + "occurrences": 2, + "last_seen": "2026-06-12T13:12:07.541Z" + }, + { + "id": "bug-102", + "timestamp": "2026-06-12T13:14:31.653Z", + "error_message": "Wrong reference: footer_y should be s", + "file": "src/render/renderer/editor/settings.rs", + "root_cause": "Used \"footer_y\" instead of \"s\"", + "fix": "Changed footer_y → s", + "tags": [ + "auto-detected", + "wrong-reference", + "rs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-12T13:14:31.653Z" + }, + { + "id": "bug-103", + "timestamp": "2026-06-12T00:00:00+07:00", + "error_message": "Settings tab (Cmd+,) UI scale wrong + fixed-px columns overlap on narrow windows", + "file": "src/render/renderer/editor/settings.rs", + "root_cause": "Same unscaled-chrome disease as extensions manager (~40 hardcoded px) PLUS absolute column widths key_col_w=400/desc_x_offset=410/value_col_w=300 that overlap below ~750px left-pane width and shrink to half size on 2x displays", + "fix": "All chrome px multiplied by renderer ui_scale; columns now proportional (32%/26% with scaled clamps); description hidden when too narrow; config-preview pane hidden when panel < 760*s; added new 'UI Scale' setting (window.scale_factor_override) editable via h/l +-0.05 or Enter (numeric or 'auto') with live apply via refresh_runtime_scale_from_window", + "tags": [ + "settings-tab", + "ui-scaling", + "responsive-layout", + "flexibility" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-12" + }, + { + "id": "bug-104", + "timestamp": "2026-06-12T13:35:56.909Z", + "error_message": "Incorrect value in code", + "file": "src/core/command_ids.rs", + "root_cause": "Had \"editor.join_lines\"", + "fix": "Changed to \"editor.yank_to_line_end\"", + "tags": [ + "auto-detected", + "wrong-value", + "rs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-12T13:35:56.909Z" + }, + { + "id": "bug-102", + "timestamp": "2026-06-12T00:00:00+07:00", + "error_message": "Core vim keys dead in normal mode: p/P (paste), D, Y, S, J, X unbound in default.toml; bare f/F/t/T motion not implemented (only worked as operator d/c/y prefix)", + "file": "config/keymaps/default.toml, src/app/input/handler.rs, src/core/commands.rs", + "root_cause": "default.toml had a comment claiming 'use p or P (Vim-style)' but the bindings were never declared; help cheatsheet listed paste_after/delete_to_line_end/join_lines/substitute_line ids that silently disappear when unbound (append_help_binding skips unbound ids) so the gap was invisible. Bare f/t/F/T needed a dynamic char payload which keymap TOML can't express, and no pending state existed for the no-operator case.", + "fix": "Bound D/Y/S/J/p/P/X in default.toml (Y via new id editor.yank_to_line_end -> Operate{Yank, Motion::LineEnd}); added PendingState::FindCharMotion + Command::MoveFindChar(kind, char) dispatched through navigation.rs find_char_motion_and_highlight() which moves cursor (line-scoped, vim semantics) AND sets search query to the char so all occurrences highlight and n/N repeat the jump file-wide.", + "tags": [ + "keymap", + "vim", + "normal-mode", + "find-char", + "paste", + "unbound-binding" + ], + "related_bugs": [ + "bug-100" + ], + "occurrences": 1, + "last_seen": "2026-06-12T00:00:00+07:00" + }, + { + "id": "bug-106", + "timestamp": "2026-06-12T13:56:00.326Z", + "error_message": "Significant refactor of ", + "file": "src/core/command_dispatch/common.rs", + "root_cause": "2 lines replaced/restructured", + "fix": "Rewrote 11→26 lines (2 removed)", + "tags": [ + "auto-detected", + "refactor", + "rs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-12T13:56:00.326Z" + }, + { + "id": "bug-107", + "timestamp": "2026-06-12T13:58:57.333Z", + "error_message": "Significant refactor of ", + "file": "src/core/command_dispatch/tests.rs", + "root_cause": "4 lines replaced/restructured", + "fix": "Rewrote 29→35 lines (4 removed)", + "tags": [ + "auto-detected", + "refactor", + "rs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-12T13:58:57.333Z" + }, + { + "id": "bug-103", + "timestamp": "2026-06-12T00:00:00+07:00", + "error_message": "Ctrl+O/Ctrl+I jump list barely usable: most navigations never pushed (live grep Space f w, file picker, explorer open, / search confirm, n/N, gg/G, {/}, leap); stack had no cap or dedup", + "file": "src/core/command_dispatch/{common,palette,navigation}.rs, src/app/event_loop/commands_editor.rs, src/app/app_state/palette.rs", + "root_cause": "push_jump was only called at 5 sites (gd, references, diagnostics, document symbols, *). DispatchCtx::open_file — the choke point used by file picker, live grep, and explorer — never recorded the origin. In-buffer jump motions (n/N, gg/G, paragraph, leap) weren't treated as jumps at all.", + "fix": "DispatchCtx::open_file pushes origin when switching to a different file (covers picker/grep/explorer in one place); OpenSearchMatch and InFileSearch-confirm push explicitly (same-file case); navigation.rs jump_origin/record_jump_if_moved helpers wrap SearchNext/Prev, MoveToFirst/LastLine, MoveParagraphUp/Down; leap jump pushes. push_jump_entry now dedups consecutive same (file,line) and caps stack at 100 (vim-style).", + "tags": [ + "jumplist", + "ctrl-o", + "navigation", + "live-grep", + "vim" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-12T00:00:00+07:00" + }, + { + "id": "bug-108", + "timestamp": "2026-06-12T00:00:00+07:00", + "error_message": "Symbol selection highlight (LSP documentHighlight) jumps to wrong text after dd/dw/D/C deletes in normal mode", + "file": "src/app/event_loop/commands_editor.rs, src/app/event_loop/commands.rs", + "root_cause": "dd/dw resolve to Command::Operate{op,target} via the pending-operator system, but Operate was missing from the should_reparse match in handle_generic_editor_command and from is_text_modifying_command in commands.rs. So after the buffer mutated, semantic_symbol_highlights (stale byte offsets) were never cleared, revision was not bumped, LSP didChange was not flushed, and no reparse was scheduled. DeleteToLineEnd/ChangeToLineEnd (D/C keys) were also missing from should_reparse. Note: reconcile_highlight_spans_with_pending_edits remaps highlight_spans/semantic_highlight_spans but NOT app_state.semantic_symbol_highlights.", + "fix": "Added Command::Operate{op: Delete|Change, ..} to should_reparse (commands_editor.rs) and is_text_modifying_command (commands.rs); also added DeleteToLineEnd and ChangeToLineEnd to should_reparse. Added test operator_delete_clears_stale_semantic_symbol_highlights.", + "tags": [ + "semantic-highlight", + "documentHighlight", + "vim-operator", + "dd", + "dw", + "stale-byte-offsets", + "lsp" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-12T00:00:00+07:00" + }, + { + "id": "bug-109", + "timestamp": "2026-06-12T00:00:00+07:00", + "error_message": "User: type code, move caret elsewhere, THEN the AI ghost text appears at the new spot and follows the cursor; changes during debounce were not cancelled", + "file": "src/app/event_loop/setup.rs, src/app/event_loop/application.rs, src/app/event_loop/commands_editor.rs, src/app/event_loop/commands_lsp.rs, src/app/event_loop/async_results/ai.rs, src/app/event_loop/mod.rs", + "root_cause": "Only insert-typing commands cancelled/re-queued the inline pipeline. Cursor motion, mouse clicks, Escape, and buffer switches never cancelled the pending debounce or the in-flight request and never bumped ai_inline_revision, so a late result passed the bridge revision gate. The ghost text is stored as bare text with no position anchor and renders at the live caret, so the stale suggestion appeared at — and followed — whatever position the caret had moved to. flush_pending_ai_inline_completion also captured prefix/suffix at flush time, not queue time.", + "fix": "Added ai_inline_anchor: Option<(Option, usize)> on AppShell anchoring the whole pipeline (pending -> in-flight -> visible ghost) to (buffer, caret, Insert mode). queue_ai_inline_completion sets it; enforce_ai_inline_anchor() runs every about_to_wait pass before the flush and on mismatch drops the pending request, cancels the in-flight token, bumps ai_inline_revision (bridge drops late results as stale), and clears visible ghost text. flush re-validates the anchor before submitting. handle_ai_result (chunk + result arms) drops payloads when the anchor is no longer current. Legitimate caret moves keep the ghost by re-anchoring: prefix-retained typing (commands_editor.rs) and partial word accept AiAcceptInlineWord (commands_lsp.rs).", + "tags": [ + "ai-inline-completion", + "ghost-text", + "debounce", + "stale-result", + "cursor-anchor" + ], + "related_bugs": [ + "bug-078" + ], + "occurrences": 1, + "last_seen": "2026-06-12T00:00:00+07:00" + }, + { + "id": "bug-111", + "timestamp": "2026-06-12T14:42:33.205Z", + "error_message": "Wrong reference: ai_chat_text_system should be whichkey_text_system", + "file": "src/render/renderer/lifecycle.rs", + "root_cause": "Used \"ai_chat_text_system\" instead of \"whichkey_text_system\"", + "fix": "Changed ai_chat_text_system → whichkey_text_system", + "tags": [ + "auto-detected", + "wrong-reference", + "rs" + ], + "related_bugs": [], + "occurrences": 3, + "last_seen": "2026-06-12T14:42:39.092Z" + }, + { + "id": "bug-112", + "timestamp": "2026-06-12T14:42:50.503Z", + "error_message": "Wrong reference: toast_chrome_instances should be whichkey_chrome_instances", + "file": "src/render/renderer/lifecycle/frame.rs", + "root_cause": "Used \"toast_chrome_instances\" instead of \"whichkey_chrome_instances\"", + "fix": "Changed toast_chrome_instances → whichkey_chrome_instances", + "tags": [ + "auto-detected", + "wrong-reference", + "rs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-12T14:42:50.503Z" + }, + { + "id": "bug-113", + "timestamp": "2026-06-12T14:47:24.150Z", + "error_message": "Incorrect value in code", + "file": "src/app/resolved_keymap.rs", + "root_cause": "Had \"\"", + "fix": "Changed to \"\"", + "tags": [ + "auto-detected", + "wrong-value", + "rs" + ], + "related_bugs": [], + "occurrences": 2, + "last_seen": "2026-06-12T14:47:26.004Z" + }, + { + "id": "bug-114", + "timestamp": "2026-06-12T14:47:52.689Z", + "error_message": "Incorrect value in code", + "file": "src/render/renderer/ui/welcome.rs", + "root_cause": "Had \"Press Space P J for Recent Projects | Press ⌘ ,", + "fix": "Changed to \"Press Space ? or F1 for all keybindings | Space", + "tags": [ + "auto-detected", + "wrong-value", + "rs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-12T14:47:52.689Z" + }, + { + "id": "bug-115", + "timestamp": "2026-06-12T14:50:54.867Z", + "error_message": "Wrong reference: ai_status should be lsp_indicator", + "file": "src/render/renderer/ui/statusbar.rs", + "root_cause": "Used \"ai_status\" instead of \"lsp_indicator\"", + "fix": "Changed ai_status → lsp_indicator", + "tags": [ + "auto-detected", + "wrong-reference", + "rs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-12T14:50:54.867Z" + }, + { + "id": "bug-116", + "timestamp": "2026-06-12T14:55:04.508Z", + "error_message": "Wrong reference: super should be crate", + "file": "src/async_runtime/scheduler/syntax_jobs.rs", + "root_cause": "Used \"super\" instead of \"crate\"", + "fix": "Changed super → crate", + "tags": [ + "auto-detected", + "wrong-reference", + "rs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-12T14:55:04.508Z" + }, + { + "id": "bug-117", + "timestamp": "2026-06-12T14:57:57.298Z", + "error_message": "Significant refactor of ", + "file": "src/render/renderer/editor/fuzzy.rs", + "root_cause": "2 lines replaced/restructured", + "fix": "Rewrote 10→15 lines (2 removed) | Also: [right_x + 6.0, row_y, (right_w - 12.0).max(1.0), ; [right_x + 6.0, row_y, (right_w - 12.0).max(1.0), ", + "tags": [ + "auto-detected", + "refactor", + "rs" + ], + "related_bugs": [], + "occurrences": 2, + "last_seen": "2026-06-12T14:58:30.975Z" + }, + { + "id": "bug-118", + "timestamp": "2026-06-12T14:59:02.095Z", + "error_message": "Significant refactor of ", + "file": "src/render/renderer/editor/help.rs", + "root_cause": "4 lines replaced/restructured", + "fix": "Rewrote 4→7 lines (4 removed)", + "tags": [ + "auto-detected", + "refactor", + "rs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-12T14:59:02.095Z" + }, + { + "id": "bug-119", + "timestamp": "2026-06-12T15:30:23.878Z", + "error_message": "Significant refactor of ", + "file": "src/app/input/tests.rs", + "root_cause": "5 lines replaced/restructured", + "fix": "Rewrote 37→77 lines (5 removed)", + "tags": [ + "auto-detected", + "refactor", + "rs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-12T15:30:23.878Z" + }, + { + "id": "bug-120", + "timestamp": "2026-06-12", + "error_message": "Which-key: doi lau roi bam phim tiep theo (Space f ... p) thi paste thay vi chay chord; Esc khong tat overlay", + "file": "src/app/input/handler.rs, src/app/event_loop/application.rs", + "root_cause": "(1) prefix_timeout 1.5s reset pending chord NGAM trong khi overlay van hien -> phim tiep theo roi ve normal mode. (2) InputRouteOutcome::NoDispatch khong request_redraw -> overlay khong bao gio duoc ve lai khi chord bi huy/het han; Esc fallback cung khong refresh.", + "fix": "reset_prefix_if_timed_out bo qua pending.sequence (chord visible khong expire); Esc intercept trong pending-sequence branch -> reset_prefix + NoDispatch (khong dispatch mode.escape); giu nguyen started_at qua cac buoc chord (overlay khong re-arm delay); NoDispatch gio request_redraw. Tests: pending_chord_survives_timeout_window, escape_cancels_pending_chord_without_fallback_dispatch.", + "tags": [ + "whichkey", + "input", + "pending-chord", + "timeout", + "redraw", + "esc" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-12" + }, + { + "id": "bug-121", + "timestamp": "2026-06-12T15:38:50.654Z", + "error_message": "Wrong reference: submit_lsp_completion should be submit_lsp_completion_manual", + "file": "src/app/event_loop/commands_lsp.rs", + "root_cause": "Used \"submit_lsp_completion\" instead of \"submit_lsp_completion_manual\"", + "fix": "Changed submit_lsp_completion → submit_lsp_completion_manual", + "tags": [ + "auto-detected", + "wrong-reference", + "rs" + ], + "related_bugs": [], + "occurrences": 1, + "last_seen": "2026-06-12T15:38:50.654Z" + }, + { + "id": "bug-122", + "timestamp": "2026-06-12T23:06:27+07:00", + "error_message": "When AI ghost text is visible, pressing Ctrl+Space should explicitly open LSP completion instead of being suppressed by the ghost-text-wins policy.", + "file": "src/app/event_loop/commands_completion.rs, src/app/event_loop/commands_lsp.rs, src/app/event_loop/commands_tests.rs", + "root_cause": "TriggerCompletion reused the automatic LSP completion path. The visible inline suggestion remained active, so the LSP result handler intentionally dropped the completion menu; a late AI result could also dismiss a newly opened menu.", + "fix": "Route TriggerCompletion through a manual path that clears ghost text and its anchor, cancels pending/in-flight AI work, increments ai_inline_revision, then submits LSP completion. Added an end-to-end command regression test proving the LSP menu opens and remains visible after a late cancelled AI result.", + "tags": [ + "completion", + "ctrl-space", + "lsp", + "ai-inline", + "ghost-text", + "user-reported" + ], + "related_bugs": [ + "bug-121" + ], + "occurrences": 1, + "last_seen": "2026-06-12T23:06:27+07:00" + }, + { + "id": "bug-123", + "timestamp": "2026-06-12T23:10:55+07:00", + "error_message": "rustfmt --check reported an unformatted set_inline_suggestion call in the new manual completion regression test.", + "file": "src/app/event_loop/commands_tests.rs", + "root_cause": "The initial test extension left a chained AppState call on one line instead of the repository's rustfmt layout.", + "fix": "Applied the rustfmt-proposed multiline layout and reran rustfmt --check successfully.", + "tags": [ + "formatting", + "rustfmt", + "tests" + ], + "related_bugs": [ + "bug-122" + ], + "occurrences": 1, + "last_seen": "2026-06-12T23:10:55+07:00" } ] -} \ No newline at end of file +} diff --git a/.wolf/cerebrum.md b/.wolf/cerebrum.md index b71fee95..f73c8147 100644 --- a/.wolf/cerebrum.md +++ b/.wolf/cerebrum.md @@ -5,11 +5,14 @@ > Last updated: 2026-05-30 ## User Preferences +- **User runs the editor via `cargo run --release` from the repo (2026-06-12):** so CWD = repo and the active UI profile is the repo's `config/ui/default.toml` (+ `~/.config/netherize/ui.toml` field overrides). The /Applications bundle and ~/.local/bin copies are secondary. - **RTK Usage:** Do not blindly prefix every shell command with `rtk`. Use `rtk` only for supported simple command shapes; use raw shell commands for compound `find` predicates/actions (`-exec`, `-not`, grouped predicates), pipelines, redirection, shell expansion/substitution, or when `rtk` reports unsupported syntax. +- **Report Language:** When producing project status/audit files, use full English. Do not write Vietnamese without diacritics. ## Key Learnings +- **Config path resolution (2026-06-12):** UI profile loads from `$CWD/config/ui/.toml` -> `~/.config/netherize/config/ui/` (added 2026-06-12) -> exe-dir `config/ui/` -> builtin. User overrides (subset of fields) come from `~/.config/netherize/ui.toml`. The installed `/Applications/Netherize.app` bundles its own config copy — editing repo config does NOT affect the installed app until the bundle binary/config is refreshed. - **Project:** netherize_editor - **Description:** A GPU-accelerated terminal/text editor written in Rust. Currently in active development (Module 12 / Phase 2–3). @@ -19,11 +22,46 @@ - **Tree-sitter Span Adjustment on Truncation:** When truncating folded lines, syntax highlight spans must be adjusted using a proper byte offset map. The old line-by-line offset calculation was broken and caused highlight colors to appear on wrong tokens. Fixed approach: build a sorted `(old_byte, new_byte)` map for every byte position, then use binary search to remap span start/end positions. This preserves tree-sitter highlighting on truncated folded lines. - **Markdown Preview Close Semantics:** Markdown preview focus should close its buffer tab with `q` / `Space+x`, matching live grep and references tabs. `q` is routed in `src/app/input_map/focus.rs`; leader chords need the `preview` keymap scope in both `src/app/resolved_keymap.rs` and `config/keymaps/default.toml`. - **RTK Limitations:** `rtk find` does not support compound predicates/actions such as `-exec`; `rtk git diff -- ` may also mis-handle pathspec-style commands. Fall back to the raw command instead of trying to force the proxy. +- **Terminal surfaces are three-way:** There are three terminal grids — center buffer terminal (`active_terminal_grid`), bottom-panel tabs (`active_terminal_tab().grid`), and right-sidebar opencode chat (`right_terminal_grid`). Any helper that resolves "the focused terminal" must handle all three `FocusTarget`s. `focused_terminal_session_id()` had all three but `focused_terminal_grid_mut()` and `mark_focused_terminal_layout_dirty()` were missing the `RightSidebar` branch — that asymmetry is a common bug source. Each has its own dirty flag: `buffer_terminal_needs_layout` / `terminal_needs_layout` / `right_terminal_needs_layout`. +- **`InputFocusContext::Terminal` is shared** by bottom-panel terminal AND right-sidebar opencode (see `build_context` in `setup.rs`). To scope input behavior to only one of them, use the `KeybindingContext.right_sidebar_terminal` flag rather than adding an enum variant (lighter: only 3 struct literals to update). Raw PTY forwarding in `route_normalized_input`/`route_repeated_normalized_input` eats Ctrl+* in `TerminalFocus`, so any "rebind Ctrl+X in terminal" must intercept BEFORE `terminal_input_payload` (which returns `None` for Esc, `Some(ctrl-seq)` for Ctrl+letters). +- **The right "AI chat" is a real PTY terminal:** `AiChatFocus` switches the right sidebar to `PanelTabId::Terminal`, spawns opencode, and enters `ModeEvent::FocusTerminal` — so its focus context is `Terminal`, not `AiChat`. `PanelTabId::AiChat` is a separate custom chat-input UI. The ANSI parser ignores alt-screen (`?1049h`), so opencode output flows into the normal grid + scrollback (scrolling works via `view_scroll_up/down`). +- **Right-dock opencode mouse/input scope:** For right-dock opencode behavior, scope keyboard/mouse handling with `KeybindingContext.right_sidebar_terminal` and `PanelTabId::Terminal`, not `InputFocusContext::AiChat`. `AiChatFocus` must re-apply `ModeEvent::FocusTerminal` even if `FocusTarget::RightSidebar` is already active, because mode drift otherwise makes focused right-dock shortcuts miss the terminal path. Mouse support belongs in `WindowEvent` with hit-testing against `RegionId::RightSidebar`, then route through commands (`AiChatFocus`, `TerminalScrollUp/Down`, `TerminalWriteInput`) so the editor/bottom terminal stay unaffected. +- **Terminal grid is one Unicode scalar per cell, no combining-mark handling by default:** `TerminalGrid::print_char_at_cursor` writes `cell.ch: char` and advances the cursor; the ANSI parser (`src/terminal/ansi_parser.rs`) emits each decoded codepoint as a separate `PrintChar` with no normalization. Zero-width combining marks (Vietnamese tone marks U+0300–U+036F etc.) must be folded into the previous cell via `compose_combining_into_prev` (NFC-normalize base+mark, adopt only if it collapses to one scalar). Use **NFC on the whole grapheme**, not pairwise `char::compose` — the stream often sends marks out of canonical order (precomposed 'ô' + combining dot-below) which pairwise compose misses. This is unrelated to line height (panel_line_height 22 vs font 14 is generous). The terminal grid still does NOT handle wide (CJK, width-2) chars — `print_char_at_cursor` always advances by 1. +- **Terminal regex highlight must work per LOGICAL line, not per physical row:** `apply_regex_highlights` highlights wrapped lines correctly only by joining soft-wrap continuation rows. Soft wraps are tracked via `TerminalCell.wrap_continued`, set on a row's first cell when `print_char_at_cursor` auto-wraps (`cursor_col >= cols` with no newline) — explicit `Newline`/`CarriageReturn` do NOT set it. Highlighting walks maximal runs of `[start, end)` where each row after the first has `wrap_continued`, joins their text, runs the regexes once, and maps char index `i` back to physical `(start + i/cols, i%cols)` (every physical row contributes exactly `cols` chars). Do it two-phase: collect `(row,col,color)` under an immutable borrow, then apply via `set_style_fg_absolute`. Push data-type matches lowest-precedence-first (time→null→bool→number→string) so later writes win, keeping string>number>bool>null>time>base-line-color. Per-physical-row highlighting drops color on every wrapped continuation (no log prefix → no base color; a token split across the wrap → no match on row 2). - **Terminal Cell Backgrounds:** ANSI background-colored terminal cells must render as `RegionDrawInstance` quads underneath text, not as `"█"` glyphs in `TerminalViewRenderer::build_instances()`. Full-block glyphs leave font-metric seams and make full-screen TUIs look striped/broken. - **Terminal ANSI Color Fidelity:** ANSI colors are specified in sRGB but the renderer's sRGB target expects linear inputs. Convert ANSI RGB/xterm colors through `srgb_rgba_to_linear_f32`, emit every style event in combined SGR sequences like `0;38;5;...;48;2;...m`, shape cells with `CellStyle.bold` using bold font weight, and do not run regex foreground highlighting on interactive PTY output because it overwrites application-provided terminal colors. +- **Block/box-drawing chars must be drawn procedurally, never via font glyphs:** Terminal cells are `panel_line_height` tall (22px) but font glyphs only cover the font's natural line box (~17px at 14px). Block elements (▀▄█) and box-drawing (┃│─) from the font therefore leave ~5px seams between rows — the right-dock opencode logo and borders looked "đứt nét" (broken dashes). Fix: `src/terminal/cell_shapes.rs::solid_cell_rects()` maps U+2580–259F and common U+2500–257F to cell-filling rects, rendered as solid quads via `GlyphAtlas::solid_entry()` (white 4×4 texel region, entry inset to 2×2 to avoid Nearest-sampling bleed). Dashed/diagonal box chars still go through the font. This extends the earlier "Terminal Cell Backgrounds" learning to foreground glyphs. +- **Hack Nerd Font has NO Vietnamese Latin Extended Additional glyphs** (ạ ứ ề ậ ữ missing; ô ê â đ present). Google Sans Code has full Vietnamese coverage. `apply_theme` (lifecycle.rs) sets terminal text systems to `nerd_font_family` for PUA icons — Vietnamese in terminals then renders via cosmic-text font fallback (verified to produce non-empty rasters). If Vietnamese looks wrong in terminals, check this fallback path first. +- **Debugging terminal rendering: capture + replay beats speculation.** Spawn the real TUI (opencode) under `python3 pty.fork()` at a fixed size, type/capture raw bytes to a file, then feed them to `TerminalGrid::feed_bytes` in a test and dump rows. This conclusively separates grid-layer bugs from render-layer bugs in minutes. - **Dart/Flutter LSP with FVM:** Dart LSP server must use FVM-managed dart binary when available, not system dart. Priority: `.fvm/flutter_sdk/bin/cache/dart-sdk/bin/dart` (workspace-local) > `~/.fvm/versions/*/bin/cache/dart-sdk/bin/dart` (global FVM cache, newest first) > system `dart` from PATH. Detection logic in `src/lsp/client.rs::detect_fvm_dart_binary()` checks for `pubspec.yaml` first, then resolves the correct dart binary path. This ensures LSP uses the same Flutter/Dart version as the project. +- **Right-dock opencode scrolling must be forwarded INTO opencode, never done locally:** opencode is a full-screen TUI that repaints in place (cursor positioning + erase); it never emits newlines at the bottom row, so its chat history NEVER reaches our grid scrollback — the earlier cerebrum claim that "scrolling works via view_scroll_up/down" was wrong for the right dock. Correct path: Ctrl-U/D → `TerminalWriteInput("\x1b\x15"/"\x1b\x04")` (opencode's default `messages_half_page_up/down` = ctrl+alt+u/d, legacy ESC-prefix encoding); mouse wheel → SGR wheel events `\x1b[<64;col;rowM` / `\x1b[<65;col;rowM` (press-only, `sgr_wheel_sequence` in application.rs). opencode's mouse capture is on by default, so it scrolls its own message list and handles clicks (copy/revert UI) natively. Local `TerminalScrollUp/Down` & scrollback view offsets remain valid for the bottom-panel shell, which DOES produce scrollback. + +- **macOS FSEvents splits renames; atomic saves look like Rename{new_path: None}:** Editors/agents that save via write-temp-then-rename deliver a `Modify(ModifyKind::Name)` notify event per path. `apply_external_file_events` normalizes `Rename` with `new_path: None` on a still-existing path into `Modify` (immediate reload) — the rescan decision is made on the RAW events before this normalization so renames still trigger workspace rescan. +- **LSP overlays shadow disk content — externally reloaded INACTIVE buffers must be re-synced:** Once a document is didOpen'ed, the server uses the overlay, not the file on disk. After an external reload of an inactive buffer, send `LspDidOpen` (worker maps to didOpen-or-didChange) via `submit_lsp_sync_for_externally_reloaded_path` — from BOTH the notify path and the 3s poll. Guard: only when the running server matches the file's language profile; never start a server for an inactive buffer. +- **File watcher restart policy:** never give up (exponential backoff 2s→30s cap, counter resets after a 60s stable run); emit `WorkerEventKind::Failed` exactly once past the degraded threshold so failure.rs can toast "File watcher degraded". Remaining known debt: apply_external_file_events still does sync `read_to_string`/rescan on the main thread (UI hitch on mass checkouts), and external reloads clear undo history (`clear_history()` in `replace_text_buffer_preserving_view`) instead of applying a diff transaction. + +- **External-change pipeline is two-phase (async):** `apply_external_file_events` (phase 1) only triages — it fills `pending_reload_paths` / `workspace_rescan_needed` in `ExternalChangeReport`, NO disk reads. The shell submits `ReadExternalFiles` / `RescanWorkspace` worker requests; results come back as `ExternalFilesRead` → `apply_external_file_contents` (re-validates dirty at apply time; self-save echo = content compare, the old `last_saved_at` window heuristic is deleted) and `WorkspaceRescanned` → `apply_workspace_rescan`. The 3s poll (`collect_externally_modified_open_buffers`) is stat-only and feeds the same read pipeline. Tests drive it synchronously via `apply_external_file_events_for_test`. +- **Terminal regex highlight is incremental:** PTY feeds call `apply_regex_highlights_incremental(scrolled_rows)` — only the live grid + rows just pushed into scrollback get re-highlighted (walk back over `wrap_continued` to the logical-line start). The full-pass `apply_regex_highlights()` exists for tests. Never reintroduce a full-scrollback pass per chunk: it's O(scrollback × chunks) on the UI thread. +- **`feed_chunk` test gotcha:** bare `\n` is Newline only — `cursor_col` is NOT reset (no implicit CR), so multi-line test input must use `\r\n` or subsequent lines start mid-row and wrap. +- **AI inline completion provider (proxy `localhost:20128/v1`, OpenAI-compatible):** `/v1/models` lists only `cc/claude-*` but proxy routes by prefix so unlisted models (`xmtp/`, `ds/`, `ocg/`, `mistral/`) still work. As of 2026-06-12: `mistral/devstral-2512` (Mistral code model, chosen) and `mistral/mistral-tiny` (→ `ministral-8b-latest`) are fast non-reasoning models that work — ~0.6s, `finish_reason=stop`, clean streamed `delta.content`. devstral is cleaner on string contexts (no markdown fences / prefix-repeat); tiny occasionally wraps in backticks. Avoid for inline completion: `xmtp/mimo-v2.5`, `ocg/deepseek-v4-flash`, `ocg/qwen3.6-plus` are reasoning-only (burn the whole `max_tokens` budget on hidden reasoning → empty content, `reasoning_tokens==completion_tokens`, `finish_reason=length`). `ds/deepseek-*` = 402 Insufficient Balance; `cc/claude-*` = 403 OAuth blocked for the org; `ocg/deepseek-chat|deepseek-v3|qwen3.6-coder` = "not supported". Diagnose empty ghost text by curling the endpoint and checking `completion_tokens_details.reasoning_tokens` vs `content` — NOT the trigger gating (strings are never blocked; `should_queue_ai_inline_completion` only checks `min_prefix_chars` + `suppress_in_middle_of_word`). + +- **Ghost text (AI inline) vs LSP completion menu are two independent overlays — keep them mutually exclusive:** `inline_suggestion` (ghost text) and `completion` (LSP menu, `has_completion()`) both trigger on typing and used to render stacked. Policy: **ghost text wins for automatic results, but an explicit Insert-mode `Ctrl+Space` wins over ghost text**. Manual completion clears the visible suggestion, anchor, pending/in-flight AI work, and bumps `ai_inline_revision` before submitting LSP so late AI results cannot close the manually opened menu. Automatic AI results still call `dismiss_completion_for_ghost_text`, and automatic LSP results still return early while ghost text is visible. Keybindings: ghost text is accepted ONLY by `Ctrl+j` (full) / `Ctrl+l` (word) — NOT Tab. Tab→`INSERT_TAB` in `resolved_keymap.rs` + config keymap. The completion-MENU still uses Tab→`CompletionAccept` via the `completion_visible`-guarded intercept at `handler.rs:1271` (separate path, untouched). Do NOT re-add a hardcoded Tab→AiAcceptInline intercept in handler.rs — that was the old duplicate-behavior bug. + +- Vim operators (dd, dw, cw, d+motion) dispatch `Command::Operate{op,target}` via the pending-operator system (input/handler.rs), NOT the standalone variants like DeleteCurrentLine/DeleteWordForward. Any per-command match list in event_loop (should_reparse, is_text_modifying_command, etc.) must include `Operate{op: Delete|Change, ..}` or operator edits silently skip highlight-clear/didChange/reparse hooks. Also: `reconcile_highlight_spans_with_pending_edits` remaps highlight_spans/semantic_highlight_spans but NOT app_state.semantic_symbol_highlights — those must be cleared on edit, not remapped. +### 2026-06-12 — Onboarding session (which-key, F1, first-run, LSP chip, pre-check, ui_scale) +- **Key Learning:** New full-window popup surface pattern = 5 renderer fields (text_system/text_pipeline/glyphs/chrome/scissor) + lifecycle.rs (make_text_system + make_text_pipeline + struct init + apply_theme metrics/font + apply_ui_config metrics + resize pipeline list) + frame.rs (chrome range arithmetic chain + extend_from_slice in SAME order + draw block before toast). Copy the toast plumbing. +- **Key Learning:** Delayed overlays (which-key 300ms) cannot rely on a later event with ControlFlow::Wait — add a tick in about_to_wait + a WaitUntil deadline (started_at + delay), and a fired-flag on AppShell so the redraw happens exactly once. Same lesson as the AI debounce-at-flush rule. +- **Key Learning:** `ResolvedKeymap.sequence_continuations(prefix, mode)` enumerates next-key continuations for which-key; mode-exact wins over global, deeper chords show as +more group. `InputMap::whichkey_entries` filters ids through command_ids::parse so dead ids stay hidden (mirrors dispatch). +- **Key Learning:** Named keys must be added in THREE places to fully work: parse_non_leader_key ("f1"), named_key_display (""), and the TOML binding. F1 was unparseable before this session. +- **Key Learning:** `resolve_system_path()` (PATH + brew/cargo/go/nvm/gvm dirs) is now pub(crate), re-exported from async_runtime::scheduler — use it for any "is binary installed" check instead of spawning `which`. +- **Key Learning:** statusbar LSP dot was always green regardless of server state; now driven by LspStatusIndicator computed in redraw (profile match via active.server_name == profile.lsp_binary, since ActiveLspServer.server_name IS the binary name). +- **Key Learning:** ui_scale sweep pattern: `let s = self.ui_scale.max(0.5);` then multiply hardcoded chrome px. For function-scoped `const PAD_X` popup blocks (overlays.rs), convert to `#[allow(non_snake_case)] let PAD_X = … * ui_s;` to avoid renaming every usage. + +- **Do-Not-Repeat (2026-06-12, which-key v1):** Hai loi ghep voi nhau: (a) de prefix_timeout 1.5s reset ngam chord trong khi which-key dang moi user bam tiep -> phim tiep theo thanh lenh normal-mode (paste); (b) InputRouteOutcome::NoDispatch khong request_redraw nen overlay stale mai. Quy tac: trang thai input nao co UI hien thi thi KHONG duoc expire ngam, va moi thay doi pending state phai keo theo redraw. + ## Do-Not-Repeat +- **2026-06-12 — Keymap command-id drift:** a command bound in config/keymaps/default.toml is silently skipped unless its id is in command_ids::ALL_IDS (is_valid). 11 ids were in parse() but not ALL_IDS, killing C/{/}/ctrl+v/Space-? bindings. is_valid now also falls back to parse(); regression test default_keymap_has_no_unknown_commands guards this. When adding a command: enum + const + parse() + ALL_IDS + keybinding. @@ -33,8 +71,31 @@ - **[2026-05-22] Star Search Missing Jump Stack Push:** Visual star search (`*` in Normal/Visual mode) didn't call `push_jump()` before jumping to search result, so Ctrl-O couldn't return to original position. **Fix:** Add `ctx.app_state.push_jump()` before `search_word_under_cursor()` in `navigation.rs`, matching LSP goto-definition behavior. - **[2026-05-24] Code Actions Missing Undo Stack Integration:** `do_apply_code_action_edits()` used `replace_active_document_text_preserve_cursor()` (no-undo variant) instead of `replace_active_document_text_preserve_cursor_with_undo()`. This meant code actions (space ca) couldn't be undone with `u`. **Fix:** Always use the `_with_undo` variant for user-initiated text changes. The no-undo variant is only for file loading operations that intentionally reset history. - **[2026-05-26] LSP Overlay Persisting After Editing Commands:** When executing editing commands like `cw`, `x`, `d` (change/delete), LSP hover overlays (documentation popups) remained visible even after text was deleted and mode changed to Insert. This happened because: (1) overlays were cleared before command dispatch but not after, and (2) LSP hover responses arrived asynchronously after the user had already edited/moved, and the response handler didn't validate whether the cursor was still at the original position. **Fix:** Two-layer protection: (1) Clear overlay immediately when executing text-modifying commands or transitioning to Insert mode, and invalidate `latest_hover_request_id` to mark pending requests as stale. (2) Add `latest_hover_request_id` tracking (similar to `latest_definition_request_id`) and validate it in the hover response handler to drop stale responses. This prevents hover overlays from reappearing after the user has moved on. +- **[2026-06-11] Async worker results need emit_message_and_wake, NOT emit_message:** With `ControlFlow::Wait` the winit loop sleeps until `event_proxy.send_event(AppEvent::WorkerMessageReady)` wakes it (then `user_event` → `pump_bridge` drains the std-mpsc channel). `emit_message` only pushes to the channel WITHOUT waking — so any worker that uses it produces results that sit unconsumed until an unrelated event (keypress/mouse/terminal/the 1s poll tick) happens to wake the loop. The file-watch worker (`run_file_watch_request`) used `emit_message` and `dispatch.rs` never even passed it `event_proxy`, so the entire notify path was dead and the app silently relied on the 1s mtime poll. **Rule:** any worker emitting a result the UI must react to MUST use `emit_message_and_wake` and be given `event_proxy`. PTY output uses a dedicated `AppEvent::TerminalOutputReady` wake; everything else uses `WorkerMessageReady`. +- **[2026-06-11] Two parallel external-change mechanisms — keep both in sync:** External file changes are handled by (a) the notify watcher → `apply_external_file_events` (palette.rs) which does the FULL job: workspace rescan, file-picker/live-grep refresh, conflict detection, rename/delete, missing_on_disk; and (b) a 1s (now 3s) mtime poll → `check_and_reload_external_changes` (buffers.rs) which ONLY reloads content of already-open buffers. The poll is a safety-net, not a replacement — tree-level changes (create/delete/rename) ONLY work via the notify path. When debugging "X doesn't refresh", check which mechanism owns X. +- **[2026-06-11] Right-dock terminal focus can drift out of TerminalFocus:** A focused right-sidebar opencode terminal may still have `EditorMode::Normal`/other context after prior focus/mode transitions. Do not gate right-dock `Ctrl-U/D` solely on `InputFocusContext::Terminal && TerminalFocus`; use `right_sidebar_terminal` for the scope and make focus commands re-enter terminal mode. +- **[2026-06-12] Scrolled the wrong surface for right-dock opencode chat:** Two fix rounds (bug-045, bug-053) wired Ctrl-U/D and mouse wheel to local grid-scrollback scrolling, but a full-screen TUI's history never enters our scrollback, so "scroll" was a no-op for the user. Lesson: before wiring a scroll path for an embedded TUI surface, ask WHERE the content lives — if it's inside the child app's viewport, forward the scroll intent to the PTY (app keybinds or SGR mouse), don't move our view offset. - **[2026-05-27] File Watcher Not Reloading After Lazygit Discard:** When saving a file and then immediately discarding changes in lazygit (space gf), the editor continues displaying the saved code instead of reloading the discarded (old) code. **Root cause:** The self-save ignore window (2 seconds after save) blindly ignores ALL Modify events, including legitimate external changes from lazygit. This prevents the file from reloading even when the disk content differs from memory. **Fix:** Instead of blindly ignoring events within the 2-second window, read the file content and compare it with in-memory text. Only ignore if content matches (true self-save echo). If content differs (external change like lazygit discard), reload immediately. Also clear `buffer.in_memory_text = None` when a dirty-file conflict is detected, so closing/reopening will reload from disk. ## Decision Log + + + +- AI_INLINE_COMPLETION_STATUS_REPORT.md (2026-06-12) is partially stale: claims Tab/Ctrl+l accept inline suggestion — false. Only Ctrl+j (ai.accept_inline) is bound; AiAcceptInlineWord has NO keybinding. Tab is plain editor.insert_tab. Model in config/ai.toml is xmtp/mimo-v2.5-pro, not cx/gpt-5.3-codex. + +- **AI inline completion (2026-06-12 improvements):** Smart-Tab accept lives in `input/handler.rs` as an intercept AFTER the completion-menu block (priority: completion menu > ghost text > insert_tab), gated by `KeybindingContext.inline_suggestion_visible` (populated in `build_context`, setup.rs). Prefix-retention lives in `handle_command_with_count` (commands.rs): typed char/text matching the ghost head trims it instead of clear+re-request, bumps `ai_inline_revision` so in-flight results drop as stale, and sets `ai_inline_suggestion_retained` so `handle_insert_edit_command` skips re-queueing. Retention is BLOCKED for auto-pairing chars `([{"'` ` because dispatch inserts a closing char the suggestion doesn't know about. Output sanitizer (`sanitize_inline_suggestion` in async_results/ai.rs) strips fences, echoed line-prefix, and suffix overlap. Failure circuit breaker in failure.rs: 3 consecutive non-cancelled failures → one toast + 60s cooldown (checked in queue_ai_inline_completion). Statusbar AI chip: `AiInlineStatus` enum in render/renderer.rs threaded through update_statusbar_content. +- **localhost:20128 AI proxy:** multi-provider aggregator (cc/=claude OAuth-blocked, ds/=deepseek no balance, ocg/, xmtp/=mimo, cx/=codex ChatGPT-blocked). Effectively ALL usable models are reasoning models. `xmtp/mimo-v2.5-pro` accepts `reasoning_effort` only as low|medium|high (400 on "none"/"minimal"); with effort=low it spends ~150 thinking tokens and needs max_tokens ≥ ~384 to return content (~5s latency). Streaming separates `delta.reasoning_content` (ignored by our parser) from `delta.content`. kimi-k2.x on this proxy LEAKS reasoning into content — unusable for inline completion. A model that returns `finish_reason: "length"` with empty content = token budget eaten by thinking. +- **[2026-06-12] Inline-AI trigger gating made typing dead:** Don't gate background-work queueing on "time since last keystroke" evaluated AT keystroke time — when the user stops typing there is no later event to pass the check, so the work never fires. Debounce-at-flush (re-queue every keystroke, flush after quiet period via about_to_wait deadline) is the correct pattern; rate limits belong at flush time as a delay, not at queue time as a drop. + +### 2026-06-12 — Vim keybinding architecture (find-char session) +- **Key Learning:** Phím cần payload ký tự động (r, f, t, F, T) KHÔNG thể khai báo trong keymap TOML — phải đi qua `PendingState` trong `src/app/input/handler.rs` + dispatch trực tiếp Command có payload. Pattern chuẩn: thêm variant vào `pending.rs`, start-condition trước `resolve_sequence_start`, resolve-arm trong match `&pending.state`. +- **Key Learning:** `append_help_binding` trong cheatsheet (app_state/mod.rs) lặng lẽ ẩn id chưa bind → cheatsheet trông đầy đủ nhưng phím chết không bị phát hiện. Khi audit keybinding, phải so default.toml với danh sách id, không tin cheatsheet. +- **Do-Not-Repeat:** Comment trong default.toml từng nói "Paste in normal mode: use 'p' or 'P'" nhưng binding không tồn tại — không tin comment, phải verify binding thật. +- **Decision Log:** f/F/t/T motion giữ vim semantics (line-scoped) nhưng đồng thời set search query = ký tự đó (như `*`) để user repeat bằng n/N toàn file thay vì bấm lại f+char — theo yêu cầu trực tiếp của Min. `;`/`,` (vim repeat find) chưa làm vì n/N đã phủ use case. + +### 2026-06-12 — Jump list (Ctrl+O/I) +- **Key Learning:** `DispatchCtx::open_file` (command_dispatch/common.rs) là choke point cho mọi file-open qua dispatch (file picker, live grep, explorer) — thêm cross-cutting behavior (jump record, v.v.) ở đây thay vì từng call site. LSP/references/diagnostics mở file qua `app_state.open_file` trực tiếp và tự push riêng — KHÔNG thêm push vào AppState::open_file kẻo double-push. +- **Key Learning:** `AppState::open_file` canonicalize path (macOS: /var -> /private/var) — test so sánh path phải lấy từ `active_file()` sau khi mở, không dùng temp path gốc. `AppState::from_text` không set active_file — test cần jump stack phải dispatch Command::OpenFile với file thật trên disk. +- **Decision Log:** Jump semantics theo vim: n/N, gg/G, {/}, leap, *, mở file khác đều push; dedup liên tiếp cùng (file, line) + cap 100 entry. h/j/k/l/w/b và Ctrl+U/D không push (đúng vim). diff --git a/.wolf/cron-state.json b/.wolf/cron-state.json index d0b7b56b..92d8da8b 100644 --- a/.wolf/cron-state.json +++ b/.wolf/cron-state.json @@ -1,5 +1,5 @@ { - "last_heartbeat": "2026-05-30T12:09:00.537Z", + "last_heartbeat": "2026-06-12T04:25:06.788Z", "engine_status": "stopped", "execution_log": [ { diff --git a/.wolf/daemon.log b/.wolf/daemon.log index f4c28a19..853953c9 100644 --- a/.wolf/daemon.log +++ b/.wolf/daemon.log @@ -77,7 +77,14 @@ [2026-05-29T17:00:00.630Z] [INFO] Task Full anatomy rescan completed in 141ms [2026-05-30T05:00:00.235Z] [INFO] Executing task: Full anatomy rescan [2026-05-30T05:00:00.317Z] [INFO] Task Full anatomy rescan completed in 81ms -[2026-05-30T11:00:00.666Z] [INFO] Executing task: Full anatomy rescan -[2026-05-30T11:00:00.790Z] [INFO] Task Full anatomy rescan completed in 124ms -[2026-05-30T12:25:21.340Z] [INFO] Daemon shutting down... -[2026-05-30T12:25:21.341Z] [INFO] Daemon stopped +[2026-06-12T02:55:06.795Z] [INFO] Scheduled task: Full anatomy rescan (0 */6 * * *) +[2026-06-12T02:55:06.796Z] [INFO] Scheduled task: Consolidate old memory (0 2 * * *) +[2026-06-12T02:55:06.796Z] [INFO] Scheduled task: Token audit report (0 0 * * 1) +[2026-06-12T02:55:06.796Z] [INFO] Scheduled task: Cerebrum reflection (0 3 * * 0) +[2026-06-12T02:55:06.797Z] [INFO] Scheduled task: AI suggestions (0 4 * * 1) +[2026-06-12T02:55:06.797Z] [INFO] File watcher started on .wolf/ +[2026-06-12T02:55:06.798Z] [INFO] OpenWolf daemon started +[2026-06-12T02:55:06.798Z] [INFO] Dashboard server listening on port 18791 +[2026-06-12T02:55:17.286Z] [INFO] WebSocket client connected +[2026-06-12T04:34:09.792Z] [INFO] Daemon shutting down... +[2026-06-12T04:34:09.802Z] [INFO] Daemon stopped diff --git a/.wolf/hooks/_session.json b/.wolf/hooks/_session.json index cf73653c..9b8124b7 100644 --- a/.wolf/hooks/_session.json +++ b/.wolf/hooks/_session.json @@ -1,12 +1,732 @@ { - "session_id": "session-2026-05-30-1306", - "started": "2026-05-30T06:06:18.190Z", - "files_read": {}, - "files_written": [], - "edit_counts": {}, - "anatomy_hits": 0, - "anatomy_misses": 0, - "repeated_reads_warned": 0, + "session_id": "session-2026-06-12-2130", + "started": "2026-06-12T14:30:31.346Z", + "files_read": { + "/Users/qc-bright/.claude/projects/-Users-qc-bright-Project-netherize-editor/memory/onboarding-roadmap.md": { + "count": 1, + "tokens": 0, + "first_read": "2026-06-12T14:31:12.391Z" + }, + "/Users/qc-bright/.claude/projects/-Users-qc-bright-Project-netherize-editor/memory/project_stack.md": { + "count": 1, + "tokens": 0, + "first_read": "2026-06-12T14:31:20.798Z" + }, + "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs": { + "count": 3, + "tokens": 19639, + "first_read": "2026-06-12T14:33:04.677Z" + }, + "/Users/qc-bright/Project/netherize_editor/src/app/resolved_keymap.rs": { + "count": 4, + "tokens": 15986, + "first_read": "2026-06-12T14:33:14.779Z" + }, + "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/components/help_keycaps.rs": { + "count": 1, + "tokens": 0, + "first_read": "2026-06-12T14:33:26.079Z" + }, + "/Users/qc-bright/Project/netherize_editor/src/render/renderer/components/help_keycaps.rs": { + "count": 1, + "tokens": 2003, + "first_read": "2026-06-12T14:33:41.909Z" + }, + "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs": { + "count": 6, + "tokens": 26522, + "first_read": "2026-06-12T14:34:24.136Z" + }, + "/Users/qc-bright/Project/netherize_editor/src/app/input_map/mod.rs": { + "count": 2, + "tokens": 4904, + "first_read": "2026-06-12T14:34:45.799Z" + }, + "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs": { + "count": 1, + "tokens": 23243, + "first_read": "2026-06-12T14:35:10.064Z" + }, + "/Users/qc-bright/Project/netherize_editor/src/render/renderer.rs": { + "count": 2, + "tokens": 4387, + "first_read": "2026-06-12T14:35:24.942Z" + }, + "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/popups.rs": { + "count": 2, + "tokens": 6724, + "first_read": "2026-06-12T14:35:46.637Z" + }, + "/Users/qc-bright/Project/netherize_editor/src/app/input/pending.rs": { + "count": 1, + "tokens": 2406, + "first_read": "2026-06-12T14:36:07.604Z" + }, + "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle/frame.rs": { + "count": 2, + "tokens": 8679, + "first_read": "2026-06-12T14:36:46.430Z" + }, + "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs": { + "count": 2, + "tokens": 6558, + "first_read": "2026-06-12T14:36:58.501Z" + }, + "/Users/qc-bright/Project/netherize_editor/src/render/renderer/palette.rs": { + "count": 1, + "tokens": 3160, + "first_read": "2026-06-12T14:40:04.029Z" + }, + "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui.rs": { + "count": 1, + "tokens": 42, + "first_read": "2026-06-12T14:41:41.354Z" + }, + "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/mod.rs": { + "count": 1, + "tokens": 6742, + "first_read": "2026-06-12T14:43:12.506Z" + }, + "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs": { + "count": 2, + "tokens": 21919, + "first_read": "2026-06-12T14:44:21.752Z" + }, + "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/welcome.rs": { + "count": 1, + "tokens": 9183, + "first_read": "2026-06-12T14:46:29.130Z" + }, + "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml": { + "count": 1, + "tokens": 5163, + "first_read": "2026-06-12T14:46:42.207Z" + }, + "/Users/qc-bright/Project/netherize_editor/src/app/persistence.rs": { + "count": 1, + "tokens": 1318, + "first_read": "2026-06-12T14:48:23.089Z" + }, + "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands.rs": { + "count": 1, + "tokens": 10052, + "first_read": "2026-06-12T14:48:42.674Z" + }, + "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/statusbar.rs": { + "count": 3, + "tokens": 5314, + "first_read": "2026-06-12T14:49:37.821Z" + }, + "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings.rs": { + "count": 1, + "tokens": 4857, + "first_read": "2026-06-12T14:53:35.352Z" + }, + "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs": { + "count": 1, + "tokens": 7423, + "first_read": "2026-06-12T14:54:00.553Z" + }, + "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler.rs": { + "count": 1, + "tokens": 2480, + "first_read": "2026-06-12T14:54:56.743Z" + }, + "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs": { + "count": 1, + "tokens": 9544, + "first_read": "2026-06-12T14:56:12.594Z" + }, + "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs": { + "count": 1, + "tokens": 8206, + "first_read": "2026-06-12T14:56:28.890Z" + }, + "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/help.rs": { + "count": 1, + "tokens": 4176, + "first_read": "2026-06-12T14:58:51.763Z" + }, + "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/buffers.rs": { + "count": 1, + "tokens": 5570, + "first_read": "2026-06-12T14:59:41.577Z" + }, + "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/overlays.rs": { + "count": 3, + "tokens": 19960, + "first_read": "2026-06-12T15:00:44.396Z" + }, + "/Users/qc-bright/Project/netherize_editor/src/app/input/tests.rs": { + "count": 1, + "tokens": 18106, + "first_read": "2026-06-12T15:30:00.072Z" + }, + "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_completion.rs": { + "count": 1, + "tokens": 16330, + "first_read": "2026-06-12T15:37:46.273Z" + }, + "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_lsp.rs": { + "count": 1, + "tokens": 9139, + "first_read": "2026-06-12T15:38:44.245Z" + } + }, + "files_written": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/resolved_keymap.rs", + "action": "edit", + "tokens": 573, + "at": "2026-06-12T14:40:45.101Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/resolved_keymap.rs", + "action": "edit", + "tokens": 131, + "at": "2026-06-12T14:40:52.339Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/mod.rs", + "action": "edit", + "tokens": 249, + "at": "2026-06-12T14:41:05.222Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/mod.rs", + "action": "edit", + "tokens": 338, + "at": "2026-06-12T14:41:13.443Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "action": "edit", + "tokens": 153, + "at": "2026-06-12T14:41:23.243Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer.rs", + "action": "edit", + "tokens": 203, + "at": "2026-06-12T14:41:35.371Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui.rs", + "action": "edit", + "tokens": 14, + "at": "2026-06-12T14:41:46.786Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/whichkey.rs", + "action": "create", + "tokens": 2070, + "at": "2026-06-12T14:42:16.876Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "action": "edit", + "tokens": 50, + "at": "2026-06-12T14:42:30.253Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "action": "edit", + "tokens": 66, + "at": "2026-06-12T14:42:30.748Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "action": "edit", + "tokens": 114, + "at": "2026-06-12T14:42:31.608Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "action": "edit", + "tokens": 50, + "at": "2026-06-12T14:42:33.203Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "action": "edit", + "tokens": 50, + "at": "2026-06-12T14:42:35.356Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "action": "edit", + "tokens": 71, + "at": "2026-06-12T14:42:37.532Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "action": "edit", + "tokens": 39, + "at": "2026-06-12T14:42:39.091Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle/frame.rs", + "action": "edit", + "tokens": 94, + "at": "2026-06-12T14:42:48.273Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle/frame.rs", + "action": "edit", + "tokens": 63, + "at": "2026-06-12T14:42:50.502Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle/frame.rs", + "action": "edit", + "tokens": 273, + "at": "2026-06-12T14:42:53.565Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "action": "edit", + "tokens": 84, + "at": "2026-06-12T14:43:44.877Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "action": "edit", + "tokens": 390, + "at": "2026-06-12T14:43:50.377Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "action": "edit", + "tokens": 52, + "at": "2026-06-12T14:44:00.062Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "action": "edit", + "tokens": 203, + "at": "2026-06-12T14:44:04.320Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "action": "edit", + "tokens": 214, + "at": "2026-06-12T14:44:07.445Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/mod.rs", + "action": "edit", + "tokens": 79, + "at": "2026-06-12T14:44:15.352Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "action": "edit", + "tokens": 36, + "at": "2026-06-12T14:44:30.768Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/resolved_keymap.rs", + "action": "edit", + "tokens": 548, + "at": "2026-06-12T14:45:49.950Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/resolved_keymap.rs", + "action": "edit", + "tokens": 36, + "at": "2026-06-12T14:47:24.148Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/resolved_keymap.rs", + "action": "edit", + "tokens": 32, + "at": "2026-06-12T14:47:26.002Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "action": "edit", + "tokens": 70, + "at": "2026-06-12T14:47:28.107Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/welcome.rs", + "action": "edit", + "tokens": 145, + "at": "2026-06-12T14:47:51.321Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/welcome.rs", + "action": "edit", + "tokens": 34, + "at": "2026-06-12T14:47:52.688Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/persistence.rs", + "action": "edit", + "tokens": 54, + "at": "2026-06-12T14:49:16.404Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "action": "edit", + "tokens": 44, + "at": "2026-06-12T14:49:18.194Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands.rs", + "action": "edit", + "tokens": 248, + "at": "2026-06-12T14:49:22.340Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer.rs", + "action": "edit", + "tokens": 195, + "at": "2026-06-12T14:50:42.701Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer.rs", + "action": "edit", + "tokens": 42, + "at": "2026-06-12T14:50:44.457Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/statusbar.rs", + "action": "edit", + "tokens": 25, + "at": "2026-06-12T14:50:51.028Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/statusbar.rs", + "action": "edit", + "tokens": 52, + "at": "2026-06-12T14:50:53.359Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/statusbar.rs", + "action": "edit", + "tokens": 38, + "at": "2026-06-12T14:50:54.866Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/statusbar.rs", + "action": "edit", + "tokens": 241, + "at": "2026-06-12T14:51:06.051Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/statusbar.rs", + "action": "edit", + "tokens": 66, + "at": "2026-06-12T14:51:12.963Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/statusbar.rs", + "action": "edit", + "tokens": 62, + "at": "2026-06-12T14:51:15.339Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/statusbar.rs", + "action": "edit", + "tokens": 245, + "at": "2026-06-12T14:51:19.769Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "action": "edit", + "tokens": 360, + "at": "2026-06-12T14:51:30.423Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "action": "edit", + "tokens": 62, + "at": "2026-06-12T14:51:32.368Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "action": "edit", + "tokens": 425, + "at": "2026-06-12T14:52:21.324Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler.rs", + "action": "edit", + "tokens": 24, + "at": "2026-06-12T14:55:03.324Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "action": "edit", + "tokens": 14, + "at": "2026-06-12T14:55:04.507Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings.rs", + "action": "edit", + "tokens": 296, + "at": "2026-06-12T14:55:16.490Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "action": "edit", + "tokens": 447, + "at": "2026-06-12T14:56:49.633Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "action": "edit", + "tokens": 37, + "at": "2026-06-12T14:56:55.542Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "action": "edit", + "tokens": 160, + "at": "2026-06-12T14:56:59.794Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "action": "edit", + "tokens": 225, + "at": "2026-06-12T14:57:04.924Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "action": "edit", + "tokens": 168, + "at": "2026-06-12T14:57:14.496Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "action": "edit", + "tokens": 149, + "at": "2026-06-12T14:57:18.285Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "action": "edit", + "tokens": 44, + "at": "2026-06-12T14:57:20.379Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "action": "edit", + "tokens": 125, + "at": "2026-06-12T14:57:29.126Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "action": "edit", + "tokens": 226, + "at": "2026-06-12T14:57:35.169Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "action": "edit", + "tokens": 17, + "at": "2026-06-12T14:57:35.917Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "action": "edit", + "tokens": 194, + "at": "2026-06-12T14:57:47.160Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "action": "edit", + "tokens": 339, + "at": "2026-06-12T14:57:53.738Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "action": "edit", + "tokens": 154, + "at": "2026-06-12T14:57:57.297Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "action": "edit", + "tokens": 306, + "at": "2026-06-12T14:58:12.725Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "action": "edit", + "tokens": 144, + "at": "2026-06-12T14:58:16.502Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "action": "edit", + "tokens": 40, + "at": "2026-06-12T14:58:25.263Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "action": "edit", + "tokens": 298, + "at": "2026-06-12T14:58:30.973Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "action": "edit", + "tokens": 112, + "at": "2026-06-12T14:58:33.524Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "action": "edit", + "tokens": 31, + "at": "2026-06-12T14:58:42.242Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "action": "edit", + "tokens": 125, + "at": "2026-06-12T14:58:45.522Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/help.rs", + "action": "edit", + "tokens": 129, + "at": "2026-06-12T14:59:02.094Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/help.rs", + "action": "edit", + "tokens": 33, + "at": "2026-06-12T14:59:03.977Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/help.rs", + "action": "edit", + "tokens": 401, + "at": "2026-06-12T14:59:14.651Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/help.rs", + "action": "edit", + "tokens": 62, + "at": "2026-06-12T14:59:23.538Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/help.rs", + "action": "edit", + "tokens": 22, + "at": "2026-06-12T14:59:25.396Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/help.rs", + "action": "edit", + "tokens": 14, + "at": "2026-06-12T14:59:26.814Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/help.rs", + "action": "edit", + "tokens": 24, + "at": "2026-06-12T14:59:28.720Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/help.rs", + "action": "edit", + "tokens": 33, + "at": "2026-06-12T14:59:30.113Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/help.rs", + "action": "edit", + "tokens": 33, + "at": "2026-06-12T14:59:31.827Z" + }, + { + "file": "/Users/qc-bright/.claude/projects/-Users-qc-bright-Project-netherize-editor/memory/onboarding-roadmap.md", + "action": "create", + "tokens": 546, + "at": "2026-06-12T15:05:44.388Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "action": "edit", + "tokens": 236, + "at": "2026-06-12T15:28:54.836Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "action": "edit", + "tokens": 337, + "at": "2026-06-12T15:29:06.340Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "action": "edit", + "tokens": 117, + "at": "2026-06-12T15:29:22.220Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "action": "edit", + "tokens": 136, + "at": "2026-06-12T15:29:24.763Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "action": "edit", + "tokens": 225, + "at": "2026-06-12T15:29:35.635Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/tests.rs", + "action": "edit", + "tokens": 723, + "at": "2026-06-12T15:30:23.877Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_completion.rs", + "action": "edit", + "tokens": 246, + "at": "2026-06-12T15:38:39.105Z" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_lsp.rs", + "action": "edit", + "tokens": 24, + "at": "2026-06-12T15:38:50.653Z" + } + ], + "edit_counts": { + "src/app/resolved_keymap.rs": 5, + "src/app/input_map/mod.rs": 2, + "src/app/input/handler.rs": 5, + "src/render/renderer.rs": 3, + "src/render/renderer/ui.rs": 1, + "src/render/renderer/ui/whichkey.rs": 1, + "src/render/renderer/lifecycle.rs": 7, + "src/render/renderer/lifecycle/frame.rs": 3, + "src/app/event_loop/application.rs": 10, + "src/app/event_loop/mod.rs": 1, + "src/app/event_loop/setup.rs": 1, + "config/keymaps/default.toml": 1, + "src/render/renderer/ui/welcome.rs": 2, + "src/app/persistence.rs": 1, + "src/app/event_loop/commands.rs": 1, + "src/render/renderer/ui/statusbar.rs": 7, + "src/async_runtime/scheduler.rs": 1, + "src/async_runtime/scheduler/syntax_jobs.rs": 1, + "src/app/event_loop/commands_settings.rs": 1, + "src/render/renderer/editor/fuzzy.rs": 20, + "src/render/renderer/editor/help.rs": 9, + "../../.claude/projects/-Users-qc-bright-Project-netherize-editor/memory/onboarding-roadmap.md": 1, + "src/app/input/tests.rs": 1, + "src/app/event_loop/commands_completion.rs": 1, + "src/app/event_loop/commands_lsp.rs": 1 + }, + "anatomy_hits": 31, + "anatomy_misses": 3, + "repeated_reads_warned": 20, "cerebrum_warnings": 0, - "stop_count": 0 + "stop_count": 2 } \ No newline at end of file diff --git a/.wolf/memory.md b/.wolf/memory.md index 251fe7af..1d12f494 100644 --- a/.wolf/memory.md +++ b/.wolf/memory.md @@ -574,3 +574,625 @@ | Time | Action | File(s) | Outcome | ~Tokens | |------|--------|---------|---------|--------| + +## Session: 2026-06-11 10:25 + +| Time | Action | File(s) | Outcome | ~Tokens | +|------|--------|---------|---------|--------| + +## Session: 2026-06-11 10:30 + +| Time | Action | File(s) | Outcome | ~Tokens | +|------|--------|---------|---------|--------| +| 10:53 | Edited src/app/event_loop/commands_ai_chat.rs | modified is_some() | ~218 | +| 10:53 | Edited src/app/input/handler.rs | modified Some() | ~400 | +| 10:54 | Edited src/app/input/tests.rs | modified zen_mode_active_forwards_escape_to_terminal_pty() | ~466 | +| 10:57 | Zen Esc UX fix: raw ESC->PTY in zen terminal; AiChatUnfocus no-op in zen | handler.rs, commands_ai_chat.rs, input/tests.rs | 662 tests pass | ~9k | +| 11:05 | Edited src/app/input_map/mod.rs | 5→9 lines | ~109 | +| 11:05 | Edited src/app/input_map/mod.rs | modified for_mode_with_palette() | ~82 | +| 11:05 | Edited src/app/input_map/mod.rs | modified with_focus() | ~113 | +| 11:05 | Edited src/app/event_loop/setup.rs | 4→6 lines | ~96 | +| 11:05 | Edited src/core/commands.rs | 4→8 lines | ~104 | +| 11:06 | Edited src/core/commands.rs | 3→5 lines | ~62 | +| 11:06 | Edited src/core/command_dispatch/session.rs | 6→8 lines | ~81 | +| 11:06 | Edited src/core/command_dispatch/mod.rs | 7→9 lines | ~91 | +| 11:07 | Edited src/app/event_loop/mod.rs | modified focused_terminal_session_id() | ~124 | +| 11:07 | Edited src/app/event_loop/commands_terminal.rs | reduced (-20 lines) | ~110 | +| 11:08 | Edited src/app/event_loop/commands_terminal.rs | modified scroll_focused_terminal() | ~420 | +| 11:09 | Edited src/app/event_loop/commands.rs | modified mark_focused_terminal_layout_dirty() | ~125 | +| 11:09 | Edited src/app/event_loop/commands_terminal.rs | modified handle_terminal_and_focus_command() | ~56 | +| 11:09 | Edited src/app/input/handler.rs | modified right_chat_scroll_command() | ~314 | +| 11:09 | Edited src/app/input/handler.rs | modified right_chat_scroll_command() | ~288 | +| 11:09 | Edited src/app/input/handler.rs | modified right_chat_scroll_command() | ~265 | +| 11:10 | Edited src/app/input/tests.rs | modified right_sidebar_terminal_ctrl_u_d_scroll_half_page() | ~520 | +| 11:13 | Right opencode chat scroll: Ctrl+U/D half-page (TerminalScrollHalfPage cmds), fix focused_terminal_grid_mut for RightSidebar | mod.rs, handler.rs, commands_terminal.rs, commands.rs, core/commands.rs | 664 tests pass | ~30k | +| 11:21 | Edited Cargo.toml | 2→3 lines | ~19 | +| 11:21 | Edited src/terminal/grid.rs | modified print_char_at_cursor() | ~138 | +| 11:22 | Edited src/terminal/grid.rs | modified compose_combining_into_prev() | ~268 | +| 11:22 | Edited src/terminal/grid.rs | modified is_combining_mark() | ~39 | +| 11:22 | Edited src/terminal/grid.rs | modified newline_advances_row() | ~422 | +| 11:23 | Edited src/terminal/grid.rs | added 1 import(s) | ~32 | +| 11:23 | Edited src/terminal/grid.rs | modified get_mut() | ~257 | +| 11:26 | Fix VN tone marks lost in terminal: fold combining marks into base cell via NFC (not line-height) | terminal/grid.rs, Cargo.toml | 668 tests pass | ~25k | +| 11:27 | Session end: 27 writes across 10 files (commands_ai_chat.rs, handler.rs, tests.rs, mod.rs, setup.rs) | 21 reads | ~197767 tok | + +## Session: 2026-06-11 13:17 + +| Time | Action | File(s) | Outcome | ~Tokens | +|------|--------|---------|---------|--------| +| 13:26 | Edited src/async_runtime/scheduler/dispatch.rs | 7→8 lines | ~93 | +| 13:27 | Edited src/async_runtime/scheduler/file_watch.rs | modified run_file_watch_request() | ~1315 | +| 13:27 | Edited src/async_runtime/scheduler/file_watch.rs | modified execute_file_watch_loop() | ~47 | +| 13:27 | Edited src/async_runtime/scheduler/file_watch.rs | emit_message() → emit_message_and_wake() | ~249 | +| 13:27 | Edited src/async_runtime/message.rs | 3→6 lines | ~68 | +| 13:27 | Edited src/async_runtime/scheduler/file_watch.rs | expanded (+9 lines) | ~221 | +| 13:27 | Edited src/app/event_loop/setup.rs | 3→4 lines | ~43 | +| 13:28 | Edited src/app/event_loop/commands_explorer.rs | 8→9 lines | ~92 | +| 13:28 | Edited src/app/event_loop/commands_explorer.rs | 9→10 lines | ~87 | +| 13:29 | Edited src/app/event_loop/mod.rs | 3→6 lines | ~108 | +| 13:29 | Edited src/app/event_loop/setup.rs | 3→4 lines | ~65 | +| 13:29 | Edited src/app/event_loop/setup.rs | modified ensure_external_file_watch_for_active() | ~349 | +| 13:30 | Edited src/app/app_state/buffers.rs | 4→6 lines | ~100 | +| 13:30 | Edited src/app/app_state/overlays.rs | modified should_ignore_self_save_event() | ~211 | +| 13:30 | Edited src/app/app_state/buffers.rs | modified active_disk_content_matches_memory() | ~114 | +| 13:30 | Edited src/app/app_state/palette.rs | modified active_disk_content_matches_memory() | ~141 | +| 13:30 | Edited src/app/event_loop/application.rs | modified from_secs() | ~132 | +| 13:30 | Edited src/app/event_loop/application.rs | 1 → 3 | ~25 | +| 13:30 | Edited src/app/event_loop/async_results/filesystem.rs | modified iter() | ~48 | +| 13:31 | Edited src/app/app_state/palette.rs | modified is_dirty() | ~24 | +| 13:31 | Edited src/app/app_state/palette.rs | modified load_buffer_from_file() | ~101 | +| 13:31 | Edited src/app/app_state/palette.rs | modified read_to_string() | ~92 | +| 13:31 | Edited src/app/app_state/buffers.rs | modified load_buffer_from_file() | ~32 | +| 13:31 | Edited src/app/app_state/buffers.rs | modified read_to_string() | ~31 | +| 13:35 | Edited src/app/app_state/tests.rs | modified external_edit_within_self_save_window_still_reloads() | ~516 | +| 13:45 | Fixed file watcher not waking event loop (#1-#8) | file_watch.rs, dispatch.rs, emit-path, message.rs, application.rs, palette.rs, buffers.rs, overlays.rs, mod.rs, setup.rs | notify path now wakes winit loop via emit_message_and_wake; +watcher restart, +out-of-root watch, !=mtime, content-based self-save, 3s poll, removed eprintln; 676 tests pass | ~4200 | +| 13:39 | Session end: 25 writes across 12 files (dispatch.rs, file_watch.rs, message.rs, setup.rs, commands_explorer.rs) | 16 reads | ~157646 tok | + +## Session: 2026-06-11 21:39 + +| Time | Action | File(s) | Outcome | ~Tokens | +|------|--------|---------|---------|--------| +| 23:37 | Fixed right-dock opencode scroll/mouse routing | input/handler.rs, event_loop/application.rs, commands_ai_chat.rs, tests | Ctrl-U/D now survives mode drift; right dock gets scoped wheel scroll + SGR click forwarding; focused tests/check pass | ~52000 | + +## Session: 2026-06-11 23:40 + +| Time | Action | File(s) | Outcome | ~Tokens | +|------|--------|---------|---------|--------| +| 00:00 | Edited src/terminal/grid.rs | 6→10 lines | ~126 | +| 00:00 | Edited src/terminal/grid.rs | 7→8 lines | ~42 | +| 00:00 | Edited src/terminal/grid.rs | 9→10 lines | ~61 | +| 00:00 | Edited src/terminal/grid.rs | 18→23 lines | ~232 | +| 00:01 | Edited src/terminal/grid.rs | modified apply_regex_highlights() | ~1294 | +| 00:01 | Edited src/terminal/grid.rs | inline fix | ~21 | +| 00:01 | Edited src/terminal/grid.rs | modified apply_regex_highlights_string_spanning_soft_wrap_keeps_color_on_continuation() | ~437 | +| 00:03 | terminal highlight lost on soft-wrap: track wrap_continued + highlight per logical line | src/terminal/grid.rs | fixed, 28 grid tests pass | ~9k | +| 00:04 | Session end: 7 writes across 1 files (grid.rs) | 4 reads | ~67261 tok | + +## Session: 2026-06-11 00:15 + +| Time | Action | File(s) | Outcome | ~Tokens | +|------|--------|---------|---------|--------| +| 00:26 | Created tests/vietnamese_terminal_render.rs | — | ~1065 | +| 00:29 | Edited tests/vietnamese_terminal_render.rs | added 2 import(s) | ~83 | +| 00:29 | Edited tests/vietnamese_terminal_render.rs | modified dock_build_instances_emits_glyph_per_vietnamese_cell() | ~591 | +| 00:34 | Edited tests/vietnamese_terminal_render.rs | modified nerd_font_family_still_shapes_vietnamese_via_fallback() | ~384 | + +## Session: 2026-06-11 00:43 + +| Time | Action | File(s) | Outcome | ~Tokens | +|------|--------|---------|---------|--------| +| 00:46 | Edited src/text/atlas.rs | 6→9 lines | ~129 | +| 00:46 | Edited src/text/atlas.rs | 4→5 lines | ~30 | +| 00:46 | Edited src/text/atlas.rs | modified solid_entry() | ~265 | +| 00:47 | Created src/terminal/cell_shapes.rs | — | ~2945 | +| 00:48 | Edited src/terminal/terminal_renderer.rs | 5→6 lines | ~71 | +| 00:48 | Edited src/terminal/terminal_renderer.rs | modified solid_cell_rects() | ~387 | +| 00:49 | Session end: 6 writes across 3 files (atlas.rs, cell_shapes.rs, terminal_renderer.rs) | 1 reads | ~7321 tok | +| 00:52 | Edited src/terminal/mod.rs | 2→3 lines | ~15 | +| 00:50 | Diagnosed right-dock "đứt nét": cell h=22 vs font glyph ~17px → gaps in ▀▄█/┃. Captured real opencode PTY stream, replayed through grid — Vietnamese NFC intact (bug-047 fix works) | src/terminal/cell_shapes.rs (new), terminal_renderer.rs, src/text/atlas.rs | procedural block/box-drawing quads fill cells exactly; 704 tests pass | ~60k | +| 00:52 | Added regression tests: NFC/NFD Vietnamese through grid+shaping+headless wgpu dock path | tests/vietnamese_terminal_render.rs (new) | 9 tests pass | ~8k | +| 00:57 | Session end: 7 writes across 4 files (atlas.rs, cell_shapes.rs, terminal_renderer.rs, mod.rs) | 2 reads | ~7365 tok | + +## Session: 2026-06-11 00:58 + +| Time | Action | File(s) | Outcome | ~Tokens | +|------|--------|---------|---------|--------| + +## Session: 2026-06-11 01:00 + +| Time | Action | File(s) | Outcome | ~Tokens | +|------|--------|---------|---------|--------| +| 01:07 | Edited src/app/input/handler.rs | modified right_chat_scroll_command() | ~338 | +| 01:07 | Edited src/app/event_loop/application.rs | modified sgr_mouse_sequence() | ~119 | +| 01:07 | Edited src/app/event_loop/application.rs | modified min() | ~244 | +| 01:07 | Edited src/app/input/tests.rs | expanded (+9 lines) | ~269 | +| 01:07 | Edited src/app/input/tests.rs | 8→11 lines | ~113 | +| 01:08 | Edited src/app/input/tests.rs | expanded (+6 lines) | ~174 | +| 01:08 | Edited src/app/event_loop/application.rs | modified sgr_wheel_sequence_is_press_only_with_wheel_button_codes() | ~100 | +| 01:08 | Edited src/app/event_loop/application.rs | 1→2 lines | ~31 | +| 01:12 | Fix right-dock opencode scroll: forward ctrl+alt+u/d + SGR wheel into PTY instead of local scrollback (bug-057) | src/app/input/handler.rs, src/app/event_loop/application.rs, src/app/input/tests.rs | 705 tests pass | ~30k | +| 01:12 | Session end: 8 writes across 3 files (handler.rs, application.rs, tests.rs) | 5 reads | ~82863 tok | +| 01:19 | Session end: 8 writes across 3 files (handler.rs, application.rs, tests.rs) | 11 reads | ~134371 tok | +| 01:23 | Edited src/async_runtime/scheduler/file_watch.rs | 1→6 lines | ~32 | +| 01:23 | Edited src/async_runtime/scheduler/file_watch.rs | modified file_watch_restart_backoff() | ~183 | +| 01:23 | Edited src/async_runtime/scheduler/file_watch.rs | modified degraded() | ~768 | +| 01:23 | Edited src/async_runtime/scheduler/file_watch.rs | extend_unique_file_events() → extend_unique_file_events_with_seen() | ~260 | +| 01:24 | Edited src/async_runtime/scheduler/file_watch.rs | modified extend_unique_file_events() | ~158 | +| 01:24 | Edited src/app/event_loop/async_results/failure.rs | expanded (+8 lines) | ~147 | +| 01:24 | Edited src/async_runtime/scheduler/file_watch.rs | modified extend_unique_file_events() | ~82 | +| 01:24 | Edited src/app/app_state/palette.rs | modified exists() | ~354 | +| 01:25 | Edited src/app/app_state/palette.rs | 6→8 lines | ~153 | +| 01:25 | Edited src/app/app_state/palette.rs | modified read_to_string() | ~153 | +| 01:25 | Edited src/app/app_state/buffers.rs | modified buffer_text_for_path() | ~215 | +| 01:25 | Edited src/app/event_loop/setup.rs | modified submit_lsp_sync_for_externally_reloaded_path() | ~563 | +| 01:26 | Edited src/app/event_loop/async_results/filesystem.rs | 4→7 lines | ~100 | +| 01:26 | Edited src/app/event_loop/async_results/filesystem.rs | 3→3 lines | ~42 | +| 01:26 | Edited src/app/event_loop/application.rs | modified is_empty() | ~254 | +| 01:26 | Edited src/app/app_state/tests.rs | modified external_rename_without_new_path_on_existing_file_reloads_like_modify() | ~883 | +| 01:27 | Edited src/async_runtime/scheduler/file_watch.rs | modified file_watch_restart_backoff() | ~38 | +| 01:27 | Edited src/async_runtime/scheduler/tests.rs | inline fix | ~27 | +| 01:27 | Edited src/async_runtime/scheduler/tests.rs | modified file_watch_restart_backoff_grows_exponentially_and_caps_at_30s() | ~176 | +| 01:30 | File-watcher audit fixes: rename-as-modify normalization, LSP sync for inactive reloads, watcher never gives up + degraded toast, HashSet dedup (bug-058) | palette.rs, setup.rs, file_watch.rs, filesystem.rs, failure.rs, application.rs, buffers.rs, mod.rs | 708 tests pass | ~35k | +| 01:30 | Session end: 27 writes across 9 files (handler.rs, application.rs, tests.rs, file_watch.rs, failure.rs) | 16 reads | ~188508 tok | +| 02:29 | Edited src/async_runtime/scheduler/dispatch.rs | expanded (+64 lines) | ~746 | +| 02:29 | Edited src/async_runtime/scheduler/dispatch.rs | 13→17 lines | ~223 | +| 02:31 | Edited src/app/app_state/palette.rs | modified is_some() | ~132 | +| 02:31 | Edited src/app/app_state/palette.rs | removed 50 lines | ~168 | +| 02:31 | Edited src/app/app_state/palette.rs | reduced (-27 lines) | ~158 | +| 02:32 | Edited src/app/app_state/palette.rs | reduced (-7 lines) | ~71 | +| 02:32 | Edited src/app/app_state/palette.rs | modified apply_external_file_contents() | ~1618 | +| 02:32 | Edited src/app/app_state/palette.rs | added 1 import(s) | ~27 | +| 02:32 | Edited src/app/app_state/palette.rs | 4→4 lines | ~40 | +| 02:33 | Edited src/app/app_state/buffers.rs | modified collect_externally_modified_open_buffers() | ~671 | +| 02:34 | Edited src/app/app_state/buffers.rs | 3→3 lines | ~8 | +| 02:34 | Edited src/app/event_loop/application.rs | modified is_empty() | ~180 | +| 02:34 | Edited src/app/event_loop/async_results/filesystem.rs | modified apply_external_file_events() | ~418 | +| 02:35 | Edited src/app/event_loop/async_results/mod.rs | expanded (+6 lines) | ~123 | +| 02:35 | Edited src/app/event_loop/async_results/filesystem.rs | modified handle_external_files_read() | ~707 | +| 02:36 | Edited src/app/event_loop/setup.rs | modified submit_workspace_rescan() | ~192 | +| 02:36 | Edited src/app/app_state/palette.rs | modified workspace_rescan_request_params() | ~175 | +| 02:39 | Edited src/terminal/grid.rs | modified apply_regex_highlights() | ~381 | +| 02:39 | Edited src/terminal/grid.rs | 3→3 lines | ~36 | +| 02:40 | Edited src/terminal/grid.rs | modified apply_regex_highlights_incremental_covers_rows_scrolled_into_scrollback() | ~559 | +| 02:41 | Edited src/terminal/grid.rs | scrollback() → len() | ~142 | +| 02:45 | #4 async I/O: two-phase external-change pipeline (ReadExternalFiles + RescanWorkspace workers) + incremental terminal regex highlight (bug-059) | palette.rs, buffers.rs, message.rs, dispatch.rs, filesystem.rs, setup.rs, application.rs, grid.rs, model.rs | 710 tests pass | ~45k | +| 02:45 | Session end: 48 writes across 12 files (handler.rs, application.rs, tests.rs, file_watch.rs, failure.rs) | 18 reads | ~211360 tok | + +## Session: 2026-06-12 09:57 + +| Time | Action | File(s) | Outcome | ~Tokens | +|------|--------|---------|---------|--------| + +## Session: 2026-06-12 10:00 + +| Time | Action | File(s) | Outcome | ~Tokens | +|------|--------|---------|---------|--------| +| 10:15 | Terminal cursor no-blink: removed caret_blink_visible gate for all 3 terminal cursors | src/render/renderer/lifecycle/frame.rs | cargo check ✅ | ~800 | +| 11:01 | Edited src/render/renderer/lifecycle/frame.rs | reduced (-12 lines) | ~184 | +| 11:02 | Edited src/render/renderer/lifecycle/frame.rs | 9→5 lines | ~111 | +| 11:02 | Session end: 2 writes across 1 files (frame.rs) | 2 reads | ~14245 tok | + +## Session: 2026-06-12 11:06 + +| Time | Action | File(s) | Outcome | ~Tokens | +|------|--------|---------|---------|--------| +| 11:23 | Rewrote AI inline completion status report in full English and recorded report-language preference | AI_INLINE_COMPLETION_STATUS_REPORT.md, .wolf/cerebrum.md | report language fixed | ~6000 | + +## Session: 2026-06-12 11:38 + +| Time | Action | File(s) | Outcome | ~Tokens | +|------|--------|---------|---------|--------| +| 2026-06-12 | Re-audit AI inline completion: report stale on keybindings (no Tab/Ctrl+l accept, only Ctrl+j; AiAcceptInlineWord unbound), model is xmtp/mimo-v2.5-pro, no prefix-retention | AI_INLINE_COMPLETION_STATUS_REPORT.md, setup.rs, ai.rs, default.toml | verified | ~8k | +| 12:07 | Edited src/app/input_map/mod.rs | 2→5 lines | ~70 | +| 12:07 | Edited src/app/input_map/mod.rs | modified for_mode_with_palette() | ~116 | +| 12:07 | Edited src/app/input_map/mod.rs | 10→11 lines | ~92 | +| 12:07 | Edited src/app/event_loop/setup.rs | 1→2 lines | ~40 | +| 12:07 | Edited src/app/input/handler.rs | modified Some() | ~441 | +| 12:08 | Edited src/app/event_loop/commands_lsp.rs | 2→2 lines | ~42 | +| 12:08 | Edited config/keymaps/default.toml | 4→9 lines | ~41 | +| 12:08 | Edited src/app/app_state/mod.rs | expanded (+6 lines) | ~83 | +| 12:08 | Edited src/app/app_state/mod.rs | 1→2 lines | ~32 | +| 12:08 | Edited src/app/event_loop/mod.rs | expanded (+11 lines) | ~230 | +| 12:08 | Edited src/app/event_loop/setup.rs | 5→9 lines | ~102 | +| 12:09 | Edited src/app/app_state/state.rs | modified retain_inline_suggestion_for_typed_text() | ~438 | +| 12:09 | Edited src/app/event_loop/commands.rs | expanded (+20 lines) | ~294 | +| 12:09 | Edited src/app/event_loop/commands_editor.rs | 4→6 lines | ~91 | +| 12:10 | Edited src/app/event_loop/async_results/ai.rs | modified sanitize_inline_suggestion() | ~1193 | +| 12:10 | Edited src/app/event_loop/async_results/failure.rs | modified handle_worker_failure() | ~111 | +| 12:10 | Edited src/app/event_loop/async_results/failure.rs | modified contains() | ~260 | +| 12:10 | Edited src/app/event_loop/setup.rs | expanded (+7 lines) | ~112 | +| 12:10 | Edited src/app/event_loop/setup.rs | 3→5 lines | ~67 | +| 12:11 | Edited src/app/event_loop/setup.rs | modified cancel_ai_inline_completion() | ~70 | +| 12:11 | Edited src/render/renderer.rs | expanded (+11 lines) | ~133 | +| 12:11 | Edited src/render/renderer.rs | 3→4 lines | ~45 | +| 12:11 | Edited src/render/renderer/ui/statusbar.rs | 7→7 lines | ~47 | +| 12:11 | Edited src/render/renderer/ui/statusbar.rs | 2→3 lines | ~30 | +| 12:11 | Edited src/render/renderer/ui/statusbar.rs | 2→3 lines | ~28 | +| 12:11 | Edited src/render/renderer/ui/statusbar.rs | expanded (+10 lines) | ~164 | +| 12:11 | Edited src/app/event_loop/application.rs | expanded (+17 lines) | ~400 | +| 12:14 | Edited src/app/app_state/state.rs | modified state_with_text() | ~758 | +| 12:14 | Edited config/ai.toml | 3→3 lines | ~14 | +| 12:18 | Edited src/app/event_loop/setup.rs | 5→6 lines | ~88 | +| 12:18 | Edited src/app/event_loop/setup.rs | 6→7 lines | ~62 | +| 12:18 | Edited src/async_runtime/scheduler/ai.rs | 6→7 lines | ~46 | +| 12:18 | Edited src/async_runtime/scheduler/ai.rs | 11→14 lines | ~151 | +| 12:18 | Edited src/config/ai_config.rs | 7→12 lines | ~134 | +| 12:18 | Edited src/async_runtime/message.rs | 6→7 lines | ~58 | +| 12:19 | Edited config/ai.toml | expanded (+6 lines) | ~174 | +| 12:22 | Edited src/app/event_loop/commands.rs | 7→11 lines | ~155 | +| 2026-06-12 | Implemented inline-completion improvements: smart Tab accept, Ctrl+l word-accept + sync fix, prefix retention, output sanitizer, AI statusbar chip + circuit breaker, reasoning_effort config; 723 tests pass | commands.rs setup.rs state.rs ai.rs failure.rs statusbar.rs ai.toml default.toml | done | ~60k | +| 12:24 | Session end: 37 writes across 16 files (mod.rs, setup.rs, handler.rs, commands_lsp.rs, default.toml) | 26 reads | ~257638 tok | +| 12:26 | Session end: 37 writes across 16 files (mod.rs, setup.rs, handler.rs, commands_lsp.rs, default.toml) | 26 reads | ~257638 tok | +| 12:35 | Edited src/app/event_loop/setup.rs | modified should_queue_ai_inline_completion() | ~427 | +| 12:35 | Edited src/app/event_loop/setup.rs | expanded (+7 lines) | ~134 | +| 12:35 | Edited src/app/event_loop/setup.rs | modified next_ai_inline_flush_deadline() | ~167 | +| 12:36 | Edited src/app/event_loop/mod.rs | 3→2 lines | ~27 | +| 12:36 | Edited src/app/event_loop/setup.rs | 3→2 lines | ~23 | +| 12:45 | Fixed user-reported bug: typing never triggered AI inline request (trigger-char/idle gating); now every keystroke queues + debounce coalesces | setup.rs, mod.rs | 723 tests pass | ~8k | +| 12:39 | Session end: 42 writes across 16 files (mod.rs, setup.rs, handler.rs, commands_lsp.rs, default.toml) | 26 reads | ~256866 tok | + +## Session: 2026-06-12 14:07 + +| Time | Action | File(s) | Outcome | ~Tokens | +|------|--------|---------|---------|--------| +| 14:56 | Edited config/ai.toml | modified tiny() | ~177 | +| 14:30 | Diagnosed empty AI inline completion + picked working model | config/ai.toml | mimo/deepseek/qwen reasoning-only or blocked; switched to mistral/mistral-tiny (non-reasoning, ~0.5s, streams clean) | ~9k | +| 14:57 | Session end: 1 writes across 1 files (ai.toml) | 5 reads | ~26372 tok | +| 15:06 | Edited config/ai.toml | tiny() → repeat() | ~90 | +| 14:45 | Verified devstral-2512 > mistral-tiny for inline completion | config/ai.toml | devstral clean on string+code, ~0.6s, streams; set as model | ~3k | +| 15:07 | Session end: 2 writes across 1 files (ai.toml) | 5 reads | ~26468 tok | +| 15:17 | Edited src/app/resolved_keymap.rs | 1→4 lines | ~73 | +| 15:17 | Edited src/app/input/handler.rs | modified resolve() | ~81 | +| 15:17 | Edited src/app/event_loop/async_results/ai.rs | modified append_inline_suggestion_chunk() | ~366 | +| 15:18 | Edited src/app/event_loop/async_results/lsp.rs | modified is_empty() | ~146 | +| 15:18 | Edited src/app/event_loop/async_results/ai.rs | modified dismiss_completion_for_ghost_text() | ~136 | +| 15:15 | Ghost text UX: removed Tab accept (Ctrl+j only), ghost text suppresses LSP popup | handler.rs, resolved_keymap.rs, async_results/ai.rs, async_results/lsp.rs | build+tests green (80 input, 6 ai) | ~22k | +| 15:23 | Session end: 7 writes across 5 files (ai.toml, resolved_keymap.rs, handler.rs, ai.rs, lsp.rs) | 14 reads | ~138552 tok | +| 15:27 | Edited config/ai.toml | repeat() → works() | ~112 | +| 15:25 | Switched inline model to codestral-2508 (FIM autocomplete, ~0.5s) | config/ai.toml | clean, non-reasoning, streams; best-fit for inline | ~2k | +| 15:28 | Session end: 8 writes across 5 files (ai.toml, resolved_keymap.rs, handler.rs, ai.rs, lsp.rs) | 14 reads | ~138672 tok | +| 15:29 | Created ../../../../tmp/codestral_ctx_test.py | — | ~1020 | +| 15:30 | Created ../../../../tmp/sanitize_check.py | — | ~490 | +| 15:31 | Edited src/app/event_loop/async_results/ai.rs | modified is_empty() | ~153 | +| 15:31 | Edited src/app/event_loop/async_results/ai.rs | modified trims_overlap_with_suffix() | ~238 | +| 15:40 | Verified codestral context (6/6 correct); fixed multi-line suffix overlap in sanitizer | async_results/ai.rs | 7 sanitize tests green | ~12k | +| 15:34 | Session end: 12 writes across 7 files (ai.toml, resolved_keymap.rs, handler.rs, ai.rs, lsp.rs) | 14 reads | ~140601 tok | +| 16:31 | Created ../../../../tmp/multiline_test.py | — | ~588 | +| 16:33 | Edited src/app/app_state/state.rs | modified accept_multiline_preserves_text_and_cursor_at_end() | ~303 | +| 16:35 | Edited src/app/event_loop/async_results/ai.rs | modified is_empty() | ~231 | +| 16:35 | Edited src/app/event_loop/async_results/ai.rs | modified strips_echoed_indentation_on_fresh_line() | ~349 | +| 16:10 | Fixed multi-line ghost text double-indent (echoed whitespace prefix not stripped) | async_results/ai.rs, state.rs | 9 sanitize + 20 accept tests green | ~30k | +| 16:39 | Session end: 16 writes across 9 files (ai.toml, resolved_keymap.rs, handler.rs, ai.rs, lsp.rs) | 16 reads | ~159410 tok | + +## Session: 2026-06-12 16:48 + +| Time | Action | File(s) | Outcome | ~Tokens | +|------|--------|---------|---------|--------| +| 17:01 | Diagnosed UI scale issue on Full HD monitor (stale window_size in ScaleFactorChanged + DPI-blind content_scale) | application.rs, setup.rs, helpers.rs, welcome.rs | diagnosed, not fixed | ~8k | +| 17:40 | Edited src/config/ui_config.rs | 4→4 lines | ~30 | +| 17:40 | Edited src/config/ui_config.rs | 4→4 lines | ~38 | +| 17:40 | Edited src/config/ui_config.rs | 6→6 lines | ~70 | +| 17:40 | Edited src/config/ui_config.rs | 3→3 lines | ~38 | +| 17:40 | Edited src/config/ui_config.rs | 4→4 lines | ~31 | +| 17:40 | Edited src/config/ui_config.rs | 4→5 lines | ~41 | +| 17:40 | Edited src/config/ui_config.rs | 4→4 lines | ~60 | +| 17:40 | Edited src/app/event_loop/setup.rs | modified update_runtime_scaling_for_window() | ~82 | +| 17:40 | Edited src/app/event_loop/application.rs | modified as_ref() | ~154 | +| 17:40 | Edited src/config/ui_config.rs | modified current_dir() | ~102 | +| 17:41 | Fixed UI scale: re-enabled scale_factor_override, stale window_size fix, user config/ui path | ui_config.rs, setup.rs, application.rs | cargo check pass | ~6k | +| 17:42 | Session end: 10 writes across 3 files (ui_config.rs, setup.rs, application.rs) | 6 reads | ~85494 tok | +| 17:42 | Session end: 10 writes across 3 files (ui_config.rs, setup.rs, application.rs) | 6 reads | ~85494 tok | +| 17:52 | Session end: 10 writes across 3 files (ui_config.rs, setup.rs, application.rs) | 6 reads | ~85494 tok | +| 18:00 | Edited src/render/renderer/ui/welcome.rs | max() → scale() | ~92 | +| 18:01 | Edited config/ui/default.toml | 6→8 lines | ~102 | +| 18:01 | Edited ../../.config/netherize/ui.toml | inline fix | ~5 | +| 18:02 | Fixed welcome rs^2 double-scale + disabled auto_scale by default | welcome.rs, config/ui/default.toml, ~/.config/netherize/ui.toml | release build pass | ~5k | +| 18:03 | Session end: 13 writes across 6 files (ui_config.rs, setup.rs, application.rs, welcome.rs, default.toml) | 9 reads | ~95878 tok | +| 18:14 | Audited extensions manager (Space M E): detection, install popup, UI scale | app_state/mod.rs, syntax_jobs.rs, commands_settings.rs, focus.rs, extensions.rs | diagnosed | ~12k | +| 18:15 | Session end: 13 writes across 6 files (ui_config.rs, setup.rs, application.rs, welcome.rs, default.toml) | 19 reads | ~198778 tok | +| 19:35 | Edited src/render/renderer.rs | 2→5 lines | ~71 | +| 19:35 | Edited src/render/renderer/lifecycle.rs | 2→3 lines | ~26 | +| 19:35 | Edited src/render/renderer/lifecycle.rs | modified set_ui_scale() | ~41 | +| 19:35 | Edited src/app/event_loop/setup.rs | modified as_mut() | ~59 | +| 19:36 | Edited src/render/renderer/editor/extensions.rs | expanded (+8 lines) | ~188 | +| 19:36 | Edited src/render/renderer/editor/extensions.rs | 2→2 lines | ~31 | +| 19:36 | Edited src/render/renderer/editor/extensions.rs | 5→5 lines | ~50 | +| 19:36 | Edited src/render/renderer/editor/extensions.rs | 7→6 lines | ~70 | +| 19:36 | Edited src/render/renderer/editor/extensions.rs | 13→18 lines | ~165 | +| 19:36 | Edited src/render/renderer/editor/extensions.rs | 9→9 lines | ~101 | +| 19:36 | Edited src/render/renderer/editor/extensions.rs | 10→10 lines | ~139 | +| 19:36 | Edited src/render/renderer/editor/extensions.rs | 7→7 lines | ~81 | +| 19:36 | Edited src/render/renderer/editor/extensions.rs | 2→2 lines | ~35 | +| 19:36 | Edited src/render/renderer/editor/extensions.rs | 4→4 lines | ~35 | +| 19:36 | Edited src/render/renderer/editor/extensions.rs | 4→4 lines | ~36 | +| 19:36 | Edited src/render/renderer/editor/extensions.rs | 27→27 lines | ~260 | +| 19:36 | Edited src/render/renderer/editor/extensions.rs | inline fix | ~18 | +| 19:37 | Edited src/render/renderer/editor/extensions.rs | 9→9 lines | ~136 | +| 19:37 | Edited src/render/renderer/editor/extensions.rs | 4→4 lines | ~49 | +| 19:37 | Edited src/render/renderer/editor/extensions.rs | 10→10 lines | ~86 | +| 19:37 | Edited src/render/renderer/editor/extensions.rs | 2→2 lines | ~27 | +| 19:37 | Edited src/render/renderer/editor/extensions.rs | 16→19 lines | ~207 | +| 19:37 | Edited src/render/renderer/editor/extensions.rs | 4→4 lines | ~30 | +| 19:37 | Edited src/render/renderer/editor/extensions.rs | 4→4 lines | ~30 | +| 19:37 | Edited src/render/renderer/editor/extensions.rs | 5→5 lines | ~54 | +| 19:37 | Edited src/render/renderer/editor/extensions.rs | 6→6 lines | ~60 | +| 19:37 | Edited src/render/renderer/editor/extensions.rs | 10→11 lines | ~158 | +| 19:37 | Edited src/render/renderer/editor/extensions.rs | 6→7 lines | ~59 | +| 19:37 | Edited src/render/renderer/editor/extensions.rs | 4→4 lines | ~27 | +| 19:37 | Edited src/render/renderer/editor/extensions.rs | inline fix | ~16 | +| 19:38 | Edited src/render/renderer/editor/extensions.rs | 14→14 lines | ~134 | +| 19:38 | Edited src/render/renderer/editor/extensions.rs | modified is_empty() | ~161 | +| 19:38 | Edited src/render/renderer/editor/extensions.rs | 2→2 lines | ~34 | +| 19:38 | Edited src/render/renderer/editor/extensions.rs | 20→23 lines | ~252 | +| 19:38 | Edited src/render/renderer/editor/extensions.rs | inline fix | ~16 | +| 19:38 | Edited src/render/renderer/editor/extensions.rs | 4→4 lines | ~39 | +| 19:38 | Edited src/render/renderer/editor/extensions.rs | 4→4 lines | ~55 | +| 19:38 | Edited src/render/renderer/editor/extensions.rs | 10→10 lines | ~139 | +| 19:38 | Edited src/render/renderer/editor/extensions.rs | 25→25 lines | ~354 | +| 19:38 | Edited src/render/renderer/editor/extensions.rs | 4→4 lines | ~52 | +| 19:38 | Edited src/render/renderer/editor/extensions.rs | 4→4 lines | ~47 | +| 19:38 | Edited src/render/renderer/editor/extensions.rs | 4→4 lines | ~42 | +| 19:38 | Edited src/render/renderer/editor/extensions.rs | 4→4 lines | ~48 | +| 19:38 | Edited src/render/renderer/editor/extensions.rs | 4→4 lines | ~47 | +| 19:39 | Edited src/render/renderer/editor/extensions.rs | 14→19 lines | ~229 | +| 19:39 | Edited src/render/renderer/editor/extensions.rs | 11→11 lines | ~134 | +| 19:39 | Edited src/render/renderer/editor/extensions.rs | 11→11 lines | ~142 | +| 19:39 | Edited src/render/renderer/editor/extensions.rs | 11→11 lines | ~149 | +| 19:39 | Edited src/render/renderer/editor/extensions.rs | 11→11 lines | ~163 | +| 19:39 | Edited src/app/event_loop/commands_settings.rs | modified active_extensions_manager_buffer_mut() | ~208 | +| 19:39 | Edited src/app/event_loop/commands_settings.rs | modified filter() | ~235 | +| 19:40 | Edited src/app/event_loop/commands_settings.rs | modified filter() | ~273 | +| 19:40 | Edited src/render/renderer/editor/extensions.rs | 5→5 lines | ~68 | +| 19:40 | Edited src/async_runtime/scheduler/syntax_jobs.rs | modified resolve_nvm_bin_paths() | ~105 | +| 19:40 | Edited src/async_runtime/scheduler/syntax_jobs.rs | expanded (+7 lines) | ~180 | +| 19:40 | Edited src/async_runtime/scheduler/syntax_jobs.rs | modified resolve_nvm_bin_paths() | ~342 | +| 19:41 | Edited src/app/app_state/palette.rs | modified active_extensions_manager_buffer_mut() | ~226 | +| 19:41 | Edited src/app/event_loop/async_results/system.rs | active_extensions_manager_buffer_mut() → any_extensions_manager_buffer_mut() | ~167 | +| 19:41 | Edited src/app/event_loop/async_results/system.rs | active_extensions_manager_buffer_mut() → any_extensions_manager_buffer_mut() | ~35 | +| 19:42 | Edited src/app/event_loop/async_results/system.rs | active_extensions_manager_buffer_mut() → any_extensions_manager_buffer_mut() | ~37 | +| 19:42 | Edited src/app/event_loop/async_results/system.rs | active_extensions_manager_buffer_mut() → any_extensions_manager_buffer_mut() | ~42 | +| 19:42 | Edited src/app/app_state/mod.rs | 11→14 lines | ~136 | +| 19:42 | Edited src/app/app_state/mod.rs | 5→6 lines | ~42 | +| 19:42 | Edited src/app/app_state/mod.rs | 3→3 lines | ~23 | +| 19:42 | Edited src/app/app_state/mod.rs | 3→3 lines | ~26 | +| 19:42 | Edited src/app/app_state/mod.rs | 3→3 lines | ~26 | +| 19:42 | Edited src/app/app_state/mod.rs | 3→3 lines | ~27 | +| 19:42 | Edited src/app/app_state/mod.rs | 3→3 lines | ~37 | +| 19:42 | Edited src/app/app_state/mod.rs | 3→3 lines | ~25 | +| 19:42 | Edited src/app/app_state/mod.rs | 3→3 lines | ~24 | +| 19:42 | Edited src/app/app_state/mod.rs | 3→3 lines | ~28 | +| 19:42 | Edited src/app/app_state/mod.rs | 3→3 lines | ~28 | +| 19:42 | Edited src/app/app_state/mod.rs | 3→3 lines | ~26 | +| 19:42 | Edited src/render/renderer/editor/extensions.rs | expanded (+8 lines) | ~157 | +| 19:43 | Edited src/app/app_state/mod.rs | expanded (+9 lines) | ~140 | +| 19:43 | Edited src/async_runtime/scheduler/syntax_jobs.rs | 3→4 lines | ~34 | +| 19:43 | Edited config/keymaps/default.toml | expanded (+11 lines) | ~79 | +| 19:44 | Edited config/keymaps/default.toml | 10→5 lines | ~35 | +| 19:47 | Fixed ext-manager scale/Esc/guard/PATH/installed-detection + Dart LSP + Space-? cheatsheet binding; 727 tests pass | extensions.rs, commands_settings.rs, syntax_jobs.rs, app_state/mod.rs, palette.rs, system.rs, default.toml | done | ~20k | +| 19:48 | Session end: 91 writes across 14 files (ui_config.rs, setup.rs, application.rs, welcome.rs, default.toml) | 29 reads | ~274595 tok | +| 19:49 | Session end: 91 writes across 14 files (ui_config.rs, setup.rs, application.rs, welcome.rs, default.toml) | 29 reads | ~274595 tok | +| 19:55 | Edited src/core/command_ids.rs | modified is_valid() | ~178 | +| 19:55 | Edited config/keymaps/default.toml | 4→4 lines | ~20 | +| 19:55 | Edited src/config/keymap_loader.rs | modified default_keymap_has_no_unknown_commands() | ~214 | +| 19:56 | Fixed keymap registry drift: 11 ids missing from ALL_IDS killed bindings silently (incl. Space ? help, C, {, }, ctrl+v); is_valid now falls back to parse(); regression test added | command_ids.rs, keymap_loader.rs, default.toml | keymap tests pass | ~6k | +| 19:57 | Session end: 94 writes across 16 files (ui_config.rs, setup.rs, application.rs, welcome.rs, default.toml) | 30 reads | ~276747 tok | +| 19:59 | Session end: 94 writes across 16 files (ui_config.rs, setup.rs, application.rs, welcome.rs, default.toml) | 30 reads | ~276747 tok | +| 20:11 | Edited src/render/renderer/editor/settings.rs | expanded (+12 lines) | ~392 | +| 20:11 | Edited src/render/renderer/editor/settings.rs | expanded (+7 lines) | ~383 | +| 20:11 | Edited src/render/renderer/editor/settings.rs | 58→62 lines | ~543 | +| 20:11 | Edited src/render/renderer/editor/settings.rs | modified setting_control_hint() | ~353 | +| 20:11 | Edited src/render/renderer/editor/settings.rs | 19→19 lines | ~187 | +| 20:12 | Edited src/render/renderer/editor/settings.rs | 5→7 lines | ~113 | +| 20:12 | Edited src/render/renderer/editor/settings.rs | modified Some() | ~161 | +| 20:12 | Edited src/render/renderer/editor/settings.rs | 30→30 lines | ~346 | +| 20:12 | Edited src/render/renderer/editor/settings.rs | expanded (+11 lines) | ~712 | +| 20:12 | Edited src/render/renderer/editor/settings.rs | 11→13 lines | ~138 | +| 20:12 | Edited src/render/renderer/editor/settings.rs | 4→4 lines | ~64 | +| 20:12 | Edited src/render/renderer/editor/settings.rs | 19→19 lines | ~210 | +| 20:13 | Edited src/render/renderer/editor/settings.rs | 4→4 lines | ~72 | +| 20:13 | Edited src/render/renderer/editor/settings.rs | modified setting_value_ratio() | ~797 | +| 20:13 | Edited src/render/renderer/editor/settings.rs | modified enumerate() | ~1017 | +| 20:14 | Edited src/render/renderer/editor/settings.rs | 3→3 lines | ~44 | +| 20:14 | Edited src/render/renderer/editor/settings.rs | 3→3 lines | ~21 | +| 20:14 | Edited src/app/app_state/settings.rs | 15→17 lines | ~169 | +| 20:14 | Edited src/app/app_state/settings.rs | 5→6 lines | ~48 | +| 20:14 | Edited src/app/app_state/settings.rs | 11→12 lines | ~61 | +| 20:15 | Edited src/app/app_state/settings.rs | 19→23 lines | ~212 | +| 20:15 | Edited src/app/app_state/settings.rs | expanded (+6 lines) | ~146 | +| 20:15 | Edited src/render/renderer/editor/settings.rs | 3→4 lines | ~50 | +| 20:15 | Edited src/render/renderer/editor/settings.rs | 5→8 lines | ~97 | +| 20:15 | Edited src/render/renderer/editor/settings.rs | modified display_value() | ~122 | +| 20:15 | Edited src/render/renderer/editor/settings.rs | 10→13 lines | ~115 | +| 20:15 | Edited src/app/app_state/palette.rs | 7→8 lines | ~84 | +| 20:16 | Edited src/app/app_state/palette.rs | 9→10 lines | ~88 | +| 20:16 | Edited src/app/event_loop/commands_settings.rs | 5→6 lines | ~83 | +| 20:16 | Edited src/app/event_loop/commands_settings_helpers.rs | 7→12 lines | ~168 | +| 20:16 | Edited src/app/event_loop/commands_settings_helpers.rs | modified refresh_runtime_scale_from_window() | ~363 | +| 20:16 | Edited src/app/event_loop/commands_settings_helpers.rs | 8→9 lines | ~168 | +| 20:16 | Edited src/app/event_loop/commands_settings_helpers.rs | modified selected_item_mut() | ~463 | +| 20:17 | Edited src/render/renderer/editor/settings.rs | 10→11 lines | ~205 | +| 20:17 | Edited src/render/renderer/editor/settings.rs | expanded (+7 lines) | ~83 | +| 20:17 | Settings tab: scaled chrome, proportional columns, responsive preview pane, new UI Scale setting (scale_factor_override) | settings.rs (renderer+state), palette.rs, commands_settings*.rs | compile clean | ~15k | +| 20:18 | Session end: 129 writes across 18 files (ui_config.rs, setup.rs, application.rs, welcome.rs, default.toml) | 33 reads | ~303959 tok | +| 20:20 | Session end: 129 writes across 18 files (ui_config.rs, setup.rs, application.rs, welcome.rs, default.toml) | 33 reads | ~303959 tok | +| 20:22 | Created ../../.claude/projects/-Users-qc-bright-Project-netherize-editor/memory/onboarding-roadmap.md | — | ~345 | +| 20:23 | Session end: 130 writes across 19 files (ui_config.rs, setup.rs, application.rs, welcome.rs, default.toml) | 33 reads | ~304329 tok | + +## Session: 2026-06-12 20:26 + +| Time | Action | File(s) | Outcome | ~Tokens | +|------|--------|---------|---------|--------| +| 20:35 | Edited src/core/commands.rs | 3→6 lines | ~68 | +| 20:35 | Edited src/core/commands.rs | 6→7 lines | ~76 | +| 20:35 | Edited src/core/command_ids.rs | 2→3 lines | ~48 | +| 20:35 | Edited src/core/command_ids.rs | 3→4 lines | ~23 | +| 20:36 | Edited src/core/command_ids.rs | expanded (+6 lines) | ~98 | +| 20:36 | Edited src/app/app_state/editor.rs | modified move_find_char() | ~413 | +| 20:36 | Edited src/app/app_state/state.rs | modified clear_search_highlights() | ~178 | +| 20:36 | Edited src/core/command_dispatch/mod.rs | 3→4 lines | ~39 | +| 20:36 | Edited src/core/command_dispatch/navigation.rs | expanded (+13 lines) | ~138 | +| 20:36 | Edited src/app/input/pending.rs | 5→9 lines | ~78 | +| 20:36 | Edited src/app/input/pending.rs | 4→5 lines | ~60 | +| 20:36 | Edited src/app/input/pending.rs | expanded (+6 lines) | ~116 | +| 20:36 | Edited src/core/command_dispatch/navigation.rs | inline fix | ~17 | +| 20:37 | Edited src/app/input/handler.rs | modified replace_char_from_input() | ~414 | +| 20:37 | Edited src/app/input/handler.rs | modified alt_key() | ~387 | +| 20:37 | Edited config/keymaps/default.toml | expanded (+36 lines) | ~175 | +| 20:38 | Edited src/app/app_state/mod.rs | expanded (+6 lines) | ~58 | +| 20:38 | Edited src/app/app_state/mod.rs | 7→10 lines | ~117 | +| 20:38 | Edited src/app/app_state/mod.rs | 2→3 lines | ~43 | +| 20:40 | Edited src/core/command_dispatch/tests.rs | modified move_find_char_jumps_to_char_and_highlights_all_matches() | ~807 | +| 20:43 | Edited src/app/input/tests.rs | 8→9 lines | ~85 | +| 20:43 | Edited src/app/input/tests.rs | 3→4 lines | ~63 | +| 20:43 | Edited src/app/input/tests.rs | modified bare_find_char_motion_waits_then_dispatches_move_find_char() | ~495 | +| 23:30 | Vim core key audit: bound D/Y/S/J/p/P/X in normal mode (p/P were dead despite comment claiming otherwise); new id editor.yank_to_line_end | config/keymaps/default.toml, src/core/command_ids.rs | ok | ~9k | +| 23:35 | Bare f/F/t/T motion implemented: PendingState::FindCharMotion + Command::MoveFindChar; motion is line-scoped (vim) but also sets search query to the char -> all occurrences highlighted, n/N jump file-wide | src/app/input/{handler,pending}.rs, src/core/{commands.rs,command_dispatch/{mod,navigation}.rs}, src/app/app_state/{editor,state}.rs | ok | ~12k | +| 23:40 | Cheatsheet: added Y entry + f/t/n tip lines; palette label for yank_to_line_end | src/app/app_state/mod.rs | ok | ~1k | +| 23:45 | Tests: 6 new (4 dispatch find-char, 2 input pending); fixed 2 input tests that relied on 'f' being unmapped (swapped to 'q'); full suite 735 pass | src/core/command_dispatch/tests.rs, src/app/input/tests.rs | ok | ~5k | +| 20:47 | Session end: 23 writes across 10 files (commands.rs, command_ids.rs, editor.rs, state.rs, mod.rs) | 15 reads | ~159632 tok | +| 20:55 | Edited src/app/app_state/palette.rs | modified push_jump() | ~311 | +| 20:56 | Edited src/core/command_dispatch/common.rs | modified open_file() | ~311 | +| 20:56 | Edited src/core/command_dispatch/palette.rs | expanded (+10 lines) | ~295 | +| 20:56 | Edited src/core/command_dispatch/palette.rs | expanded (+7 lines) | ~150 | +| 20:56 | Edited src/core/command_dispatch/navigation.rs | modified jump_origin() | ~219 | +| 20:56 | Edited src/core/command_dispatch/navigation.rs | expanded (+8 lines) | ~305 | +| 20:57 | Edited src/core/command_dispatch/navigation.rs | 22→26 lines | ~251 | +| 20:57 | Edited src/app/event_loop/commands_editor.rs | 2→4 lines | ~70 | +| 20:57 | Edited src/core/command_dispatch/tests.rs | modified move_to_last_line_pushes_jump_origin() | ~617 | +| 20:58 | Edited src/core/command_dispatch/tests.rs | modified move_to_last_line_pushes_jump_origin() | ~423 | +| 20:59 | Edited src/core/command_dispatch/tests.rs | 9→10 lines | ~135 | +| 20:59 | Edited src/core/command_dispatch/tests.rs | 13→14 lines | ~200 | +| 00:20 | Jump list coverage: DispatchCtx::open_file pushes origin on file switch (picker/grep/explorer); OpenSearchMatch + / confirm + n/N + gg/G + {} + leap push; stack dedup + cap 100 | command_dispatch/{common,palette,navigation}.rs, commands_editor.rs, app_state/palette.rs | ok | ~8k | +| 00:25 | Tests: 3 new jump tests (origin on G, origin on n, dedup/cap); open_file canonicalizes paths on macOS (/var -> /private/var) — tests must compare active_file() not raw temp path | command_dispatch/tests.rs | ok | ~3k | +| 21:03 | Session end: 35 writes across 13 files (commands.rs, command_ids.rs, editor.rs, state.rs, mod.rs) | 22 reads | ~211796 tok | + +## Session: 2026-06-12 21:05 + +| Time | Action | File(s) | Outcome | ~Tokens | +|------|--------|---------|---------|--------| +| 21:10 | Edited src/app/event_loop/commands_editor.rs | 3→8 lines | ~92 | +| 21:10 | Edited src/app/event_loop/commands_editor.rs | 3→5 lines | ~59 | +| 21:10 | Edited src/app/event_loop/commands.rs | 3→8 lines | ~82 | +| 21:11 | Edited src/app/event_loop/commands_tests.rs | modified operator_delete_clears_stale_semantic_symbol_highlights() | ~252 | +| 2026-06-12 | Fix symbol highlight jumping after dd/dw: added Operate{Delete|Change} + DeleteToLineEnd/ChangeToLineEnd to should_reparse & is_text_modifying_command; new test | commands_editor.rs, commands.rs, commands_tests.rs | 739 tests pass | ~30k | +| 21:14 | Session end: 4 writes across 3 files (commands_editor.rs, commands.rs, commands_tests.rs) | 6 reads | ~78906 tok | + +## Session: 2026-06-12 21:16 + +| Time | Action | File(s) | Outcome | ~Tokens | +|------|--------|---------|---------|--------| +| 21:24 | Edited src/app/event_loop/mod.rs | expanded (+6 lines) | ~167 | +| 21:24 | Edited src/app/event_loop/setup.rs | 2→3 lines | ~32 | +| 21:24 | Edited src/app/event_loop/setup.rs | modified cancel_ai_inline_completion() | ~581 | +| 21:24 | Edited src/app/event_loop/setup.rs | 6→7 lines | ~74 | +| 21:24 | Edited src/app/event_loop/setup.rs | modified ai_inline_anchor_is_current() | ~168 | +| 21:24 | Edited src/app/event_loop/application.rs | 3→4 lines | ~53 | +| 21:25 | Edited src/app/event_loop/commands_editor.rs | 3→8 lines | ~120 | +| 21:25 | Edited src/app/event_loop/commands_lsp.rs | modified is_some() | ~323 | +| 21:25 | Edited src/app/event_loop/async_results/ai.rs | modified ai_inline_anchor_is_current() | ~127 | +| 21:25 | Edited src/app/event_loop/async_results/ai.rs | modified ai_inline_anchor_is_current() | ~114 | +| 2026-06-12 | Fix AI inline ghost text appearing late at moved caret + following cursor: added ai_inline_anchor watchdog (cancel pending/in-flight + clear ghost on caret/mode/buffer change), re-anchor on retained typing & word accept | setup.rs, application.rs, commands_editor.rs, commands_lsp.rs, async_results/ai.rs, mod.rs | 739 tests pass | ~35k | +| 21:28 | Session end: 10 writes across 6 files (mod.rs, setup.rs, application.rs, commands_editor.rs, commands_lsp.rs) | 11 reads | ~116460 tok | + +## Session: 2026-06-12 21:30 + +| Time | Action | File(s) | Outcome | ~Tokens | +|------|--------|---------|---------|--------| +| 21:40 | Edited src/app/resolved_keymap.rs | modified sequence_continuations() | ~535 | +| 21:40 | Edited src/app/resolved_keymap.rs | expanded (+10 lines) | ~122 | +| 21:41 | Edited src/app/input_map/mod.rs | modified humanize_command_id() | ~232 | +| 21:41 | Edited src/app/input_map/mod.rs | modified whichkey_entries() | ~316 | +| 21:41 | Edited src/app/input/handler.rs | modified pending_chord_sequence() | ~143 | +| 21:41 | Edited src/render/renderer.rs | expanded (+7 lines) | ~190 | +| 21:41 | Edited src/render/renderer/ui.rs | 2→3 lines | ~13 | +| 21:42 | Created src/render/renderer/ui/whichkey.rs | — | ~1932 | +| 21:42 | Edited src/render/renderer/lifecycle.rs | 1→2 lines | ~47 | +| 21:42 | Edited src/render/renderer/lifecycle.rs | 2→4 lines | ~62 | +| 21:42 | Edited src/render/renderer/lifecycle.rs | 5→10 lines | ~107 | +| 21:42 | Edited src/render/renderer/lifecycle.rs | 2→3 lines | ~46 | +| 21:42 | Edited src/render/renderer/lifecycle.rs | 2→3 lines | ~47 | +| 21:42 | Edited src/render/renderer/lifecycle.rs | 3→4 lines | ~66 | +| 21:42 | Edited src/render/renderer/lifecycle.rs | 2→3 lines | ~37 | +| 21:42 | Edited src/render/renderer/lifecycle/frame.rs | 4→7 lines | ~87 | +| 21:42 | Edited src/render/renderer/lifecycle/frame.rs | 2→3 lines | ~59 | +| 21:42 | Edited src/render/renderer/lifecycle/frame.rs | expanded (+25 lines) | ~255 | +| 21:43 | Edited src/app/event_loop/application.rs | 2→5 lines | ~78 | +| 21:43 | Edited src/app/event_loop/application.rs | modified as_mut() | ~364 | +| 21:44 | Edited src/app/event_loop/application.rs | modified clear_expired_transient_toast() | ~48 | +| 21:44 | Edited src/app/event_loop/application.rs | modified next_lsp_retry_deadline() | ~189 | +| 21:44 | Edited src/app/event_loop/application.rs | modified tick_whichkey_delay() | ~200 | +| 21:44 | Edited src/app/event_loop/mod.rs | 2→5 lines | ~74 | +| 21:44 | Edited src/app/event_loop/setup.rs | 2→3 lines | ~33 | +| 21:45 | Edited src/app/resolved_keymap.rs | modified parse_unknown_key_returns_none() | ~511 | +| 21:47 | Edited src/app/resolved_keymap.rs | 2→3 lines | ~34 | +| 21:47 | Edited src/app/resolved_keymap.rs | 2→3 lines | ~30 | +| 21:47 | Edited config/keymaps/default.toml | 5→10 lines | ~65 | +| 21:47 | Edited src/render/renderer/ui/welcome.rs | expanded (+8 lines) | ~135 | +| 21:47 | Edited src/render/renderer/ui/welcome.rs | "Press Space P J for Recen" → "Press Space ? or F1 for a" | ~32 | +| 21:49 | Edited src/app/persistence.rs | 3→6 lines | ~51 | +| 21:49 | Edited src/app/event_loop/application.rs | 4→5 lines | ~41 | +| 21:49 | Edited src/app/event_loop/commands.rs | modified show_first_run_tour_if_needed() | ~231 | +| 21:50 | Edited src/render/renderer.rs | expanded (+15 lines) | ~182 | +| 21:50 | Edited src/render/renderer.rs | 2→3 lines | ~39 | +| 21:50 | Edited src/render/renderer/ui/statusbar.rs | inline fix | ~23 | +| 21:50 | Edited src/render/renderer/ui/statusbar.rs | 4→5 lines | ~48 | +| 21:50 | Edited src/render/renderer/ui/statusbar.rs | 2→3 lines | ~36 | +| 21:51 | Edited src/render/renderer/ui/statusbar.rs | modified is_empty() | ~225 | +| 21:51 | Edited src/render/renderer/ui/statusbar.rs | 2→4 lines | ~61 | +| 21:51 | Edited src/render/renderer/ui/statusbar.rs | 2→6 lines | ~58 | +| 21:51 | Edited src/render/renderer/ui/statusbar.rs | modified is_some() | ~229 | +| 21:51 | Edited src/app/event_loop/application.rs | modified is_some() | ~336 | +| 21:51 | Edited src/app/event_loop/application.rs | 4→5 lines | ~58 | +| 21:52 | Edited src/app/event_loop/application.rs | modified filter() | ~396 | +| 21:55 | Edited src/async_runtime/scheduler.rs | 1→2 lines | ~22 | +| 21:55 | Edited src/async_runtime/scheduler/syntax_jobs.rs | inline fix | ~13 | +| 21:55 | Edited src/app/event_loop/commands_settings.rs | modified is_empty() | ~276 | +| 21:56 | Edited src/render/renderer/editor/fuzzy.rs | 31→36 lines | ~418 | +| 21:56 | Edited src/render/renderer/editor/fuzzy.rs | 5→5 lines | ~35 | +| 21:56 | Edited src/render/renderer/editor/fuzzy.rs | 14→14 lines | ~149 | +| 21:57 | Edited src/render/renderer/editor/fuzzy.rs | 21→21 lines | ~210 | +| 21:57 | Edited src/render/renderer/editor/fuzzy.rs | 14→14 lines | ~157 | +| 21:57 | Edited src/render/renderer/editor/fuzzy.rs | 12→12 lines | ~139 | +| 21:57 | Edited src/render/renderer/editor/fuzzy.rs | 2→2 lines | ~41 | +| 21:57 | Edited src/render/renderer/editor/fuzzy.rs | 11→11 lines | ~117 | +| 21:57 | Edited src/render/renderer/editor/fuzzy.rs | 19→23 lines | ~211 | +| 21:57 | Edited src/render/renderer/editor/fuzzy.rs | inline fix | ~16 | +| 21:57 | Edited src/render/renderer/editor/fuzzy.rs | 13→13 lines | ~181 | +| 21:57 | Edited src/render/renderer/editor/fuzzy.rs | 27→27 lines | ~316 | +| 21:57 | Edited src/render/renderer/editor/fuzzy.rs | 10→15 lines | ~143 | +| 21:58 | Edited src/render/renderer/editor/fuzzy.rs | modified is_empty() | ~286 | +| 21:58 | Edited src/render/renderer/editor/fuzzy.rs | 12→12 lines | ~135 | +| 21:58 | Edited src/render/renderer/editor/fuzzy.rs | 3→3 lines | ~38 | +| 21:58 | Edited src/render/renderer/editor/fuzzy.rs | expanded (+10 lines) | ~279 | +| 21:58 | Edited src/render/renderer/editor/fuzzy.rs | 9→9 lines | ~104 | +| 21:58 | Edited src/render/renderer/editor/fuzzy.rs | 3→3 lines | ~29 | +| 21:58 | Edited src/render/renderer/editor/fuzzy.rs | 11→11 lines | ~116 | +| 21:59 | Edited src/render/renderer/editor/help.rs | 4→7 lines | ~121 | +| 21:59 | Edited src/render/renderer/editor/help.rs | 2→2 lines | ~31 | +| 21:59 | Edited src/render/renderer/editor/help.rs | 36→36 lines | ~374 | +| 21:59 | Edited src/render/renderer/editor/help.rs | 8→8 lines | ~58 | +| 21:59 | Edited src/render/renderer/editor/help.rs | inline fix | ~21 | +| 21:59 | Edited src/render/renderer/editor/help.rs | inline fix | ~13 | +| 21:59 | Edited src/render/renderer/editor/help.rs | inline fix | ~23 | +| 21:59 | Edited src/render/renderer/editor/help.rs | 2→2 lines | ~31 | +| 21:59 | Edited src/render/renderer/editor/help.rs | 2→2 lines | ~31 | +| 2026-06-12 | Which-key overlay: sequence_continuations + whichkey_entries + new render surface, 300ms delay via about_to_wait deadline | resolved_keymap.rs, input_map/mod.rs, input/handler.rs, renderer.rs, ui/whichkey.rs, lifecycle.rs, frame.rs, application.rs, mod.rs, setup.rs | done, test added | ~8k | +| 2026-06-12 | F1 -> app.open_help (parser + display_token + default.toml), welcome screen Cheat Sheet card + footer hint | resolved_keymap.rs, default.toml, ui/welcome.rs | done | ~1.5k | +| 2026-06-12 | First-run tour: persistent flag first_run_tour_shown + 12s onboarding toast in resumed() | persistence.rs, commands.rs, application.rs | done | ~1k | +| 2026-06-12 | LSP status indicator: LspStatusIndicator enum (NotApplicable/Inactive/Starting/Running/Missing), chip + state-aware dot in statusbar | renderer.rs, ui/statusbar.rs, application.rs | done | ~2k | +| 2026-06-12 | Extensions pre-check: missing_install_prerequisite gates brew/npm/go/cargo/pip/gem/dotnet before RunExtensionCommand, resolve_system_path now pub(crate) | commands_settings.rs, scheduler.rs, syntax_jobs.rs | done | ~1.5k | +| 2026-06-12 | ui_scale sweep: fuzzy.rs, help.rs, buffers.rs, overlays.rs (popup const blocks -> scaled lets) | render/renderer/editor/* | done, 728 tests pass | ~6k | +| 22:05 | Created ../../.claude/projects/-Users-qc-bright-Project-netherize-editor/memory/onboarding-roadmap.md | — | ~509 | +| 22:06 | Session end: 79 writes across 21 files (resolved_keymap.rs, mod.rs, handler.rs, renderer.rs, ui.rs) | 31 reads | ~259804 tok | +| 22:28 | Edited src/app/input/handler.rs | modified reset_prefix_if_timed_out() | ~220 | +| 22:29 | Edited src/app/input/handler.rs | modified Some() | ~315 | +| 22:29 | Edited src/app/input/handler.rs | modified resolve_sequence_next() | ~109 | +| 22:29 | Edited src/app/input/handler.rs | 8→8 lines | ~127 | +| 22:29 | Edited src/app/event_loop/application.rs | modified handle_command_with_count() | ~210 | +| 22:30 | Edited src/app/input/tests.rs | modified pending_chord_survives_timeout_window() | ~675 | +| 2026-06-12 | Fix which-key: chord khong expire khi overlay hien, Esc cancel chord, started_at giu qua cac buoc, NoDispatch request_redraw | input/handler.rs, application.rs, input/tests.rs | done, 729 tests pass | ~2k | +| 22:33 | Session end: 85 writes across 22 files (resolved_keymap.rs, mod.rs, handler.rs, renderer.rs, ui.rs) | 32 reads | ~279684 tok | +| 22:38 | Edited src/app/event_loop/commands_completion.rs | modified submit_lsp_completion_manual() | ~230 | +| 22:38 | Edited src/app/event_loop/commands_lsp.rs | inline fix | ~23 | +| 23:06 | Completed Ctrl+Space-over-ghost-text fix and strengthened regression coverage through LSP result plus late AI result | commands_completion.rs, commands_lsp.rs, commands_tests.rs | cargo check + 46 completion tests + 9 AI tests pass | ~8k | +| 23:10 | Final verification for manual Ctrl+Space completion override | commands_completion.rs, commands_lsp.rs, commands_tests.rs | rustfmt/diff/JSON clean; full lib suite 730 passed | ~2k | diff --git a/.wolf/token-ledger.json b/.wolf/token-ledger.json index 70e24c84..20c67968 100644 --- a/.wolf/token-ledger.json +++ b/.wolf/token-ledger.json @@ -2,14 +2,14 @@ "version": 1, "created_at": "2026-05-16T05:48:05.030Z", "lifetime": { - "total_tokens_estimated": 6029271, - "total_reads": 644, - "total_writes": 716, - "total_sessions": 65, - "anatomy_hits": 577, - "anatomy_misses": 22, - "repeated_reads_blocked": 844, - "estimated_savings_vs_bare_cli": 9320560 + "total_tokens_estimated": 12500583, + "total_reads": 1304, + "total_writes": 2093, + "total_sessions": 84, + "anatomy_hits": 1219, + "anatomy_misses": 40, + "repeated_reads_blocked": 1571, + "estimated_savings_vs_bare_cli": 18040863 }, "sessions": [ { @@ -9168,6 +9168,11576 @@ "repeated_reads_blocked": 2, "anatomy_lookups": 3 } + }, + { + "id": "session-2026-06-11-1030", + "started": "2026-06-11T03:30:51.289Z", + "ended": "2026-06-11T04:27:21.607Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/focus.rs", + "tokens_estimated": 11317, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/workbench/focus_manager.rs", + "tokens_estimated": 1406, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 18107, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 24745, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_terminal.rs", + "tokens_estimated": 6215, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 21193, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_ai_chat.rs", + "tokens_estimated": 11667, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/helpers.rs", + "tokens_estimated": 4349, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/tests.rs", + "tokens_estimated": 16444, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/mod.rs", + "tokens_estimated": 6794, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/mod.rs", + "tokens_estimated": 5068, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/commands.rs", + "tokens_estimated": 5479, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/session.rs", + "tokens_estimated": 4001, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/mod.rs", + "tokens_estimated": 4332, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands.rs", + "tokens_estimated": 10302, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/terminal/terminal_renderer.rs", + "tokens_estimated": 3221, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/text/text_system.rs", + "tokens_estimated": 6296, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/terminal.rs", + "tokens_estimated": 7189, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "tokens_estimated": 6978, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/terminal/grid.rs", + "tokens_estimated": 16414, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/Cargo.toml", + "tokens_estimated": 660, + "was_repeated": false, + "anatomy_had_description": false + } + ], + "writes": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_ai_chat.rs", + "tokens_estimated": 233, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 429, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/tests.rs", + "tokens_estimated": 499, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/mod.rs", + "tokens_estimated": 116, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/mod.rs", + "tokens_estimated": 87, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/mod.rs", + "tokens_estimated": 121, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 102, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/commands.rs", + "tokens_estimated": 111, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/commands.rs", + "tokens_estimated": 66, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/session.rs", + "tokens_estimated": 87, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/mod.rs", + "tokens_estimated": 98, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/mod.rs", + "tokens_estimated": 133, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_terminal.rs", + "tokens_estimated": 118, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_terminal.rs", + "tokens_estimated": 450, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands.rs", + "tokens_estimated": 134, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_terminal.rs", + "tokens_estimated": 60, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 337, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 309, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 284, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/tests.rs", + "tokens_estimated": 557, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/Cargo.toml", + "tokens_estimated": 21, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/terminal/grid.rs", + "tokens_estimated": 148, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/terminal/grid.rs", + "tokens_estimated": 287, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/terminal/grid.rs", + "tokens_estimated": 42, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/terminal/grid.rs", + "tokens_estimated": 452, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/terminal/grid.rs", + "tokens_estimated": 34, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/terminal/grid.rs", + "tokens_estimated": 275, + "action": "edit" + } + ], + "totals": { + "input_tokens_estimated": 192177, + "output_tokens_estimated": 5590, + "reads_count": 21, + "writes_count": 27, + "repeated_reads_blocked": 29, + "anatomy_lookups": 21 + } + }, + { + "id": "session-2026-06-11-1317", + "started": "2026-06-11T06:17:00.425Z", + "ended": "2026-06-11T06:39:42.908Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/file_watch.rs", + "tokens_estimated": 2469, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/filesystem.rs", + "tokens_estimated": 929, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 19972, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/buffers.rs", + "tokens_estimated": 8124, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 16953, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/overlays.rs", + "tokens_estimated": 18409, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/mod.rs", + "tokens_estimated": 9176, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/mod.rs", + "tokens_estimated": 6403, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 24745, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/emit.rs", + "tokens_estimated": 458, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/dispatch.rs", + "tokens_estimated": 6293, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/pty.rs", + "tokens_estimated": 3903, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_explorer.rs", + "tokens_estimated": 7738, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/message.rs", + "tokens_estimated": 7171, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/Cargo.toml", + "tokens_estimated": 624, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/tests.rs", + "tokens_estimated": 19670, + "was_repeated": false, + "anatomy_had_description": false + } + ], + "writes": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/dispatch.rs", + "tokens_estimated": 99, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/file_watch.rs", + "tokens_estimated": 1408, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/file_watch.rs", + "tokens_estimated": 50, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/file_watch.rs", + "tokens_estimated": 267, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/message.rs", + "tokens_estimated": 73, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/file_watch.rs", + "tokens_estimated": 236, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 46, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_explorer.rs", + "tokens_estimated": 99, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_explorer.rs", + "tokens_estimated": 93, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/mod.rs", + "tokens_estimated": 116, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 70, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 374, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/buffers.rs", + "tokens_estimated": 107, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/overlays.rs", + "tokens_estimated": 226, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/buffers.rs", + "tokens_estimated": 122, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 151, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 141, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 27, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/filesystem.rs", + "tokens_estimated": 52, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 25, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 108, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 99, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/buffers.rs", + "tokens_estimated": 34, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/buffers.rs", + "tokens_estimated": 33, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/tests.rs", + "tokens_estimated": 553, + "action": "edit" + } + ], + "totals": { + "input_tokens_estimated": 153037, + "output_tokens_estimated": 4609, + "reads_count": 16, + "writes_count": 25, + "repeated_reads_blocked": 13, + "anatomy_lookups": 16 + } + }, + { + "id": "session-2026-06-11-2340", + "started": "2026-06-11T16:40:43.284Z", + "ended": "2026-06-11T16:43:27.624Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 18687, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_terminal.rs", + "tokens_estimated": 6441, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 23143, + "was_repeated": false, + "anatomy_had_description": false + } + ], + "writes": [], + "totals": { + "input_tokens_estimated": 48271, + "output_tokens_estimated": 0, + "reads_count": 3, + "writes_count": 0, + "repeated_reads_blocked": 0, + "anatomy_lookups": 3 + } + }, + { + "id": "session-2026-06-11-2340", + "started": "2026-06-11T16:40:43.284Z", + "ended": "2026-06-11T17:04:02.302Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 18687, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_terminal.rs", + "tokens_estimated": 6441, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 23143, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/terminal/grid.rs", + "tokens_estimated": 16620, + "was_repeated": true, + "anatomy_had_description": false + } + ], + "writes": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/terminal/grid.rs", + "tokens_estimated": 134, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/terminal/grid.rs", + "tokens_estimated": 45, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/terminal/grid.rs", + "tokens_estimated": 65, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/terminal/grid.rs", + "tokens_estimated": 249, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/terminal/grid.rs", + "tokens_estimated": 1386, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/terminal/grid.rs", + "tokens_estimated": 23, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/terminal/grid.rs", + "tokens_estimated": 468, + "action": "edit" + } + ], + "totals": { + "input_tokens_estimated": 64891, + "output_tokens_estimated": 2370, + "reads_count": 4, + "writes_count": 7, + "repeated_reads_blocked": 8, + "anatomy_lookups": 4 + } + }, + { + "id": "session-2026-06-11-0043", + "started": "2026-06-11T17:43:38.997Z", + "ended": "2026-06-11T17:49:34.208Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/terminal/terminal_renderer.rs", + "tokens_estimated": 3221, + "was_repeated": false, + "anatomy_had_description": false + } + ], + "writes": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/text/atlas.rs", + "tokens_estimated": 138, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/text/atlas.rs", + "tokens_estimated": 32, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/text/atlas.rs", + "tokens_estimated": 284, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/terminal/cell_shapes.rs", + "tokens_estimated": 3155, + "action": "create" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/terminal/terminal_renderer.rs", + "tokens_estimated": 76, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/terminal/terminal_renderer.rs", + "tokens_estimated": 415, + "action": "edit" + } + ], + "totals": { + "input_tokens_estimated": 3221, + "output_tokens_estimated": 4100, + "reads_count": 1, + "writes_count": 6, + "repeated_reads_blocked": 0, + "anatomy_lookups": 1 + } + }, + { + "id": "session-2026-06-11-0043", + "started": "2026-06-11T17:43:38.997Z", + "ended": "2026-06-11T17:57:28.274Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/terminal/terminal_renderer.rs", + "tokens_estimated": 3221, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/terminal/mod.rs", + "tokens_estimated": 28, + "was_repeated": false, + "anatomy_had_description": false + } + ], + "writes": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/text/atlas.rs", + "tokens_estimated": 138, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/text/atlas.rs", + "tokens_estimated": 32, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/text/atlas.rs", + "tokens_estimated": 284, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/terminal/cell_shapes.rs", + "tokens_estimated": 3155, + "action": "create" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/terminal/terminal_renderer.rs", + "tokens_estimated": 76, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/terminal/terminal_renderer.rs", + "tokens_estimated": 415, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/terminal/mod.rs", + "tokens_estimated": 16, + "action": "edit" + } + ], + "totals": { + "input_tokens_estimated": 3249, + "output_tokens_estimated": 4116, + "reads_count": 2, + "writes_count": 7, + "repeated_reads_blocked": 0, + "anatomy_lookups": 2 + } + }, + { + "id": "session-2026-06-11-0100", + "started": "2026-06-11T18:00:32.752Z", + "ended": "2026-06-11T18:12:54.723Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 25383, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 18687, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/terminal/grid.rs", + "tokens_estimated": 17409, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/terminal.rs", + "tokens_estimated": 2954, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/tests.rs", + "tokens_estimated": 16944, + "was_repeated": false, + "anatomy_had_description": false + } + ], + "writes": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 362, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 127, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 262, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/tests.rs", + "tokens_estimated": 288, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/tests.rs", + "tokens_estimated": 121, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/tests.rs", + "tokens_estimated": 186, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 107, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 33, + "action": "edit" + } + ], + "totals": { + "input_tokens_estimated": 81377, + "output_tokens_estimated": 1486, + "reads_count": 5, + "writes_count": 8, + "repeated_reads_blocked": 2, + "anatomy_lookups": 5 + } + }, + { + "id": "session-2026-06-11-0100", + "started": "2026-06-11T18:00:32.752Z", + "ended": "2026-06-11T18:19:08.946Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 25450, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 18687, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/terminal/grid.rs", + "tokens_estimated": 17409, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/terminal.rs", + "tokens_estimated": 2954, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/tests.rs", + "tokens_estimated": 16944, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/file_watch.rs", + "tokens_estimated": 2860, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 16911, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/buffers.rs", + "tokens_estimated": 8071, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/filesystem.rs", + "tokens_estimated": 826, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/overlays.rs", + "tokens_estimated": 17332, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_editor.rs", + "tokens_estimated": 5441, + "was_repeated": false, + "anatomy_had_description": false + } + ], + "writes": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 362, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 127, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 262, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/tests.rs", + "tokens_estimated": 288, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/tests.rs", + "tokens_estimated": 121, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/tests.rs", + "tokens_estimated": 186, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 107, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 33, + "action": "edit" + } + ], + "totals": { + "input_tokens_estimated": 132885, + "output_tokens_estimated": 1486, + "reads_count": 11, + "writes_count": 8, + "repeated_reads_blocked": 5, + "anatomy_lookups": 11 + } + }, + { + "id": "session-2026-06-11-0100", + "started": "2026-06-11T18:00:32.752Z", + "ended": "2026-06-11T18:30:59.918Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 25450, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 18687, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/terminal/grid.rs", + "tokens_estimated": 17409, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/terminal.rs", + "tokens_estimated": 2954, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/tests.rs", + "tokens_estimated": 16944, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/file_watch.rs", + "tokens_estimated": 2860, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 16911, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/buffers.rs", + "tokens_estimated": 8071, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/filesystem.rs", + "tokens_estimated": 826, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/overlays.rs", + "tokens_estimated": 17332, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_editor.rs", + "tokens_estimated": 5441, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 20285, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/lsp.rs", + "tokens_estimated": 7248, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/failure.rs", + "tokens_estimated": 1255, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/tests.rs", + "tokens_estimated": 18684, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/tests.rs", + "tokens_estimated": 1752, + "was_repeated": false, + "anatomy_had_description": false + } + ], + "writes": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 362, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 127, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 262, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/tests.rs", + "tokens_estimated": 288, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/tests.rs", + "tokens_estimated": 121, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/tests.rs", + "tokens_estimated": 186, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 107, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 33, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/file_watch.rs", + "tokens_estimated": 34, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/file_watch.rs", + "tokens_estimated": 196, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/file_watch.rs", + "tokens_estimated": 823, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/file_watch.rs", + "tokens_estimated": 279, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/file_watch.rs", + "tokens_estimated": 169, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/failure.rs", + "tokens_estimated": 157, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/file_watch.rs", + "tokens_estimated": 87, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 379, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 164, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 164, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/buffers.rs", + "tokens_estimated": 230, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 603, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/filesystem.rs", + "tokens_estimated": 107, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/filesystem.rs", + "tokens_estimated": 45, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 272, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/tests.rs", + "tokens_estimated": 946, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/file_watch.rs", + "tokens_estimated": 41, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/tests.rs", + "tokens_estimated": 29, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/tests.rs", + "tokens_estimated": 188, + "action": "edit" + } + ], + "totals": { + "input_tokens_estimated": 182109, + "output_tokens_estimated": 6399, + "reads_count": 16, + "writes_count": 27, + "repeated_reads_blocked": 6, + "anatomy_lookups": 16 + } + }, + { + "id": "session-2026-06-11-0100", + "started": "2026-06-11T18:00:32.752Z", + "ended": "2026-06-11T19:45:33.510Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 25450, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 18687, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/terminal/grid.rs", + "tokens_estimated": 17409, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/terminal.rs", + "tokens_estimated": 2954, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/tests.rs", + "tokens_estimated": 16944, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/file_watch.rs", + "tokens_estimated": 2860, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 16911, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/buffers.rs", + "tokens_estimated": 7691, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/filesystem.rs", + "tokens_estimated": 826, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/overlays.rs", + "tokens_estimated": 17332, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_editor.rs", + "tokens_estimated": 5441, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 20285, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/lsp.rs", + "tokens_estimated": 7248, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/failure.rs", + "tokens_estimated": 1255, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/tests.rs", + "tokens_estimated": 19594, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/tests.rs", + "tokens_estimated": 1752, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/dispatch.rs", + "tokens_estimated": 5890, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/mod.rs", + "tokens_estimated": 9176, + "was_repeated": false, + "anatomy_had_description": false + } + ], + "writes": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 362, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 127, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 262, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/tests.rs", + "tokens_estimated": 288, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/tests.rs", + "tokens_estimated": 121, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/tests.rs", + "tokens_estimated": 186, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 107, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 33, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/file_watch.rs", + "tokens_estimated": 34, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/file_watch.rs", + "tokens_estimated": 196, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/file_watch.rs", + "tokens_estimated": 823, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/file_watch.rs", + "tokens_estimated": 279, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/file_watch.rs", + "tokens_estimated": 169, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/failure.rs", + "tokens_estimated": 157, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/file_watch.rs", + "tokens_estimated": 87, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 379, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 164, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 164, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/buffers.rs", + "tokens_estimated": 230, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 603, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/filesystem.rs", + "tokens_estimated": 107, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/filesystem.rs", + "tokens_estimated": 45, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 272, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/tests.rs", + "tokens_estimated": 946, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/file_watch.rs", + "tokens_estimated": 41, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/tests.rs", + "tokens_estimated": 29, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/tests.rs", + "tokens_estimated": 188, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/dispatch.rs", + "tokens_estimated": 800, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/dispatch.rs", + "tokens_estimated": 238, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 141, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 180, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 169, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 76, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 1734, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 29, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 43, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/buffers.rs", + "tokens_estimated": 718, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/buffers.rs", + "tokens_estimated": 9, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 193, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/filesystem.rs", + "tokens_estimated": 448, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/mod.rs", + "tokens_estimated": 132, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/filesystem.rs", + "tokens_estimated": 758, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 205, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 187, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/terminal/grid.rs", + "tokens_estimated": 408, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/terminal/grid.rs", + "tokens_estimated": 38, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/terminal/grid.rs", + "tokens_estimated": 598, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/terminal/grid.rs", + "tokens_estimated": 152, + "action": "edit" + } + ], + "totals": { + "input_tokens_estimated": 197705, + "output_tokens_estimated": 13655, + "reads_count": 18, + "writes_count": 48, + "repeated_reads_blocked": 13, + "anatomy_lookups": 18 + } + }, + { + "id": "session-2026-06-12-1000", + "started": "2026-06-12T03:00:47.641Z", + "ended": "2026-06-12T04:02:23.827Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle/frame.rs", + "tokens_estimated": 9400, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer.rs", + "tokens_estimated": 4530, + "was_repeated": false, + "anatomy_had_description": false + } + ], + "writes": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle/frame.rs", + "tokens_estimated": 197, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle/frame.rs", + "tokens_estimated": 118, + "action": "edit" + } + ], + "totals": { + "input_tokens_estimated": 13930, + "output_tokens_estimated": 315, + "reads_count": 2, + "writes_count": 2, + "repeated_reads_blocked": 1, + "anatomy_lookups": 2 + } + }, + { + "id": "session-2026-06-12-1138", + "started": "2026-06-12T04:38:47.732Z", + "ended": "2026-06-12T04:48:29.624Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ai.toml", + "tokens_estimated": 79, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 22515, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/state.rs", + "tokens_estimated": 11562, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/ai.rs", + "tokens_estimated": 1994, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_lsp.rs", + "tokens_estimated": 9653, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/viewport.rs", + "tokens_estimated": 7848, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 5308, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/editing.rs", + "tokens_estimated": 10629, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/async_bridge.rs", + "tokens_estimated": 4589, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/editor.rs", + "tokens_estimated": 10839, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/overlays.rs", + "tokens_estimated": 18369, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ai_config.rs", + "tokens_estimated": 1206, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/mod.rs", + "tokens_estimated": 9705, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/ai.rs", + "tokens_estimated": 938, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/failure.rs", + "tokens_estimated": 1384, + "was_repeated": false, + "anatomy_had_description": false + } + ], + "writes": [], + "totals": { + "input_tokens_estimated": 116618, + "output_tokens_estimated": 0, + "reads_count": 15, + "writes_count": 0, + "repeated_reads_blocked": 6, + "anatomy_lookups": 15 + } + }, + { + "id": "session-2026-06-12-1138", + "started": "2026-06-12T04:38:47.732Z", + "ended": "2026-06-12T05:24:26.832Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ai.toml", + "tokens_estimated": 79, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 21036, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/state.rs", + "tokens_estimated": 11214, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/ai.rs", + "tokens_estimated": 1994, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_lsp.rs", + "tokens_estimated": 9653, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/viewport.rs", + "tokens_estimated": 7848, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 5308, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/editing.rs", + "tokens_estimated": 10629, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/async_bridge.rs", + "tokens_estimated": 4589, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/editor.rs", + "tokens_estimated": 10839, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/overlays.rs", + "tokens_estimated": 18369, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ai_config.rs", + "tokens_estimated": 1206, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/mod.rs", + "tokens_estimated": 9705, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/ai.rs", + "tokens_estimated": 938, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/failure.rs", + "tokens_estimated": 1384, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands.rs", + "tokens_estimated": 10368, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_editor.rs", + "tokens_estimated": 5438, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 20186, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/mod.rs", + "tokens_estimated": 5174, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/mod.rs", + "tokens_estimated": 6944, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/statusbar.rs", + "tokens_estimated": 5546, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 27326, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 24547, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer.rs", + "tokens_estimated": 4530, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/tests.rs", + "tokens_estimated": 18328, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/message.rs", + "tokens_estimated": 7595, + "was_repeated": false, + "anatomy_had_description": false + } + ], + "writes": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/mod.rs", + "tokens_estimated": 75, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/mod.rs", + "tokens_estimated": 124, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/mod.rs", + "tokens_estimated": 98, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 43, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 472, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_lsp.rs", + "tokens_estimated": 45, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 44, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 89, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 34, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/mod.rs", + "tokens_estimated": 246, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 110, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/state.rs", + "tokens_estimated": 469, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands.rs", + "tokens_estimated": 315, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_editor.rs", + "tokens_estimated": 97, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/ai.rs", + "tokens_estimated": 1278, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/failure.rs", + "tokens_estimated": 119, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/failure.rs", + "tokens_estimated": 278, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 120, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 71, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 75, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer.rs", + "tokens_estimated": 142, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer.rs", + "tokens_estimated": 48, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/statusbar.rs", + "tokens_estimated": 50, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/statusbar.rs", + "tokens_estimated": 32, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/statusbar.rs", + "tokens_estimated": 30, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/statusbar.rs", + "tokens_estimated": 176, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 429, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/state.rs", + "tokens_estimated": 812, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ai.toml", + "tokens_estimated": 15, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 94, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 67, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/ai.rs", + "tokens_estimated": 49, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/ai.rs", + "tokens_estimated": 162, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ai_config.rs", + "tokens_estimated": 143, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/message.rs", + "tokens_estimated": 62, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ai.toml", + "tokens_estimated": 186, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands.rs", + "tokens_estimated": 166, + "action": "edit" + } + ], + "totals": { + "input_tokens_estimated": 250773, + "output_tokens_estimated": 6865, + "reads_count": 26, + "writes_count": 37, + "repeated_reads_blocked": 21, + "anatomy_lookups": 26 + } + }, + { + "id": "session-2026-06-12-1138", + "started": "2026-06-12T04:38:47.732Z", + "ended": "2026-06-12T05:26:52.772Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ai.toml", + "tokens_estimated": 79, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 21036, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/state.rs", + "tokens_estimated": 11214, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/ai.rs", + "tokens_estimated": 1994, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_lsp.rs", + "tokens_estimated": 9653, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/viewport.rs", + "tokens_estimated": 7848, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 5308, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/editing.rs", + "tokens_estimated": 10629, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/async_bridge.rs", + "tokens_estimated": 4589, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/editor.rs", + "tokens_estimated": 10839, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/overlays.rs", + "tokens_estimated": 18369, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ai_config.rs", + "tokens_estimated": 1206, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/mod.rs", + "tokens_estimated": 9705, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/ai.rs", + "tokens_estimated": 938, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/failure.rs", + "tokens_estimated": 1384, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands.rs", + "tokens_estimated": 10368, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_editor.rs", + "tokens_estimated": 5438, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 20186, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/mod.rs", + "tokens_estimated": 5174, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/mod.rs", + "tokens_estimated": 6944, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/statusbar.rs", + "tokens_estimated": 5546, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 27326, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 24547, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer.rs", + "tokens_estimated": 4530, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/tests.rs", + "tokens_estimated": 18328, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/message.rs", + "tokens_estimated": 7595, + "was_repeated": false, + "anatomy_had_description": false + } + ], + "writes": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/mod.rs", + "tokens_estimated": 75, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/mod.rs", + "tokens_estimated": 124, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/mod.rs", + "tokens_estimated": 98, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 43, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 472, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_lsp.rs", + "tokens_estimated": 45, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 44, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 89, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 34, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/mod.rs", + "tokens_estimated": 246, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 110, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/state.rs", + "tokens_estimated": 469, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands.rs", + "tokens_estimated": 315, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_editor.rs", + "tokens_estimated": 97, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/ai.rs", + "tokens_estimated": 1278, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/failure.rs", + "tokens_estimated": 119, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/failure.rs", + "tokens_estimated": 278, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 120, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 71, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 75, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer.rs", + "tokens_estimated": 142, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer.rs", + "tokens_estimated": 48, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/statusbar.rs", + "tokens_estimated": 50, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/statusbar.rs", + "tokens_estimated": 32, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/statusbar.rs", + "tokens_estimated": 30, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/statusbar.rs", + "tokens_estimated": 176, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 429, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/state.rs", + "tokens_estimated": 812, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ai.toml", + "tokens_estimated": 15, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 94, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 67, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/ai.rs", + "tokens_estimated": 49, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/ai.rs", + "tokens_estimated": 162, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ai_config.rs", + "tokens_estimated": 143, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/message.rs", + "tokens_estimated": 62, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ai.toml", + "tokens_estimated": 186, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands.rs", + "tokens_estimated": 166, + "action": "edit" + } + ], + "totals": { + "input_tokens_estimated": 250773, + "output_tokens_estimated": 6865, + "reads_count": 26, + "writes_count": 37, + "repeated_reads_blocked": 21, + "anatomy_lookups": 26 + } + }, + { + "id": "session-2026-06-12-1138", + "started": "2026-06-12T04:38:47.732Z", + "ended": "2026-06-12T05:39:22.046Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ai.toml", + "tokens_estimated": 79, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 21036, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/state.rs", + "tokens_estimated": 11214, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/ai.rs", + "tokens_estimated": 1994, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_lsp.rs", + "tokens_estimated": 9653, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/viewport.rs", + "tokens_estimated": 7848, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 5308, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/editing.rs", + "tokens_estimated": 10629, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/async_bridge.rs", + "tokens_estimated": 4589, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/editor.rs", + "tokens_estimated": 10839, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/overlays.rs", + "tokens_estimated": 18369, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ai_config.rs", + "tokens_estimated": 1206, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/mod.rs", + "tokens_estimated": 9705, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/ai.rs", + "tokens_estimated": 938, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/failure.rs", + "tokens_estimated": 1384, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands.rs", + "tokens_estimated": 10368, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_editor.rs", + "tokens_estimated": 5438, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 20186, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/mod.rs", + "tokens_estimated": 5174, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/mod.rs", + "tokens_estimated": 6944, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/statusbar.rs", + "tokens_estimated": 5546, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 25721, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 24547, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer.rs", + "tokens_estimated": 4530, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/tests.rs", + "tokens_estimated": 18328, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/message.rs", + "tokens_estimated": 7595, + "was_repeated": false, + "anatomy_had_description": false + } + ], + "writes": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/mod.rs", + "tokens_estimated": 75, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/mod.rs", + "tokens_estimated": 124, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/mod.rs", + "tokens_estimated": 98, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 43, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 472, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_lsp.rs", + "tokens_estimated": 45, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 44, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 89, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 34, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/mod.rs", + "tokens_estimated": 246, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 110, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/state.rs", + "tokens_estimated": 469, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands.rs", + "tokens_estimated": 315, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_editor.rs", + "tokens_estimated": 97, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/ai.rs", + "tokens_estimated": 1278, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/failure.rs", + "tokens_estimated": 119, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/failure.rs", + "tokens_estimated": 278, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 120, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 71, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 75, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer.rs", + "tokens_estimated": 142, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer.rs", + "tokens_estimated": 48, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/statusbar.rs", + "tokens_estimated": 50, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/statusbar.rs", + "tokens_estimated": 32, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/statusbar.rs", + "tokens_estimated": 30, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/statusbar.rs", + "tokens_estimated": 176, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 429, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/state.rs", + "tokens_estimated": 812, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ai.toml", + "tokens_estimated": 15, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 94, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 67, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/ai.rs", + "tokens_estimated": 49, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/ai.rs", + "tokens_estimated": 162, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ai_config.rs", + "tokens_estimated": 143, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/message.rs", + "tokens_estimated": 62, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ai.toml", + "tokens_estimated": 186, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands.rs", + "tokens_estimated": 166, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 457, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 143, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 179, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/mod.rs", + "tokens_estimated": 29, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 25, + "action": "edit" + } + ], + "totals": { + "input_tokens_estimated": 249168, + "output_tokens_estimated": 7698, + "reads_count": 26, + "writes_count": 42, + "repeated_reads_blocked": 22, + "anatomy_lookups": 26 + } + }, + { + "id": "session-2026-06-12-1407", + "started": "2026-06-12T07:07:26.357Z", + "ended": "2026-06-12T07:17:35.673Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/AI_INLINE_COMPLETION_STATUS_REPORT.md", + "tokens_estimated": 1600, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/ai.rs", + "tokens_estimated": 1916, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 21287, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ai.toml", + "tokens_estimated": 174, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ai_config.rs", + "tokens_estimated": 1206, + "was_repeated": false, + "anatomy_had_description": false + } + ], + "writes": [], + "totals": { + "input_tokens_estimated": 26183, + "output_tokens_estimated": 0, + "reads_count": 5, + "writes_count": 0, + "repeated_reads_blocked": 0, + "anatomy_lookups": 5 + } + }, + { + "id": "session-2026-06-12-1407", + "started": "2026-06-12T07:07:26.357Z", + "ended": "2026-06-12T07:25:20.234Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/AI_INLINE_COMPLETION_STATUS_REPORT.md", + "tokens_estimated": 1600, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/ai.rs", + "tokens_estimated": 1916, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 21287, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ai.toml", + "tokens_estimated": 174, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ai_config.rs", + "tokens_estimated": 1206, + "was_repeated": false, + "anatomy_had_description": false + } + ], + "writes": [], + "totals": { + "input_tokens_estimated": 26183, + "output_tokens_estimated": 0, + "reads_count": 5, + "writes_count": 0, + "repeated_reads_blocked": 0, + "anatomy_lookups": 5 + } + }, + { + "id": "session-2026-06-12-1407", + "started": "2026-06-12T07:07:26.357Z", + "ended": "2026-06-12T07:30:21.011Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/AI_INLINE_COMPLETION_STATUS_REPORT.md", + "tokens_estimated": 1600, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/ai.rs", + "tokens_estimated": 1916, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 21287, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ai.toml", + "tokens_estimated": 174, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ai_config.rs", + "tokens_estimated": 1206, + "was_repeated": false, + "anatomy_had_description": false + } + ], + "writes": [], + "totals": { + "input_tokens_estimated": 26183, + "output_tokens_estimated": 0, + "reads_count": 5, + "writes_count": 0, + "repeated_reads_blocked": 0, + "anatomy_lookups": 5 + } + }, + { + "id": "session-2026-06-12-1407", + "started": "2026-06-12T07:07:26.357Z", + "ended": "2026-06-12T07:57:30.101Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/AI_INLINE_COMPLETION_STATUS_REPORT.md", + "tokens_estimated": 1600, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/ai.rs", + "tokens_estimated": 1916, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 21287, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ai.toml", + "tokens_estimated": 174, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ai_config.rs", + "tokens_estimated": 1206, + "was_repeated": false, + "anatomy_had_description": false + } + ], + "writes": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ai.toml", + "tokens_estimated": 189, + "action": "edit" + } + ], + "totals": { + "input_tokens_estimated": 26183, + "output_tokens_estimated": 189, + "reads_count": 5, + "writes_count": 1, + "repeated_reads_blocked": 0, + "anatomy_lookups": 5 + } + }, + { + "id": "session-2026-06-12-1407", + "started": "2026-06-12T07:07:26.357Z", + "ended": "2026-06-12T08:07:00.022Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/AI_INLINE_COMPLETION_STATUS_REPORT.md", + "tokens_estimated": 1600, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/ai.rs", + "tokens_estimated": 1916, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 21287, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ai.toml", + "tokens_estimated": 174, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ai_config.rs", + "tokens_estimated": 1206, + "was_repeated": false, + "anatomy_had_description": false + } + ], + "writes": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ai.toml", + "tokens_estimated": 189, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ai.toml", + "tokens_estimated": 96, + "action": "edit" + } + ], + "totals": { + "input_tokens_estimated": 26183, + "output_tokens_estimated": 285, + "reads_count": 5, + "writes_count": 2, + "repeated_reads_blocked": 0, + "anatomy_lookups": 5 + } + }, + { + "id": "session-2026-06-12-1407", + "started": "2026-06-12T07:07:26.357Z", + "ended": "2026-06-12T08:23:56.415Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/AI_INLINE_COMPLETION_STATUS_REPORT.md", + "tokens_estimated": 1600, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/ai.rs", + "tokens_estimated": 1916, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 21287, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ai.toml", + "tokens_estimated": 174, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ai_config.rs", + "tokens_estimated": 1206, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 18902, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 4976, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/resolved_keymap.rs", + "tokens_estimated": 15913, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/editing.rs", + "tokens_estimated": 10629, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/ai.rs", + "tokens_estimated": 1850, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/state.rs", + "tokens_estimated": 11907, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/overlays.rs", + "tokens_estimated": 18369, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/lsp.rs", + "tokens_estimated": 9836, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/tests.rs", + "tokens_estimated": 18842, + "was_repeated": false, + "anatomy_had_description": false + } + ], + "writes": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ai.toml", + "tokens_estimated": 189, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ai.toml", + "tokens_estimated": 96, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/resolved_keymap.rs", + "tokens_estimated": 78, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 87, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/ai.rs", + "tokens_estimated": 392, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/lsp.rs", + "tokens_estimated": 157, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/ai.rs", + "tokens_estimated": 146, + "action": "edit" + } + ], + "totals": { + "input_tokens_estimated": 137407, + "output_tokens_estimated": 1145, + "reads_count": 14, + "writes_count": 7, + "repeated_reads_blocked": 4, + "anatomy_lookups": 14 + } + }, + { + "id": "session-2026-06-12-1407", + "started": "2026-06-12T07:07:26.357Z", + "ended": "2026-06-12T08:28:01.469Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/AI_INLINE_COMPLETION_STATUS_REPORT.md", + "tokens_estimated": 1600, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/ai.rs", + "tokens_estimated": 1916, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 21287, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ai.toml", + "tokens_estimated": 174, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ai_config.rs", + "tokens_estimated": 1206, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 18902, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 4976, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/resolved_keymap.rs", + "tokens_estimated": 15913, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/editing.rs", + "tokens_estimated": 10629, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/ai.rs", + "tokens_estimated": 1850, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/state.rs", + "tokens_estimated": 11907, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/overlays.rs", + "tokens_estimated": 18369, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/lsp.rs", + "tokens_estimated": 9836, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/tests.rs", + "tokens_estimated": 18842, + "was_repeated": false, + "anatomy_had_description": false + } + ], + "writes": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ai.toml", + "tokens_estimated": 189, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ai.toml", + "tokens_estimated": 96, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/resolved_keymap.rs", + "tokens_estimated": 78, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 87, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/ai.rs", + "tokens_estimated": 392, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/lsp.rs", + "tokens_estimated": 157, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/ai.rs", + "tokens_estimated": 146, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ai.toml", + "tokens_estimated": 120, + "action": "edit" + } + ], + "totals": { + "input_tokens_estimated": 137407, + "output_tokens_estimated": 1265, + "reads_count": 14, + "writes_count": 8, + "repeated_reads_blocked": 4, + "anatomy_lookups": 14 + } + }, + { + "id": "session-2026-06-12-1407", + "started": "2026-06-12T07:07:26.357Z", + "ended": "2026-06-12T08:34:56.986Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/AI_INLINE_COMPLETION_STATUS_REPORT.md", + "tokens_estimated": 1600, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/ai.rs", + "tokens_estimated": 1916, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 21287, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ai.toml", + "tokens_estimated": 174, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ai_config.rs", + "tokens_estimated": 1206, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 18902, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 4976, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/resolved_keymap.rs", + "tokens_estimated": 15913, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/editing.rs", + "tokens_estimated": 10629, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/ai.rs", + "tokens_estimated": 1850, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/state.rs", + "tokens_estimated": 11907, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/overlays.rs", + "tokens_estimated": 18369, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/lsp.rs", + "tokens_estimated": 9836, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/tests.rs", + "tokens_estimated": 18842, + "was_repeated": false, + "anatomy_had_description": false + } + ], + "writes": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ai.toml", + "tokens_estimated": 189, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ai.toml", + "tokens_estimated": 96, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/resolved_keymap.rs", + "tokens_estimated": 78, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 87, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/ai.rs", + "tokens_estimated": 392, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/lsp.rs", + "tokens_estimated": 157, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/ai.rs", + "tokens_estimated": 146, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ai.toml", + "tokens_estimated": 120, + "action": "edit" + }, + { + "file": "/tmp/codestral_ctx_test.py", + "tokens_estimated": 1020, + "action": "create" + }, + { + "file": "/tmp/sanitize_check.py", + "tokens_estimated": 490, + "action": "create" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/ai.rs", + "tokens_estimated": 164, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/ai.rs", + "tokens_estimated": 255, + "action": "edit" + } + ], + "totals": { + "input_tokens_estimated": 137407, + "output_tokens_estimated": 3194, + "reads_count": 14, + "writes_count": 12, + "repeated_reads_blocked": 4, + "anatomy_lookups": 14 + } + }, + { + "id": "session-2026-06-12-1407", + "started": "2026-06-12T07:07:26.357Z", + "ended": "2026-06-12T09:39:02.246Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/AI_INLINE_COMPLETION_STATUS_REPORT.md", + "tokens_estimated": 1600, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/ai.rs", + "tokens_estimated": 1916, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 21287, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ai.toml", + "tokens_estimated": 174, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ai_config.rs", + "tokens_estimated": 1206, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 18902, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 4976, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/resolved_keymap.rs", + "tokens_estimated": 15913, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/editing.rs", + "tokens_estimated": 10629, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/ai.rs", + "tokens_estimated": 2259, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/state.rs", + "tokens_estimated": 11907, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/overlays.rs", + "tokens_estimated": 18369, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/lsp.rs", + "tokens_estimated": 9836, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/tests.rs", + "tokens_estimated": 18842, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_lsp.rs", + "tokens_estimated": 9018, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/viewport.rs", + "tokens_estimated": 7848, + "was_repeated": false, + "anatomy_had_description": false + } + ], + "writes": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ai.toml", + "tokens_estimated": 189, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ai.toml", + "tokens_estimated": 96, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/resolved_keymap.rs", + "tokens_estimated": 78, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 87, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/ai.rs", + "tokens_estimated": 392, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/lsp.rs", + "tokens_estimated": 157, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/ai.rs", + "tokens_estimated": 146, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ai.toml", + "tokens_estimated": 120, + "action": "edit" + }, + { + "file": "/tmp/codestral_ctx_test.py", + "tokens_estimated": 1020, + "action": "create" + }, + { + "file": "/tmp/sanitize_check.py", + "tokens_estimated": 490, + "action": "create" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/ai.rs", + "tokens_estimated": 164, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/ai.rs", + "tokens_estimated": 255, + "action": "edit" + }, + { + "file": "/tmp/multiline_test.py", + "tokens_estimated": 588, + "action": "create" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/state.rs", + "tokens_estimated": 324, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/ai.rs", + "tokens_estimated": 248, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/ai.rs", + "tokens_estimated": 374, + "action": "edit" + } + ], + "totals": { + "input_tokens_estimated": 154682, + "output_tokens_estimated": 4728, + "reads_count": 16, + "writes_count": 16, + "repeated_reads_blocked": 8, + "anatomy_lookups": 16 + } + }, + { + "id": "session-2026-06-12-1648", + "started": "2026-06-12T09:48:42.430Z", + "ended": "2026-06-12T10:02:29.782Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 21287, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 9203, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/helpers.rs", + "tokens_estimated": 18582, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/welcome.rs", + "tokens_estimated": 9806, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 25721, + "was_repeated": false, + "anatomy_had_description": false + } + ], + "writes": [], + "totals": { + "input_tokens_estimated": 84599, + "output_tokens_estimated": 0, + "reads_count": 5, + "writes_count": 0, + "repeated_reads_blocked": 1, + "anatomy_lookups": 5 + } + }, + { + "id": "session-2026-06-12-1648", + "started": "2026-06-12T09:48:42.430Z", + "ended": "2026-06-12T10:42:24.853Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 21287, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 9203, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/helpers.rs", + "tokens_estimated": 18582, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/welcome.rs", + "tokens_estimated": 9806, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 25721, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/paths.rs", + "tokens_estimated": 205, + "was_repeated": false, + "anatomy_had_description": false + } + ], + "writes": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 32, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 40, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 75, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 41, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 33, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 44, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 64, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 87, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 165, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 109, + "action": "edit" + } + ], + "totals": { + "input_tokens_estimated": 84804, + "output_tokens_estimated": 690, + "reads_count": 6, + "writes_count": 10, + "repeated_reads_blocked": 6, + "anatomy_lookups": 6 + } + }, + { + "id": "session-2026-06-12-1648", + "started": "2026-06-12T09:48:42.430Z", + "ended": "2026-06-12T10:42:41.821Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 21287, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 9203, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/helpers.rs", + "tokens_estimated": 18582, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/welcome.rs", + "tokens_estimated": 9806, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 25721, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/paths.rs", + "tokens_estimated": 205, + "was_repeated": false, + "anatomy_had_description": false + } + ], + "writes": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 32, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 40, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 75, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 41, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 33, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 44, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 64, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 87, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 165, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 109, + "action": "edit" + } + ], + "totals": { + "input_tokens_estimated": 84804, + "output_tokens_estimated": 690, + "reads_count": 6, + "writes_count": 10, + "repeated_reads_blocked": 6, + "anatomy_lookups": 6 + } + }, + { + "id": "session-2026-06-12-1648", + "started": "2026-06-12T09:48:42.430Z", + "ended": "2026-06-12T10:52:34.842Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 21287, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 9203, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/helpers.rs", + "tokens_estimated": 18582, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/welcome.rs", + "tokens_estimated": 9806, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 25721, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/paths.rs", + "tokens_estimated": 205, + "was_repeated": false, + "anatomy_had_description": false + } + ], + "writes": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 32, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 40, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 75, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 41, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 33, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 44, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 64, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 87, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 165, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 109, + "action": "edit" + } + ], + "totals": { + "input_tokens_estimated": 84804, + "output_tokens_estimated": 690, + "reads_count": 6, + "writes_count": 10, + "repeated_reads_blocked": 6, + "anatomy_lookups": 6 + } + }, + { + "id": "session-2026-06-12-1648", + "started": "2026-06-12T09:48:42.430Z", + "ended": "2026-06-12T11:03:24.666Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 21287, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 9203, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/helpers.rs", + "tokens_estimated": 18582, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/welcome.rs", + "tokens_estimated": 9806, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 25721, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/paths.rs", + "tokens_estimated": 205, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 9794, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ui/default.toml", + "tokens_estimated": 377, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/.config/netherize/ui.toml", + "tokens_estimated": 0, + "was_repeated": false, + "anatomy_had_description": false + } + ], + "writes": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 32, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 40, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 75, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 41, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 33, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 44, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 64, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 87, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 165, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 109, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/welcome.rs", + "tokens_estimated": 98, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ui/default.toml", + "tokens_estimated": 109, + "action": "edit" + }, + { + "file": "/Users/qc-bright/.config/netherize/ui.toml", + "tokens_estimated": 6, + "action": "edit" + } + ], + "totals": { + "input_tokens_estimated": 94975, + "output_tokens_estimated": 903, + "reads_count": 9, + "writes_count": 13, + "repeated_reads_blocked": 7, + "anatomy_lookups": 8 + } + }, + { + "id": "session-2026-06-12-1648", + "started": "2026-06-12T09:48:42.430Z", + "ended": "2026-06-12T11:15:14.169Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 21287, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 9203, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/helpers.rs", + "tokens_estimated": 18582, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/welcome.rs", + "tokens_estimated": 9806, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 25823, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/paths.rs", + "tokens_estimated": 205, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 9794, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ui/default.toml", + "tokens_estimated": 377, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/.config/netherize/ui.toml", + "tokens_estimated": 0, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings.rs", + "tokens_estimated": 4692, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 18910, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_ids.rs", + "tokens_estimated": 9872, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 22966, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/system.rs", + "tokens_estimated": 1996, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 7416, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 4976, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/commands.rs", + "tokens_estimated": 5569, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/resolved_keymap.rs", + "tokens_estimated": 14907, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/focus.rs", + "tokens_estimated": 11494, + "was_repeated": true, + "anatomy_had_description": false + } + ], + "writes": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 32, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 40, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 75, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 41, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 33, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 44, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 64, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 87, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 165, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 109, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/welcome.rs", + "tokens_estimated": 98, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ui/default.toml", + "tokens_estimated": 109, + "action": "edit" + }, + { + "file": "/Users/qc-bright/.config/netherize/ui.toml", + "tokens_estimated": 6, + "action": "edit" + } + ], + "totals": { + "input_tokens_estimated": 197875, + "output_tokens_estimated": 903, + "reads_count": 19, + "writes_count": 13, + "repeated_reads_blocked": 30, + "anatomy_lookups": 18 + } + }, + { + "id": "session-2026-06-12-1648", + "started": "2026-06-12T09:48:42.430Z", + "ended": "2026-06-12T12:48:46.635Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 21287, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 9203, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/helpers.rs", + "tokens_estimated": 18582, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/welcome.rs", + "tokens_estimated": 9806, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 25823, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/paths.rs", + "tokens_estimated": 205, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 9794, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ui/default.toml", + "tokens_estimated": 377, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/.config/netherize/ui.toml", + "tokens_estimated": 0, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings.rs", + "tokens_estimated": 4664, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 18910, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_ids.rs", + "tokens_estimated": 9872, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 22966, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/system.rs", + "tokens_estimated": 1996, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 7416, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 4976, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/commands.rs", + "tokens_estimated": 5569, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/resolved_keymap.rs", + "tokens_estimated": 14907, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/focus.rs", + "tokens_estimated": 11494, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/palette.rs", + "tokens_estimated": 6937, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer.rs", + "tokens_estimated": 4336, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "tokens_estimated": 6532, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/help.rs", + "tokens_estimated": 4176, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 18902, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/mod.rs", + "tokens_estimated": 4904, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/components/help_keycaps.rs", + "tokens_estimated": 2003, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/workbench/overlay_manager.rs", + "tokens_estimated": 2247, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands.rs", + "tokens_estimated": 9996, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_palette.rs", + "tokens_estimated": 7900, + "was_repeated": false, + "anatomy_had_description": false + } + ], + "writes": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 32, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 40, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 75, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 41, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 33, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 44, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 64, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 87, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 165, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 109, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/welcome.rs", + "tokens_estimated": 98, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ui/default.toml", + "tokens_estimated": 109, + "action": "edit" + }, + { + "file": "/Users/qc-bright/.config/netherize/ui.toml", + "tokens_estimated": 6, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer.rs", + "tokens_estimated": 76, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "tokens_estimated": 27, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "tokens_estimated": 44, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 63, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 202, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 33, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 53, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 74, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 177, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 108, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 149, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 87, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 38, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 38, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 39, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 279, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 19, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 146, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 52, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 92, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 28, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 222, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 32, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 32, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 58, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 64, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 169, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 64, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 29, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 17, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 144, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 172, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 36, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 270, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 17, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 41, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 59, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 149, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 379, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 55, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 50, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 45, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 52, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 50, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 245, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 144, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 152, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 160, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 174, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings.rs", + "tokens_estimated": 223, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings.rs", + "tokens_estimated": 251, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings.rs", + "tokens_estimated": 292, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 73, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 112, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 193, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 366, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 242, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/system.rs", + "tokens_estimated": 179, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/system.rs", + "tokens_estimated": 38, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/system.rs", + "tokens_estimated": 40, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/system.rs", + "tokens_estimated": 44, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 145, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 45, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 25, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 27, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 27, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 28, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 39, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 27, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 26, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 30, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 30, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 27, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 168, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 150, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 37, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 85, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 38, + "action": "edit" + } + ], + "totals": { + "input_tokens_estimated": 265780, + "output_tokens_estimated": 8815, + "reads_count": 29, + "writes_count": 91, + "repeated_reads_blocked": 57, + "anatomy_lookups": 28 + } + }, + { + "id": "session-2026-06-12-1648", + "started": "2026-06-12T09:48:42.430Z", + "ended": "2026-06-12T12:49:06.279Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 21287, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 9203, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/helpers.rs", + "tokens_estimated": 18582, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/welcome.rs", + "tokens_estimated": 9806, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 25823, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/paths.rs", + "tokens_estimated": 205, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 9794, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ui/default.toml", + "tokens_estimated": 377, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/.config/netherize/ui.toml", + "tokens_estimated": 0, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings.rs", + "tokens_estimated": 4664, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 18910, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_ids.rs", + "tokens_estimated": 9872, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 22966, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/system.rs", + "tokens_estimated": 1996, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 7416, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 4976, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/commands.rs", + "tokens_estimated": 5569, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/resolved_keymap.rs", + "tokens_estimated": 14907, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/focus.rs", + "tokens_estimated": 11494, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/palette.rs", + "tokens_estimated": 6937, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer.rs", + "tokens_estimated": 4336, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "tokens_estimated": 6532, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/help.rs", + "tokens_estimated": 4176, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 18902, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/mod.rs", + "tokens_estimated": 4904, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/components/help_keycaps.rs", + "tokens_estimated": 2003, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/workbench/overlay_manager.rs", + "tokens_estimated": 2247, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands.rs", + "tokens_estimated": 9996, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_palette.rs", + "tokens_estimated": 7900, + "was_repeated": false, + "anatomy_had_description": false + } + ], + "writes": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 32, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 40, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 75, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 41, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 33, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 44, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 64, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 87, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 165, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 109, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/welcome.rs", + "tokens_estimated": 98, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ui/default.toml", + "tokens_estimated": 109, + "action": "edit" + }, + { + "file": "/Users/qc-bright/.config/netherize/ui.toml", + "tokens_estimated": 6, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer.rs", + "tokens_estimated": 76, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "tokens_estimated": 27, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "tokens_estimated": 44, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 63, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 202, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 33, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 53, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 74, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 177, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 108, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 149, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 87, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 38, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 38, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 39, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 279, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 19, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 146, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 52, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 92, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 28, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 222, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 32, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 32, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 58, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 64, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 169, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 64, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 29, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 17, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 144, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 172, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 36, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 270, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 17, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 41, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 59, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 149, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 379, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 55, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 50, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 45, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 52, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 50, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 245, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 144, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 152, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 160, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 174, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings.rs", + "tokens_estimated": 223, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings.rs", + "tokens_estimated": 251, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings.rs", + "tokens_estimated": 292, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 73, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 112, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 193, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 366, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 242, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/system.rs", + "tokens_estimated": 179, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/system.rs", + "tokens_estimated": 38, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/system.rs", + "tokens_estimated": 40, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/system.rs", + "tokens_estimated": 44, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 145, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 45, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 25, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 27, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 27, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 28, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 39, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 27, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 26, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 30, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 30, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 27, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 168, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 150, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 37, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 85, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 38, + "action": "edit" + } + ], + "totals": { + "input_tokens_estimated": 265780, + "output_tokens_estimated": 8815, + "reads_count": 29, + "writes_count": 91, + "repeated_reads_blocked": 57, + "anatomy_lookups": 28 + } + }, + { + "id": "session-2026-06-12-1648", + "started": "2026-06-12T09:48:42.430Z", + "ended": "2026-06-12T12:57:21.203Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 21287, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 9203, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/helpers.rs", + "tokens_estimated": 18582, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/welcome.rs", + "tokens_estimated": 9806, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 25823, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/paths.rs", + "tokens_estimated": 205, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 9794, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ui/default.toml", + "tokens_estimated": 377, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/.config/netherize/ui.toml", + "tokens_estimated": 0, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings.rs", + "tokens_estimated": 4664, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 18910, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_ids.rs", + "tokens_estimated": 9872, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 22966, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/system.rs", + "tokens_estimated": 1996, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 7416, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 5011, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/commands.rs", + "tokens_estimated": 5569, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/resolved_keymap.rs", + "tokens_estimated": 14907, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/focus.rs", + "tokens_estimated": 11494, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/palette.rs", + "tokens_estimated": 6937, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer.rs", + "tokens_estimated": 4336, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "tokens_estimated": 6532, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/help.rs", + "tokens_estimated": 4176, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 18902, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/mod.rs", + "tokens_estimated": 4904, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/components/help_keycaps.rs", + "tokens_estimated": 2003, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/workbench/overlay_manager.rs", + "tokens_estimated": 2247, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands.rs", + "tokens_estimated": 9996, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_palette.rs", + "tokens_estimated": 7900, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/keymap_loader.rs", + "tokens_estimated": 1675, + "was_repeated": false, + "anatomy_had_description": false + } + ], + "writes": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 32, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 40, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 75, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 41, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 33, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 44, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 64, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 87, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 165, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 109, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/welcome.rs", + "tokens_estimated": 98, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ui/default.toml", + "tokens_estimated": 109, + "action": "edit" + }, + { + "file": "/Users/qc-bright/.config/netherize/ui.toml", + "tokens_estimated": 6, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer.rs", + "tokens_estimated": 76, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "tokens_estimated": 27, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "tokens_estimated": 44, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 63, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 202, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 33, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 53, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 74, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 177, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 108, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 149, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 87, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 38, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 38, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 39, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 279, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 19, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 146, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 52, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 92, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 28, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 222, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 32, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 32, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 58, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 64, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 169, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 64, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 29, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 17, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 144, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 172, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 36, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 270, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 17, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 41, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 59, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 149, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 379, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 55, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 50, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 45, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 52, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 50, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 245, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 144, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 152, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 160, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 174, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings.rs", + "tokens_estimated": 223, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings.rs", + "tokens_estimated": 251, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings.rs", + "tokens_estimated": 292, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 73, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 112, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 193, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 366, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 242, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/system.rs", + "tokens_estimated": 179, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/system.rs", + "tokens_estimated": 38, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/system.rs", + "tokens_estimated": 40, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/system.rs", + "tokens_estimated": 44, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 145, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 45, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 25, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 27, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 27, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 28, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 39, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 27, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 26, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 30, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 30, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 27, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 168, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 150, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 37, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 85, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 38, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_ids.rs", + "tokens_estimated": 191, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 22, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/keymap_loader.rs", + "tokens_estimated": 229, + "action": "edit" + } + ], + "totals": { + "input_tokens_estimated": 267490, + "output_tokens_estimated": 9257, + "reads_count": 30, + "writes_count": 94, + "repeated_reads_blocked": 61, + "anatomy_lookups": 29 + } + }, + { + "id": "session-2026-06-12-1648", + "started": "2026-06-12T09:48:42.430Z", + "ended": "2026-06-12T12:59:31.641Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 21287, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 9203, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/helpers.rs", + "tokens_estimated": 18582, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/welcome.rs", + "tokens_estimated": 9806, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 25823, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/paths.rs", + "tokens_estimated": 205, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 9794, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ui/default.toml", + "tokens_estimated": 377, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/.config/netherize/ui.toml", + "tokens_estimated": 0, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings.rs", + "tokens_estimated": 4664, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 18910, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_ids.rs", + "tokens_estimated": 9872, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 22966, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/system.rs", + "tokens_estimated": 1996, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 7416, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 5011, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/commands.rs", + "tokens_estimated": 5569, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/resolved_keymap.rs", + "tokens_estimated": 14907, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/focus.rs", + "tokens_estimated": 11494, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/palette.rs", + "tokens_estimated": 6937, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer.rs", + "tokens_estimated": 4336, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "tokens_estimated": 6532, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/help.rs", + "tokens_estimated": 4176, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 18902, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/mod.rs", + "tokens_estimated": 4904, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/components/help_keycaps.rs", + "tokens_estimated": 2003, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/workbench/overlay_manager.rs", + "tokens_estimated": 2247, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands.rs", + "tokens_estimated": 9996, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_palette.rs", + "tokens_estimated": 7900, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/keymap_loader.rs", + "tokens_estimated": 1675, + "was_repeated": false, + "anatomy_had_description": false + } + ], + "writes": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 32, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 40, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 75, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 41, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 33, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 44, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 64, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 87, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 165, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 109, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/welcome.rs", + "tokens_estimated": 98, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ui/default.toml", + "tokens_estimated": 109, + "action": "edit" + }, + { + "file": "/Users/qc-bright/.config/netherize/ui.toml", + "tokens_estimated": 6, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer.rs", + "tokens_estimated": 76, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "tokens_estimated": 27, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "tokens_estimated": 44, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 63, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 202, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 33, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 53, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 74, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 177, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 108, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 149, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 87, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 38, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 38, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 39, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 279, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 19, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 146, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 52, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 92, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 28, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 222, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 32, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 32, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 58, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 64, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 169, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 64, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 29, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 17, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 144, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 172, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 36, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 270, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 17, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 41, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 59, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 149, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 379, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 55, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 50, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 45, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 52, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 50, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 245, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 144, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 152, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 160, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 174, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings.rs", + "tokens_estimated": 223, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings.rs", + "tokens_estimated": 251, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings.rs", + "tokens_estimated": 292, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 73, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 112, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 193, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 366, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 242, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/system.rs", + "tokens_estimated": 179, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/system.rs", + "tokens_estimated": 38, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/system.rs", + "tokens_estimated": 40, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/system.rs", + "tokens_estimated": 44, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 145, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 45, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 25, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 27, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 27, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 28, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 39, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 27, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 26, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 30, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 30, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 27, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 168, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 150, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 37, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 85, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 38, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_ids.rs", + "tokens_estimated": 191, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 22, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/keymap_loader.rs", + "tokens_estimated": 229, + "action": "edit" + } + ], + "totals": { + "input_tokens_estimated": 267490, + "output_tokens_estimated": 9257, + "reads_count": 30, + "writes_count": 94, + "repeated_reads_blocked": 61, + "anatomy_lookups": 29 + } + }, + { + "id": "session-2026-06-12-1648", + "started": "2026-06-12T09:48:42.430Z", + "ended": "2026-06-12T13:18:12.983Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 21287, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 9203, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/helpers.rs", + "tokens_estimated": 18582, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/welcome.rs", + "tokens_estimated": 9806, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 25823, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/paths.rs", + "tokens_estimated": 205, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 9794, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ui/default.toml", + "tokens_estimated": 377, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/.config/netherize/ui.toml", + "tokens_estimated": 0, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings.rs", + "tokens_estimated": 4664, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 17777, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_ids.rs", + "tokens_estimated": 9872, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 22966, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/system.rs", + "tokens_estimated": 1996, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 7416, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 5011, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/commands.rs", + "tokens_estimated": 5569, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/resolved_keymap.rs", + "tokens_estimated": 14907, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/focus.rs", + "tokens_estimated": 11494, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/palette.rs", + "tokens_estimated": 6937, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer.rs", + "tokens_estimated": 4336, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "tokens_estimated": 6532, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/help.rs", + "tokens_estimated": 4176, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 18902, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/mod.rs", + "tokens_estimated": 4904, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/components/help_keycaps.rs", + "tokens_estimated": 2003, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/workbench/overlay_manager.rs", + "tokens_estimated": 2247, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands.rs", + "tokens_estimated": 9996, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_palette.rs", + "tokens_estimated": 7900, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/keymap_loader.rs", + "tokens_estimated": 1675, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 10698, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/settings.rs", + "tokens_estimated": 1971, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings_helpers.rs", + "tokens_estimated": 6809, + "was_repeated": true, + "anatomy_had_description": false + } + ], + "writes": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 32, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 40, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 75, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 41, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 33, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 44, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 64, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 87, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 165, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 109, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/welcome.rs", + "tokens_estimated": 98, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ui/default.toml", + "tokens_estimated": 109, + "action": "edit" + }, + { + "file": "/Users/qc-bright/.config/netherize/ui.toml", + "tokens_estimated": 6, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer.rs", + "tokens_estimated": 76, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "tokens_estimated": 27, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "tokens_estimated": 44, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 63, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 202, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 33, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 53, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 74, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 177, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 108, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 149, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 87, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 38, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 38, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 39, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 279, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 19, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 146, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 52, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 92, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 28, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 222, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 32, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 32, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 58, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 64, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 169, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 64, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 29, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 17, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 144, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 172, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 36, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 270, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 17, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 41, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 59, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 149, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 379, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 55, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 50, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 45, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 52, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 50, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 245, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 144, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 152, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 160, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 174, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings.rs", + "tokens_estimated": 223, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings.rs", + "tokens_estimated": 251, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings.rs", + "tokens_estimated": 292, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 73, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 112, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 193, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 366, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 242, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/system.rs", + "tokens_estimated": 179, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/system.rs", + "tokens_estimated": 38, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/system.rs", + "tokens_estimated": 40, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/system.rs", + "tokens_estimated": 44, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 145, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 45, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 25, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 27, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 27, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 28, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 39, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 27, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 26, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 30, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 30, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 27, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 168, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 150, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 37, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 85, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 38, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_ids.rs", + "tokens_estimated": 191, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 22, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/keymap_loader.rs", + "tokens_estimated": 229, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 420, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 410, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 581, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 378, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 201, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 121, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 173, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 371, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 763, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 148, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 68, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 225, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 77, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 854, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 1090, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 47, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 22, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/settings.rs", + "tokens_estimated": 181, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/settings.rs", + "tokens_estimated": 52, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/settings.rs", + "tokens_estimated": 65, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/settings.rs", + "tokens_estimated": 227, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/settings.rs", + "tokens_estimated": 156, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 53, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 104, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 130, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 123, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 90, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 94, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings.rs", + "tokens_estimated": 89, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings_helpers.rs", + "tokens_estimated": 180, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings_helpers.rs", + "tokens_estimated": 389, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings_helpers.rs", + "tokens_estimated": 180, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings_helpers.rs", + "tokens_estimated": 496, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 220, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 89, + "action": "edit" + } + ], + "totals": { + "input_tokens_estimated": 285835, + "output_tokens_estimated": 18124, + "reads_count": 33, + "writes_count": 129, + "repeated_reads_blocked": 71, + "anatomy_lookups": 32 + } + }, + { + "id": "session-2026-06-12-1648", + "started": "2026-06-12T09:48:42.430Z", + "ended": "2026-06-12T13:20:53.993Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 21287, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 9203, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/helpers.rs", + "tokens_estimated": 18582, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/welcome.rs", + "tokens_estimated": 9806, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 25823, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/paths.rs", + "tokens_estimated": 205, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 9794, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ui/default.toml", + "tokens_estimated": 377, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/.config/netherize/ui.toml", + "tokens_estimated": 0, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings.rs", + "tokens_estimated": 4664, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 17777, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_ids.rs", + "tokens_estimated": 9872, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 22966, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/system.rs", + "tokens_estimated": 1996, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 7416, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 5011, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/commands.rs", + "tokens_estimated": 5569, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/resolved_keymap.rs", + "tokens_estimated": 14907, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/focus.rs", + "tokens_estimated": 11494, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/palette.rs", + "tokens_estimated": 6937, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer.rs", + "tokens_estimated": 4336, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "tokens_estimated": 6532, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/help.rs", + "tokens_estimated": 4176, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 18902, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/mod.rs", + "tokens_estimated": 4904, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/components/help_keycaps.rs", + "tokens_estimated": 2003, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/workbench/overlay_manager.rs", + "tokens_estimated": 2247, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands.rs", + "tokens_estimated": 9996, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_palette.rs", + "tokens_estimated": 7900, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/keymap_loader.rs", + "tokens_estimated": 1675, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 10698, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/settings.rs", + "tokens_estimated": 1971, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings_helpers.rs", + "tokens_estimated": 6809, + "was_repeated": true, + "anatomy_had_description": false + } + ], + "writes": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 32, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 40, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 75, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 41, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 33, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 44, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 64, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 87, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 165, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 109, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/welcome.rs", + "tokens_estimated": 98, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ui/default.toml", + "tokens_estimated": 109, + "action": "edit" + }, + { + "file": "/Users/qc-bright/.config/netherize/ui.toml", + "tokens_estimated": 6, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer.rs", + "tokens_estimated": 76, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "tokens_estimated": 27, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "tokens_estimated": 44, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 63, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 202, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 33, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 53, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 74, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 177, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 108, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 149, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 87, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 38, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 38, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 39, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 279, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 19, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 146, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 52, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 92, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 28, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 222, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 32, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 32, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 58, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 64, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 169, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 64, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 29, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 17, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 144, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 172, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 36, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 270, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 17, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 41, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 59, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 149, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 379, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 55, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 50, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 45, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 52, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 50, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 245, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 144, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 152, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 160, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 174, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings.rs", + "tokens_estimated": 223, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings.rs", + "tokens_estimated": 251, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings.rs", + "tokens_estimated": 292, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 73, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 112, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 193, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 366, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 242, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/system.rs", + "tokens_estimated": 179, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/system.rs", + "tokens_estimated": 38, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/system.rs", + "tokens_estimated": 40, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/system.rs", + "tokens_estimated": 44, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 145, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 45, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 25, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 27, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 27, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 28, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 39, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 27, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 26, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 30, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 30, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 27, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 168, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 150, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 37, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 85, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 38, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_ids.rs", + "tokens_estimated": 191, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 22, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/keymap_loader.rs", + "tokens_estimated": 229, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 420, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 410, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 581, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 378, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 201, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 121, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 173, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 371, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 763, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 148, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 68, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 225, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 77, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 854, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 1090, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 47, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 22, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/settings.rs", + "tokens_estimated": 181, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/settings.rs", + "tokens_estimated": 52, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/settings.rs", + "tokens_estimated": 65, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/settings.rs", + "tokens_estimated": 227, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/settings.rs", + "tokens_estimated": 156, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 53, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 104, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 130, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 123, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 90, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 94, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings.rs", + "tokens_estimated": 89, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings_helpers.rs", + "tokens_estimated": 180, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings_helpers.rs", + "tokens_estimated": 389, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings_helpers.rs", + "tokens_estimated": 180, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings_helpers.rs", + "tokens_estimated": 496, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 220, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 89, + "action": "edit" + } + ], + "totals": { + "input_tokens_estimated": 285835, + "output_tokens_estimated": 18124, + "reads_count": 33, + "writes_count": 129, + "repeated_reads_blocked": 71, + "anatomy_lookups": 32 + } + }, + { + "id": "session-2026-06-12-1648", + "started": "2026-06-12T09:48:42.430Z", + "ended": "2026-06-12T13:23:03.747Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 21287, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 9203, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/helpers.rs", + "tokens_estimated": 18582, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/welcome.rs", + "tokens_estimated": 9806, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 25823, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/paths.rs", + "tokens_estimated": 205, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 9794, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ui/default.toml", + "tokens_estimated": 377, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/.config/netherize/ui.toml", + "tokens_estimated": 0, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings.rs", + "tokens_estimated": 4664, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 17777, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_ids.rs", + "tokens_estimated": 9872, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 22966, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/system.rs", + "tokens_estimated": 1996, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 7416, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 5011, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/commands.rs", + "tokens_estimated": 5569, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/resolved_keymap.rs", + "tokens_estimated": 14907, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/focus.rs", + "tokens_estimated": 11494, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/palette.rs", + "tokens_estimated": 6937, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer.rs", + "tokens_estimated": 4336, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "tokens_estimated": 6532, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/help.rs", + "tokens_estimated": 4176, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 18902, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/mod.rs", + "tokens_estimated": 4904, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/components/help_keycaps.rs", + "tokens_estimated": 2003, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/workbench/overlay_manager.rs", + "tokens_estimated": 2247, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands.rs", + "tokens_estimated": 9996, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_palette.rs", + "tokens_estimated": 7900, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/keymap_loader.rs", + "tokens_estimated": 1675, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 10698, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/settings.rs", + "tokens_estimated": 1971, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings_helpers.rs", + "tokens_estimated": 6809, + "was_repeated": true, + "anatomy_had_description": false + } + ], + "writes": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 32, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 40, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 75, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 41, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 33, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 44, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 64, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 87, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 165, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/ui_config.rs", + "tokens_estimated": 109, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/welcome.rs", + "tokens_estimated": 98, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ui/default.toml", + "tokens_estimated": 109, + "action": "edit" + }, + { + "file": "/Users/qc-bright/.config/netherize/ui.toml", + "tokens_estimated": 6, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer.rs", + "tokens_estimated": 76, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "tokens_estimated": 27, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "tokens_estimated": 44, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 63, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 202, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 33, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 53, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 74, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 177, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 108, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 149, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 87, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 38, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 38, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 39, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 279, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 19, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 146, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 52, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 92, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 28, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 222, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 32, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 32, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 58, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 64, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 169, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 64, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 29, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 17, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 144, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 172, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 36, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 270, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 17, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 41, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 59, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 149, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 379, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 55, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 50, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 45, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 52, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 50, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 245, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 144, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 152, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 160, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 174, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings.rs", + "tokens_estimated": 223, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings.rs", + "tokens_estimated": 251, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings.rs", + "tokens_estimated": 292, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 73, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 112, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 193, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 366, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 242, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/system.rs", + "tokens_estimated": 179, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/system.rs", + "tokens_estimated": 38, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/system.rs", + "tokens_estimated": 40, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/system.rs", + "tokens_estimated": 44, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 145, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 45, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 25, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 27, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 27, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 28, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 39, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 27, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 26, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 30, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 30, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 27, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 168, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 150, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 37, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 85, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 38, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_ids.rs", + "tokens_estimated": 191, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 22, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/config/keymap_loader.rs", + "tokens_estimated": 229, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 420, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 410, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 581, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 378, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 201, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 121, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 173, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 371, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 763, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 148, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 68, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 225, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 77, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 854, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 1090, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 47, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 22, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/settings.rs", + "tokens_estimated": 181, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/settings.rs", + "tokens_estimated": 52, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/settings.rs", + "tokens_estimated": 65, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/settings.rs", + "tokens_estimated": 227, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/settings.rs", + "tokens_estimated": 156, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 53, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 104, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 130, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 123, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 90, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 94, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings.rs", + "tokens_estimated": 89, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings_helpers.rs", + "tokens_estimated": 180, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings_helpers.rs", + "tokens_estimated": 389, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings_helpers.rs", + "tokens_estimated": 180, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings_helpers.rs", + "tokens_estimated": 496, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 220, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/settings.rs", + "tokens_estimated": 89, + "action": "edit" + }, + { + "file": "/Users/qc-bright/.claude/projects/-Users-qc-bright-Project-netherize-editor/memory/onboarding-roadmap.md", + "tokens_estimated": 370, + "action": "create" + } + ], + "totals": { + "input_tokens_estimated": 285835, + "output_tokens_estimated": 18494, + "reads_count": 33, + "writes_count": 130, + "repeated_reads_blocked": 71, + "anatomy_lookups": 32 + } + }, + { + "id": "session-2026-06-12-2026", + "started": "2026-06-12T13:26:14.048Z", + "ended": "2026-06-12T13:47:17.989Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 5009, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/pending.rs", + "tokens_estimated": 2421, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 18902, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/commands.rs", + "tokens_estimated": 5569, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_ids.rs", + "tokens_estimated": 9356, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/state.rs", + "tokens_estimated": 12191, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/editor.rs", + "tokens_estimated": 10839, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/navigation.rs", + "tokens_estimated": 3287, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/mod.rs", + "tokens_estimated": 4642, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/helpers.rs", + "tokens_estimated": 4366, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/overlays.rs", + "tokens_estimated": 18369, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 23228, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/tests.rs", + "tokens_estimated": 18328, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/tests.rs", + "tokens_estimated": 18842, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/private/tmp/claude-501/-Users-qc-bright-Project-netherize-editor/3c465aaa-2ee8-45b4-942b-b0b7c0ade28e/tasks/bw8ji1c2v.output", + "tokens_estimated": 0, + "was_repeated": false, + "anatomy_had_description": false + } + ], + "writes": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/commands.rs", + "tokens_estimated": 73, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/commands.rs", + "tokens_estimated": 81, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_ids.rs", + "tokens_estimated": 51, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_ids.rs", + "tokens_estimated": 25, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_ids.rs", + "tokens_estimated": 105, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/editor.rs", + "tokens_estimated": 442, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/state.rs", + "tokens_estimated": 191, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/mod.rs", + "tokens_estimated": 41, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/navigation.rs", + "tokens_estimated": 148, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/pending.rs", + "tokens_estimated": 84, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/pending.rs", + "tokens_estimated": 65, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/pending.rs", + "tokens_estimated": 124, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/navigation.rs", + "tokens_estimated": 18, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 443, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 415, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 188, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 62, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 126, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 46, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/tests.rs", + "tokens_estimated": 865, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/tests.rs", + "tokens_estimated": 91, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/tests.rs", + "tokens_estimated": 68, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/tests.rs", + "tokens_estimated": 531, + "action": "edit" + } + ], + "totals": { + "input_tokens_estimated": 155349, + "output_tokens_estimated": 4283, + "reads_count": 15, + "writes_count": 23, + "repeated_reads_blocked": 4, + "anatomy_lookups": 14 + } + }, + { + "id": "session-2026-06-12-2026", + "started": "2026-06-12T13:26:14.048Z", + "ended": "2026-06-12T14:03:13.961Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 5009, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/pending.rs", + "tokens_estimated": 2421, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 18902, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/commands.rs", + "tokens_estimated": 5569, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_ids.rs", + "tokens_estimated": 9356, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/state.rs", + "tokens_estimated": 12339, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/editor.rs", + "tokens_estimated": 10839, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/navigation.rs", + "tokens_estimated": 3196, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/mod.rs", + "tokens_estimated": 4642, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/helpers.rs", + "tokens_estimated": 4366, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/overlays.rs", + "tokens_estimated": 18369, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 23243, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/tests.rs", + "tokens_estimated": 18491, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/tests.rs", + "tokens_estimated": 18842, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/private/tmp/claude-501/-Users-qc-bright-Project-netherize-editor/3c465aaa-2ee8-45b4-942b-b0b7c0ade28e/tasks/bw8ji1c2v.output", + "tokens_estimated": 0, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 17796, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/palette.rs", + "tokens_estimated": 6937, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_lsp.rs", + "tokens_estimated": 9018, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_editor.rs", + "tokens_estimated": 5099, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_explorer.rs", + "tokens_estimated": 7819, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/common.rs", + "tokens_estimated": 1739, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/private/tmp/claude-501/-Users-qc-bright-Project-netherize-editor/3c465aaa-2ee8-45b4-942b-b0b7c0ade28e/tasks/by5acamfs.output", + "tokens_estimated": 0, + "was_repeated": false, + "anatomy_had_description": false + } + ], + "writes": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/commands.rs", + "tokens_estimated": 73, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/commands.rs", + "tokens_estimated": 81, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_ids.rs", + "tokens_estimated": 51, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_ids.rs", + "tokens_estimated": 25, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_ids.rs", + "tokens_estimated": 105, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/editor.rs", + "tokens_estimated": 442, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/state.rs", + "tokens_estimated": 191, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/mod.rs", + "tokens_estimated": 41, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/navigation.rs", + "tokens_estimated": 148, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/pending.rs", + "tokens_estimated": 84, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/pending.rs", + "tokens_estimated": 65, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/pending.rs", + "tokens_estimated": 124, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/navigation.rs", + "tokens_estimated": 18, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 443, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 415, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 188, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 62, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 126, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 46, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/tests.rs", + "tokens_estimated": 865, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/tests.rs", + "tokens_estimated": 91, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/tests.rs", + "tokens_estimated": 68, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/tests.rs", + "tokens_estimated": 531, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/palette.rs", + "tokens_estimated": 333, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/common.rs", + "tokens_estimated": 333, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/palette.rs", + "tokens_estimated": 316, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/palette.rs", + "tokens_estimated": 161, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/navigation.rs", + "tokens_estimated": 234, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/navigation.rs", + "tokens_estimated": 327, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/navigation.rs", + "tokens_estimated": 269, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_editor.rs", + "tokens_estimated": 75, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/tests.rs", + "tokens_estimated": 661, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/tests.rs", + "tokens_estimated": 453, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/tests.rs", + "tokens_estimated": 145, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/core/command_dispatch/tests.rs", + "tokens_estimated": 214, + "action": "edit" + } + ], + "totals": { + "input_tokens_estimated": 203992, + "output_tokens_estimated": 7804, + "reads_count": 22, + "writes_count": 35, + "repeated_reads_blocked": 9, + "anatomy_lookups": 20 + } + }, + { + "id": "session-2026-06-12-2105", + "started": "2026-06-12T14:05:47.958Z", + "ended": "2026-06-12T14:14:43.504Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_editor.rs", + "tokens_estimated": 5175, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/lsp.rs", + "tokens_estimated": 9284, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/pending.rs", + "tokens_estimated": 2406, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 19639, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands.rs", + "tokens_estimated": 9996, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_tests.rs", + "tokens_estimated": 31888, + "was_repeated": false, + "anatomy_had_description": false + } + ], + "writes": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_editor.rs", + "tokens_estimated": 98, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_editor.rs", + "tokens_estimated": 63, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands.rs", + "tokens_estimated": 87, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_tests.rs", + "tokens_estimated": 270, + "action": "edit" + } + ], + "totals": { + "input_tokens_estimated": 78388, + "output_tokens_estimated": 518, + "reads_count": 6, + "writes_count": 4, + "repeated_reads_blocked": 3, + "anatomy_lookups": 6 + } + }, + { + "id": "session-2026-06-12-2116", + "started": "2026-06-12T14:16:57.491Z", + "ended": "2026-06-12T14:28:41.306Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/ai.rs", + "tokens_estimated": 2742, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 21281, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands.rs", + "tokens_estimated": 10052, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_editor.rs", + "tokens_estimated": 5254, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/mod.rs", + "tokens_estimated": 9705, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/state.rs", + "tokens_estimated": 12339, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/dispatch.rs", + "tokens_estimated": 7139, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/async_bridge.rs", + "tokens_estimated": 4589, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/mod.rs", + "tokens_estimated": 6635, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 25823, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_lsp.rs", + "tokens_estimated": 9018, + "was_repeated": false, + "anatomy_had_description": false + } + ], + "writes": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/mod.rs", + "tokens_estimated": 179, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 35, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 622, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 79, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 180, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 56, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_editor.rs", + "tokens_estimated": 128, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_lsp.rs", + "tokens_estimated": 346, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/ai.rs", + "tokens_estimated": 136, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/async_results/ai.rs", + "tokens_estimated": 122, + "action": "edit" + } + ], + "totals": { + "input_tokens_estimated": 114577, + "output_tokens_estimated": 1883, + "reads_count": 11, + "writes_count": 10, + "repeated_reads_blocked": 1, + "anatomy_lookups": 11 + } + }, + { + "id": "session-2026-06-12-2130", + "started": "2026-06-12T14:30:31.346Z", + "ended": "2026-06-12T15:06:11.953Z", + "reads": [ + { + "file": "/Users/qc-bright/.claude/projects/-Users-qc-bright-Project-netherize-editor/memory/onboarding-roadmap.md", + "tokens_estimated": 0, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/.claude/projects/-Users-qc-bright-Project-netherize-editor/memory/project_stack.md", + "tokens_estimated": 0, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 19639, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/resolved_keymap.rs", + "tokens_estimated": 15986, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/components/help_keycaps.rs", + "tokens_estimated": 0, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/components/help_keycaps.rs", + "tokens_estimated": 2003, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 26522, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/mod.rs", + "tokens_estimated": 4904, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 23243, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer.rs", + "tokens_estimated": 4387, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/popups.rs", + "tokens_estimated": 6724, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/pending.rs", + "tokens_estimated": 2406, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle/frame.rs", + "tokens_estimated": 8679, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "tokens_estimated": 6558, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/palette.rs", + "tokens_estimated": 3160, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui.rs", + "tokens_estimated": 42, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/mod.rs", + "tokens_estimated": 6742, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 21908, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/welcome.rs", + "tokens_estimated": 9183, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 5163, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/persistence.rs", + "tokens_estimated": 1318, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands.rs", + "tokens_estimated": 10052, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/statusbar.rs", + "tokens_estimated": 5314, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings.rs", + "tokens_estimated": 4857, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 7423, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler.rs", + "tokens_estimated": 2480, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 9544, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 8206, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/help.rs", + "tokens_estimated": 4176, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/buffers.rs", + "tokens_estimated": 5570, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/overlays.rs", + "tokens_estimated": 19960, + "was_repeated": true, + "anatomy_had_description": false + } + ], + "writes": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/resolved_keymap.rs", + "tokens_estimated": 573, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/resolved_keymap.rs", + "tokens_estimated": 131, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/mod.rs", + "tokens_estimated": 249, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/mod.rs", + "tokens_estimated": 338, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 153, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer.rs", + "tokens_estimated": 203, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui.rs", + "tokens_estimated": 14, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/whichkey.rs", + "tokens_estimated": 2070, + "action": "create" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "tokens_estimated": 50, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "tokens_estimated": 66, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "tokens_estimated": 114, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "tokens_estimated": 50, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "tokens_estimated": 50, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "tokens_estimated": 71, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "tokens_estimated": 39, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle/frame.rs", + "tokens_estimated": 94, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle/frame.rs", + "tokens_estimated": 63, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle/frame.rs", + "tokens_estimated": 273, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 84, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 390, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 52, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 203, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 214, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/mod.rs", + "tokens_estimated": 79, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 36, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/resolved_keymap.rs", + "tokens_estimated": 548, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/resolved_keymap.rs", + "tokens_estimated": 36, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/resolved_keymap.rs", + "tokens_estimated": 32, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 70, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/welcome.rs", + "tokens_estimated": 145, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/welcome.rs", + "tokens_estimated": 34, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/persistence.rs", + "tokens_estimated": 54, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 44, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands.rs", + "tokens_estimated": 248, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer.rs", + "tokens_estimated": 195, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer.rs", + "tokens_estimated": 42, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/statusbar.rs", + "tokens_estimated": 25, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/statusbar.rs", + "tokens_estimated": 52, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/statusbar.rs", + "tokens_estimated": 38, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/statusbar.rs", + "tokens_estimated": 241, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/statusbar.rs", + "tokens_estimated": 66, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/statusbar.rs", + "tokens_estimated": 62, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/statusbar.rs", + "tokens_estimated": 245, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 360, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 62, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 425, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler.rs", + "tokens_estimated": 24, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 14, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings.rs", + "tokens_estimated": 296, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 447, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 37, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 160, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 225, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 168, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 149, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 44, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 125, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 226, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 17, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 194, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 339, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 154, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 306, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 144, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 40, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 298, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 112, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 31, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 125, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/help.rs", + "tokens_estimated": 129, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/help.rs", + "tokens_estimated": 33, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/help.rs", + "tokens_estimated": 401, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/help.rs", + "tokens_estimated": 62, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/help.rs", + "tokens_estimated": 22, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/help.rs", + "tokens_estimated": 14, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/help.rs", + "tokens_estimated": 24, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/help.rs", + "tokens_estimated": 33, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/help.rs", + "tokens_estimated": 33, + "action": "edit" + }, + { + "file": "/Users/qc-bright/.claude/projects/-Users-qc-bright-Project-netherize-editor/memory/onboarding-roadmap.md", + "tokens_estimated": 546, + "action": "create" + } + ], + "totals": { + "input_tokens_estimated": 246149, + "output_tokens_estimated": 13655, + "reads_count": 31, + "writes_count": 79, + "repeated_reads_blocked": 19, + "anatomy_lookups": 28 + } + }, + { + "id": "session-2026-06-12-2130", + "started": "2026-06-12T14:30:31.346Z", + "ended": "2026-06-12T15:33:25.771Z", + "reads": [ + { + "file": "/Users/qc-bright/.claude/projects/-Users-qc-bright-Project-netherize-editor/memory/onboarding-roadmap.md", + "tokens_estimated": 0, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/.claude/projects/-Users-qc-bright-Project-netherize-editor/memory/project_stack.md", + "tokens_estimated": 0, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 19639, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/resolved_keymap.rs", + "tokens_estimated": 15986, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/components/help_keycaps.rs", + "tokens_estimated": 0, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/components/help_keycaps.rs", + "tokens_estimated": 2003, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 26522, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/mod.rs", + "tokens_estimated": 4904, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/app_state/mod.rs", + "tokens_estimated": 23243, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer.rs", + "tokens_estimated": 4387, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/popups.rs", + "tokens_estimated": 6724, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/pending.rs", + "tokens_estimated": 2406, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle/frame.rs", + "tokens_estimated": 8679, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "tokens_estimated": 6558, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/palette.rs", + "tokens_estimated": 3160, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui.rs", + "tokens_estimated": 42, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/mod.rs", + "tokens_estimated": 6742, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 21908, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/welcome.rs", + "tokens_estimated": 9183, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 5163, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/persistence.rs", + "tokens_estimated": 1318, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands.rs", + "tokens_estimated": 10052, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/statusbar.rs", + "tokens_estimated": 5314, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings.rs", + "tokens_estimated": 4857, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 7423, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler.rs", + "tokens_estimated": 2480, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/extensions.rs", + "tokens_estimated": 9544, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 8206, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/help.rs", + "tokens_estimated": 4176, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/buffers.rs", + "tokens_estimated": 5570, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/overlays.rs", + "tokens_estimated": 19960, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/tests.rs", + "tokens_estimated": 18106, + "was_repeated": false, + "anatomy_had_description": false + } + ], + "writes": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/resolved_keymap.rs", + "tokens_estimated": 573, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/resolved_keymap.rs", + "tokens_estimated": 131, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/mod.rs", + "tokens_estimated": 249, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input_map/mod.rs", + "tokens_estimated": 338, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 153, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer.rs", + "tokens_estimated": 203, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui.rs", + "tokens_estimated": 14, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/whichkey.rs", + "tokens_estimated": 2070, + "action": "create" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "tokens_estimated": 50, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "tokens_estimated": 66, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "tokens_estimated": 114, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "tokens_estimated": 50, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "tokens_estimated": 50, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "tokens_estimated": 71, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle.rs", + "tokens_estimated": 39, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle/frame.rs", + "tokens_estimated": 94, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle/frame.rs", + "tokens_estimated": 63, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/lifecycle/frame.rs", + "tokens_estimated": 273, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 84, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 390, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 52, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 203, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 214, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/mod.rs", + "tokens_estimated": 79, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/setup.rs", + "tokens_estimated": 36, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/resolved_keymap.rs", + "tokens_estimated": 548, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/resolved_keymap.rs", + "tokens_estimated": 36, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/resolved_keymap.rs", + "tokens_estimated": 32, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/keymaps/default.toml", + "tokens_estimated": 70, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/welcome.rs", + "tokens_estimated": 145, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/welcome.rs", + "tokens_estimated": 34, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/persistence.rs", + "tokens_estimated": 54, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 44, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands.rs", + "tokens_estimated": 248, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer.rs", + "tokens_estimated": 195, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer.rs", + "tokens_estimated": 42, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/statusbar.rs", + "tokens_estimated": 25, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/statusbar.rs", + "tokens_estimated": 52, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/statusbar.rs", + "tokens_estimated": 38, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/statusbar.rs", + "tokens_estimated": 241, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/statusbar.rs", + "tokens_estimated": 66, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/statusbar.rs", + "tokens_estimated": 62, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/ui/statusbar.rs", + "tokens_estimated": 245, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 360, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 62, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 425, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler.rs", + "tokens_estimated": 24, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/async_runtime/scheduler/syntax_jobs.rs", + "tokens_estimated": 14, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/commands_settings.rs", + "tokens_estimated": 296, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 447, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 37, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 160, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 225, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 168, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 149, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 44, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 125, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 226, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 17, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 194, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 339, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 154, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 306, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 144, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 40, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 298, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 112, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 31, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/fuzzy.rs", + "tokens_estimated": 125, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/help.rs", + "tokens_estimated": 129, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/help.rs", + "tokens_estimated": 33, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/help.rs", + "tokens_estimated": 401, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/help.rs", + "tokens_estimated": 62, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/help.rs", + "tokens_estimated": 22, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/help.rs", + "tokens_estimated": 14, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/help.rs", + "tokens_estimated": 24, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/help.rs", + "tokens_estimated": 33, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/render/renderer/editor/help.rs", + "tokens_estimated": 33, + "action": "edit" + }, + { + "file": "/Users/qc-bright/.claude/projects/-Users-qc-bright-Project-netherize-editor/memory/onboarding-roadmap.md", + "tokens_estimated": 546, + "action": "create" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 236, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 337, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 117, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 136, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 225, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/tests.rs", + "tokens_estimated": 723, + "action": "edit" + } + ], + "totals": { + "input_tokens_estimated": 264255, + "output_tokens_estimated": 15429, + "reads_count": 32, + "writes_count": 85, + "repeated_reads_blocked": 19, + "anatomy_lookups": 29 + } } ], "daemon_usage": [], diff --git a/AGENTS.md b/AGENTS.md index 2850eec1..8cb1c7e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,7 +64,7 @@ For any input-to-action feature, you MUST follow this exact path: # GitNexus — Code Intelligence -This project is indexed by GitNexus as **netherize_editor** (7835 symbols, 20595 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **netherize_editor** (8048 symbols, 21242 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/BUILD.md b/BUILD.md index 82c11798..065452ea 100644 --- a/BUILD.md +++ b/BUILD.md @@ -121,12 +121,12 @@ All bundle scripts create ready-to-distribute packages: ```bash # macOS: Creates .app bundle + .zip -./scripts/bundle_macos.sh v1.0.4-alpha -# Output: target/Netherize-v1.0.4-alpha-macos.zip +./scripts/bundle_macos.sh v1.0.5-alpha +# Output: target/Netherize-v1.0.5-alpha-macos.zip # Linux: Creates tarball with install script -./scripts/bundle_linux.sh v1.0.4-alpha -# Output: dist/netherize-editor-v1.0.4-alpha-linux-x86_64.tar.gz +./scripts/bundle_linux.sh v1.0.5-alpha +# Output: dist/netherize-editor-v1.0.5-alpha-linux-x86_64.tar.gz # Windows: Creates folder with .exe + launcher ./scripts/bundle_windows.sh diff --git a/CLAUDE.md b/CLAUDE.md index 7376cf9a..89dfb0fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,7 +71,7 @@ For any input-to-action feature, you MUST follow this exact path: # GitNexus — Code Intelligence -This project is indexed by GitNexus as **netherize_editor** (7835 symbols, 20595 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **netherize_editor** (8048 symbols, 21242 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/Cargo.lock b/Cargo.lock index 3324098d..920fcb10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2258,6 +2258,7 @@ dependencies = [ "tree-sitter-typescript", "tree-sitter-xml", "tree-sitter-yaml", + "unicode-normalization", "url", "usvg", "wgpu", @@ -4548,6 +4549,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-properties" version = "0.1.4" diff --git a/Cargo.toml b/Cargo.toml index 9d1ba8af..558b9743 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ notify = "6.1.1" rfd = "0.14" pollster = "0.4.0" portable-pty = "0.9.0" +unicode-normalization = "0.1" ropey = "1.6.1" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.145" diff --git a/config/ai.toml b/config/ai.toml index cb10cd97..2d43cb52 100644 --- a/config/ai.toml +++ b/config/ai.toml @@ -1,12 +1,12 @@ [inline_completion] -enabled = false -debounce_ms = 300 +enabled = true +debounce_ms = 150 prefix_chars = 1200 suffix_chars = 400 max_tokens = 96 [inline_completion.provider] api_url = "http://localhost:20128/v1" -model = "cx/gpt-5.3-codex" +model = "mistral/codestral-2508" api_key = "sk-2c73203b37ac06c4-lvvv2y-f8b99b2a" endpoint_kind = "chat" diff --git a/config/keymaps/default.toml b/config/keymaps/default.toml index a9750245..e3ca6916 100644 --- a/config/keymaps/default.toml +++ b/config/keymaps/default.toml @@ -157,6 +157,11 @@ mode = "insert" key = "Ctrl+j" command = "ai.accept_inline" +[[bindings]] +mode = "insert" +key = "Ctrl+l" +command = "ai.accept_inline_word" + # [[bindings]] # mode = "insert" # key = "Ctrl+k" @@ -251,6 +256,42 @@ mode = "normal" key = "C" command = "editor.change_to_line_end" +[[bindings]] +mode = "normal" +key = "D" +command = "editor.delete_to_line_end" + +[[bindings]] +mode = "normal" +key = "Y" +command = "editor.yank_to_line_end" + +[[bindings]] +mode = "normal" +key = "S" +command = "editor.substitute_line" + +[[bindings]] +mode = "normal" +key = "J" +command = "editor.join_lines" + +[[bindings]] +mode = "normal" +key = "p" +command = "editor.paste_after" + +[[bindings]] +mode = "normal" +key = "P" +command = "editor.paste_before" + +# X = xoá ký tự trước con trỏ (Vim X ≈ backspace trong normal mode) +[[bindings]] +mode = "normal" +key = "X" +command = "editor.backspace" + [[bindings]] mode = "normal" key = "a" @@ -1039,6 +1080,17 @@ mode = "normal" key = "m e" command = "app.open_extensions_manager" +# Cheatsheet / keybinding help (also available via :help) +[[bindings]] +mode = "normal" +key = "?" +command = "app.open_help" + +# F1 opens the cheat sheet from any mode (new-user friendly). +[[bindings]] +key = "F1" +command = "app.open_help" + # ───────────────── PALETTE ───────────────── [[bindings]] mode = "palette" @@ -1133,4 +1185,4 @@ command = "editor.backspace" [[bindings]] mode = "multiinsert" key = "Enter" -command = "editor.insert_newline" +command = "editor.newline" diff --git a/config/ui/default.toml b/config/ui/default.toml index d01d16b5..29fdb07b 100644 --- a/config/ui/default.toml +++ b/config/ui/default.toml @@ -3,11 +3,13 @@ title = "Netherize Editor" width = 1920 height = 1080 startup_mode = "windowed" -auto_scale = true +# Keep UI size independent of window size (stable like other IDEs). +# When true, content zooms with window size relative to width/height above. +auto_scale = false min_content_scale = 1.0 max_content_scale = 2.0 # Force a specific scale factor / DPI override globally (e.g. 1.0 on a Full HD monitor to avoid excessive scaling) -# scale_factor_override = 2.0 +# scale_factor_override = 1.0 [layout] outer_gap = 14.0 @@ -60,7 +62,7 @@ tab_width = 4 insert_spaces = true [welcome] -version = "v1.0.4-alpha" +version = "v1.0.5-alpha" card_max_width = 560.0 card_padding_x = 42.0 card_padding_y = 34.0 diff --git a/scripts/bundle_linux.sh b/scripts/bundle_linux.sh index 34b3ebb0..5ff5847f 100755 --- a/scripts/bundle_linux.sh +++ b/scripts/bundle_linux.sh @@ -7,7 +7,7 @@ set -euo pipefail APP_NAME="netherize-editor" BINARY="netherize_editor" -VERSION="${1:-v1.0.4-alpha}" +VERSION="${1:-v1.0.5-alpha}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" TARGET="x86_64-unknown-linux-gnu" diff --git a/scripts/bundle_macos.sh b/scripts/bundle_macos.sh index a61bf261..7e369d9f 100755 --- a/scripts/bundle_macos.sh +++ b/scripts/bundle_macos.sh @@ -3,7 +3,7 @@ set -euo pipefail APP_NAME="Netherize" BINARY="netherize_editor" -VERSION="${1:-v1.0.4-alpha}" +VERSION="${1:-v1.0.5-alpha}" PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" BUNDLE="$PROJECT_DIR/target/$APP_NAME.app" ZIP="$PROJECT_DIR/target/${APP_NAME}-${VERSION}-macos.zip" diff --git a/src/app/app_state/buffers.rs b/src/app/app_state/buffers.rs index a0537f76..97619b61 100644 --- a/src/app/app_state/buffers.rs +++ b/src/app/app_state/buffers.rs @@ -70,7 +70,6 @@ impl AppState { fs::write(&path, self.text.to_string()) .map_err(|err| format!("save file {:?} failed: {err}", path))?; - self.last_saved_at = Some(Instant::now()); let canonical_path = path .canonicalize() @@ -167,9 +166,9 @@ impl AppState { } pub fn close_buffer_for_path(&mut self, path: &Path) -> Result { - let Some(index) = self.buffers.iter().position(|entry| { - matches!(&entry.content, BufferContent::Text(buffer) if buffer.path == path) - }) else { + let Some(index) = self.buffers.iter().position( + |entry| matches!(&entry.content, BufferContent::Text(buffer) if buffer.path == path), + ) else { return Ok(false); }; self.close_buffer_index(index) @@ -256,7 +255,8 @@ impl AppState { pub fn begin_visual_block_selection(&mut self) -> bool { let (line_idx, col) = self.cursor_line_col(); if self.visual_block_anchor_line == Some(line_idx) - && self.visual_block_anchor_col == Some(col) { + && self.visual_block_anchor_col == Some(col) + { return false; } self.visual_block_anchor_line = Some(line_idx); @@ -691,14 +691,40 @@ impl AppState { true } - pub fn check_and_reload_external_changes( + /// In-memory text of the open buffer at `path` (active or inactive). + /// Used to push externally reloaded content to the LSP server. + pub fn buffer_text_for_path(&self, path: &Path) -> Option { + if self + .active_file() + .is_some_and(|active| crate::app::app_state::overlays::path_matches(active, path)) + { + return Some(self.text_string()); + } + self.buffers.iter().find_map(|entry| match &entry.content { + BufferContent::Text(buffer) + if crate::app::app_state::overlays::path_matches(&buffer.path, path) => + { + buffer.in_memory_text.as_ref().map(|rope| rope.to_string()) + } + _ => None, + }) + } + + /// 3s safety-net poll: stat every clean open buffer and return the paths + /// whose mtime changed. NO file contents are read here (#4) — the caller + /// submits a `ReadExternalFiles` worker request and the contents come back + /// through `apply_external_file_contents`. The self-save echo check also + /// lives in the apply phase (content compare beats re-reading the disk). + /// `buffer.last_known_modified_time` is only advanced when the content is + /// applied, so an in-flight change keeps being re-detected (idempotent) + /// rather than silently dropped if a read fails. + pub fn collect_externally_modified_open_buffers( &mut self, last_checked_times: &mut HashMap, - ) -> (Vec, bool) { - let mut reloaded_paths = Vec::new(); - let mut active_reloaded = false; + ) -> Vec { + let mut changed_paths = Vec::new(); - let candidates: Vec<(usize, PathBuf, bool)> = self + let candidates: Vec<(usize, PathBuf)> = self .buffers .iter() .enumerate() @@ -707,7 +733,7 @@ impl AppState { let is_active = self.active_buffer_index == Some(idx); let is_dirty = buffer.dirty || (is_active && self.dirty); if !is_dirty { - Some((idx, buffer.path.clone(), is_active)) + Some((idx, buffer.path.clone())) } else { None } @@ -717,14 +743,16 @@ impl AppState { }) .collect(); - for (idx, path, is_active) in candidates { + for (idx, path) in candidates { if let Ok(metadata) = std::fs::metadata(&path) { if let Ok(modified_time) = metadata.modified() { let needs_reload = if let Some(slot) = self.buffers.get(idx) && let BufferContent::Text(ref buffer) = slot.content { + // #2: dùng `!=` thay vì `>` — git checkout/stash có thể khôi + // phục mtime CŨ HƠN, mà `>` sẽ bỏ sót hoàn toàn. match buffer.last_known_modified_time { - Some(last_checked) => modified_time > last_checked, + Some(last_checked) => modified_time != last_checked, None => true, } } else { @@ -732,56 +760,14 @@ impl AppState { }; if needs_reload { - if is_active && self.should_ignore_self_save_event() { - if let Some(slot) = self.buffers.get_mut(idx) - && let BufferContent::Text(ref mut buffer) = slot.content - { - buffer.last_known_modified_time = Some(modified_time); - } - last_checked_times.insert(path, modified_time); - continue; - } - - if is_active { - eprintln!("[FilePoll] Reloading active file from disk: {:?}", path); - if let Ok(()) = self.load_buffer_from_file(&path) { - self.active_file = Some(path.clone()); - let reloaded_text = self.text.clone(); - if let Some(slot) = self.buffers.get_mut(idx) { - if let BufferContent::Text(ref mut buffer) = slot.content { - buffer.in_memory_text = Some(reloaded_text); - buffer.missing_on_disk = false; - buffer.dirty = false; - buffer.last_known_modified_time = Some(modified_time); - } - } - self.dirty = false; - active_reloaded = true; - reloaded_paths.push(path.clone()); - } - } else { - eprintln!("[FilePoll] Reloading background file from disk: {:?}", path); - if let Ok(content) = std::fs::read_to_string(&path) { - if let Some(slot) = self.buffers.get_mut(idx) { - if let BufferContent::Text(ref mut buffer) = slot.content { - buffer.in_memory_text = Some(Rope::from(content)); - buffer.missing_on_disk = false; - buffer.dirty = false; - buffer.last_known_modified_time = Some(modified_time); - } - } - reloaded_paths.push(path.clone()); - } - } - last_checked_times.insert(path, modified_time); - } else { - last_checked_times.insert(path, modified_time); + changed_paths.push(path.clone()); } + last_checked_times.insert(path, modified_time); } } } - (reloaded_paths, active_reloaded) + changed_paths } } diff --git a/src/app/app_state/editor.rs b/src/app/app_state/editor.rs index 241479b5..362c34e7 100644 --- a/src/app/app_state/editor.rs +++ b/src/app/app_state/editor.rs @@ -826,6 +826,41 @@ impl AppState { } } + /// Vim f/F/t/T: tìm `target` trên dòng hiện tại và di chuyển cursor tới đó + /// (f/F đứng trên ký tự, t/T đứng cạnh ký tự). Trả về true nếu cursor di chuyển. + pub fn move_find_char(&mut self, kind: FindMotionKind, target: char) -> bool { + if self.text.len_chars() == 0 { + return false; + } + let cursor = self + .cursor_char_idx + .min(self.text.len_chars().saturating_sub(1)); + let line = self.text.char_to_line(cursor); + let line_start = self.text.line_to_char(line); + let line_end = self.line_content_end_char_idx(line); + let dest = match kind { + FindMotionKind::ForwardTo => { + ((cursor + 1).min(line_end)..line_end).find(|&i| self.text.char(i) == target) + } + FindMotionKind::ForwardTill => ((cursor + 1).min(line_end)..line_end) + .find(|&i| self.text.char(i) == target) + .map(|hit| hit.saturating_sub(1)) + .filter(|&pos| pos > cursor), + FindMotionKind::BackwardTo => (line_start..cursor) + .rev() + .find(|&i| self.text.char(i) == target), + FindMotionKind::BackwardTill => (line_start..cursor) + .rev() + .find(|&i| self.text.char(i) == target) + .map(|hit| hit + 1) + .filter(|&pos| pos < cursor), + }; + let Some(dest) = dest else { + return false; + }; + self.move_cursor_to_char_idx(dest) + } + pub fn move_left(&mut self) { let (line_idx, col) = self.cursor_line_col(); let previous_cursor = self.cursor_char_idx; diff --git a/src/app/app_state/mod.rs b/src/app/app_state/mod.rs index 85edba44..e35ca6cb 100644 --- a/src/app/app_state/mod.rs +++ b/src/app/app_state/mod.rs @@ -3,7 +3,6 @@ use std::{ fs, path::{Path, PathBuf}, sync::Arc, - time::{Duration, Instant}, }; use ropey::Rope; @@ -55,6 +54,17 @@ pub struct ExternalChangeReport { pub active_file_reloaded: bool, pub conflict_detected: bool, pub conflict_path: Option, + /// Inactive buffers whose content was reloaded from disk. The shell must + /// re-sync these with the LSP server: a stale didOpen overlay would keep + /// shadowing the new on-disk content for cross-file diagnostics. + pub inactive_reloaded_paths: Vec, + /// Buffers whose content must be re-read from disk. The shell submits a + /// `ReadExternalFiles` worker request for these and applies the results + /// via `apply_external_file_contents` — the UI thread never reads files. + pub pending_reload_paths: Vec, + /// The workspace tree shape may have changed; the shell submits an async + /// `RescanWorkspace` and applies fresh nodes via `apply_workspace_rescan`. + pub workspace_rescan_needed: bool, pub notices: Vec, } @@ -156,9 +166,7 @@ pub struct EditorBuffer { impl EditorBuffer { pub fn new(path: PathBuf, language_id: Option) -> Self { - let last_known_modified_time = std::fs::metadata(&path) - .and_then(|m| m.modified()) - .ok(); + let last_known_modified_time = std::fs::metadata(&path).and_then(|m| m.modified()).ok(); Self { path, language_id, @@ -335,6 +343,9 @@ pub struct ExtensionsManagerState { pub expanded_binary: Option, pub items: Vec, pub command: Option, + /// False until the first async `which` sweep returns — installed states are + /// unknown before that and must render as "checking", not as a guess. + pub deps_checked: bool, } #[derive(Debug, Clone, PartialEq)] @@ -345,7 +356,7 @@ pub struct ExtensionCommandState { pub success: Option, pub exit_code: Option, pub logs: Vec, -} +} impl ExtensionsManagerState { pub fn new() -> Self { @@ -363,6 +374,7 @@ impl ExtensionsManagerState { expanded_binary: None, items: default_extension_items(), command: None, + deps_checked: false, } } @@ -404,19 +416,29 @@ impl ExtensionsManagerState { || item.subtitle.to_ascii_lowercase().contains(&query) || item.binary.to_ascii_lowercase().contains(&query) || item.tag.to_ascii_lowercase().contains(&query) - || item.extensions.iter().any(|ext| ext.to_ascii_lowercase().contains(&query)); - if matches_tab && matches_query { Some(idx) } else { None } + || item + .extensions + .iter() + .any(|ext| ext.to_ascii_lowercase().contains(&query)); + if matches_tab && matches_query { + Some(idx) + } else { + None + } }) .collect() } pub fn selected_item_index(&self) -> Option { let visible = self.visible_item_indices(); - visible.get(self.selected_index.min(visible.len().saturating_sub(1))).copied() + visible + .get(self.selected_index.min(visible.len().saturating_sub(1))) + .copied() } pub fn selected_item(&self) -> Option<&ExtensionItem> { - self.selected_item_index().and_then(|idx| self.items.get(idx)) + self.selected_item_index() + .and_then(|idx| self.items.get(idx)) } pub fn select_next(&mut self) -> bool { @@ -538,35 +560,188 @@ impl ExtensionsManagerState { fn default_extension_items() -> Vec { let mut items = vec![ - cli_extension("fzf", "Fuzzy finder for file picker & live grep", "fzf", "SEARCH", "brew install fzf", "sudo apt install fzf", true), - cli_extension("ripgrep", "Fast text search for live grep", "rg", "SEARCH", "brew install ripgrep", "sudo apt install ripgrep", true), - cli_extension("fd", "Fast file finder (alternative to find)", "fd", "SEARCH", "brew install fd", "sudo apt install fd-find", false), - cli_extension("lazygit", "Git TUI integration", "lazygit", "GIT", "brew install lazygit", "sudo apt install lazygit", true), - cli_extension("lazydocker", "Docker TUI integration", "lazydocker", "DEVOPS", "brew install lazydocker", "sudo apt install lazydocker", false), - cli_extension("bat", "Syntax-highlighted file previews", "bat", "UTILITY", "brew install bat", "sudo apt install bat", false), - cli_extension("delta", "Git diff viewer with syntax highlighting", "delta", "GIT", "brew install git-delta", "sudo apt install git-delta", true), - cli_extension("opencode", "AI code assistant", "opencode", "AI", "curl -fsSL https://opencode.ai/install | sh", "curl -fsSL https://opencode.ai/install | sh", true), + cli_extension( + "fzf", + "Fuzzy finder for file picker & live grep", + "fzf", + "SEARCH", + "brew install fzf", + "sudo apt install fzf", + false, + ), + cli_extension( + "ripgrep", + "Fast text search for live grep", + "rg", + "SEARCH", + "brew install ripgrep", + "sudo apt install ripgrep", + false, + ), + cli_extension( + "fd", + "Fast file finder (alternative to find)", + "fd", + "SEARCH", + "brew install fd", + "sudo apt install fd-find", + false, + ), + cli_extension( + "lazygit", + "Git TUI integration", + "lazygit", + "GIT", + "brew install lazygit", + "sudo apt install lazygit", + false, + ), + cli_extension( + "lazydocker", + "Docker TUI integration", + "lazydocker", + "DEVOPS", + "brew install lazydocker", + "sudo apt install lazydocker", + false, + ), + cli_extension( + "bat", + "Syntax-highlighted file previews", + "bat", + "UTILITY", + "brew install bat", + "sudo apt install bat", + false, + ), + cli_extension( + "delta", + "Git diff viewer with syntax highlighting", + "delta", + "GIT", + "brew install git-delta", + "sudo apt install git-delta", + false, + ), + cli_extension( + "opencode", + "AI code assistant", + "opencode", + "AI", + "curl -fsSL https://opencode.ai/install | sh", + "curl -fsSL https://opencode.ai/install | sh", + false, + ), ]; items.extend([ - lsp_extension("Rust", "rust-analyzer", "rustup component add rust-analyzer", vec![".rs"], true), - lsp_extension("JavaScript", "typescript-language-server", "npm install -g typescript typescript-language-server", vec![".js", ".mjs", ".cjs"], false), - lsp_extension("JSX", "typescript-language-server", "npm install -g typescript typescript-language-server", vec![".jsx"], false), - lsp_extension("TypeScript", "typescript-language-server", "npm install -g typescript typescript-language-server", vec![".ts"], false), - lsp_extension("TSX", "typescript-language-server", "npm install -g typescript typescript-language-server", vec![".tsx"], false), - lsp_extension("Go", "gopls", "go install golang.org/x/tools/gopls@latest", vec![".go"], false), - lsp_extension("Python", "pylsp", "pip install python-lsp-server", vec![".py"], true), + lsp_extension( + "Rust", + "rust-analyzer", + "rustup component add rust-analyzer", + vec![".rs"], + false, + ), + lsp_extension( + "JavaScript", + "typescript-language-server", + "npm install -g typescript typescript-language-server", + vec![".js", ".mjs", ".cjs"], + false, + ), + lsp_extension( + "JSX", + "typescript-language-server", + "npm install -g typescript typescript-language-server", + vec![".jsx"], + false, + ), + lsp_extension( + "TypeScript", + "typescript-language-server", + "npm install -g typescript typescript-language-server", + vec![".ts"], + false, + ), + lsp_extension( + "TSX", + "typescript-language-server", + "npm install -g typescript typescript-language-server", + vec![".tsx"], + false, + ), + lsp_extension( + "Go", + "gopls", + "go install golang.org/x/tools/gopls@latest", + vec![".go"], + false, + ), + // Dart's language server ships inside the SDK (`dart language-server`), + // so detection checks the `dart` binary itself (Flutter/FVM also provide it). + lsp_extension( + "Dart", + "dart", + "brew tap dart-lang/dart && brew install dart", + vec![".dart"], + false, + ), + lsp_extension( + "Python", + "pylsp", + "pip install python-lsp-server", + vec![".py"], + false, + ), lsp_extension("Java", "jdtls", "brew install jdtls", vec![".java"], false), - lsp_extension("SQL", "sqls", "go install github.com/sqls-server/sqls@latest", vec![".sql"], false), - lsp_extension("YAML", "yaml-language-server", "npm install -g yaml-language-server", vec![".yaml", ".yml"], true), - lsp_extension("Dockerfile", "docker-langserver", "npm install -g dockerfile-language-server-nodejs", vec!["Dockerfile*"], false), - lsp_extension("JSON", "vscode-json-language-server", "npm install -g vscode-langservers-extracted", vec![".json"], true), - lsp_extension("Bash", "bash-language-server", "npm install -g bash-language-server", vec![".sh"], true), + lsp_extension( + "SQL", + "sqls", + "go install github.com/sqls-server/sqls@latest", + vec![".sql"], + false, + ), + lsp_extension( + "YAML", + "yaml-language-server", + "npm install -g yaml-language-server", + vec![".yaml", ".yml"], + false, + ), + lsp_extension( + "Dockerfile", + "docker-langserver", + "npm install -g dockerfile-language-server-nodejs", + vec!["Dockerfile*"], + false, + ), + lsp_extension( + "JSON", + "vscode-json-language-server", + "npm install -g vscode-langservers-extracted", + vec![".json"], + false, + ), + lsp_extension( + "Bash", + "bash-language-server", + "npm install -g bash-language-server", + vec![".sh"], + false, + ), ]); items } -fn cli_extension(name: &str, subtitle: &str, binary: &str, tag: &str, macos: &str, linux: &str, installed: bool) -> ExtensionItem { +fn cli_extension( + name: &str, + subtitle: &str, + binary: &str, + tag: &str, + macos: &str, + linux: &str, + installed: bool, +) -> ExtensionItem { let macos_uninstall = macos .strip_prefix("brew install ") .map(|package| format!("brew uninstall {package}")) @@ -590,7 +765,13 @@ fn cli_extension(name: &str, subtitle: &str, binary: &str, tag: &str, macos: &st } } -fn lsp_extension(language: &str, binary: &str, install: &str, extensions: Vec<&str>, installed: bool) -> ExtensionItem { +fn lsp_extension( + language: &str, + binary: &str, + install: &str, + extensions: Vec<&str>, + installed: bool, +) -> ExtensionItem { ExtensionItem { name: format!("({binary})"), subtitle: format!("{language} language server"), @@ -1045,6 +1226,11 @@ fn build_help_lines( "editor.center_cursor_line", "Center cursor", ); + lines.push( + " f/F Find char on line (→/←), highlights all matches".to_string(), + ); + lines.push(" t/T Till char on line (→/←)".to_string()); + lines.push(" n / N Repeat find-char or search jump (→/←)".to_string()); lines.push("".to_string()); // ── Editing ───────────────────────────────────────────────────────────── @@ -1128,6 +1314,12 @@ fn build_help_lines( "editor.change_to_line_end", "Change to line end", ); + append_help_binding( + &mut lines, + bindings, + "editor.yank_to_line_end", + "Yank to line end", + ); append_help_binding(&mut lines, bindings, "editor.join_lines", "Join lines"); append_help_binding( &mut lines, @@ -1305,6 +1497,12 @@ fn build_help_lines( "ai.accept_inline", "Accept inline suggestion", ); + append_help_binding( + &mut lines, + bindings, + "ai.accept_inline_word", + "Accept inline suggestion word", + ); lines.push("".to_string()); // ── Tools ─────────────────────────────────────────────────────────────── @@ -1428,6 +1626,7 @@ fn command_label_for_help(command_id: &str) -> String { "editor.substitute_line" => "Substitute line", "editor.yank_selection" => "Yank selection", "editor.yank_current_line" => "Yank line", + "editor.yank_to_line_end" => "Yank to line end", "editor.toggle_line_comment" => "Toggle line comment", "editor.toggle_selection_comment" => "Toggle comment", "editor.wrap_selection_with_star" => "Wrap with *", @@ -1520,6 +1719,7 @@ fn command_label_for_help(command_id: &str) -> String { "diagnostics.open_picker" => "Diagnostics picker", // ── AI ──────────────────────────────────────────────────────────── "ai.accept_inline" => "Accept AI suggestion", + "ai.accept_inline_word" => "Accept AI suggestion word", "ai.chat_toggle" => "Toggle AI chat", "ai.chat_send" => "Send AI message", "ai.chat_stop" => "Stop AI chat generation", @@ -1941,7 +2141,6 @@ pub struct AppState { terminal_panel_open: bool, external_conflict: Option, external_notice: Option, - last_saved_at: Option, clipboard_record: Option, history: EditHistory, current_transaction: Option, @@ -1987,8 +2186,6 @@ pub struct AppState { } impl AppState { - const SELF_SAVE_IGNORE_WINDOW: Duration = Duration::from_secs(2); - pub fn new(default_save_path: PathBuf) -> Self { Self { text: Rope::new(), @@ -2020,7 +2217,6 @@ impl AppState { terminal_panel_open: false, external_conflict: None, external_notice: None, - last_saved_at: None, clipboard_record: None, history: EditHistory::new(), current_transaction: None, @@ -2092,7 +2288,6 @@ impl AppState { terminal_panel_open: false, external_conflict: None, external_notice: None, - last_saved_at: None, clipboard_record: None, history: EditHistory::new(), current_transaction: None, @@ -2319,10 +2514,14 @@ impl AppState { } pub fn toggle_fold_at_line(&mut self, logical_line: usize) -> bool { - if let Some(pos) = self.auto_folded_long_lines.iter().position(|&line| line == logical_line) + if let Some(pos) = self + .auto_folded_long_lines + .iter() + .position(|&line| line == logical_line) { self.auto_folded_long_lines.remove(pos); - self.folded_ranges.retain(|&range| range != (logical_line, logical_line)); + self.folded_ranges + .retain(|&range| range != (logical_line, logical_line)); self.bump_revision(); return true; } @@ -2425,16 +2624,10 @@ fn merge_fold_ranges(ranges: &[(usize, usize)]) -> Vec<(usize, usize)> { return Vec::new(); } - let mut regular: Vec<(usize, usize)> = ranges - .iter() - .copied() - .filter(|&(s, e)| s != e) - .collect(); - let mut point_folds: Vec<(usize, usize)> = ranges - .iter() - .copied() - .filter(|&(s, e)| s == e) - .collect(); + let mut regular: Vec<(usize, usize)> = + ranges.iter().copied().filter(|&(s, e)| s != e).collect(); + let mut point_folds: Vec<(usize, usize)> = + ranges.iter().copied().filter(|&(s, e)| s == e).collect(); regular.sort_by_key(|&(s, _)| s); point_folds.sort_by_key(|&(s, _)| s); point_folds.dedup(); diff --git a/src/app/app_state/overlays.rs b/src/app/app_state/overlays.rs index 87d9d3fc..2d35a442 100644 --- a/src/app/app_state/overlays.rs +++ b/src/app/app_state/overlays.rs @@ -135,7 +135,9 @@ impl AppState { }; let next = if delta_lines.is_negative() { - scroll.offset_lines.saturating_sub(delta_lines.unsigned_abs()) + scroll + .offset_lines + .saturating_sub(delta_lines.unsigned_abs()) } else { scroll.offset_lines.saturating_add(delta_lines as usize) }; @@ -952,12 +954,6 @@ impl AppState { self.revision += 1; } - pub(super) fn should_ignore_self_save_event(&self) -> bool { - self.last_saved_at.is_some_and(|saved_at| { - Instant::now().saturating_duration_since(saved_at) < Self::SELF_SAVE_IGNORE_WINDOW - }) - } - pub(super) fn load_buffer_from_file(&mut self, canonical_path: &Path) -> Result<(), String> { let content = fs::read_to_string(canonical_path) .map_err(|err| format!("open file {:?} failed: {err}", canonical_path))?; @@ -1705,8 +1701,10 @@ pub(super) fn build_completion_display_items_with_cache( && existing.item.source_path.is_none() && existing.item.export_kind.is_none() { - existing.item.source_path = - symbol.source_path.clone().or_else(|| Some(symbol.file_path.clone())); + existing.item.source_path = symbol + .source_path + .clone() + .or_else(|| Some(symbol.file_path.clone())); existing.item.import_path = symbol.import_path.clone(); existing.item.export_kind = symbol.export_kind.clone(); if existing.item.detail.is_none() { @@ -1852,7 +1850,6 @@ pub(super) fn filter_cached_completion_items( scored } - pub(super) fn score_completion_match( label: &str, query: &str, diff --git a/src/app/app_state/palette.rs b/src/app/app_state/palette.rs index 1ab25a2c..3b7e4f17 100644 --- a/src/app/app_state/palette.rs +++ b/src/app/app_state/palette.rs @@ -1,5 +1,6 @@ use super::overlays::path_matches; use super::*; +use crate::async_runtime::message::ExternalFileRead; const WELCOME_RECENT_PROJECT_LIMIT: usize = 5; @@ -87,14 +88,26 @@ impl AppState { return; }; let (line, col) = self.cursor_line_col(); - self.jump_back_stack.push((path, line, col)); - self.jump_forward_stack.clear(); + self.push_jump_entry(path, line, col); } /// Push an explicit file+line+column onto the jump back stack. /// Useful when the current active surface is a non-file buffer. + /// Vim-style: dedup khi đỉnh stack đã là cùng (file, line); cap 100 entry. pub fn push_jump_entry(&mut self, path: PathBuf, line: usize, col: usize) { + const JUMP_STACK_LIMIT: usize = 100; + if let Some(top) = self.jump_back_stack.last_mut() + && top.0 == path + && top.1 == line + { + top.2 = col; + self.jump_forward_stack.clear(); + return; + } self.jump_back_stack.push((path, line, col)); + if self.jump_back_stack.len() > JUMP_STACK_LIMIT { + self.jump_back_stack.remove(0); + } self.jump_forward_stack.clear(); } @@ -154,7 +167,10 @@ impl AppState { pub fn open_recent_projects_palette_with_meta( &mut self, recent: &[std::path::PathBuf], - meta: &std::collections::HashMap, + meta: &std::collections::HashMap< + std::path::PathBuf, + crate::app::persistence::RecentProjectMeta, + >, ) -> Result<(), String> { use crate::app::command_palette::CommandPaletteItem; let items = recent @@ -165,7 +181,8 @@ impl AppState { // Older persisted metadata may contain a stale/generic icon source, // which makes the recent-project picker render a file icon while // the welcome page correctly infers the project language/framework. - let inferred_icon_source = crate::app::persistence::AppPersistentState::infer_project_icon_source(path); + let inferred_icon_source = + crate::app::persistence::AppPersistentState::infer_project_icon_source(path); CommandPaletteItem::recent_project_with_meta( path, Some(inferred_icon_source.as_str()), @@ -184,7 +201,8 @@ impl AppState { .iter() .take(WELCOME_RECENT_PROJECT_LIMIT) .map(|path| { - let icon_source = crate::app::persistence::AppPersistentState::infer_project_icon_source(path); + let icon_source = + crate::app::persistence::AppPersistentState::infer_project_icon_source(path); CommandPaletteItem::recent_project_with_meta(path, Some(icon_source.as_str()), None) }) .collect(); @@ -793,6 +811,17 @@ impl AppState { }) } + /// Extensions manager buffer regardless of which buffer is active — async + /// detection/install results must land even when the user switched away. + pub fn any_extensions_manager_buffer_mut(&mut self) -> Option<&mut ExtensionsManagerState> { + self.buffers + .iter_mut() + .find_map(|buffer| match &mut buffer.content { + BufferContent::ExtensionsManager(state) => Some(state), + _ => None, + }) + } + pub fn active_settings_buffer(&self) -> Option<&SettingsState> { match self.active_buffer().map(|buffer| &buffer.content) { Some(BufferContent::SettingsTab(state)) => Some(state), @@ -1243,6 +1272,7 @@ impl AppState { border_radius_px: f32, enable_outline: bool, inline_suggestion_enabled: bool, + ui_scale_override: Option, ) -> usize { // Save current text buffer before switching to settings buffer self.save_current_text_buffer_history(); @@ -1274,6 +1304,7 @@ impl AppState { border_radius_px, enable_outline, inline_suggestion_enabled, + ui_scale_override, ); self.is_initial_launch_welcome = false; self.buffers.push(BufferEntry { @@ -1503,26 +1534,37 @@ impl AppState { ) || (matches!(event.kind, FileSystemChangeKind::Modify) && !event.path.exists()) }); + // macOS FSEvents thường tách một rename thành HAI event riêng lẻ, mỗi cái + // một path và new_path = None. Atomic save (ghi temp rồi rename đè) của + // editor/agent khác vì vậy đến đây dưới dạng Rename{new_path: None} trên + // chính file thật — nếu bỏ qua thì buffer chỉ được poll 3s cứu. Path còn + // tồn tại trên đĩa nghĩa là nội dung mới vừa đáp xuống: đối xử như Modify + // để reload ngay. (Rescan đã được quyết định ở trên, trước bước này.) + let events: Vec = events + .iter() + .map(|event| { + let mut event = event.clone(); + if matches!(event.kind, FileSystemChangeKind::Rename) + && event.new_path.is_none() + && event.path.exists() + { + event.kind = FileSystemChangeKind::Modify; + } + event + }) + .collect(); + if self.refresh_text_buffer_missing_on_disk_states() { report.workspace_reloaded = true; } // Chỉ rescan khi tree shape có thể đổi (create/delete/rename). // Modify-only thường không đổi cấu trúc workspace, tránh quét cả cây quá nhiều. - if requires_workspace_rescan && let Some(workspace) = self.workspace_model.as_mut() { - workspace.rescan()?; - report.workspace_reloaded = true; - } - - if report.workspace_reloaded && self.is_file_picker_open() { - if self.refresh_file_picker_results_if_open()? { - let note = format!( - "file picker refreshed ({} results)", - self.file_picker_results().len() - ); - self.external_notice = Some(note.clone()); - report.notices.push(note); - } + // #4: rescan là full tree walk — KHÔNG chạy trên main thread. Chỉ đánh + // dấu; shell submit RescanWorkspace cho worker và áp nodes mới về sau + // qua apply_workspace_rescan. + if requires_workspace_rescan && self.workspace_model.is_some() { + report.workspace_rescan_needed = true; } for event in events { @@ -1546,7 +1588,6 @@ impl AppState { if is_active { // This is the active buffer! if self.is_dirty() { - eprintln!("[FileWatch] Active file is dirty, showing conflict warning"); let warning = format!( "external {:?} detected on active file while dirty: {}", event.kind, @@ -1571,55 +1612,12 @@ impl AppState { match event.kind { FileSystemChangeKind::Modify | FileSystemChangeKind::Create => { - // Check if this is a self-save event by comparing file content - let current_active_path = event.path.clone(); - if matches!(event.kind, FileSystemChangeKind::Modify) - && self.should_ignore_self_save_event() - { - eprintln!("[FileWatch] Ignoring self-save event"); - continue; - } - - eprintln!("[FileWatch] Reloading active file from disk: {:?}", current_active_path); - eprintln!("[FileWatch] Text before reload: {} chars", self.text.len_chars()); - match self.load_buffer_from_file(¤t_active_path) { - Ok(()) => { - eprintln!("[FileWatch] Text after reload: {} chars", self.text.len_chars()); - self.active_file = Some(current_active_path.clone()); - let reloaded_text = self.text.clone(); - eprintln!("[FileWatch] Updating active buffer.in_memory_text with {} chars", reloaded_text.len_chars()); - if let Some(slot) = self.buffers.get_mut(idx) { - if let BufferContent::Text(ref mut buffer) = slot.content { - buffer.in_memory_text = Some(reloaded_text); - buffer.missing_on_disk = false; - buffer.dirty = false; - } - } - self.dirty = false; - let note = format!( - "auto reloaded active file from disk: {}", - current_active_path.display() - ); - self.external_notice = Some(note.clone()); - self.external_conflict = None; - report.active_file_reloaded = true; - report.notices.push(note); - } - Err(err) => { - if let Some(slot) = self.buffers.get_mut(idx) { - if let BufferContent::Text(ref mut buffer) = slot.content { - buffer.missing_on_disk = !current_active_path.exists(); - } - } - let note = format!( - "auto reload skipped for active file {}: {}", - current_active_path.display(), - err - ); - self.external_notice = Some(note.clone()); - report.notices.push(note); - } - } + // #4: không đọc file trên main thread. Chỉ ghi nhận + // path; worker đọc nội dung, apply_external_file_contents + // áp vào sau. Echo check (self-save) cũng dời sang apply + // phase — so sánh content fetch được với memory thay vì + // đọc đĩa đồng bộ ở đây. + report.pending_reload_paths.push(event.path.clone()); } FileSystemChangeKind::Rename => { if let Some(new_path) = &event.new_path { @@ -1669,52 +1667,24 @@ impl AppState { match event.kind { FileSystemChangeKind::Modify | FileSystemChangeKind::Create => { if buffer.dirty { - eprintln!("[FileWatch] Inactive file {:?} is dirty, ignoring auto-reload to prevent data loss", buffer.path); + // Inactive buffer dirty: giữ thay đổi chưa lưu, không auto-reload. buffer.missing_on_disk = false; } else { - eprintln!("[FileWatch] Reloading inactive file from disk: {:?}", buffer.path); - match std::fs::read_to_string(&buffer.path) { - Ok(content) => { - let reloaded_text = Rope::from_str(&content); - buffer.in_memory_text = Some(reloaded_text); - buffer.missing_on_disk = false; - buffer.dirty = false; - buffer.last_known_modified_time = std::fs::metadata(&buffer.path) - .and_then(|m| m.modified()) - .ok(); - let note = format!( - "auto reloaded inactive file from disk: {}", - buffer.path.display() - ); - report.notices.push(note); - report.workspace_reloaded = true; - } - Err(err) => { - buffer.missing_on_disk = !buffer.path.exists(); - let note = format!( - "auto reload skipped for inactive file {}: {}", - buffer.path.display(), - err - ); - report.notices.push(note); - } - } + // #4: worker đọc nội dung, không đọc ở đây. + report.pending_reload_paths.push(buffer.path.clone()); } } FileSystemChangeKind::Rename => { if let Some(new_path) = &event.new_path { let old_path = buffer.path.clone(); buffer.path = new_path.clone(); - buffer.language_id = crate::lsp::registry::language_profile_for_path(new_path) - .map(|profile| profile.language_id.to_string()); - + buffer.language_id = + crate::lsp::registry::language_profile_for_path(new_path) + .map(|profile| profile.language_id.to_string()); + if !buffer.dirty { - if let Ok(content) = std::fs::read_to_string(new_path) { - buffer.in_memory_text = Some(Rope::from_str(&content)); - buffer.missing_on_disk = false; - } else { - buffer.missing_on_disk = !new_path.exists(); - } + // #4: worker đọc nội dung tại path mới. + report.pending_reload_paths.push(new_path.clone()); } let note = format!( "inactive file renamed externally: {} -> {}", @@ -1740,6 +1710,162 @@ impl AppState { } } + report.pending_reload_paths.sort(); + report.pending_reload_paths.dedup(); Ok(report) } + + /// Phase 2 of the external-change pipeline: apply file contents the worker + /// read off the UI thread. Re-validates buffer state at apply time — the + /// user may have typed while the read was in flight, in which case the + /// reload becomes a conflict instead of silently overwriting their edits. + pub fn apply_external_file_contents( + &mut self, + files: &[ExternalFileRead], + ) -> ExternalChangeReport { + let mut report = ExternalChangeReport::default(); + + for file in files { + let Some(idx) = self.buffers.iter().position(|entry| { + matches!(&entry.content, BufferContent::Text(buffer) + if path_matches(&buffer.path, &file.path)) + }) else { + continue; + }; + let is_active = self.active_buffer_index == Some(idx); + + let Some(content) = file.content.as_deref() else { + if let Some(slot) = self.buffers.get_mut(idx) + && let BufferContent::Text(ref mut buffer) = slot.content + { + buffer.missing_on_disk = !file.path.exists(); + } + continue; + }; + + if is_active { + if self.is_dirty() { + let warning = format!( + "external change conflicts with unsaved edits: {}", + file.path.display() + ); + self.external_conflict = Some(warning.clone()); + self.external_notice = Some(warning.clone()); + report.conflict_detected = true; + report.conflict_path = Some(file.path.clone()); + report.notices.push(warning); + if let Some(slot) = self.buffers.get_mut(idx) + && let BufferContent::Text(ref mut buffer) = slot.content + { + buffer.in_memory_text = None; + } + continue; + } + + // Self-save echo hoặc nội dung không đổi: chỉ cập nhật mtime. + if self.text_string() == content { + if let Some(slot) = self.buffers.get_mut(idx) + && let BufferContent::Text(ref mut buffer) = slot.content + { + buffer.last_known_modified_time = file.modified_time; + } + continue; + } + + self.replace_text_buffer_preserving_view(content); + let _ = self.refresh_active_search_highlights(); + let reloaded_text = self.text.clone(); + if let Some(slot) = self.buffers.get_mut(idx) + && let BufferContent::Text(ref mut buffer) = slot.content + { + buffer.in_memory_text = Some(reloaded_text); + buffer.missing_on_disk = false; + buffer.dirty = false; + buffer.last_known_modified_time = file.modified_time; + } + self.dirty = false; + let note = format!( + "auto reloaded active file from disk: {}", + file.path.display() + ); + self.external_notice = Some(note.clone()); + self.external_conflict = None; + report.active_file_reloaded = true; + report.notices.push(note); + } else { + let Some(slot) = self.buffers.get_mut(idx) else { + continue; + }; + let BufferContent::Text(ref mut buffer) = slot.content else { + continue; + }; + if buffer.dirty { + // Người dùng sửa buffer trong lúc đọc — giữ bản chưa lưu. + continue; + } + let unchanged = buffer + .in_memory_text + .as_ref() + .is_some_and(|rope| rope == content); + buffer.missing_on_disk = false; + buffer.last_known_modified_time = file.modified_time; + if unchanged { + continue; + } + buffer.in_memory_text = Some(Rope::from_str(content)); + buffer.dirty = false; + let note = format!( + "auto reloaded inactive file from disk: {}", + buffer.path.display() + ); + report.notices.push(note); + report.inactive_reloaded_paths.push(buffer.path.clone()); + } + } + + report + } + + /// Parameters the shell needs to submit an async `RescanWorkspace` request. + pub fn workspace_rescan_request_params( + &self, + ) -> Option<( + PathBuf, + crate::workspace::model::WorkspaceIgnoreRules, + crate::workspace::scanner::WorkspaceScanOptions, + )> { + self.workspace_model + .as_ref() + .map(|workspace| workspace.rescan_request_params()) + } + + /// Apply a fresh workspace tree produced by the async `RescanWorkspace` + /// worker, then refresh everything that mirrors the tree (missing-on-disk + /// flags, open file picker). Returns whether anything user-visible changed. + pub fn apply_workspace_rescan( + &mut self, + root_path: &Path, + nodes: Vec, + ) -> Result { + let Some(workspace) = self.workspace_model.as_mut() else { + return Ok(false); + }; + if workspace.root_path != root_path { + // Stale result from a previous workspace — drop it. + return Ok(false); + } + workspace.apply_rescanned_nodes(nodes); + let mut changed = true; + if self.refresh_text_buffer_missing_on_disk_states() { + changed = true; + } + if self.is_file_picker_open() && self.refresh_file_picker_results_if_open()? { + let note = format!( + "file picker refreshed ({} results)", + self.file_picker_results().len() + ); + self.external_notice = Some(note); + } + Ok(changed) + } } diff --git a/src/app/app_state/settings.rs b/src/app/app_state/settings.rs index b4d532cb..c7301653 100644 --- a/src/app/app_state/settings.rs +++ b/src/app/app_state/settings.rs @@ -1,17 +1,46 @@ #[derive(Debug, Clone, PartialEq)] pub enum SettingItem { - ThemeSelector { current: String }, - FontFamily { current: String }, - FontSize { current: f32 }, - LineHeight { current: f32 }, - IndentTabWidth { current: u8 }, - IndentInsertSpaces { enabled: bool }, - InlineSuggestion { enabled: bool }, - SidebarWidth { current: i32 }, - RightSidebarWidth { current: i32 }, - BottomPanelHeight { current: i32 }, - UiRounding { enabled: bool, radius_px: f32 }, - EnableOutline { enabled: bool }, + ThemeSelector { + current: String, + }, + FontFamily { + current: String, + }, + FontSize { + current: f32, + }, + LineHeight { + current: f32, + }, + IndentTabWidth { + current: u8, + }, + IndentInsertSpaces { + enabled: bool, + }, + InlineSuggestion { + enabled: bool, + }, + SidebarWidth { + current: i32, + }, + RightSidebarWidth { + current: i32, + }, + BottomPanelHeight { + current: i32, + }, + UiRounding { + enabled: bool, + radius_px: f32, + }, + EnableOutline { + enabled: bool, + }, + /// window.scale_factor_override — None = follow the display ("Auto"). + UiScale { + current: Option, + }, } impl SettingItem { @@ -29,6 +58,7 @@ impl SettingItem { Self::BottomPanelHeight { .. } => "Bottom Dock Height", Self::UiRounding { .. } => "UI Rounding", Self::EnableOutline { .. } => "Panel Outlines", + Self::UiScale { .. } => "UI Scale", } } } @@ -43,6 +73,7 @@ pub enum SettingsEditingKind { SidebarWidth, RightSidebarWidth, BottomPanelHeight, + UiScale, } #[derive(Debug, Clone, PartialEq)] @@ -73,6 +104,7 @@ impl SettingsState { border_radius_px: f32, enable_outline: bool, inline_suggestion_enabled: bool, + ui_scale_override: Option, ) -> Self { Self { selected_index: 0, @@ -88,6 +120,9 @@ impl SettingsState { SettingItem::EnableOutline { enabled: enable_outline, }, + SettingItem::UiScale { + current: ui_scale_override, + }, // TYPOGRAPHY SettingItem::FontFamily { current: font_family.into(), @@ -184,6 +219,12 @@ impl SettingsState { "0".to_string() }, ), + SettingItem::UiScale { current } => ( + SettingsEditingKind::UiScale, + current + .map(|v| format!("{v:.2}")) + .unwrap_or_else(|| "auto".to_string()), + ), SettingItem::ThemeSelector { .. } | SettingItem::EnableOutline { .. } | SettingItem::IndentInsertSpaces { .. } diff --git a/src/app/app_state/state.rs b/src/app/app_state/state.rs index ed77c623..5a305148 100644 --- a/src/app/app_state/state.rs +++ b/src/app/app_state/state.rs @@ -1,6 +1,4 @@ -use super::overlays::{ - collect_search_highlights, is_completion_identifier_char, -}; +use super::overlays::{collect_search_highlights, is_completion_identifier_char}; use super::*; fn inline_suggestion_accept_prefix_byte_len(suggestion: &str) -> usize { @@ -492,6 +490,19 @@ impl AppState { self.set_search_query_internal("", false) } + /// Vim f/F/t/T motion + highlight: di chuyển cursor tới ký tự trên dòng hiện + /// tại VÀ set search query thành ký tự đó (như `*`) để mọi occurrence được + /// highlight và n/N nhảy tới/lui giữa các match toàn file. + pub fn find_char_motion_and_highlight( + &mut self, + kind: crate::core::commands::FindMotionKind, + target: char, + ) -> bool { + let moved = self.move_find_char(kind, target); + let highlighted = self.set_search_query_internal(&target.to_string(), false); + moved || highlighted + } + pub fn jump_to_line_and_column(&mut self, line_idx: usize, col_idx: usize) -> bool { if self.text.len_lines() == 0 { return false; @@ -915,6 +926,42 @@ impl AppState { true } + /// Prefix-matching retention: when the user types exactly the head of the + /// visible ghost text, consume that head and keep showing the rest instead + /// of clearing and re-requesting. Returns true only when a non-empty + /// remainder stays visible. + pub fn retain_inline_suggestion_for_typed_text(&mut self, typed: &str) -> bool { + let Some(suggestion) = self.inline_suggestion.as_ref() else { + return false; + }; + if typed.is_empty() || typed.len() >= suggestion.len() || !suggestion.starts_with(typed) { + return false; + } + let remaining = suggestion[typed.len()..].to_string(); + self.inline_suggestion = Some(remaining); + self.bump_revision(); + true + } + + /// Context around the caret for sanitizing model output: the current line + /// up to the caret, and up to `suffix_take` chars after the caret. + pub fn inline_suggestion_context(&self, suffix_take: usize) -> (String, String) { + let total = self.text.len_chars(); + let cursor = self.cursor_char_idx.min(total); + let (line, _) = self.cursor_line_col(); + let line_start = self + .text + .line_to_char(line.min(self.text.len_lines().saturating_sub(1))); + let line_prefix = if line_start <= cursor { + self.text.slice(line_start..cursor).to_string() + } else { + String::new() + }; + let suffix_end = cursor.saturating_add(suffix_take).min(total); + let suffix = self.text.slice(cursor..suffix_end).to_string(); + (line_prefix, suffix) + } + pub fn accept_inline_suggestion(&mut self) -> bool { let Some(suggestion) = self.inline_suggestion.clone() else { return false; @@ -1050,10 +1097,8 @@ impl AppState { }; // Use cached items for incremental filtering (much faster than re-requesting from LSP) - let mut filtered_items = super::overlays::filter_cached_completion_items( - &state.cached_full_items, - prefix, - ); + let mut filtered_items = + super::overlays::filter_cached_completion_items(&state.cached_full_items, prefix); let next_selected = if filtered_items.is_empty() { 0 } else { 0 }; let changed = state.filtered_items != filtered_items @@ -1251,3 +1296,95 @@ fn range_matches_rope( } text.slice(start_char_idx..end_char_idx).to_string() == expected } + +#[cfg(test)] +mod inline_suggestion_tests { + use super::AppState; + + fn state_with_text(text: &str) -> AppState { + AppState::from_text(std::path::PathBuf::from("inline_suggestion_test.rs"), text) + } + + #[test] + fn retain_consumes_matching_typed_char() { + let mut state = state_with_text(""); + assert!(state.set_inline_suggestion(Some("println".to_string()))); + assert!(state.retain_inline_suggestion_for_typed_text("p")); + assert_eq!(state.inline_suggestion(), Some("rintln")); + } + + #[test] + fn retain_rejects_mismatch_and_keeps_suggestion_for_caller_to_clear() { + let mut state = state_with_text(""); + assert!(state.set_inline_suggestion(Some("println".to_string()))); + assert!(!state.retain_inline_suggestion_for_typed_text("x")); + assert_eq!(state.inline_suggestion(), Some("println")); + } + + #[test] + fn retain_rejects_when_typed_text_exhausts_suggestion() { + let mut state = state_with_text(""); + assert!(state.set_inline_suggestion(Some("ok".to_string()))); + assert!(!state.retain_inline_suggestion_for_typed_text("ok")); + } + + #[test] + fn retain_handles_multichar_typed_text() { + let mut state = state_with_text(""); + assert!(state.set_inline_suggestion(Some("hello world".to_string()))); + assert!(state.retain_inline_suggestion_for_typed_text("hello")); + assert_eq!(state.inline_suggestion(), Some(" world")); + } + + #[test] + fn accept_full_inserts_at_cursor_and_clears() { + let mut state = state_with_text(""); + assert!(state.set_inline_suggestion(Some("fn main() {}".to_string()))); + assert!(state.accept_inline_suggestion()); + assert_eq!(state.text_string(), "fn main() {}"); + assert_eq!(state.inline_suggestion(), None); + } + + #[test] + fn accept_multiline_preserves_text_and_cursor_at_end() { + // Caret sits after one tab of indentation on an empty body line. + let initial = "func Sum() int {\n\ttotal := 0\n\t"; + let mut state = state_with_text(initial); + for _ in 0..initial.chars().count() { + state.move_right(); + } + // Model continuation with absolute indentation for lines 2+. + let suggestion = "for _, num := range nums {\n\t\ttotal += num\n\t}\n\treturn total"; + assert!(state.set_inline_suggestion(Some(suggestion.to_string()))); + assert!(state.accept_inline_suggestion()); + + let expected = format!("{initial}{suggestion}"); + assert_eq!(state.text_string(), expected); + // Cursor must land at the very end of the inserted text. + assert_eq!(state.cursor_char_idx(), expected.chars().count()); + let (line, col) = state.cursor_line_col(); + assert_eq!((line, col), (5, "\treturn total".chars().count())); + assert_eq!(state.inline_suggestion(), None); + } + + #[test] + fn accept_word_inserts_first_token_and_keeps_rest() { + let mut state = state_with_text(""); + assert!(state.set_inline_suggestion(Some("hello world".to_string()))); + assert!(state.accept_inline_suggestion_word()); + assert_eq!(state.text_string(), "hello"); + assert_eq!(state.inline_suggestion(), Some(" world")); + } + + #[test] + fn context_returns_line_prefix_and_suffix() { + let mut state = state_with_text("foo(\nbar"); + // Place caret after "foo(" on the first line. + for _ in 0..4 { + state.move_right(); + } + let (line_prefix, suffix) = state.inline_suggestion_context(200); + assert_eq!(line_prefix, "foo("); + assert_eq!(suffix, "\nbar"); + } +} diff --git a/src/app/app_state/tests.rs b/src/app/app_state/tests.rs index df6907ad..3c37e7f0 100644 --- a/src/app/app_state/tests.rs +++ b/src/app/app_state/tests.rs @@ -6,11 +6,13 @@ mod tests { use std::{ fs, path::PathBuf, - time::{Instant, SystemTime, UNIX_EPOCH}, + time::{Duration, SystemTime, UNIX_EPOCH}, }; use crate::app::command_palette::{CommandPaletteItem, CommandPaletteMode}; - use crate::async_runtime::message::{FilePreviewLine, FileSystemChangeKind, FileSystemEvent}; + use crate::async_runtime::message::{ + ExternalFileRead, FilePreviewLine, FileSystemChangeKind, FileSystemEvent, + }; use crate::config::keymap_config::KeyBinding; use crate::core::commands::{TextObjectKind, TextObjectModifier}; use crate::core::mode::{EditorMode, ModeEvent}; @@ -23,6 +25,52 @@ mod tests { .as_nanos(); std::env::temp_dir().join(format!("netherize_phase4_{suffix}_{nanos}.txt")) } + fn read_external_files(paths: &[PathBuf]) -> Vec { + paths + .iter() + .map(|path| ExternalFileRead { + path: path.clone(), + content: fs::read_to_string(path).ok(), + modified_time: fs::metadata(path).and_then(|m| m.modified()).ok(), + }) + .collect() + } + + impl AppState { + /// Test driver for the async external-change pipeline: runs phase 1 + /// (event triage), a synchronous stand-in for the worker (file reads + + /// workspace rescan), then phase 2 (content apply) — and merges the + /// reports so existing assertions keep working. + fn apply_external_file_events_for_test( + &mut self, + events: &[FileSystemEvent], + ) -> Result { + let mut report = self.apply_external_file_events(events)?; + if report.workspace_rescan_needed + && let Some((root, rules, options)) = self.workspace_rescan_request_params() + { + let scanner = crate::workspace::scanner::WorkspaceScanner::new(rules, options); + if let Ok(nodes) = scanner.scan(&root) { + let _ = self.apply_workspace_rescan(&root, nodes)?; + report.workspace_reloaded = true; + } + } + let files = read_external_files(&report.pending_reload_paths); + let applied = self.apply_external_file_contents(&files); + report.active_file_reloaded |= applied.active_file_reloaded; + report.conflict_detected |= applied.conflict_detected; + if report.conflict_path.is_none() { + report.conflict_path = applied.conflict_path; + } + report.workspace_reloaded |= !applied.inactive_reloaded_paths.is_empty(); + report + .inactive_reloaded_paths + .extend(applied.inactive_reloaded_paths); + report.notices.extend(applied.notices); + Ok(report) + } + } + fn unique_temp_dir(suffix: &str) -> std::path::PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -731,7 +779,7 @@ mod tests { let created_path = root.join("src/new_file.rs"); fs::write(&created_path, "pub fn new_file() {}\n").expect("write new file"); let report = state - .apply_external_file_events(&[FileSystemEvent { + .apply_external_file_events_for_test(&[FileSystemEvent { kind: FileSystemChangeKind::Create, path: created_path, new_path: None, @@ -761,6 +809,84 @@ mod tests { let _ = fs::remove_dir_all(root); } + #[test] + fn external_rename_without_new_path_on_existing_file_reloads_like_modify() { + // macOS FSEvents splits a rename into two events with one path each + // (new_path = None). Atomic saves (write temp + rename over the real + // file) arrive as Rename{new_path: None} on a path that still exists — + // that must reload immediately, not wait for the 3s poll. + let save_path = unique_temp_path("external_rename_modify"); + let mut state = AppState::new(save_path); + let root = unique_temp_dir("external_rename_modify"); + fs::create_dir_all(root.join("src")).expect("create src"); + let active = root.join("src/main.rs"); + fs::write(&active, "fn main() {}\n").expect("write initial"); + + state + .attach_workspace(root.clone()) + .expect("attach workspace should succeed"); + state.open_file(active.clone()).expect("open active file"); + + fs::write(&active, "fn main() { println!(\"atomic save\"); }\n") + .expect("write replacement content"); + let report = state + .apply_external_file_events_for_test(&[FileSystemEvent { + kind: FileSystemChangeKind::Rename, + path: active.clone(), + new_path: None, + }]) + .expect("apply external rename"); + + assert!(report.active_file_reloaded); + assert!(state.preview(64).contains("atomic save")); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn external_modify_of_inactive_buffer_is_reported_for_lsp_sync() { + let save_path = unique_temp_path("external_inactive_lsp"); + let mut state = AppState::new(save_path); + let root = unique_temp_dir("external_inactive_lsp"); + fs::create_dir_all(root.join("src")).expect("create src"); + let first = root.join("src/lib.rs"); + let second = root.join("src/main.rs"); + fs::write(&first, "pub fn lib() {}\n").expect("write first"); + fs::write(&second, "fn main() {}\n").expect("write second"); + + state + .attach_workspace(root.clone()) + .expect("attach workspace should succeed"); + state.open_file(first.clone()).expect("open first file"); + state.open_file(second.clone()).expect("open second file"); + + fs::write(&first, "pub fn lib() { /* edited externally */ }\n") + .expect("rewrite inactive file"); + let report = state + .apply_external_file_events_for_test(&[FileSystemEvent { + kind: FileSystemChangeKind::Modify, + path: first.clone(), + new_path: None, + }]) + .expect("apply external modify on inactive buffer"); + + assert!(!report.active_file_reloaded); + assert!( + report + .inactive_reloaded_paths + .iter() + .any(|path| path.ends_with("src/lib.rs")), + "inactive reload must be reported so the shell can re-sync the LSP overlay" + ); + assert!( + state + .buffer_text_for_path(&first) + .is_some_and(|text| text.contains("edited externally")) + ); + + let _ = fs::remove_dir_all(root); + } + #[test] fn external_modify_reloads_when_clean_and_warns_when_dirty() { let save_path = unique_temp_path("external"); @@ -777,7 +903,7 @@ mod tests { fs::write(&active, "fn main() { println!(\"reload\"); }\n").expect("write modified"); let report = state - .apply_external_file_events(&[FileSystemEvent { + .apply_external_file_events_for_test(&[FileSystemEvent { kind: FileSystemChangeKind::Modify, path: active.clone(), new_path: None, @@ -789,7 +915,7 @@ mod tests { state.insert_char('x'); let dirty_report = state - .apply_external_file_events(&[FileSystemEvent { + .apply_external_file_events_for_test(&[FileSystemEvent { kind: FileSystemChangeKind::Modify, path: active.clone(), new_path: None, @@ -813,8 +939,12 @@ mod tests { let mut state = AppState::new(save_path); let root = unique_temp_dir("external_inactive_modify"); fs::create_dir_all(root.join("src")).expect("create src"); - let inactive = fs::canonicalize(root.join("src")).expect("canonicalize src").join("inactive.rs"); - let active = fs::canonicalize(root.join("src")).expect("canonicalize src").join("active.rs"); + let inactive = fs::canonicalize(root.join("src")) + .expect("canonicalize src") + .join("inactive.rs"); + let active = fs::canonicalize(root.join("src")) + .expect("canonicalize src") + .join("active.rs"); fs::write(&inactive, "fn inactive() {}\n").expect("write inactive"); fs::write(&active, "fn active() {}\n").expect("write active"); @@ -824,9 +954,10 @@ mod tests { state.open_file(inactive.clone()).expect("open inactive"); state.open_file(active.clone()).expect("open active"); - fs::write(&inactive, "fn inactive() { println!(\"reload\"); }\n").expect("write modified inactive"); + fs::write(&inactive, "fn inactive() { println!(\"reload\"); }\n") + .expect("write modified inactive"); let report = state - .apply_external_file_events(&[FileSystemEvent { + .apply_external_file_events_for_test(&[FileSystemEvent { kind: FileSystemChangeKind::Modify, path: inactive.clone(), new_path: None, @@ -836,9 +967,11 @@ mod tests { assert!(report.workspace_reloaded); assert!(!report.active_file_reloaded); - let inactive_entry = state.buffers().iter().find(|b| { - matches!(&b.content, BufferContent::Text(t) if t.path == inactive) - }).unwrap(); + let inactive_entry = state + .buffers() + .iter() + .find(|b| matches!(&b.content, BufferContent::Text(t) if t.path == inactive)) + .unwrap(); if let BufferContent::Text(ref text_buf) = inactive_entry.content { assert_eq!( text_buf.in_memory_text.as_ref().unwrap().to_string(), @@ -859,8 +992,12 @@ mod tests { let mut state = AppState::new(save_path); let root = unique_temp_dir("external_inactive_delete"); fs::create_dir_all(root.join("src")).expect("create src"); - let inactive = fs::canonicalize(root.join("src")).expect("canonicalize src").join("inactive.rs"); - let active = fs::canonicalize(root.join("src")).expect("canonicalize src").join("active.rs"); + let inactive = fs::canonicalize(root.join("src")) + .expect("canonicalize src") + .join("inactive.rs"); + let active = fs::canonicalize(root.join("src")) + .expect("canonicalize src") + .join("active.rs"); fs::write(&inactive, "fn inactive() {}\n").expect("write inactive"); fs::write(&active, "fn active() {}\n").expect("write active"); @@ -872,7 +1009,7 @@ mod tests { fs::remove_file(&inactive).expect("remove inactive file"); let report = state - .apply_external_file_events(&[FileSystemEvent { + .apply_external_file_events_for_test(&[FileSystemEvent { kind: FileSystemChangeKind::Delete, path: inactive.clone(), new_path: None, @@ -881,9 +1018,11 @@ mod tests { assert!(report.workspace_reloaded); - let inactive_entry = state.buffers().iter().find(|b| { - matches!(&b.content, BufferContent::Text(t) if t.path == inactive) - }).unwrap(); + let inactive_entry = state + .buffers() + .iter() + .find(|b| matches!(&b.content, BufferContent::Text(t) if t.path == inactive)) + .unwrap(); if let BufferContent::Text(ref text_buf) = inactive_entry.content { assert!(text_buf.missing_on_disk); } else { @@ -937,14 +1076,10 @@ mod tests { state.save_file().expect("save file"); - fs::write( - &file_path, - "changed externally but should be ignored in debounce window\n", - ) - .expect("rewrite file quickly"); - + // Self-save THẬT: đĩa == bộ nhớ (chính nội dung vừa lưu). Modify echo do OS + // bắn ra phải bị bỏ qua, không reload, không nhảy con trỏ. let report = state - .apply_external_file_events(&[FileSystemEvent { + .apply_external_file_events_for_test(&[FileSystemEvent { kind: FileSystemChangeKind::Modify, path: file_path.clone(), new_path: None, @@ -953,7 +1088,36 @@ mod tests { assert!(!report.active_file_reloaded); assert_eq!(state.cursor_char_idx(), cursor_before); - assert!(!state.preview(128).contains("changed externally")); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn external_edit_within_self_save_window_still_reloads() { + // #3: edit NGOÀI thật xảy ra ngay sau khi save (trong cửa sổ debounce) KHÔNG + // được nuốt — vì nội dung đĩa khác bộ nhớ thì đó là thay đổi thật, phải reload. + let root = unique_temp_dir("external_edit_in_window"); + fs::create_dir_all(&root).expect("create temp dir"); + let file_path = root.join("main.rs"); + fs::write(&file_path, "one\ntwo\nthree\n").expect("write initial"); + + let mut state = AppState::new(unique_temp_path("external_edit_in_window_fallback")); + state.open_file(file_path.clone()).expect("open file"); + state.save_file().expect("save file"); + + // Ghi nội dung khác ngay lập tức (vẫn trong SELF_SAVE_IGNORE_WINDOW). + fs::write(&file_path, "external content arrived\n").expect("rewrite file quickly"); + + let report = state + .apply_external_file_events_for_test(&[FileSystemEvent { + kind: FileSystemChangeKind::Modify, + path: file_path.clone(), + new_path: None, + }]) + .expect("apply modify event"); + + assert!(report.active_file_reloaded); + assert!(state.preview(64).contains("external content")); let _ = fs::remove_dir_all(root); } @@ -976,10 +1140,9 @@ mod tests { state.move_up(); fs::write(&file_path, "x\n").expect("write shorter file"); - state.last_saved_at = Some(Instant::now() - AppState::SELF_SAVE_IGNORE_WINDOW); let report = state - .apply_external_file_events(&[FileSystemEvent { + .apply_external_file_events_for_test(&[FileSystemEvent { kind: FileSystemChangeKind::Modify, path: file_path.clone(), new_path: None, @@ -1022,7 +1185,7 @@ mod tests { let created_path = root.join("src/created_after_delete.rs"); fs::write(&created_path, "pub fn created() {}\n").expect("write created file"); let report = state - .apply_external_file_events(&[ + .apply_external_file_events_for_test(&[ FileSystemEvent { kind: FileSystemChangeKind::Modify, path: active.clone(), @@ -1088,7 +1251,7 @@ mod tests { fs::rename(&old_path, &new_path).expect("rename file"); let report = state - .apply_external_file_events(&[FileSystemEvent { + .apply_external_file_events_for_test(&[FileSystemEvent { kind: FileSystemChangeKind::Modify, path: old_path.clone(), new_path: None, @@ -1719,10 +1882,8 @@ mod tests { #[test] fn check_and_reload_external_changes_reloads_modified_files() { - let temp_dir = std::env::temp_dir().join(format!( - "netherize_external_reload_{}", - std::process::id() - )); + let temp_dir = + std::env::temp_dir().join(format!("netherize_external_reload_{}", std::process::id())); fs::create_dir_all(&temp_dir).expect("create temp dir"); let file_path = temp_dir.join("test.txt"); fs::write(&file_path, "original content").expect("write test file"); @@ -1730,12 +1891,11 @@ mod tests { let mut state = AppState::new(file_path.clone()); state.open_file(file_path.clone()).expect("open file"); - + let mut last_checked = HashMap::new(); // First check: populates the check time registry - let (reloaded, active_reloaded) = state.check_and_reload_external_changes(&mut last_checked); - assert!(reloaded.is_empty()); - assert!(!active_reloaded); + let changed = state.collect_externally_modified_open_buffers(&mut last_checked); + assert!(changed.is_empty()); assert!(last_checked.contains_key(&file_path)); // Sleep briefly to ensure time difference is detectable if filesystem timestamps are coarse @@ -1744,11 +1904,12 @@ mod tests { // Modify file externally on disk fs::write(&file_path, "updated content").expect("write update"); - // Second check: should detect modify and reload - let (reloaded, active_reloaded) = state.check_and_reload_external_changes(&mut last_checked); - assert_eq!(reloaded.len(), 1); - assert_eq!(reloaded[0], file_path); - assert!(active_reloaded); + // Second check: should detect the modify; contents are applied via the + // (worker-simulated) read + apply phase. + let changed = state.collect_externally_modified_open_buffers(&mut last_checked); + assert_eq!(changed, vec![file_path.clone()]); + let applied = state.apply_external_file_contents(&read_external_files(&changed)); + assert!(applied.active_file_reloaded); assert_eq!(state.text_string(), "updated content"); let _ = fs::remove_dir_all(temp_dir); @@ -1775,11 +1936,11 @@ mod tests { fs::write(&file_path, "modified before tick").expect("write update"); let mut last_checked = HashMap::new(); - // First check: should detect the modify against last_known_modified_time and reload - let (reloaded, active_reloaded) = state.check_and_reload_external_changes(&mut last_checked); - assert_eq!(reloaded.len(), 1); - assert_eq!(reloaded[0], file_path); - assert!(active_reloaded); + // First check: should detect the modify against last_known_modified_time + let changed = state.collect_externally_modified_open_buffers(&mut last_checked); + assert_eq!(changed, vec![file_path.clone()]); + let applied = state.apply_external_file_contents(&read_external_files(&changed)); + assert!(applied.active_file_reloaded); assert_eq!(state.text_string(), "modified before tick"); let _ = fs::remove_dir_all(temp_dir); diff --git a/src/app/app_state/workspace.rs b/src/app/app_state/workspace.rs index ab9a4383..52d1d661 100644 --- a/src/app/app_state/workspace.rs +++ b/src/app/app_state/workspace.rs @@ -138,9 +138,9 @@ impl AppState { let Some(idx) = self.active_buffer_index else { return false; }; - self.buffers.get(idx).is_some_and(|entry| { - matches!(&entry.content, BufferContent::Text(buffer) if buffer.missing_on_disk) - }) + self.buffers.get(idx).is_some_and( + |entry| matches!(&entry.content, BufferContent::Text(buffer) if buffer.missing_on_disk), + ) } } diff --git a/src/app/event_loop/application.rs b/src/app/event_loop/application.rs index a0b2f166..49343775 100644 --- a/src/app/event_loop/application.rs +++ b/src/app/event_loop/application.rs @@ -2,11 +2,15 @@ use super::*; #[cfg(target_os = "macos")] use winit::platform::macos::WindowAttributesExtMacOS; use winit::{ - event::ElementState, + event::{ElementState, MouseButton, MouseScrollDelta}, keyboard::{Key, KeyCode, NamedKey, PhysicalKey}, }; const FOCUS_RING_THICKNESS: f32 = 2.0; +const TERMINAL_SAFE_INSET_X: f32 = 2.0; +/// Delay before the which-key overlay appears for a pending chord — long +/// enough that fast chords never flash it, short enough to help a stuck user. +const WHICHKEY_DELAY: Duration = Duration::from_millis(300); #[cfg(target_os = "macos")] fn apply_platform_window_chrome( @@ -65,6 +69,63 @@ fn visible_region_bounds( }) } +fn point_in_bounds(position: (f32, f32), bounds: [f32; 4]) -> bool { + let (x, y) = position; + x >= bounds[0] && x <= bounds[0] + bounds[2] && y >= bounds[1] && y <= bounds[1] + bounds[3] +} + +fn terminal_cell_at_position( + position: (f32, f32), + bounds: [f32; 4], + cols: usize, + rows: usize, + panel_padding: f32, + font_size: f32, + line_height: f32, +) -> Option<(usize, usize)> { + if cols == 0 || rows == 0 || !point_in_bounds(position, bounds) { + return None; + } + + let origin_x = bounds[0] + panel_padding + TERMINAL_SAFE_INSET_X; + let origin_y = bounds[1] + panel_padding; + let cell_width = (font_size * 0.6).max(1.0); + let cell_height = line_height.max(1.0); + let x = position.0 - origin_x; + let y = position.1 - origin_y; + if x < 0.0 || y < 0.0 { + return None; + } + + let col = (x / cell_width).floor() as usize; + let row = (y / cell_height).floor() as usize; + if col >= cols || row >= rows { + return None; + } + // Xterm mouse coordinates are 1-based column/row values. + Some((col + 1, row + 1)) +} + +fn sgr_mouse_sequence(button_code: u8, col: usize, row: usize, pressed: bool) -> String { + let suffix = if pressed { 'M' } else { 'm' }; + format!("\x1b[<{button_code};{col};{row}{suffix}") +} + +/// SGR wheel events are press-only: button 64 scrolls up, 65 scrolls down. +fn sgr_wheel_sequence(scroll_up: bool, col: usize, row: usize) -> String { + let code = if scroll_up { 64 } else { 65 }; + sgr_mouse_sequence(code, col, row, true) +} + +fn mouse_button_code(button: MouseButton) -> Option { + match button { + MouseButton::Left => Some(0), + MouseButton::Middle => Some(1), + MouseButton::Right => Some(2), + _ => None, + } +} + fn symbol_kind_label(kind: &str) -> String { match kind { "Class" => "class".to_string(), @@ -166,6 +227,141 @@ fn build_editor_breadcrumb_segments( segments } +impl AppShell { + fn current_right_sidebar_bounds(&self) -> Option<[f32; 4]> { + let layout = self + .layout_engine + .compute(self.window_size, &self.panel_state); + let flat_regions: Vec<_> = layout.model.flatten(); + visible_region_bounds(&flat_regions, RegionId::RightSidebar) + } + + fn right_terminal_active(&self) -> bool { + self.panel_state.right.visible + && self.panel_state.right.active_tab_id() == Some(PanelTabId::Terminal) + } + + fn right_terminal_mouse_cell_at(&self, position: (f32, f32)) -> Option<(usize, usize)> { + if !self.right_terminal_active() { + return None; + } + let bounds = self.current_right_sidebar_bounds()?; + let scaled_ui = scale_ui_config(&self.ui_config, self.runtime_scale); + let panel_padding = scaled_ui + .layout + .inner_padding + .max(scaled_ui.spacing.panel_padding); + terminal_cell_at_position( + position, + bounds, + self.right_terminal_grid.cols, + self.right_terminal_grid.rows, + panel_padding, + self.theme.ui.panel_font_size, + self.theme.ui.panel_line_height, + ) + } + + fn right_terminal_mouse_targeted(&self) -> bool { + if !self.right_terminal_active() { + return false; + } + let Some(bounds) = self.current_right_sidebar_bounds() else { + return false; + }; + self.last_cursor_position + .is_some_and(|position| point_in_bounds(position, bounds)) + || self.focus_manager.current() == FocusTarget::RightSidebar + } + + fn focus_right_terminal_for_mouse(&mut self) { + let _ = self.handle_command(Command::AiChatFocus); + } + + fn handle_right_terminal_mouse_wheel(&mut self, delta: MouseScrollDelta) -> bool { + if !self.right_terminal_mouse_targeted() { + return false; + } + + let line_height = self.theme.ui.panel_line_height.max(1.0) as f64; + let Some((scroll_up, steps)) = (match delta { + MouseScrollDelta::LineDelta(_, y) if y.abs() > f32::EPSILON => { + Some((y > 0.0, y.abs().ceil() as usize)) + } + MouseScrollDelta::PixelDelta(position) if position.y.abs() > f64::EPSILON => Some(( + position.y > 0.0, + (position.y.abs() / line_height).ceil() as usize, + )), + _ => None, + }) else { + return false; + }; + + self.focus_right_terminal_for_mouse(); + + // opencode keeps its chat history inside its own TUI viewport, so the + // wheel must be forwarded as SGR mouse events for opencode to scroll + // itself (its mouse capture is on by default). Scrolling our grid + // scrollback would show nothing — full-screen repaints never reach it. + let (col, row) = self + .last_cursor_position + .and_then(|position| self.right_terminal_mouse_cell_at(position)) + .unwrap_or(( + (self.right_terminal_grid.cols / 2).max(1), + (self.right_terminal_grid.rows / 2).max(1), + )); + let mut changed = false; + for _ in 0..steps.max(1).min(24) { + changed |= self.handle_command(Command::TerminalWriteInput(sgr_wheel_sequence( + scroll_up, col, row, + ))); + } + changed + } + + fn handle_right_terminal_mouse_input( + &mut self, + button: MouseButton, + state: ElementState, + ) -> bool { + let Some(base_code) = mouse_button_code(button) else { + return false; + }; + let Some(position) = self.last_cursor_position else { + return false; + }; + if !self.right_terminal_active() { + return false; + } + let Some(bounds) = self.current_right_sidebar_bounds() else { + return false; + }; + if !point_in_bounds(position, bounds) { + return false; + } + + let cell = self.right_terminal_mouse_cell_at(position); + self.focus_right_terminal_for_mouse(); + + if let Some((col, row)) = cell { + let modifiers = self.input_handler.current_modifiers(); + let mut code = base_code; + if modifiers.shift_key() { + code += 4; + } + if modifiers.alt_key() { + code += 8; + } + if modifiers.control_key() { + code += 16; + } + let sequence = sgr_mouse_sequence(code, col, row, state == ElementState::Pressed); + let _ = self.handle_command(Command::TerminalWriteInput(sequence)); + } + true + } +} + impl ApplicationHandler for AppShell { fn resumed(&mut self, event_loop: &ActiveEventLoop) { if self.window.is_some() { @@ -234,6 +430,7 @@ impl ApplicationHandler for AppShell { self.startup_subsystems(); self.update_window_title(); + self.show_first_run_tour_if_needed(); self.request_redraw(); } @@ -277,6 +474,12 @@ impl ApplicationHandler for AppShell { self.request_redraw(); } WindowEvent::ScaleFactorChanged { scale_factor, .. } => { + // Refresh the cached physical size before recomputing: when the + // window crosses monitors the old physical size belongs to the + // previous scale factor and inflates content_scale (UI zoom bug). + if let Some(window) = self.window.as_ref() { + self.window_size = window.inner_size(); + } self.update_runtime_scaling_for_window(scale_factor); self.request_redraw(); } @@ -408,19 +611,27 @@ impl ApplicationHandler for AppShell { return; } if let Key::Named(NamedKey::F5) = key_event.logical_key { - eprintln!("[DAP LOG] [F5 Application] WindowEvent::KeyboardInput F5 pressed: {:?}", key_event); + eprintln!( + "[DAP LOG] [F5 Application] WindowEvent::KeyboardInput F5 pressed: {:?}", + key_event + ); } let context = self.build_context(); let outcome = self.input_handler .translate_key_event(&key_event, &self.input_map, context); if let Key::Named(NamedKey::F5) = key_event.logical_key { - eprintln!("[DAP LOG] [F5 Application] WindowEvent F5 translation outcome: {:?}", outcome); + eprintln!( + "[DAP LOG] [F5 Application] WindowEvent F5 translation outcome: {:?}", + outcome + ); } match outcome { Some(InputRouteOutcome::Dispatch(translated)) => { if let Command::DebugStart = &translated.command { - eprintln!("[DAP LOG] [F5 Application] WindowEvent F5 translated to Command::DebugStart"); + eprintln!( + "[DAP LOG] [F5 Application] WindowEvent F5 translated to Command::DebugStart" + ); } if self .handle_command_with_count(translated.command, translated.repeat_count) @@ -428,11 +639,27 @@ impl ApplicationHandler for AppShell { self.request_redraw(); } } - Some(InputRouteOutcome::NoDispatch { .. }) | None => {} + Some(InputRouteOutcome::NoDispatch { .. }) => { + // Pending state may have changed (chord started, advanced, + // or cancelled by Esc/timeout): refresh so the statusbar + // pending keys and the which-key overlay track it. + self.request_redraw(); + } + None => {} + } + } + WindowEvent::CursorMoved { position, .. } => { + self.last_cursor_position = Some((position.x as f32, position.y as f32)); + } + WindowEvent::MouseWheel { delta, .. } => { + if self.handle_right_terminal_mouse_wheel(delta) { + self.request_redraw(); + } else if self.invalidate_editor_overlays() { + self.request_redraw(); } } - WindowEvent::MouseWheel { .. } => { - if self.invalidate_editor_overlays() { + WindowEvent::MouseInput { state, button, .. } => { + if self.handle_right_terminal_mouse_input(button, state) { self.request_redraw(); } } @@ -444,6 +671,7 @@ impl ApplicationHandler for AppShell { fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { self.flush_pending_parse_after_debounce(); self.flush_pending_git_diff_after_debounce(); + self.enforce_ai_inline_anchor(); self.flush_pending_ai_inline_completion(); self.flush_pending_lsp_did_change_after_debounce(); self.flush_pending_lsp_completion_after_debounce(); @@ -467,15 +695,23 @@ impl ApplicationHandler for AppShell { self.request_redraw(); } let now = Instant::now(); - if now.duration_since(self.last_external_file_check) >= Duration::from_secs(1) { + // #6: notify watcher giờ đã wake loop & realtime nên poll chỉ còn là safety-net + // (file ngoài tầm watcher, watcher chết, mtime cùng giây…). 3s đủ, đỡ wake CPU. + if now.duration_since(self.last_external_file_check) >= Duration::from_secs(3) { self.last_external_file_check = now; - let (reloaded_paths, active_reloaded) = self.app_state.check_and_reload_external_changes(&mut self.last_external_file_check_times); - if !reloaded_paths.is_empty() { - if active_reloaded { - self.invalidate_highlights_and_parse_active_buffer(); - self.force_flush_lsp_did_change_for_active_file(); - } - self.request_redraw(); + // #4: poll chỉ stat mtime; nội dung được worker đọc và áp về qua + // ExternalFilesRead -> apply_external_file_contents (kèm LSP sync). + let changed_paths = self + .app_state + .collect_externally_modified_open_buffers(&mut self.last_external_file_check_times); + if !changed_paths.is_empty() { + self.submit(RequestSpec { + revision_id: 0, + topic: RequestTopic::WorkspaceWatch, + payload: WorkerRequestPayload::ReadExternalFiles { + paths: changed_paths, + }, + }); } } if self.tick_caret_blink() { @@ -487,19 +723,26 @@ impl ApplicationHandler for AppShell { if self.clear_expired_transient_toast() { self.request_redraw(); } + if self.tick_whichkey_delay() { + self.request_redraw(); + } if self.app_state.workspace_is_inputting_filter() { self.sidebar_needs_layout = true; self.request_redraw(); } let mut next_deadline = Some(self.next_git_branch_refresh_deadline()); - let external_check_deadline = self.last_external_file_check + Duration::from_secs(1); + let external_check_deadline = self.last_external_file_check + Duration::from_secs(3); next_deadline = Some(match next_deadline { Some(existing) => existing.min(external_check_deadline), None => external_check_deadline, }); if let Some(git_status_deadline) = self.next_workspace_git_status_refresh_deadline() { - next_deadline = Some(next_deadline.unwrap_or(git_status_deadline).min(git_status_deadline)); + next_deadline = Some( + next_deadline + .unwrap_or(git_status_deadline) + .min(git_status_deadline), + ); } if let Some(lsp_deadline) = self.next_lsp_did_change_flush_deadline() { next_deadline = Some(match next_deadline { @@ -525,6 +768,15 @@ impl ApplicationHandler for AppShell { None => lsp_retry_deadline, }); } + if !self.whichkey_redraw_fired + && let Some((_, started_at)) = self.input_handler.pending_chord_sequence() + { + let whichkey_deadline = started_at + WHICHKEY_DELAY; + next_deadline = Some(match next_deadline { + Some(existing) => existing.min(whichkey_deadline), + None => whichkey_deadline, + }); + } if let Some(deadline) = next_deadline { event_loop.set_control_flow(ControlFlow::WaitUntil(deadline)); @@ -560,6 +812,25 @@ impl ApplicationHandler for AppShell { } impl AppShell { + /// True exactly once when a pending chord crosses `WHICHKEY_DELAY`, so the + /// frame that first shows the which-key overlay gets scheduled. + fn tick_whichkey_delay(&mut self) -> bool { + match self.input_handler.pending_chord_sequence() { + Some((_, started_at)) => { + if !self.whichkey_redraw_fired && started_at.elapsed() >= WHICHKEY_DELAY { + self.whichkey_redraw_fired = true; + true + } else { + false + } + } + None => { + self.whichkey_redraw_fired = false; + false + } + } + } + fn tick_smooth_scroll_animation(&mut self) -> bool { // Global kill-switch: disable smooth scroll and always snap to target. self.last_scroll_animation_tick = Instant::now(); @@ -956,7 +1227,9 @@ impl AppShell { renderer.clear_buffer_terminal(); renderer.clear_editor_content(); renderer.update_help_buffer_content(help, center_bounds); - } else if let Some(extensions) = self.app_state.active_extensions_manager_buffer() { + } else if let Some(extensions) = + self.app_state.active_extensions_manager_buffer() + { renderer.set_editor_breadcrumb_segments(Vec::new()); renderer.clear_welcome_logo(); renderer.clear_buffer_terminal(); @@ -996,11 +1269,15 @@ impl AppShell { syntax_spans_to_styled(&effective_highlights, &text, &self.theme); styled_spans .extend(diagnostic_spans_to_styled(&self.app_state, &self.theme)); - let breakpoint_lines = if let Some(active_file) = self.app_state.active_file() { - self.breakpoints.get(active_file).cloned().unwrap_or_default() - } else { - Vec::new() - }; + let breakpoint_lines = + if let Some(active_file) = self.app_state.active_file() { + self.breakpoints + .get(active_file) + .cloned() + .unwrap_or_default() + } else { + Vec::new() + }; renderer.update_editor_content( &text, &self.app_state, @@ -1008,17 +1285,19 @@ impl AppShell { &styled_spans, &breakpoint_lines, ); - let (dap_active, dap_paused, should_clear) = if let Some(session) = &self.dap_session { - let state = session.state.try_lock(); - let terminated = state.as_ref().map(|s| s.terminated).unwrap_or(false); - if terminated { - (false, false, true) + let (dap_active, dap_paused, should_clear) = + if let Some(session) = &self.dap_session { + let state = session.state.try_lock(); + let terminated = + state.as_ref().map(|s| s.terminated).unwrap_or(false); + if terminated { + (false, false, true) + } else { + (true, state.map(|s| s.paused).unwrap_or(false), false) + } } else { - (true, state.map(|s| s.paused).unwrap_or(false), false) - } - } else { - (false, false, false) - }; + (false, false, false) + }; if should_clear { self.dap_session = None; } @@ -1120,19 +1399,19 @@ impl AppShell { && let Some(renderer) = self.renderer.as_mut() { let (cursor_line, cursor_col) = self.app_state.cursor_line_col(); - let breadcrumb_viewport_changed = renderer.set_editor_breadcrumb_segments( - build_editor_breadcrumb_segments( + let breadcrumb_viewport_changed = + renderer.set_editor_breadcrumb_segments(build_editor_breadcrumb_segments( &self.cached_document_symbols, cursor_line, cursor_col, &self.theme, - ), - ); - let breakpoint_lines = if let Some(active_file) = self.app_state.active_file() { - self.breakpoints.get(active_file).cloned().unwrap_or_default() - } else { - Vec::new() - }; + )); + let breakpoint_lines = self + .app_state + .active_file() + .and_then(|active_file| self.breakpoints.get(active_file)) + .cloned() + .unwrap_or_default(); if breadcrumb_viewport_changed { let effective_highlights = crate::syntax::highlight::overlay_highlight_layers( @@ -1152,19 +1431,24 @@ impl AppShell { &breakpoint_lines, ); } else { - renderer.update_editor_caret(&self.app_state, center_bounds, &breakpoint_lines); + renderer.update_editor_caret( + &self.app_state, + center_bounds, + &breakpoint_lines, + ); } - let (dap_active, dap_paused, should_clear) = if let Some(session) = &self.dap_session { - let state = session.state.try_lock(); - let terminated = state.as_ref().map(|s| s.terminated).unwrap_or(false); - if terminated { - (false, false, true) + let (dap_active, dap_paused, should_clear) = + if let Some(session) = &self.dap_session { + let state = session.state.try_lock(); + let terminated = state.as_ref().map(|s| s.terminated).unwrap_or(false); + if terminated { + (false, false, true) + } else { + (true, state.map(|s| s.paused).unwrap_or(false), false) + } } else { - (true, state.map(|s| s.paused).unwrap_or(false), false) - } - } else { - (false, false, false) - }; + (false, false, false) + }; if should_clear { self.dap_session = None; } @@ -1291,33 +1575,41 @@ impl AppShell { } let visible = self.dap_panel_state.visible_rows(); let selected = self.dap_panel_state.selected_row; - visible.into_iter().enumerate().map(|(idx, r)| { - let arrow = self.theme.sidebar_arrow(r.expandable, r.expanded).to_string(); - let icon = if r.depth == 0 { - match r.section_index { - 0 => "󰏫", // Variables - 1 => "󰛢", // Watch - 2 => "󰢱", // CallStack - 3 => "󰔧", // Breakpoints - _ => "•", - }.to_string() - } else { - "•".to_string() - }; - SidebarRow { - path: None, - depth: r.depth, - arrow, - nerd_icon: icon, - icon_color: self.theme.icons.default_file.color.as_f32(), - label: r.label, - prefix_marker: None, - prefix_color: None, - git_marker: None, - git_color: None, - is_selected: idx == selected, - } - }).collect::>() + visible + .into_iter() + .enumerate() + .map(|(idx, r)| { + let arrow = self + .theme + .sidebar_arrow(r.expandable, r.expanded) + .to_string(); + let icon = if r.depth == 0 { + match r.section_index { + 0 => "󰏫", // Variables + 1 => "󰛢", // Watch + 2 => "󰢱", // CallStack + 3 => "󰔧", // Breakpoints + _ => "•", + } + .to_string() + } else { + "•".to_string() + }; + SidebarRow { + path: None, + depth: r.depth, + arrow, + nerd_icon: icon, + icon_color: self.theme.icons.default_file.color.as_f32(), + label: r.label, + prefix_marker: None, + prefix_color: None, + git_marker: None, + git_color: None, + is_selected: idx == selected, + } + }) + .collect::>() } else { build_sidebar_rows( &self.explorer_snapshot.entries, @@ -1358,11 +1650,12 @@ impl AppShell { let ai_chat_active = self.panel_state.right.visible && right_sidebar_bounds.is_some() && self.panel_state.right.active_tab_id() == Some(PanelTabId::AiChat); - let markdown_preview_uses_ai_chat_pipeline = self.app_state.active_buffer_is_markdown_preview() - || (self.panel_state.right.visible - && right_sidebar_bounds.is_some() - && self.panel_state.right.active_tab_id() == Some(PanelTabId::MarkdownPreview) - && self.app_state.markdown_preview.visible); + let markdown_preview_uses_ai_chat_pipeline = + self.app_state.active_buffer_is_markdown_preview() + || (self.panel_state.right.visible + && right_sidebar_bounds.is_some() + && self.panel_state.right.active_tab_id() == Some(PanelTabId::MarkdownPreview) + && self.app_state.markdown_preview.visible); if ai_chat_active { let history_bounds = visible_region_bounds(&flat_regions, RegionId::AiChatHistory); let input_bounds = visible_region_bounds(&flat_regions, RegionId::AiChatInput); @@ -1598,6 +1891,50 @@ impl AppShell { .is_indexing(profile.key) .then(|| "Indexing symbols…".to_string()) }); + let lsp_indicator = { + use crate::render::renderer::LspStatusIndicator; + let active_profile = (!show_welcome) + .then(|| { + self.app_state + .active_file() + .and_then(crate::lsp::registry::language_profile_for_path) + }) + .flatten(); + match active_profile { + None => LspStatusIndicator::NotApplicable, + Some(profile) => { + if let Some(pending) = &self.pending_lsp_server { + LspStatusIndicator::Starting(pending.server_name.clone()) + } else if let Some(guide) = &self.active_lsp_guide { + LspStatusIndicator::Missing(guide.binary.clone()) + } else if let Some(active) = self + .active_lsp_server + .as_ref() + .filter(|active| active.server_name == profile.lsp_binary) + { + LspStatusIndicator::Running(active.server_name.clone()) + } else { + LspStatusIndicator::Inactive + } + } + } + }; + let ai_status = if show_welcome { + None + } else { + self.ai_config.inline_completion().map(|_| { + let cooling_down = self + .ai_inline_cooldown_until + .is_some_and(|until| std::time::Instant::now() < until); + if cooling_down { + crate::render::renderer::AiInlineStatus::Error + } else if self.ai_inline_inflight { + crate::render::renderer::AiInlineStatus::Loading + } else { + crate::render::renderer::AiInlineStatus::Ready + } + }) + }; let pill_quads = renderer.update_statusbar_content( mode, &pending_keys, @@ -1613,6 +1950,8 @@ impl AppShell { self.pending_lsp_server.is_some(), self.lsp_loading_frame, lsp_progress_label.as_deref(), + &lsp_indicator, + ai_status, self.runtime_versions.venv_name.as_deref(), self.runtime_versions.python_version.as_deref(), self.runtime_versions.node_version.as_deref(), @@ -1703,7 +2042,10 @@ impl AppShell { let bounds_changed = self.last_terminal_bounds != Some(bottom_bounds); let grid_changed = self.sync_terminal_layout(terminal_content_bounds); - if (self.terminal_needs_layout || bounds_changed || grid_changed || is_debug_console) + if (self.terminal_needs_layout + || bounds_changed + || grid_changed + || is_debug_console) && let Some(renderer) = self.renderer.as_mut() { let grid = if is_debug_console { @@ -1773,6 +2115,34 @@ impl AppShell { } } + // ── Which-key overlay: pending chord continuations ─────────────────── + let whichkey_model = self + .input_handler + .pending_chord_sequence() + .filter(|(_, started_at)| started_at.elapsed() >= WHICHKEY_DELAY) + .map(|(sequence, _)| { + ( + self.input_handler.get_pending_keys(), + self.input_map + .whichkey_entries(sequence, self.build_context()), + ) + }) + .filter(|(_, entries)| !entries.is_empty()); + if let Some((prefix_label, entries)) = whichkey_model { + let statusbar_h = layout + .model + .find(RegionId::StatusBar) + .map(|region| region.height) + .unwrap_or(0.0); + if let Some(renderer) = self.renderer.as_mut() { + let w = self.window_size.width as f32; + let h = self.window_size.height as f32; + renderer.update_whichkey_popup(&prefix_label, &entries, w, h, statusbar_h); + } + } else if let Some(renderer) = self.renderer.as_mut() { + renderer.clear_whichkey_popup(); + } + // ── LSP Install Guide Popup (always on top) ───────────────────────── // Render sau tất cả overlay khác để popup nổi lên trên cùng. if let Some(guide) = self.active_lsp_guide.clone() @@ -1915,7 +2285,9 @@ fn focus_ring_instances( mod tests { use super::{ breadcrumb_segment_text, build_editor_breadcrumb_segments, focus_ring_instances, - focus_target_region_id, statusbar_source_path_label, visible_region_bounds, + focus_target_region_id, mouse_button_code, point_in_bounds, sgr_mouse_sequence, + sgr_wheel_sequence, statusbar_source_path_label, terminal_cell_at_position, + visible_region_bounds, }; use crate::async_runtime::message::{ LspDocumentSymbol, LspDocumentSymbolSegment, LspPosition, LspRange, @@ -1926,6 +2298,7 @@ mod tests { region_model::{FlatRegion, RegionBounds, RegionId}, }; use std::path::Path; + use winit::event::MouseButton; #[test] fn focus_target_region_id_maps_center_editor() { @@ -1990,13 +2363,57 @@ mod tests { }, ]; - assert_eq!(visible_region_bounds(®ions, RegionId::RightSidebar), None); + assert_eq!( + visible_region_bounds(®ions, RegionId::RightSidebar), + None + ); assert_eq!( visible_region_bounds(®ions, RegionId::Center), Some([0.0, 0.0, 800.0, 600.0]) ); } + #[test] + fn terminal_cell_at_position_returns_one_based_cell_inside_content() { + let cell = terminal_cell_at_position( + (114.0, 73.0), + [100.0, 50.0, 300.0, 200.0], + 20, + 10, + 10.0, + 14.0, + 20.0, + ); + + assert_eq!(cell, Some((1, 1))); + assert_eq!( + terminal_cell_at_position( + (90.0, 73.0), + [100.0, 50.0, 300.0, 200.0], + 20, + 10, + 10.0, + 14.0, + 20.0, + ), + None + ); + } + + #[test] + fn sgr_mouse_sequence_uses_press_and_release_suffixes() { + assert_eq!(mouse_button_code(MouseButton::Left), Some(0)); + assert!(point_in_bounds((12.0, 14.0), [10.0, 10.0, 20.0, 20.0])); + assert_eq!(sgr_mouse_sequence(0, 3, 4, true), "\x1b[<0;3;4M"); + assert_eq!(sgr_mouse_sequence(0, 3, 4, false), "\x1b[<0;3;4m"); + } + + #[test] + fn sgr_wheel_sequence_is_press_only_with_wheel_button_codes() { + assert_eq!(sgr_wheel_sequence(true, 5, 7), "\x1b[<64;5;7M"); + assert_eq!(sgr_wheel_sequence(false, 5, 7), "\x1b[<65;5;7M"); + } + #[test] fn build_editor_breadcrumb_segments_uses_deepest_matching_symbol() { let theme = ThemeConfig::builtin_dark(); diff --git a/src/app/event_loop/async_results/ai.rs b/src/app/event_loop/async_results/ai.rs index 46896352..b439a2a0 100644 --- a/src/app/event_loop/async_results/ai.rs +++ b/src/app/event_loop/async_results/ai.rs @@ -1,26 +1,222 @@ use super::super::*; use crate::async_runtime::message::WorkerResultPayload; +/// Chars of buffer text after the caret used to trim suggestion/suffix overlap. +const SANITIZE_SUFFIX_CONTEXT_CHARS: usize = 200; + +/// Clean raw model output before showing it as ghost text: normalize newlines, +/// strip markdown fences, drop an echoed current-line prefix, and trim the tail +/// that duplicates text already after the caret (e.g. closing brackets). +/// Returns None when nothing usable remains. +pub(crate) fn sanitize_inline_suggestion( + raw: &str, + line_prefix: &str, + suffix: &str, +) -> Option { + let mut text = raw.replace("\r\n", "\n").replace('\r', "\n"); + + if text.trim_start().starts_with("```") { + let mut lines: Vec<&str> = text.lines().collect(); + lines.remove(0); + if lines.last().is_some_and(|line| line.trim() == "```") { + lines.pop(); + } + text = lines.join("\n"); + } else if let Some(idx) = text.find("\n```") { + text.truncate(idx); + } + + // Drop an echoed copy of the current line's prefix. Models often re-emit the + // text before the caret — including, on a fresh indented line, the leading + // whitespace itself. If we don't strip that whitespace, accepting a + // multi-line completion double-indents its first line (the caret already + // sits at that indentation). Try the full prefix first (covers the + // whitespace-only case), then fall back to the non-whitespace remainder. + if !line_prefix.is_empty() { + if let Some(rest) = text.strip_prefix(line_prefix) { + text = rest.to_string(); + } else { + let trimmed_prefix = line_prefix.trim_start(); + if !trimmed_prefix.is_empty() + && let Some(rest) = text.strip_prefix(trimmed_prefix) + { + text = rest.to_string(); + } + } + } + + // Trim a tail that duplicates text already after the caret. Match against the + // FULL suffix (not just its first line) so a model that re-emits a multi-line + // closing like "`);\n});" is cleaned, not only single-line overlaps. + let suffix_match = suffix.trim_end(); + if !suffix_match.is_empty() && !text.is_empty() { + for k in (1..=suffix_match.len()).rev() { + if suffix_match.is_char_boundary(k) && text.ends_with(&suffix_match[..k]) { + text.truncate(text.len() - k); + break; + } + } + } + + if text.trim().is_empty() { + None + } else { + Some(text) + } +} + +/// Ghost text takes priority over the LSP completion popup: once an inline +/// suggestion is visible, dismiss the completion menu (and its loading state) so +/// the two overlays never render stacked on top of each other. +fn dismiss_completion_for_ghost_text(app: &mut AppShell) { + app.app_state.set_completion_loading(false); + if app.app_state.clear_completion() { + app.editor_caret_needs_layout = true; + } +} + pub(super) fn handle_ai_result(app: &mut AppShell, payload: WorkerResultPayload) { match payload { WorkerResultPayload::AiInlineCompletionChunk { chunk } => { + app.ai_inline_inflight = false; + // The caret left the position this request was made for (movement, + // mode or buffer switch) — drop the result instead of showing it at + // the caret's new location. + if !app.ai_inline_anchor_is_current() { + return; + } if app.app_state.append_inline_suggestion_chunk(&chunk) { + // Ghost text wins: dismiss the LSP completion popup so the two + // overlays do not render on top of each other. + dismiss_completion_for_ghost_text(app); app.editor_needs_layout = true; app.editor_caret_needs_layout = false; app.request_redraw(); } } WorkerResultPayload::AiInlineCompletionResult { suggestion } => { - if app.app_state.set_inline_suggestion(Some(suggestion)) { + app.ai_inline_inflight = false; + app.ai_inline_failure_streak = 0; + // Same guard as the chunk arm: never surface a completion at a + // position the user has already left. + if !app.ai_inline_anchor_is_current() { + return; + } + let (line_prefix, suffix) = app + .app_state + .inline_suggestion_context(SANITIZE_SUFFIX_CONTEXT_CHARS); + let cleaned = sanitize_inline_suggestion(&suggestion, &line_prefix, &suffix); + if app.app_state.set_inline_suggestion(cleaned) { + // Ghost text wins: dismiss the LSP completion popup so the two + // overlays do not render on top of each other. + dismiss_completion_for_ghost_text(app); app.editor_needs_layout = true; app.editor_caret_needs_layout = false; - app.request_redraw(); } + app.request_redraw(); } _ => {} } } +#[cfg(test)] +mod tests { + use super::sanitize_inline_suggestion; + + #[test] + fn keeps_plain_continuation() { + assert_eq!( + sanitize_inline_suggestion("bar(x);", " foo.", ""), + Some("bar(x);".to_string()) + ); + } + + #[test] + fn strips_markdown_fences() { + assert_eq!( + sanitize_inline_suggestion("```rust\nlet x = 1;\n```", "", ""), + Some("let x = 1;".to_string()) + ); + assert_eq!( + sanitize_inline_suggestion("let x = 1;\n```", "", ""), + Some("let x = 1;".to_string()) + ); + } + + #[test] + fn drops_echoed_line_prefix() { + assert_eq!( + sanitize_inline_suggestion("let total = a + b;", " let total = ", ""), + Some("a + b;".to_string()) + ); + } + + #[test] + fn trims_overlap_with_suffix() { + // Buffer: foo(|) → model returns "bar())" style duplicates. + assert_eq!( + sanitize_inline_suggestion("bar()", "foo(", ")"), + Some("bar(".to_string()) + ); + // Entirely duplicated closing token → nothing left to suggest. + assert_eq!(sanitize_inline_suggestion(")", "foo(", ")"), None); + } + + #[test] + fn strips_echoed_indentation_on_fresh_line() { + // Caret on a fresh line indented by 8 spaces; the model re-emits that + // indentation. Without stripping it the accepted line is double-indented. + assert_eq!( + sanitize_inline_suggestion(" results.append(item * 2)", " ", "",), + Some("results.append(item * 2)".to_string()) + ); + // Tab indentation, multi-line: line 1's echoed tab is dropped, deeper + // lines keep their own absolute indentation. + assert_eq!( + sanitize_inline_suggestion("\tfor i := range xs {\n\t\tsum += i\n\t}", "\t", "",), + Some("for i := range xs {\n\t\tsum += i\n\t}".to_string()) + ); + } + + #[test] + fn keeps_deeper_indentation_relative_to_caret() { + // Model returns MORE indentation than the caret (legitimate nesting): + // only the echoed caret-level whitespace is removed, the extra stays. + assert_eq!( + sanitize_inline_suggestion(" nested()", " ", ""), + Some(" nested()".to_string()) + ); + } + + #[test] + fn trims_multiline_suffix_overlap() { + // Model re-emits the whole statement incl. a multi-line closing that + // duplicates the suffix (`);\n});). Only the new content must remain. + assert_eq!( + sanitize_inline_suggestion( + " console.log(`Server running on ${PORT}`);\n});", + " console.log(`", + "`);\n});", + ), + Some("Server running on ${PORT}".to_string()) + ); + } + + #[test] + fn rejects_empty_and_whitespace() { + assert_eq!(sanitize_inline_suggestion("", "", ""), None); + assert_eq!(sanitize_inline_suggestion(" \n ", "", ""), None); + } + + #[test] + fn normalizes_crlf() { + assert_eq!( + sanitize_inline_suggestion("a\r\nb", "", ""), + Some("a\nb".to_string()) + ); + } +} + pub(super) fn handle_ai_message_chunk(app: &mut AppShell, text: String) { let chat = &mut app.panel_state.ai_chat; if let Some(last) = chat.messages.last_mut() diff --git a/src/app/event_loop/async_results/failure.rs b/src/app/event_loop/async_results/failure.rs index cc732ed2..eab105fd 100644 --- a/src/app/event_loop/async_results/failure.rs +++ b/src/app/event_loop/async_results/failure.rs @@ -1,6 +1,11 @@ use super::super::*; use crate::async_runtime::message::{RequestTopic, WorkerEvent}; +/// Consecutive inline-completion failures before the user is told once and +/// the feature cools down instead of failing silently on every keystroke. +const AI_INLINE_FAILURE_TOAST_THRESHOLD: u32 = 3; +const AI_INLINE_FAILURE_COOLDOWN_SECS: u64 = 60; + pub(super) fn handle_worker_failure(app: &mut AppShell, event: WorkerEvent) { let request_id = event.request_id; let revision_id = event.revision_id; @@ -19,6 +24,31 @@ pub(super) fn handle_worker_failure(app: &mut AppShell, event: WorkerEvent) { if topic == RequestTopic::LspClient { app.pending_lsp_server = None; } + // File watcher keeps restarting forever; it emits Failed exactly once + // when crossing the degraded threshold so the user learns that live + // external updates may lag (only the 3s open-buffer poll is guaranteed). + if topic == RequestTopic::AiInlineCompletion { + app.ai_inline_inflight = false; + // Cancelled requests are normal churn while typing, not provider failures. + if !error.message.contains("cancelled") { + app.ai_inline_failure_streak = app.ai_inline_failure_streak.saturating_add(1); + if app.ai_inline_failure_streak >= AI_INLINE_FAILURE_TOAST_THRESHOLD { + app.ai_inline_failure_streak = 0; + app.ai_inline_cooldown_until = Some( + std::time::Instant::now() + + std::time::Duration::from_secs(AI_INLINE_FAILURE_COOLDOWN_SECS), + ); + app.show_transient_toast(format!( + "AI completion unavailable — paused for {AI_INLINE_FAILURE_COOLDOWN_SECS}s" + )); + } + } + } + if topic == RequestTopic::WorkspaceWatch { + app.show_transient_toast( + "File watcher degraded — external changes may lag (3s poll fallback)".to_string(), + ); + } // completionItem/resolve failed (e.g. server doesn't advertise resolveProvider). // Mark as resolved so the panel shows "No documentation available". if app.completion_resolve_request_id == Some(request_id) { diff --git a/src/app/event_loop/async_results/filesystem.rs b/src/app/event_loop/async_results/filesystem.rs index 9b6b7b21..a67751fc 100644 --- a/src/app/event_loop/async_results/filesystem.rs +++ b/src/app/event_loop/async_results/filesystem.rs @@ -3,37 +3,45 @@ use crate::async_runtime::message::WorkerResultPayload; pub(super) fn handle_filesystem_result(app: &mut AppShell, payload: WorkerResultPayload) { if let WorkerResultPayload::FileSystemEvents { events, .. } = payload { - eprintln!("[FileWatch] Received {} file system events", events.len()); for event in events.iter() { - eprintln!("[FileWatch] {:?}: {:?}", event.kind, event.path); if let Ok(metadata) = std::fs::metadata(&event.path) { if let Ok(modified_time) = metadata.modified() { - app.last_external_file_check_times.insert(event.path.clone(), modified_time); + app.last_external_file_check_times + .insert(event.path.clone(), modified_time); } } if let Some(ref new_path) = event.new_path { if let Ok(metadata) = std::fs::metadata(new_path) { if let Ok(modified_time) = metadata.modified() { - app.last_external_file_check_times.insert(new_path.clone(), modified_time); + app.last_external_file_check_times + .insert(new_path.clone(), modified_time); } } } } match app.app_state.apply_external_file_events(&events) { Ok(report) => { - if report.workspace_reloaded - && matches!( - app.app_state.command_palette_mode(), - Some(CommandPaletteMode::FilePicker | CommandPaletteMode::LiveGrep) - ) - && !app.app_state.command_palette_query_text().trim().is_empty() - { - app.submit_active_palette_fzf_search(); + // #4: nội dung file và tree walk đều chạy ở worker; phase 1 chỉ + // quyết định cần đọc gì / có cần rescan không. + if !report.pending_reload_paths.is_empty() { + app.submit(RequestSpec { + revision_id: 0, + topic: RequestTopic::WorkspaceWatch, + payload: WorkerRequestPayload::ReadExternalFiles { + paths: report.pending_reload_paths.clone(), + }, + }); + } + if report.workspace_rescan_needed { + app.submit_workspace_rescan(); } if report.active_file_reloaded { app.invalidate_highlights_and_parse_active_buffer(); app.force_flush_lsp_did_change_for_active_file(); } + for path in &report.inactive_reloaded_paths { + app.submit_lsp_sync_for_externally_reloaded_path(path); + } if report.conflict_detected && app.pending_confirmation.is_none() && let Some(path) = report.conflict_path.clone() @@ -56,6 +64,64 @@ pub(super) fn handle_filesystem_result(app: &mut AppShell, payload: WorkerResult } } +/// Phase 2 of the external-change pipeline: worker-fetched file contents are +/// applied to buffers on the UI thread (no disk I/O happens here). +pub(super) fn handle_external_files_read(app: &mut AppShell, payload: WorkerResultPayload) { + if let WorkerResultPayload::ExternalFilesRead { files } = payload { + for file in &files { + if let Some(modified_time) = file.modified_time { + app.last_external_file_check_times + .insert(file.path.clone(), modified_time); + } + } + let report = app.app_state.apply_external_file_contents(&files); + if report.active_file_reloaded { + app.invalidate_highlights_and_parse_active_buffer(); + app.force_flush_lsp_did_change_for_active_file(); + } + for path in &report.inactive_reloaded_paths { + app.submit_lsp_sync_for_externally_reloaded_path(path); + } + if report.conflict_detected + && app.pending_confirmation.is_none() + && let Some(path) = report.conflict_path.clone() + { + let _ = app.begin_external_overwrite_confirmation(path); + } + if report.active_file_reloaded || !report.inactive_reloaded_paths.is_empty() { + app.submit_active_buffer_git_baseline_refresh(); + app.editor_needs_layout = true; + app.editor_caret_needs_layout = false; + app.request_redraw(); + } + } +} + +/// Fresh workspace tree from the async rescan worker — swap it in and refresh +/// everything that mirrors the tree (explorer, file picker, live grep). +pub(super) fn handle_workspace_rescanned(app: &mut AppShell, payload: WorkerResultPayload) { + if let WorkerResultPayload::WorkspaceRescanned { root_path, nodes } = payload { + match app.app_state.apply_workspace_rescan(&root_path, nodes) { + Ok(true) => { + if matches!( + app.app_state.command_palette_mode(), + Some(CommandPaletteMode::FilePicker | CommandPaletteMode::LiveGrep) + ) && !app.app_state.command_palette_query_text().trim().is_empty() + { + app.submit_active_palette_fzf_search(); + } + app.sync_explorer_expanded_with_workspace(); + app.editor_needs_layout = true; + app.request_redraw(); + } + Ok(false) => {} + Err(err) => { + eprintln!("[AppShell] workspace rescan apply failed: {err}"); + } + } + } +} + pub(super) fn handle_file_copy_result(app: &mut AppShell, payload: WorkerResultPayload) { if let WorkerResultPayload::FileCopyResult { target_path, diff --git a/src/app/event_loop/async_results/lsp.rs b/src/app/event_loop/async_results/lsp.rs index d7104def..ba411b38 100644 --- a/src/app/event_loop/async_results/lsp.rs +++ b/src/app/event_loop/async_results/lsp.rs @@ -606,6 +606,13 @@ pub(super) fn handle_lsp_result( if completion.filtered_items.is_empty() { return; } + // Ghost text wins: if an AI inline suggestion is already visible, do + // not pop the LSP completion menu over it. The next keystroke clears + // the ghost text and re-requests completion, so the menu comes back + // once the ghost text is gone. + if app.app_state.inline_suggestion().is_some() { + return; + } let changed = app.app_state.set_completion(completion); if changed { app.submit_completion_resolve(); diff --git a/src/app/event_loop/async_results/mod.rs b/src/app/event_loop/async_results/mod.rs index e3c9479e..c9dd895c 100644 --- a/src/app/event_loop/async_results/mod.rs +++ b/src/app/event_loop/async_results/mod.rs @@ -102,6 +102,12 @@ impl AsyncResultRouter for AppShell { WorkerResultPayload::FileCopyResult { .. } => { filesystem::handle_file_copy_result(self, result.payload); } + WorkerResultPayload::ExternalFilesRead { .. } => { + filesystem::handle_external_files_read(self, result.payload); + } + WorkerResultPayload::WorkspaceRescanned { .. } => { + filesystem::handle_workspace_rescanned(self, result.payload); + } _ => {} } } @@ -285,6 +291,50 @@ mod tests { ); } + #[test] + fn go_workspace_symbols_result_populates_cache_via_normal_router() { + let mut shell = AppShell::new_for_tests().expect("create app shell"); + + AsyncResultRouter::on_worker_result( + &mut shell, + WorkerResult { + request_id: 201, + revision_id: 0, + topic: RequestTopic::LspRequest, + payload: WorkerResultPayload::WorkspaceSymbols { + language_id: "go".to_string(), + symbols: vec![CachedSymbol { + name: "Connect".to_string(), + kind: "Function".to_string(), + container_name: Some("main".to_string()), + file_path: PathBuf::from("api.go"), + line: 2, + character: 5, + source_path: None, + import_path: None, + export_kind: None, + callable: Some(true), + has_parameters: Some(false), + }], + }, + }, + ); + + assert_eq!( + shell.app_state.workspace_symbol_cache().symbol_count("go"), + 1 + ); + assert_eq!( + shell + .app_state + .workspace_symbol_cache() + .query_symbols("Con", Some("go")) + .first() + .map(|symbol| symbol.name.as_str()), + Some("Connect") + ); + } + #[test] fn parse_highlight_result_for_inactive_buffer_is_rejected() { let mut shell = AppShell::new_for_tests().expect("create app shell"); diff --git a/src/app/event_loop/async_results/system.rs b/src/app/event_loop/async_results/system.rs index f32233fa..a2ac3394 100644 --- a/src/app/event_loop/async_results/system.rs +++ b/src/app/event_loop/async_results/system.rs @@ -5,10 +5,13 @@ pub(super) fn handle_system_result(app: &mut AppShell, payload: WorkerResultPayl match payload { WorkerResultPayload::SystemDepCheckResult { missing } => { let missing_names: Vec = missing.iter().map(|s| s.to_string()).collect(); - if let Some(state) = app.app_state.active_extensions_manager_buffer_mut() { + // Apply to the extensions buffer even when it is not the active one — + // otherwise results arriving after a buffer switch left stale states. + if let Some(state) = app.app_state.any_extensions_manager_buffer_mut() { for item in &mut state.items { item.installed = !missing_names.iter().any(|tool| tool == &item.binary); } + state.deps_checked = true; } let cli_tools = ["fzf", "lazygit", "lazydocker", "rg", "fd", "bat", "delta"]; @@ -68,7 +71,9 @@ pub(super) fn handle_system_result(app: &mut AppShell, payload: WorkerResultPayl .map(|env| crate::app::command_palette::CommandPaletteItem { label: env.display_name, secondary_label: Some(env.executable.display().to_string()), - action: crate::app::command_palette::CommandPaletteAction::SelectPythonEnv(env.executable), + action: crate::app::command_palette::CommandPaletteAction::SelectPythonEnv( + env.executable, + ), tone: crate::app::command_palette::CommandPaletteItemTone::Default, preview_colors: Vec::new(), }) @@ -83,7 +88,9 @@ pub(super) fn handle_system_result(app: &mut AppShell, payload: WorkerResultPayl .map(|env| crate::app::command_palette::CommandPaletteItem { label: env.display_name, secondary_label: Some(env.executable.display().to_string()), - action: crate::app::command_palette::CommandPaletteAction::SelectDartEnv(env.executable), + action: crate::app::command_palette::CommandPaletteAction::SelectDartEnv( + env.executable, + ), tone: crate::app::command_palette::CommandPaletteItemTone::Default, preview_colors: Vec::new(), }) @@ -104,11 +111,12 @@ pub(super) fn handle_system_result(app: &mut AppShell, payload: WorkerResultPayl crate::app::command_palette::CommandPaletteItem { label: dev.name, secondary_label: secondary, - action: crate::app::command_palette::CommandPaletteAction::SelectFlutterDevice { - device_id: dev.id, - is_emulator: dev.emulator, - is_active: dev.is_active, - }, + action: + crate::app::command_palette::CommandPaletteAction::SelectFlutterDevice { + device_id: dev.id, + is_emulator: dev.emulator, + is_active: dev.is_active, + }, tone: crate::app::command_palette::CommandPaletteItemTone::Default, preview_colors: Vec::new(), } @@ -160,7 +168,7 @@ pub(super) fn handle_extension_command_started( binary: String, uninstall: bool, ) { - if let Some(state) = app.app_state.active_extensions_manager_buffer_mut() { + if let Some(state) = app.app_state.any_extensions_manager_buffer_mut() { state.start_command(binary, uninstall); } app.editor_needs_layout = true; @@ -168,7 +176,7 @@ pub(super) fn handle_extension_command_started( } pub(super) fn handle_extension_command_log(app: &mut AppShell, binary: String, line: String) { - if let Some(state) = app.app_state.active_extensions_manager_buffer_mut() { + if let Some(state) = app.app_state.any_extensions_manager_buffer_mut() { let _ = state.push_command_log(&binary, line); } app.editor_needs_layout = true; @@ -182,7 +190,7 @@ pub(super) fn handle_extension_command_finished( success: bool, exit_code: Option, ) { - if let Some(state) = app.app_state.active_extensions_manager_buffer_mut() { + if let Some(state) = app.app_state.any_extensions_manager_buffer_mut() { let _ = state.finish_command(&binary, uninstall, success, exit_code); if success { for item in &mut state.items { diff --git a/src/app/event_loop/async_results/terminal.rs b/src/app/event_loop/async_results/terminal.rs index 9196d39a..15513d3f 100644 --- a/src/app/event_loop/async_results/terminal.rs +++ b/src/app/event_loop/async_results/terminal.rs @@ -118,7 +118,7 @@ pub(super) fn handle_terminal_result( for tab in &mut app.terminal_tabs { if tab.session_id == Some(session_id) { let scrolled_rows = tab.grid.feed_bytes(&chunk); - tab.grid.apply_regex_highlights(); + tab.grid.apply_regex_highlights_incremental(scrolled_rows); if preserve_viewport { tab.grid.view_scroll_up(scrolled_rows); } else { @@ -131,8 +131,11 @@ pub(super) fn handle_terminal_result( } if app.right_pty_session_id == Some(session_id) { let scrolled_rows = app.right_terminal_grid.feed_bytes(&chunk); - app.right_terminal_grid.apply_regex_highlights(); - if preserve_viewport { + app.right_terminal_grid + .apply_regex_highlights_incremental(scrolled_rows); + let preserve_right_viewport = + preserve_viewport || app.right_terminal_grid.scroll_offset > 0; + if preserve_right_viewport { app.right_terminal_grid.view_scroll_up(scrolled_rows); } else { app.right_terminal_grid.view_scroll_to_bottom(); @@ -143,7 +146,7 @@ pub(super) fn handle_terminal_result( let is_terminal_buffer = app.terminal_buffer_grids.contains_key(&session_id); if let Some(grid) = app.terminal_buffer_grids.get_mut(&session_id) { let scrolled_rows = grid.feed_bytes(&chunk); - grid.apply_regex_highlights(); + grid.apply_regex_highlights_incremental(scrolled_rows); if preserve_viewport { grid.view_scroll_up(scrolled_rows); } else { diff --git a/src/app/event_loop/commands.rs b/src/app/event_loop/commands.rs index 1554d34e..a4f8dd50 100644 --- a/src/app/event_loop/commands.rs +++ b/src/app/event_loop/commands.rs @@ -2,6 +2,8 @@ mod commands_ai_chat; #[path = "commands_completion.rs"] mod commands_completion; +#[path = "commands_dap.rs"] +mod commands_dap; #[path = "commands_editor.rs"] mod commands_editor; #[path = "commands_explorer.rs"] @@ -18,8 +20,6 @@ mod commands_settings; mod commands_settings_helpers; #[path = "commands_terminal.rs"] mod commands_terminal; -#[path = "commands_dap.rs"] -mod commands_dap; #[cfg(test)] #[path = "commands_tests.rs"] mod tests; @@ -198,6 +198,8 @@ impl AppShell { && self.focus_manager.current() == FocusTarget::CenterEditor { self.buffer_terminal_needs_layout = true; + } else if self.focus_manager.current() == FocusTarget::RightSidebar { + self.right_terminal_needs_layout = true; } else { self.terminal_needs_layout = true; } @@ -275,8 +277,32 @@ impl AppShell { | Command::Newline | Command::InsertTab ) && self.app_state.current_mode() == EditorMode::Insert; + self.ai_inline_suggestion_retained = false; if is_insert_typing { - let _ = self.app_state.clear_inline_suggestion(); + let retained = repeat_count == 1 + && match &command { + // Auto-pairing chars insert a closing char the suggestion + // doesn't know about — never retain across those. + Command::InsertChar(ch) + if !matches!(ch, '(' | '[' | '{' | '"' | '\'' | '`') => + { + let mut buf = [0u8; 4]; + self.app_state + .retain_inline_suggestion_for_typed_text(ch.encode_utf8(&mut buf)) + } + Command::InsertText(text) => { + self.app_state.retain_inline_suggestion_for_typed_text(text) + } + _ => false, + }; + if retained { + // In-flight results no longer match the retained remainder; + // bump the revision so they get dropped as stale. + self.ai_inline_revision = self.ai_inline_revision.saturating_add(1); + self.ai_inline_suggestion_retained = true; + } else { + let _ = self.app_state.clear_inline_suggestion(); + } self.cancel_ai_inline_completion(); } if matches!(command, Command::SwitchMode(ModeEvent::Escape)) { @@ -339,11 +365,16 @@ impl AppShell { ); } if let Command::DebugStart = &command { - eprintln!("[DAP LOG] [F5 Commands] handle_command_with_count: Routing Command::DebugStart to handle_dap_command"); + eprintln!( + "[DAP LOG] [F5 Commands] handle_command_with_count: Routing Command::DebugStart to handle_dap_command" + ); } if let Some(changed) = self.handle_dap_command(&command) { if let Command::DebugStart = &command { - eprintln!("[DAP LOG] [F5 Commands] handle_command_with_count: handle_dap_command returned: {:?}", changed); + eprintln!( + "[DAP LOG] [F5 Commands] handle_command_with_count: handle_dap_command returned: {:?}", + changed + ); } return self.finalize_post_command_hooks( &command_for_post_hooks, @@ -418,6 +449,11 @@ impl AppShell { | Command::ChangeWordBackward | Command::ChangeToLineEnd | Command::SubstituteLine + | Command::Operate { + op: crate::core::commands::Operator::Delete + | crate::core::commands::Operator::Change, + .. + } ); let changed = self.handle_generic_editor_command(command, repeat_count); let mode_after = self.app_state.current_mode(); @@ -619,6 +655,25 @@ impl AppShell { }); } + /// One-time onboarding toast for brand-new installs: the three keys a new + /// user needs before anything else. Marks persistent state so it never + /// shows again. + pub(super) fn show_first_run_tour_if_needed(&mut self) { + if self.persistent_state.first_run_tour_shown { + return; + } + self.persistent_state.first_run_tour_shown = true; + self.persistent_state.save(); + + let now = Instant::now(); + self.transient_toast = Some(TransientToast { + message: "Welcome to Netherize\nPress i to type, Esc to go back to Normal mode, and Space ? (or F1) to see every keybinding.".to_string(), + kind: ToastKind::Info, + created_at: now, + expires_at: now + Duration::from_secs(12), + }); + } + pub(super) fn clear_expired_transient_toast(&mut self) -> bool { let expired = self .transient_toast @@ -688,7 +743,9 @@ impl AppShell { match command { Command::ToggleMarkdownPreview => { - let is_markdown = self.app_state.active_file() + let is_markdown = self + .app_state + .active_file() .and_then(|p| p.extension()) .is_some_and(|ext| ext == "md"); @@ -703,12 +760,15 @@ impl AppShell { } else { String::new() }; - let rendered_lines = - crate::app::event_loop::helpers::parse_markdown_preview_blocks(&source, &self.theme); + let rendered_lines = crate::app::event_loop::helpers::parse_markdown_preview_blocks( + &source, + &self.theme, + ); self.app_state.markdown_preview.visible = true; self.app_state.markdown_preview.scroll_y = 0.0; - self.app_state.markdown_preview.source_path = self.app_state.active_file().map(|path| path.to_path_buf()); + self.app_state.markdown_preview.source_path = + self.app_state.active_file().map(|path| path.to_path_buf()); self.app_state.markdown_preview.source_text = source; self.app_state.markdown_preview.source_revision = self.app_state.revision(); self.app_state.markdown_preview.rendered_lines = rendered_lines; diff --git a/src/app/event_loop/commands_ai_chat.rs b/src/app/event_loop/commands_ai_chat.rs index 8cd1e1bf..2ff58a21 100644 --- a/src/app/event_loop/commands_ai_chat.rs +++ b/src/app/event_loop/commands_ai_chat.rs @@ -728,7 +728,12 @@ impl AppShell { Some(changed) } Command::AiChatUnfocus => { - if self.focus_manager.current() == FocusTarget::RightSidebar { + // In Zen Mode the editor is hidden, so unfocusing back to it would + // strand the user on a blank surface. Keep focus on the maximized + // chat panel instead — Esc should not leave the zen window. + if self.panel_state.maximized_region.is_some() { + Some(false) + } else if self.focus_manager.current() == FocusTarget::RightSidebar { let focus_changed = self.focus_manager.set(FocusTarget::CenterEditor); if focus_changed { self.input_handler.clear_pending_prefix(); @@ -748,8 +753,11 @@ impl AppShell { let focus_changed = self.focus_manager.set(FocusTarget::RightSidebar); if focus_changed { self.input_handler.clear_pending_prefix(); - // Enter terminal focus so keystrokes reach opencode. - let _ = self.app_state.apply_mode_event(ModeEvent::FocusTerminal); + } + // Enter terminal focus so keystrokes reach opencode even when + // the right sidebar already owns focus but mode drifted away. + if let Ok(result) = self.app_state.apply_mode_event(ModeEvent::FocusTerminal) { + let _ = result; } self.right_terminal_needs_layout = true; Some(true) @@ -918,8 +926,10 @@ impl AppShell { }); } else { // opencode not found — fall back to spawning a shell that tells the user. - self.right_pty_startup_command = - Some("echo 'opencode not found. Run: curl -fsSL https://opencode.ai/install | sh'\\r".to_string()); + self.right_pty_startup_command = Some( + "echo 'opencode not found. Run: curl -fsSL https://opencode.ai/install | sh'\\r" + .to_string(), + ); self.submit(RequestSpec { revision_id: 0, topic: RequestTopic::TerminalPty, diff --git a/src/app/event_loop/commands_completion.rs b/src/app/event_loop/commands_completion.rs index 18755adb..491f4641 100644 --- a/src/app/event_loop/commands_completion.rs +++ b/src/app/event_loop/commands_completion.rs @@ -23,7 +23,7 @@ impl AppShell { self.last_lsp_completion_type_at = None; return; }; - if !is_ts_js_profile_key(profile.key) { + if !supports_debounced_lsp_completion(profile.key) { self.pending_lsp_completion_after_debounce = false; self.last_lsp_completion_type_at = None; return; @@ -73,15 +73,38 @@ impl AppShell { if !is_ts_js_profile_key(profile.key) { return; } - let Some(workspace_root) = self.app_state.workspace_root_path().map(std::path::PathBuf::from) + let Some(workspace_root) = self + .app_state + .workspace_root_path() + .map(std::path::PathBuf::from) else { return; }; let text = self.app_state.text_string(); - let symbols = crate::lsp::extract_ts_js_exports_from_text(&active_path, &workspace_root, &text); - self.app_state - .workspace_symbol_cache() - .upsert_file_symbols(profile.key, &active_path, symbols); + let symbols = + crate::lsp::extract_ts_js_exports_from_text(&active_path, &workspace_root, &text); + self.app_state.workspace_symbol_cache().upsert_file_symbols( + profile.key, + &active_path, + symbols, + ); + } + + /// Explicit completion request (Ctrl+Space). Unlike the automatic typing + /// trigger, the user's intent wins over AI ghost text: dismiss the + /// suggestion and invalidate any in-flight AI request (so its late result + /// can't pop the menu back off the screen), then request the LSP menu. + pub(super) fn submit_lsp_completion_manual(&mut self) -> bool { + self.ai_inline_anchor = None; + self.ai_inline_revision = self.ai_inline_revision.saturating_add(1); + self.cancel_ai_inline_completion(); + let mut changed = self.app_state.clear_inline_suggestion(); + if changed { + self.editor_needs_layout = true; + self.editor_caret_needs_layout = false; + } + changed |= self.submit_lsp_completion(); + changed } pub(super) fn submit_lsp_completion(&mut self) -> bool { @@ -252,10 +275,8 @@ impl AppShell { /// for either inline docs or "No docs available". pub(in crate::app::event_loop) fn submit_completion_resolve(&mut self) -> bool { self.completion_resolve_request_id = None; - let Some((item_json, item_label, completion_revision, documentation)) = self - .app_state - .completion() - .and_then(|completion| { + let Some((item_json, item_label, completion_revision, documentation)) = + self.app_state.completion().and_then(|completion| { completion .filtered_items .get(completion.selected_index) @@ -379,8 +400,8 @@ impl AppShell { } text.replace_range(start_byte..end_byte, &insert_text); - let hover_character = completion.trigger_pos.col - + insert_text.chars().count().saturating_sub(1); + let hover_character = + completion.trigger_pos.col + insert_text.chars().count().saturating_sub(1); self.submit(RequestSpec { revision_id: 0, topic: RequestTopic::LspRequest, @@ -495,7 +516,8 @@ impl AppShell { let mut edits = vec![primary_edit.clone()]; edits.extend(item.additional_text_edits.clone()); if item.additional_text_edits.is_empty() { - let import_style = ts_js_module_style_for_completion(&text, self.app_state.active_file()); + let import_style = + ts_js_module_style_for_completion(&text, self.app_state.active_file()); if let Some(module_specifier) = completion_module_specifier( self.app_state.active_file(), item.source_path.as_deref(), @@ -602,6 +624,10 @@ fn is_ts_js_profile_key(key: &str) -> bool { matches!(key, "typescript" | "tsx" | "javascript" | "jsx") } +fn supports_debounced_lsp_completion(key: &str) -> bool { + is_ts_js_profile_key(key) || key == "go" +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum TsJsModuleStyle { EsModule, @@ -856,11 +882,7 @@ fn completion_kind_is_callable(kind: u32) -> bool { fn completion_insert_text_has_parameters(text: &str) -> Option { let (open, close) = completion_insert_text_call_parens(text)?; let has_params = !text[open + 1..close].trim().is_empty(); - if has_params { - Some(true) - } else { - None - } + if has_params { Some(true) } else { None } } fn completion_insert_text_call_parens(text: &str) -> Option<(usize, usize)> { @@ -1051,7 +1073,8 @@ fn lsp_text_edit_byte_range( source: &str, edit: &crate::async_runtime::message::LspTextEdit, ) -> Option<(usize, usize)> { - let start = lsp_position_to_byte_idx(source, edit.range.start.line, edit.range.start.character)?; + let start = + lsp_position_to_byte_idx(source, edit.range.start.line, edit.range.start.character)?; let end = lsp_position_to_byte_idx(source, edit.range.end.line, edit.range.end.character)?; (start <= end && end <= source.len()).then_some((start, end)) } @@ -1101,7 +1124,11 @@ fn synthesize_ts_named_import_edits( if let Some(edit) = merge_named_require_edit(source, symbol_name, module_specifier) { return vec![edit]; } - vec![new_named_require_edit(source, symbol_name, module_specifier)] + vec![new_named_require_edit( + source, + symbol_name, + module_specifier, + )] } } } @@ -1117,13 +1144,21 @@ fn synthesize_ts_default_import_edits( if let Some(edit) = merge_default_import_edit(source, symbol_name, module_specifier) { return vec![edit]; } - vec![new_default_import_edit(source, symbol_name, module_specifier)] + vec![new_default_import_edit( + source, + symbol_name, + module_specifier, + )] } TsJsModuleStyle::CommonJs => { if let Some(edit) = merge_default_require_edit(source, symbol_name, module_specifier) { return vec![edit]; } - vec![new_default_require_edit(source, symbol_name, module_specifier)] + vec![new_default_require_edit( + source, + symbol_name, + module_specifier, + )] } } } @@ -1263,7 +1298,8 @@ fn merge_default_require_edit( let Some(after_keyword) = strip_commonjs_declaration_keyword(trimmed) else { continue; }; - let Some((name, _rest)) = read_ts_js_identifier_with_rest(after_keyword.trim_start()) else { + let Some((name, _rest)) = read_ts_js_identifier_with_rest(after_keyword.trim_start()) + else { continue; }; if name == symbol_name { @@ -1521,8 +1557,7 @@ fn source_uses_commonjs(source: &str) -> bool { fn is_commonjs_require_line(trimmed: &str) -> bool { trimmed.starts_with("require(") - || strip_commonjs_declaration_keyword(trimmed) - .is_some_and(|rest| rest.contains("require(")) + || strip_commonjs_declaration_keyword(trimmed).is_some_and(|rest| rest.contains("require(")) } fn line_requires_module(line: &str, module_specifier: &str) -> bool { diff --git a/src/app/event_loop/commands_dap.rs b/src/app/event_loop/commands_dap.rs index 0dd9e85c..037e44f3 100644 --- a/src/app/event_loop/commands_dap.rs +++ b/src/app/event_loop/commands_dap.rs @@ -1,9 +1,9 @@ use super::*; -use std::path::{Path, PathBuf}; -use std::sync::Arc; use crate::core::commands::Command; -use crate::workbench::panel_state::PanelTabId; use crate::workbench::focus_manager::FocusTarget; +use crate::workbench::panel_state::PanelTabId; +use std::path::{Path, PathBuf}; +use std::sync::Arc; fn resolve_flutter_path(dart_path: &Path) -> PathBuf { let dart_str = dart_path.to_string_lossy(); @@ -70,14 +70,21 @@ impl AppShell { } } Command::DebugStart => { - eprintln!("[DAP LOG] [F5 Commands DAP] handle_dap_command: entered Command::DebugStart"); + eprintln!( + "[DAP LOG] [F5 Commands DAP] handle_dap_command: entered Command::DebugStart" + ); let mut should_clear = false; if let Some(session) = &self.dap_session { let (paused, terminated) = { let state = session.state.try_lock(); - state.map(|s| (s.paused, s.terminated)).unwrap_or((false, false)) + state + .map(|s| (s.paused, s.terminated)) + .unwrap_or((false, false)) }; - eprintln!("[DAP LOG] [F5 Commands DAP] DebugStart with existing session, paused={}, terminated={}", paused, terminated); + eprintln!( + "[DAP LOG] [F5 Commands DAP] DebugStart with existing session, paused={}, terminated={}", + paused, terminated + ); if terminated { should_clear = true; } else { @@ -101,18 +108,29 @@ impl AppShell { } // Launch new session — try launch.json first, then fallback to Flutter defaults - let workspace_root = self.app_state.workspace_root_path() + let workspace_root = self + .app_state + .workspace_root_path() .map(PathBuf::from) .or_else(|| { - self.app_state.active_file() + self.app_state + .active_file() .and_then(|file| file.parent().map(PathBuf::from)) }) .unwrap_or_default(); - eprintln!("[DAP LOG] [F5 Commands DAP] workspace_root: {:?}", workspace_root); + eprintln!( + "[DAP LOG] [F5 Commands DAP] workspace_root: {:?}", + workspace_root + ); if workspace_root.as_os_str().is_empty() { - eprintln!("[DAP LOG] [F5 Commands DAP] No workspace_root found, returning false"); - self.show_transient_toast_kind("No active workspace or file parent folder", ToastKind::Error); + eprintln!( + "[DAP LOG] [F5 Commands DAP] No workspace_root found, returning false" + ); + self.show_transient_toast_kind( + "No active workspace or file parent folder", + ToastKind::Error, + ); return Some(false); } @@ -121,7 +139,10 @@ impl AppShell { // Try to load launch.json (.vscode/launch.json or .zed/debug.json) let launch_config = crate::dap::launch_config::load_launch_json(&workspace_root) .and_then(|cfg| { - eprintln!("[DebugStart] Found launch.json with {} configurations", cfg.configurations.len()); + eprintln!( + "[DebugStart] Found launch.json with {} configurations", + cfg.configurations.len() + ); cfg.configurations.into_iter().next() }); @@ -129,10 +150,16 @@ impl AppShell { eprintln!("[DebugStart] No launch.json found, using Flutter defaults"); } - let (program, adapter_cmd, adapter_args, adapter_id) = if let Some(ref config) = launch_config { + let (program, adapter_cmd, adapter_args, adapter_id) = if let Some(ref config) = + launch_config + { let resolved = config.resolve(&workspace_root); let program = if resolved.program.is_empty() { - workspace_root.join("lib").join("main.dart").to_string_lossy().to_string() + workspace_root + .join("lib") + .join("main.dart") + .to_string_lossy() + .to_string() } else { resolved.program.clone() }; @@ -140,8 +167,9 @@ impl AppShell { "dart" | "flutter" => { // Check if FVM is available - prefer fvm flutter over direct flutter let fvm_path = Self::resolve_fvm_path(); - let has_local_fvm = workspace_root.join(".fvm").join("flutter_sdk").exists(); - + let has_local_fvm = + workspace_root.join(".fvm").join("flutter_sdk").exists(); + let (adapter_cmd, adapter_args) = if has_local_fvm { // Use FVM wrapper for local FVM projects if let Some(ref fvm) = fvm_path { @@ -153,12 +181,22 @@ impl AppShell { } else { // FVM not found in PATH, fallback to direct flutter let dart_bin = self.selected_dart_env.clone().or_else(|| { - let local_fvm = workspace_root.join(".fvm").join("flutter_sdk") - .join("bin").join("cache").join("dart-sdk") - .join("bin").join("dart"); - if local_fvm.try_exists().unwrap_or(false) { Some(local_fvm) } else { None } + let local_fvm = workspace_root + .join(".fvm") + .join("flutter_sdk") + .join("bin") + .join("cache") + .join("dart-sdk") + .join("bin") + .join("dart"); + if local_fvm.try_exists().unwrap_or(false) { + Some(local_fvm) + } else { + None + } }); - let flutter_path = dart_bin.as_ref() + let flutter_path = dart_bin + .as_ref() .map(|d| resolve_flutter_path(d)) .unwrap_or_else(|| PathBuf::from("flutter")); ( @@ -169,12 +207,22 @@ impl AppShell { } else { // No local FVM, use direct flutter let dart_bin = self.selected_dart_env.clone().or_else(|| { - let local_fvm = workspace_root.join(".fvm").join("flutter_sdk") - .join("bin").join("cache").join("dart-sdk") - .join("bin").join("dart"); - if local_fvm.try_exists().unwrap_or(false) { Some(local_fvm) } else { None } + let local_fvm = workspace_root + .join(".fvm") + .join("flutter_sdk") + .join("bin") + .join("cache") + .join("dart-sdk") + .join("bin") + .join("dart"); + if local_fvm.try_exists().unwrap_or(false) { + Some(local_fvm) + } else { + None + } }); - let flutter_path = dart_bin.as_ref() + let flutter_path = dart_bin + .as_ref() .map(|d| resolve_flutter_path(d)) .unwrap_or_else(|| PathBuf::from("flutter")); ( @@ -182,30 +230,22 @@ impl AppShell { vec!["debug_adapter".to_string()], ) }; - - ( - program, - adapter_cmd, - adapter_args, - "flutter".to_string(), - ) + + (program, adapter_cmd, adapter_args, "flutter".to_string()) } "lldb" | "codelldb" => { - ( - program, - "codelldb".to_string(), - vec![], - "lldb".to_string(), - ) + (program, "codelldb".to_string(), vec![], "lldb".to_string()) } - "python" | "debugpy" => { - ( - program, - "python".to_string(), - vec!["-m".to_string(), "debugpy".to_string(), "--adapter".to_string()], + "python" | "debugpy" => ( + program, + "python".to_string(), + vec![ + "-m".to_string(), "debugpy".to_string(), - ) - } + "--adapter".to_string(), + ], + "debugpy".to_string(), + ), _ => { // Generic adapter — use config type as adapter ID ( @@ -220,7 +260,7 @@ impl AppShell { // No launch.json — fallback to Flutter defaults with FVM support let fvm_path = Self::resolve_fvm_path(); let has_local_fvm = workspace_root.join(".fvm").join("flutter_sdk").exists(); - + let (adapter_cmd, adapter_args) = if has_local_fvm { // Use FVM wrapper for local FVM projects if let Some(ref fvm) = fvm_path { @@ -232,12 +272,22 @@ impl AppShell { } else { // FVM not found in PATH, fallback to direct flutter let dart_bin = self.selected_dart_env.clone().or_else(|| { - let local_fvm = workspace_root.join(".fvm").join("flutter_sdk") - .join("bin").join("cache").join("dart-sdk") - .join("bin").join("dart"); - if local_fvm.try_exists().unwrap_or(false) { Some(local_fvm) } else { None } + let local_fvm = workspace_root + .join(".fvm") + .join("flutter_sdk") + .join("bin") + .join("cache") + .join("dart-sdk") + .join("bin") + .join("dart"); + if local_fvm.try_exists().unwrap_or(false) { + Some(local_fvm) + } else { + None + } }); - let flutter_path = dart_bin.as_ref() + let flutter_path = dart_bin + .as_ref() .map(|d| resolve_flutter_path(d)) .unwrap_or_else(|| PathBuf::from("flutter")); ( @@ -248,12 +298,22 @@ impl AppShell { } else { // No local FVM, use direct flutter let dart_bin = self.selected_dart_env.clone().or_else(|| { - let local_fvm = workspace_root.join(".fvm").join("flutter_sdk") - .join("bin").join("cache").join("dart-sdk") - .join("bin").join("dart"); - if local_fvm.try_exists().unwrap_or(false) { Some(local_fvm) } else { None } + let local_fvm = workspace_root + .join(".fvm") + .join("flutter_sdk") + .join("bin") + .join("cache") + .join("dart-sdk") + .join("bin") + .join("dart"); + if local_fvm.try_exists().unwrap_or(false) { + Some(local_fvm) + } else { + None + } }); - let flutter_path = dart_bin.as_ref() + let flutter_path = dart_bin + .as_ref() .map(|d| resolve_flutter_path(d)) .unwrap_or_else(|| PathBuf::from("flutter")); ( @@ -261,8 +321,10 @@ impl AppShell { vec!["debug_adapter".to_string()], ) }; - - let program = self.app_state.active_file() + + let program = self + .app_state + .active_file() .and_then(|id| { if id.extension().map(|ext| ext == "dart").unwrap_or(false) { Some(id.to_string_lossy().to_string()) @@ -278,30 +340,37 @@ impl AppShell { "lib/main.dart".to_string() } }); - ( - program, - adapter_cmd, - adapter_args, - "flutter".to_string(), - ) + (program, adapter_cmd, adapter_args, "flutter".to_string()) }; - eprintln!("[DebugStart] adapter_cmd={}, adapter_args={:?}, adapter_id={}, program={}", adapter_cmd, adapter_args, adapter_id, program); + eprintln!( + "[DebugStart] adapter_cmd={}, adapter_args={:?}, adapter_id={}, program={}", + adapter_cmd, adapter_args, adapter_id, program + ); // Build FVM environment variables if using FVM - let fvm_env = if adapter_args.len() >= 2 && adapter_args[0] == "flutter" && adapter_args[1] == "debug_adapter" { + let fvm_env = if adapter_args.len() >= 2 + && adapter_args[0] == "flutter" + && adapter_args[1] == "debug_adapter" + { // Using FVM wrapper - set FLUTTER_ROOT to the local SDK let flutter_sdk = workspace_root.join(".fvm").join("flutter_sdk"); if flutter_sdk.exists() { let mut env = std::collections::HashMap::new(); - env.insert("FLUTTER_ROOT".to_string(), flutter_sdk.to_string_lossy().to_string()); + env.insert( + "FLUTTER_ROOT".to_string(), + flutter_sdk.to_string_lossy().to_string(), + ); // Also add flutter/bin to PATH let flutter_bin = flutter_sdk.join("bin"); if let Ok(path) = std::env::var("PATH") { let new_path = format!("{}:{}", flutter_bin.display(), path); env.insert("PATH".to_string(), new_path); } - eprintln!("[DebugStart] Setting FVM env: FLUTTER_ROOT={}", flutter_sdk.display()); + eprintln!( + "[DebugStart] Setting FVM env: FLUTTER_ROOT={}", + flutter_sdk.display() + ); Some(env) } else { None @@ -325,8 +394,10 @@ impl AppShell { // Open Debug Console tab in bottom dock and focus it self.panel_state.bottom.visible = true; - self.panel_state.bottom.switch_to_tab(PanelTabId::DebugConsole); - + self.panel_state + .bottom + .switch_to_tab(PanelTabId::DebugConsole); + // Open Left Sidebar and switch to DAP (Inspector) if !self.panel_state.left.visible { self.panel_state.left.visible = true; @@ -376,8 +447,15 @@ impl AppShell { eprintln!("[DebugStart] Using launch.json config: {:?}", config.name); args } else { - let device_id = self.app_state.active_dart_device_id().unwrap_or("chrome").to_string(); - eprintln!("[DebugStart] Using Flutter defaults: program={}, device={}", program, device_id); + let device_id = self + .app_state + .active_dart_device_id() + .unwrap_or("chrome") + .to_string(); + eprintln!( + "[DebugStart] Using Flutter defaults: program={}, device={}", + program, device_id + ); serde_json::json!({ "request": "launch", "program": program, @@ -395,15 +473,24 @@ impl AppShell { eprintln!("DAP Init error: {}", e); return; } - if let Err(e) = session_clone.client.send_request("launch", Some(launch_args)).await { + if let Err(e) = session_clone + .client + .send_request("launch", Some(launch_args)) + .await + { eprintln!("DAP Launch error: {}", e); return; } - let _ = session_clone.client.send_request("configurationDone", None).await; + let _ = session_clone + .client + .send_request("configurationDone", None) + .await; { let mut state = session_clone.state.lock().await; state.paused = false; - state.console_messages.push("Debug session started.".to_string()); + state + .console_messages + .push("Debug session started.".to_string()); } if let Some(w) = &window_clone { w.request_redraw(); @@ -426,28 +513,40 @@ impl AppShell { while let Some(event) = event_rx.recv().await { match event.event.as_str() { "stopped" => { - let thread_id = event.body.as_ref() + let thread_id = event + .body + .as_ref() .and_then(|b| b.get("threadId")) .and_then(|t| t.as_i64()) .unwrap_or(1); - let _ = session_event_clone.update_suspended_state(thread_id).await; - let _ = session_event_clone.evaluate_watch_expressions().await; + let _ = session_event_clone + .update_suspended_state(thread_id) + .await; + let _ = + session_event_clone.evaluate_watch_expressions().await; } "continued" => { - let mut state_guard = session_event_clone.state.lock().await; + let mut state_guard = + session_event_clone.state.lock().await; state_guard.paused = false; state_guard.execution_location = None; } "output" => { if let Some(body) = event.body { - if let Some(output) = body.get("output").and_then(|v| v.as_str()) { - let mut state_guard = session_event_clone.state.lock().await; - state_guard.console_messages.push(output.to_string()); + if let Some(output) = + body.get("output").and_then(|v| v.as_str()) + { + let mut state_guard = + session_event_clone.state.lock().await; + state_guard + .console_messages + .push(output.to_string()); } } } "terminated" => { - let mut state_guard = session_event_clone.state.lock().await; + let mut state_guard = + session_event_clone.state.lock().await; state_guard.terminated = true; state_guard.paused = true; state_guard.execution_location = None; @@ -464,7 +563,10 @@ impl AppShell { } Err(err) => { eprintln!("Failed to launch debug adapter: {}", err); - self.show_transient_toast_kind(format!("Failed to launch debug adapter: {}", err), ToastKind::Error); + self.show_transient_toast_kind( + format!("Failed to launch debug adapter: {}", err), + ToastKind::Error, + ); Some(false) } } @@ -553,7 +655,9 @@ impl AppShell { Command::DebugToggleBreakpoint => { if let Some(active_file) = self.app_state.active_file() { let (line, _) = self.app_state.cursor_line_col(); - let path = active_file.canonicalize().unwrap_or_else(|_| active_file.to_path_buf()); + let path = active_file + .canonicalize() + .unwrap_or_else(|_| active_file.to_path_buf()); // Toggle in our local breakpoints map let is_empty = { let lines = self.breakpoints.entry(path.clone()).or_default(); @@ -637,7 +741,8 @@ impl AppShell { if state.remove_watch_expression(*node_index) { drop(state); if let Ok(state_guard) = session.state.try_lock() { - self.dap_panel_state.sync_from_debug_state(&state_guard); + self.dap_panel_state + .sync_from_debug_state(&state_guard); } self.sidebar_needs_layout = true; return Some(true); @@ -687,9 +792,10 @@ impl AppShell { self.submit(crate::async_runtime::message::RequestSpec { revision_id: 0, topic: crate::async_runtime::message::RequestTopic::SystemTask, - payload: crate::async_runtime::message::WorkerRequestPayload::ScanFlutterDevices { - flutter_path: Some(flutter_path), - }, + payload: + crate::async_runtime::message::WorkerRequestPayload::ScanFlutterDevices { + flutter_path: Some(flutter_path), + }, }); Some(true) } @@ -699,20 +805,25 @@ impl AppShell { pub(super) fn confirm_flutter_device_selection(&mut self) -> bool { use crate::app::command_palette::CommandPaletteAction; - if let Some(CommandPaletteAction::SelectFlutterDevice { device_id, is_emulator, is_active }) = - self.app_state.command_palette_selected_action() + if let Some(CommandPaletteAction::SelectFlutterDevice { + device_id, + is_emulator, + is_active, + }) = self.app_state.command_palette_selected_action() { - self.app_state.set_active_dart_device_id(Some(device_id.clone())); + self.app_state + .set_active_dart_device_id(Some(device_id.clone())); if is_emulator && !is_active { let flutter_path = self.current_flutter_path(); self.show_transient_toast(format!("Launching emulator {}...", device_id)); self.submit(crate::async_runtime::message::RequestSpec { revision_id: 0, topic: crate::async_runtime::message::RequestTopic::SystemTask, - payload: crate::async_runtime::message::WorkerRequestPayload::LaunchFlutterEmulator { - flutter_path: Some(flutter_path), - emulator_id: device_id.clone(), - }, + payload: + crate::async_runtime::message::WorkerRequestPayload::LaunchFlutterEmulator { + flutter_path: Some(flutter_path), + emulator_id: device_id.clone(), + }, }); } true @@ -723,18 +834,18 @@ impl AppShell { fn resolve_fvm_path() -> Option { if let Ok(home) = std::env::var("HOME") { - for relative in &[ - "fvm/bin/fvm", - ".fvm/bin/fvm", - ".pub-cache/bin/fvm", - ] { + for relative in &["fvm/bin/fvm", ".fvm/bin/fvm", ".pub-cache/bin/fvm"] { let p = PathBuf::from(&home).join(relative); if p.exists() { return Some(p); } } } - for p in &["/opt/homebrew/bin/fvm", "/usr/local/bin/fvm", "/usr/bin/fvm"] { + for p in &[ + "/opt/homebrew/bin/fvm", + "/usr/local/bin/fvm", + "/usr/bin/fvm", + ] { let p_path = PathBuf::from(p); if p_path.exists() { return Some(p_path); @@ -744,23 +855,26 @@ impl AppShell { } pub fn current_flutter_path(&self) -> PathBuf { - let workspace_root = self.app_state.workspace_root_path().map(PathBuf::from).unwrap_or_default(); - let dart_bin = self.selected_dart_env.clone() - .or_else(|| { - let local_fvm = workspace_root - .join(".fvm") - .join("flutter_sdk") - .join("bin") - .join("cache") - .join("dart-sdk") - .join("bin") - .join("dart"); - if local_fvm.try_exists().unwrap_or(false) { - Some(local_fvm) - } else { - None - } - }); + let workspace_root = self + .app_state + .workspace_root_path() + .map(PathBuf::from) + .unwrap_or_default(); + let dart_bin = self.selected_dart_env.clone().or_else(|| { + let local_fvm = workspace_root + .join(".fvm") + .join("flutter_sdk") + .join("bin") + .join("cache") + .join("dart-sdk") + .join("bin") + .join("dart"); + if local_fvm.try_exists().unwrap_or(false) { + Some(local_fvm) + } else { + None + } + }); if let Some(d) = dart_bin.as_ref() { resolve_flutter_path(d) @@ -772,21 +886,31 @@ impl AppShell { } pub fn load_breakpoints(&mut self) { - let workspace_root = self.app_state.workspace_root_path() + let workspace_root = self + .app_state + .workspace_root_path() .map(PathBuf::from) .or_else(|| { - self.app_state.active_file() + self.app_state + .active_file() .and_then(|file| file.parent().map(PathBuf::from)) }); if let Some(root) = workspace_root { let bp_path = root.join(".vscode").join("breakpoints.json"); if bp_path.exists() { if let Ok(content) = std::fs::read_to_string(&bp_path) { - if let Ok(bps) = serde_json::from_str::>>(&content) { - self.breakpoints = bps.into_iter().map(|(p, l)| { - (p.canonicalize().unwrap_or(p), l) - }).collect(); - eprintln!("[DAP LOG] [Breakpoint Persist] Loaded breakpoints: {:?}", self.breakpoints); + if let Ok(bps) = serde_json::from_str::< + std::collections::HashMap>, + >(&content) + { + self.breakpoints = bps + .into_iter() + .map(|(p, l)| (p.canonicalize().unwrap_or(p), l)) + .collect(); + eprintln!( + "[DAP LOG] [Breakpoint Persist] Loaded breakpoints: {:?}", + self.breakpoints + ); } } } @@ -794,10 +918,13 @@ impl AppShell { } pub fn save_breakpoints(&self) { - let workspace_root = self.app_state.workspace_root_path() + let workspace_root = self + .app_state + .workspace_root_path() .map(PathBuf::from) .or_else(|| { - self.app_state.active_file() + self.app_state + .active_file() .and_then(|file| file.parent().map(PathBuf::from)) }); if let Some(root) = workspace_root { diff --git a/src/app/event_loop/commands_editor.rs b/src/app/event_loop/commands_editor.rs index 2d9c02f7..66932492 100644 --- a/src/app/event_loop/commands_editor.rs +++ b/src/app/event_loop/commands_editor.rs @@ -9,11 +9,14 @@ impl AppShell { return false; }; let (cursor_line, _) = self.app_state.cursor_line_col(); - let Some(target_col) = renderer.soft_wrap_visual_move_target(&self.app_state, center_bounds, down) else { + let Some(target_col) = + renderer.soft_wrap_visual_move_target(&self.app_state, center_bounds, down) + else { return false; }; let before = self.app_state.cursor_char_idx(); - self.app_state.jump_to_line_and_column(cursor_line, target_col) + self.app_state + .jump_to_line_and_column(cursor_line, target_col) && self.app_state.cursor_char_idx() != before } @@ -28,9 +31,8 @@ impl AppShell { | Command::Backspace | Command::Newline => { // Clear semantic highlights and increment revision when typing/deleting - self.semantic_highlight_request_revision = self - .semantic_highlight_request_revision - .saturating_add(1); + self.semantic_highlight_request_revision = + self.semantic_highlight_request_revision.saturating_add(1); let semantic_symbols_cleared = self.app_state.clear_semantic_symbol_highlights(); let semantic_spans_cleared = !self.semantic_highlight_spans.is_empty(); if semantic_spans_cleared { @@ -66,7 +68,14 @@ impl AppShell { self.queue_lsp_did_change_for_active_file(); self.pending_parse_after_debounce = true; self.last_parse_submit_at = Some(Instant::now()); - self.queue_ai_inline_completion(); + if self.ai_inline_suggestion_retained { + // Typing consumed the head of the visible ghost text; + // follow the caret with the retained remainder instead + // of letting the anchor watchdog clear it. + self.reanchor_ai_inline(); + } else { + self.queue_ai_inline_completion(); + } } let completion_changed = if report.state_changed { self.refresh_open_completion_after_text_edit() @@ -99,9 +108,10 @@ impl AppShell { Command::ScrollHalfPageUp | Command::ScrollHalfPageDown if self.app_state.has_scrollable_floating_overlay() => { - let changed = self - .app_state - .scroll_floating_overlay_half_page(matches!(command, Command::ScrollHalfPageDown)); + let changed = self.app_state.scroll_floating_overlay_half_page(matches!( + command, + Command::ScrollHalfPageDown + )); if changed { self.editor_caret_needs_layout = true; self.request_redraw(); @@ -207,6 +217,8 @@ impl AppShell { .cloned(); if let Some(target) = resolved_target { + // Leap là một jump -> ghi origin để Ctrl+O quay về. + self.app_state.push_jump(); let changed = self.app_state.leap_jump_to_char(target.char_idx); let viewport_lines = self.editor_viewport_lines(); let prev_scroll = self.app_state.target_scroll_y; @@ -295,9 +307,16 @@ impl AppShell { | Command::WrapSelectionWithStar | Command::DeleteWordForward | Command::DeleteWordBackward + | Command::Operate { + op: crate::core::commands::Operator::Delete + | crate::core::commands::Operator::Change, + .. + } | Command::ChangeSelection | Command::ChangeWordForward | Command::ChangeWordBackward + | Command::DeleteToLineEnd + | Command::ChangeToLineEnd | Command::PasteAfter | Command::PasteBefore | Command::EditorPaste @@ -321,9 +340,8 @@ impl AppShell { }; if is_typing_edit { let _ = self.app_state.clear_completion(); - self.semantic_highlight_request_revision = self - .semantic_highlight_request_revision - .saturating_add(1); + self.semantic_highlight_request_revision = + self.semantic_highlight_request_revision.saturating_add(1); if self.app_state.clear_semantic_symbol_highlights() { self.editor_caret_needs_layout = true; self.request_redraw(); @@ -345,6 +363,7 @@ impl AppShell { }; self.reconcile_highlight_spans_with_pending_edits(); if report.success && should_notify_did_open { + self.close_completion_popup(); self.invalidate_highlights_and_parse_active_buffer(); parsed_after_buffer_switch = true; self.mark_explorer_dirty(); @@ -355,6 +374,7 @@ impl AppShell { } if report.state_changed && is_cursor_move { + self.refresh_open_completion_after_text_edit(); let prev_scroll = self.app_state.target_scroll_y; let viewport_lines = self.editor_viewport_lines(); self.app_state.auto_scroll_to_cursor(viewport_lines); @@ -368,6 +388,7 @@ impl AppShell { self.submit_lsp_document_highlight(); self.ensure_document_symbol_breadcrumbs(false); } else if report.state_changed { + self.refresh_open_completion_after_text_edit(); self.editor_needs_layout = true; self.editor_caret_needs_layout = false; } @@ -382,9 +403,8 @@ impl AppShell { self.force_flush_lsp_did_change_for_active_file(); self.ensure_document_symbol_breadcrumbs(true); } - self.semantic_highlight_request_revision = self - .semantic_highlight_request_revision - .saturating_add(1); + self.semantic_highlight_request_revision = + self.semantic_highlight_request_revision.saturating_add(1); if self.app_state.clear_semantic_symbol_highlights() { self.editor_caret_needs_layout = true; self.request_redraw(); diff --git a/src/app/event_loop/commands_explorer.rs b/src/app/event_loop/commands_explorer.rs index 0d8fa780..70dcc984 100644 --- a/src/app/event_loop/commands_explorer.rs +++ b/src/app/event_loop/commands_explorer.rs @@ -125,6 +125,7 @@ impl AppShell { topic: RequestTopic::WorkspaceWatch, payload: WorkerRequestPayload::StartFileWatch { root_path: root_path.clone(), + recursive: true, }, }); self.submit_workspace_git_status_refresh(); @@ -161,9 +162,8 @@ impl AppShell { self.explorer_snapshot_dirty = true; self.explorer_cursor = 0; - let icon_source = crate::app::persistence::AppPersistentState::infer_project_icon_source( - &root_path, - ); + let icon_source = + crate::app::persistence::AppPersistentState::infer_project_icon_source(&root_path); self.persistent_state .push_recent_with_icon(root_path.clone(), Some(icon_source)); self.persistent_state.save(); @@ -179,6 +179,7 @@ impl AppShell { topic: RequestTopic::WorkspaceWatch, payload: WorkerRequestPayload::StartFileWatch { root_path: root_path.clone(), + recursive: true, }, }); @@ -402,12 +403,20 @@ impl AppShell { let target_dir = if selected_entry.file_type == WorkspaceNodeType::Folder { selected_entry.path.clone() } else { - selected_entry.path.parent().map(PathBuf::from).unwrap_or_else(|| { - self.app_state.workspace_root_path().map(PathBuf::from).unwrap_or_default() - }) + selected_entry + .path + .parent() + .map(PathBuf::from) + .unwrap_or_else(|| { + self.app_state + .workspace_root_path() + .map(PathBuf::from) + .unwrap_or_default() + }) }; - let file_name = source_path.file_name() + let file_name = source_path + .file_name() .and_then(|n| n.to_str()) .unwrap_or("file") .to_string(); @@ -633,19 +642,25 @@ impl AppShell { if let Some(path) = &row.node_path { if !path.is_empty() { let frame_idx = path[0]; - let jump_target = self.dap_session.as_ref().and_then(|session| { - if let Ok(debug_guard) = session.state.try_lock() { - debug_guard.call_stack.get(frame_idx).and_then(|frame| { - if frame.path.exists() { - Some((frame.path.clone(), frame.location.line)) - } else { - None - } - }) - } else { - None - } - }); + let jump_target = + self.dap_session.as_ref().and_then(|session| { + if let Ok(debug_guard) = session.state.try_lock() { + debug_guard.call_stack.get(frame_idx).and_then( + |frame| { + if frame.path.exists() { + Some(( + frame.path.clone(), + frame.location.line, + )) + } else { + None + } + }, + ) + } else { + None + } + }); if let Some((target_path, target_line)) = jump_target { let report = dispatch_command( &mut self.app_state, @@ -659,7 +674,8 @@ impl AppShell { self.submit_lsp_did_open_for_active_file(); self.editor_needs_layout = true; self.editor_caret_needs_layout = true; - let _ = self.focus_manager.set(FocusTarget::CenterEditor); + let _ = + self.focus_manager.set(FocusTarget::CenterEditor); return Some(true); } } @@ -669,19 +685,20 @@ impl AppShell { if let Some(path) = &row.node_path { if !path.is_empty() { let bp_idx = path[0]; - let jump_target = self.dap_session.as_ref().and_then(|session| { - if let Ok(debug_guard) = session.state.try_lock() { - debug_guard.breakpoints.get(bp_idx).and_then(|bp| { - if bp.path.exists() { - Some((bp.path.clone(), bp.location.line)) - } else { - None - } - }) - } else { - None - } - }); + let jump_target = + self.dap_session.as_ref().and_then(|session| { + if let Ok(debug_guard) = session.state.try_lock() { + debug_guard.breakpoints.get(bp_idx).and_then(|bp| { + if bp.path.exists() { + Some((bp.path.clone(), bp.location.line)) + } else { + None + } + }) + } else { + None + } + }); if let Some((target_path, target_line)) = jump_target { let report = dispatch_command( &mut self.app_state, @@ -695,7 +712,8 @@ impl AppShell { self.submit_lsp_did_open_for_active_file(); self.editor_needs_layout = true; self.editor_caret_needs_layout = true; - let _ = self.focus_manager.set(FocusTarget::CenterEditor); + let _ = + self.focus_manager.set(FocusTarget::CenterEditor); return Some(true); } } diff --git a/src/app/event_loop/commands_lsp.rs b/src/app/event_loop/commands_lsp.rs index dd2e14a0..4c176147 100644 --- a/src/app/event_loop/commands_lsp.rs +++ b/src/app/event_loop/commands_lsp.rs @@ -12,7 +12,7 @@ impl AppShell { Command::LspReferences => Some(self.submit_lsp_references()), Command::LspRename => Some(self.open_lsp_rename_prompt()), Command::LspFormatDocument => Some(self.submit_lsp_format_document()), - Command::TriggerCompletion => Some(self.submit_lsp_completion()), + Command::TriggerCompletion => Some(self.submit_lsp_completion_manual()), Command::CodeAction => Some(self.submit_lsp_code_action()), Command::LspSelectPythonEnv => Some(self.handle_lsp_select_python_env()), Command::LspSelectDartEnv => Some(self.handle_lsp_select_dart_env()), @@ -20,7 +20,7 @@ impl AppShell { Command::CompletionPrev => Some(self.select_prev_completion_item()), Command::CompletionAccept => Some(self.accept_completion_item()), Command::CompletionClose => Some(self.cancel_completion_and_return_normal()), - Command::AiAcceptInline => { + Command::AiAcceptInline | Command::AiAcceptInlineWord => { let report = dispatch_command(&mut self.app_state, command.clone()); if report.state_changed { self.reconcile_highlight_spans_with_pending_edits(); @@ -30,6 +30,14 @@ impl AppShell { self.app_state.auto_scroll_to_cursor(viewport_lines); self.queue_lsp_did_change_for_active_file(); self.submit_parse_for_active_buffer(true); + // A word accept keeps the rest of the suggestion visible at + // the caret's new position; re-anchor so the watchdog keeps + // it instead of clearing it as a stale ghost. + if self.app_state.inline_suggestion().is_some() { + self.reanchor_ai_inline(); + } else { + self.ai_inline_anchor = None; + } } Some(report.request_redraw || report.state_changed) } @@ -87,8 +95,6 @@ impl AppShell { false } - - pub(in crate::app::event_loop) fn accept_lsp_install_guide(&mut self) -> bool { let Some(guide) = self.active_lsp_guide.take() else { return false; @@ -621,14 +627,10 @@ impl AppShell { return false; }; - let origin = self - .app_state - .active_file() - .map(PathBuf::from) - .map(|path| { - let (line, col) = self.app_state.cursor_line_col(); - (path, line, col) - }); + let origin = self.app_state.active_file().map(PathBuf::from).map(|path| { + let (line, col) = self.app_state.cursor_line_col(); + (path, line, col) + }); let _ = self.app_state.close_current_buffer(); diff --git a/src/app/event_loop/commands_palette.rs b/src/app/event_loop/commands_palette.rs index 0379d481..1e918b1e 100644 --- a/src/app/event_loop/commands_palette.rs +++ b/src/app/event_loop/commands_palette.rs @@ -73,7 +73,9 @@ impl AppShell { self.begin_theme_picker_preview_session(); request_redraw |= self.preview_selected_theme_from_picker(); } - if self.app_state.command_palette_mode() == Some(CommandPaletteMode::ThemeSelector) { + if self.app_state.command_palette_mode() + == Some(CommandPaletteMode::ThemeSelector) + { self.begin_theme_picker_preview_session(); request_redraw |= self.preview_selected_theme_from_picker(); } @@ -153,7 +155,9 @@ impl AppShell { ); } } - } else if self.app_state.command_palette_mode() == Some(CommandPaletteMode::ThemeSelector) { + } else if self.app_state.command_palette_mode() + == Some(CommandPaletteMode::ThemeSelector) + { self.preview_selected_theme_from_picker(); } else { self.submit_active_palette_fzf_search(); @@ -179,7 +183,8 @@ impl AppShell { self.editor_needs_layout = true; self.editor_caret_needs_layout = false; } - if self.app_state.command_palette_mode() == Some(CommandPaletteMode::ThemeSelector) { + if self.app_state.command_palette_mode() == Some(CommandPaletteMode::ThemeSelector) + { self.preview_selected_theme_from_picker(); } self.submit_active_palette_fzf_search(); @@ -196,7 +201,8 @@ impl AppShell { { let report = dispatch_command(&mut self.app_state, command.clone()); if report.success - && self.app_state.command_palette_mode() == Some(CommandPaletteMode::ThemeSelector) + && self.app_state.command_palette_mode() + == Some(CommandPaletteMode::ThemeSelector) { self.preview_selected_theme_from_picker(); } @@ -333,8 +339,6 @@ impl AppShell { return Some(self.confirm_explorer_prompt()); } - - if matches!(command, Command::FilePickerConfirmSelection) && matches!( self.app_state.command_palette_mode(), @@ -406,7 +410,11 @@ impl AppShell { Some(CommandPaletteMode::DebugWatchInput) ) { - let expr = self.app_state.command_palette_query_text().trim().to_string(); + let expr = self + .app_state + .command_palette_query_text() + .trim() + .to_string(); let _ = dispatch_command(&mut self.app_state, Command::CloseFilePicker); if !expr.is_empty() { if let Some(session) = &self.dap_session { @@ -557,9 +565,7 @@ impl AppShell { self.submit(RequestSpec { revision_id: 0, topic: RequestTopic::SystemTask, - payload: WorkerRequestPayload::ScanDartEnvironments { - workspace_root, - }, + payload: WorkerRequestPayload::ScanDartEnvironments { workspace_root }, }); } self.arm_palette_ime_commit_suppression(); diff --git a/src/app/event_loop/commands_prompts.rs b/src/app/event_loop/commands_prompts.rs index c89f281e..cf3ee127 100644 --- a/src/app/event_loop/commands_prompts.rs +++ b/src/app/event_loop/commands_prompts.rs @@ -435,23 +435,22 @@ impl AppShell { return false; }; - let loaded_theme = if self.theme_picker_preview_profile.as_deref() - == Some(theme_profile.as_str()) - { - self.base_theme.clone() - } else { - match ThemeConfig::load(&theme_profile) { - Ok(theme) => theme, - Err(err) => { - eprintln!( - "[AppShell] theme load failed for profile '{}': {err}", - theme_profile - ); - self.show_transient_toast(format!("Failed to load theme: {theme_profile}")); - return true; + let loaded_theme = + if self.theme_picker_preview_profile.as_deref() == Some(theme_profile.as_str()) { + self.base_theme.clone() + } else { + match ThemeConfig::load(&theme_profile) { + Ok(theme) => theme, + Err(err) => { + eprintln!( + "[AppShell] theme load failed for profile '{}': {err}", + theme_profile + ); + self.show_transient_toast(format!("Failed to load theme: {theme_profile}")); + return true; + } } - } - }; + }; self.theme_picker_original_theme = None; self.theme_picker_preview_profile = None; @@ -576,25 +575,25 @@ impl AppShell { changed | self.close_current_buffer_now() } PendingConfirmationAction::ExternalOverwrite { path } => { - let active_matches = self - .app_state - .active_file() - .is_some_and(|active| { - if active == path.as_path() { - return true; - } - match (active.canonicalize().ok(), path.canonicalize().ok()) { - (Some(active_canon), Some(target_canon)) => active_canon == target_canon, - _ => false, - } - }); + let active_matches = self.app_state.active_file().is_some_and(|active| { + if active == path.as_path() { + return true; + } + match (active.canonicalize().ok(), path.canonicalize().ok()) { + (Some(active_canon), Some(target_canon)) => active_canon == target_canon, + _ => false, + } + }); if !active_matches { return changed; } if confirmed { changed |= self.handle_command(Command::SaveFile); } else { - match self.app_state.reload_active_file_from_disk_discarding_local() { + match self + .app_state + .reload_active_file_from_disk_discarding_local() + { Ok(_) => { self.invalidate_highlights_and_parse_active_buffer(); self.force_flush_lsp_did_change_for_active_file(); @@ -629,7 +628,6 @@ impl AppShell { }); changed | true } - } } } diff --git a/src/app/event_loop/commands_settings.rs b/src/app/event_loop/commands_settings.rs index 216f3c0f..30518ce3 100644 --- a/src/app/event_loop/commands_settings.rs +++ b/src/app/event_loop/commands_settings.rs @@ -30,6 +30,7 @@ impl AppShell { self.ui_config.border_radius_px, self.ui_config.enable_outline, self.ai_config.inline_completion_enabled(), + self.ui_config.window.scale_factor_override, ); let _ = self.sync_focus_mode_for_active_buffer(); self.editor_needs_layout = true; @@ -112,9 +113,16 @@ impl AppShell { Command::ExtensionsCancelFilter => { let mut cmd_changed = false; if let Some(state) = self.app_state.active_extensions_manager_buffer_mut() { - if state.command.is_some() { - state.command = None; - cmd_changed = true; + // Only dismiss the install/uninstall popup once the process has + // finished. Clearing it mid-run dropped the live logs and made the + // running install invisible ("popup does nothing" bug). + match state.command.as_ref() { + Some(cmd) if cmd.running => {} + Some(_) => { + state.command = None; + cmd_changed = true; + } + None => {} } } let changed = self.app_state.extensions_set_filter_focused(false); @@ -127,16 +135,34 @@ impl AppShell { } } Command::ExtensionsInstallSelected => { - let selected = self.app_state.active_extensions_manager_buffer().and_then(|state| { - state.selected_item().map(|item| { - let install = if state.platform == "macOS" { - item.macos_install.clone() - } else { - item.linux_install.clone() - }; - (item.name.clone(), item.binary.clone(), install) - }) - }); + if let Some(running) = self + .app_state + .active_extensions_manager_buffer() + .and_then(|state| state.command.as_ref()) + .filter(|cmd| cmd.running) + { + self.show_transient_toast_kind( + format!( + "Another operation is in progress ({})\nWait for it to finish before starting a new one.", + running.binary + ), + ToastKind::Warning, + ); + return Some(false); + } + let selected = + self.app_state + .active_extensions_manager_buffer() + .and_then(|state| { + state.selected_item().map(|item| { + let install = if state.platform == "macOS" { + item.macos_install.clone() + } else { + item.linux_install.clone() + }; + (item.name.clone(), item.binary.clone(), install) + }) + }); let Some((name, binary, install_cmd)) = selected else { return Some(false); @@ -149,6 +175,17 @@ impl AppShell { return Some(false); } + // Pre-check: the package manager this install command relies on + // must itself be installed, otherwise the command dies with a + // cryptic "command not found" in the logs. + if let Some((manager, hint)) = missing_install_prerequisite(&install_cmd) { + self.show_transient_toast_kind( + format!("Cannot install {name}\n`{manager}` is required but was not found on this system. {hint}"), + ToastKind::Warning, + ); + return Some(false); + } + if let Some(state) = self.app_state.active_extensions_manager_buffer_mut() { state.start_command(binary.clone(), false); } @@ -159,13 +196,17 @@ impl AppShell { binary: binary.clone(), command: install_cmd.clone(), uninstall: false, - working_dir: self.app_state.workspace_root_path().map(std::path::PathBuf::from), + working_dir: self + .app_state + .workspace_root_path() + .map(std::path::PathBuf::from), }, }); let changed = true; self.pending_lsp_server = None; - self.lsp_retry_at = Some(std::time::Instant::now() + std::time::Duration::from_secs(15)); + self.lsp_retry_at = + Some(std::time::Instant::now() + std::time::Duration::from_secs(15)); self.show_transient_toast_kind( format!("Installing {binary}\nRunning: {install_cmd}\nOpen Extensions Manager footer to watch live logs."), ToastKind::Info, @@ -175,23 +216,43 @@ impl AppShell { Some(changed || true) } Command::ExtensionsUninstallSelected => { - let selected = self.app_state.active_extensions_manager_buffer().and_then(|state| { - state.selected_item().map(|item| { - let uninstall = if state.platform == "macOS" { - item.macos_uninstall.clone() - } else { - item.linux_uninstall.clone() - }; - (item.name.clone(), item.binary.clone(), uninstall) - }) - }); + if let Some(running) = self + .app_state + .active_extensions_manager_buffer() + .and_then(|state| state.command.as_ref()) + .filter(|cmd| cmd.running) + { + self.show_transient_toast_kind( + format!( + "Another operation is in progress ({})\nWait for it to finish before starting a new one.", + running.binary + ), + ToastKind::Warning, + ); + return Some(false); + } + let selected = + self.app_state + .active_extensions_manager_buffer() + .and_then(|state| { + state.selected_item().map(|item| { + let uninstall = if state.platform == "macOS" { + item.macos_uninstall.clone() + } else { + item.linux_uninstall.clone() + }; + (item.name.clone(), item.binary.clone(), uninstall) + }) + }); let Some((name, binary, uninstall_cmd)) = selected else { return Some(false); }; if uninstall_cmd.trim().is_empty() { self.show_transient_toast_kind( - format!("Uninstall unavailable\nNo uninstall command configured for {name}"), + format!( + "Uninstall unavailable\nNo uninstall command configured for {name}" + ), ToastKind::Warning, ); return Some(false); @@ -207,7 +268,10 @@ impl AppShell { binary: binary.clone(), command: uninstall_cmd.clone(), uninstall: true, - working_dir: self.app_state.workspace_root_path().map(std::path::PathBuf::from), + working_dir: self + .app_state + .workspace_root_path() + .map(std::path::PathBuf::from), }, }); @@ -303,7 +367,9 @@ impl AppShell { Command::FilePickerAppendQuery(text) => { self.app_state.extensions_append_filter(text) } - Command::FilePickerBackspaceQuery => self.app_state.extensions_backspace_filter(), + Command::FilePickerBackspaceQuery => { + self.app_state.extensions_backspace_filter() + } _ => false, }; if changed { @@ -345,3 +411,39 @@ impl AppShell { } } } + +/// If `install_cmd` relies on a package manager that is missing from the +/// (version-manager-augmented) PATH, return that manager plus an install hint. +/// Unknown leading tokens are not gated — only well-known managers are checked. +fn missing_install_prerequisite(install_cmd: &str) -> Option<(&'static str, &'static str)> { + let first_token = install_cmd.split_whitespace().next()?; + let (manager, hint) = match first_token { + "brew" => ( + "brew", + "Install Homebrew first: https://brew.sh, then retry.", + ), + "npm" | "npx" => ( + "npm", + "Install Node.js (includes npm) first: https://nodejs.org or `brew install node`.", + ), + "go" => ( + "go", + "Install Go first: https://go.dev/dl or `brew install go`.", + ), + "cargo" | "rustup" => ("cargo", "Install Rust first: https://rustup.rs."), + "pip" | "pip3" | "pipx" => ( + "pip3", + "Install Python 3 first: https://www.python.org or `brew install python`.", + ), + "gem" => ("gem", "Install Ruby first: `brew install ruby`."), + "dotnet" => ( + "dotnet", + "Install the .NET SDK first: https://dotnet.microsoft.com/download.", + ), + _ => return None, + }; + + let resolved_path = crate::async_runtime::scheduler::resolve_system_path(); + let found = std::env::split_paths(&resolved_path).any(|dir| dir.join(manager).is_file()); + if found { None } else { Some((manager, hint)) } +} diff --git a/src/app/event_loop/commands_settings_helpers.rs b/src/app/event_loop/commands_settings_helpers.rs index 6ba8df86..b2bc9b31 100644 --- a/src/app/event_loop/commands_settings_helpers.rs +++ b/src/app/event_loop/commands_settings_helpers.rs @@ -184,6 +184,11 @@ impl AppShell { let next = (current + delta * 20).clamp(120, 1040); self.update_active_settings_edit_draft(next.to_string()) } + crate::app::app_state::SettingItem::UiScale { current } => { + let base = current.unwrap_or(self.runtime_scale); + let next = (base + delta as f32 * 0.05).clamp(0.5, 3.0); + self.update_active_settings_edit_draft(format!("{next:.2}")) + } _ => false, }; } @@ -278,10 +283,35 @@ impl AppShell { self.ui_config.border_radius_px = next; self.finalize_settings_change() } + crate::app::app_state::SettingItem::UiScale { current } => { + let base = current.unwrap_or(self.runtime_scale); + let next = (base + delta as f32 * 0.05).clamp(0.5, 3.0); + self.ui_config.window.scale_factor_override = Some(next); + if let Some(state) = self.app_state.active_settings_buffer_mut() + && let Some(crate::app::app_state::SettingItem::UiScale { current }) = + state.selected_item_mut() + { + *current = Some(next); + } + self.refresh_runtime_scale_from_window(); + self.finalize_settings_change() + } _ => false, } } + /// Recompute runtime scaling with the live window scale factor — needed when + /// `scale_factor_override` changes, since `apply_scaled_runtime_config` alone + /// reuses the previously computed `runtime_scale`. + fn refresh_runtime_scale_from_window(&mut self) { + let scale_factor = self + .window + .as_ref() + .map(|window| window.scale_factor()) + .unwrap_or(1.0); + self.update_runtime_scaling_for_window(scale_factor); + } + pub(super) fn activate_selected_setting(&mut self) -> bool { if self.app_state.settings_is_editing() { return self.commit_settings_editing(); @@ -356,7 +386,8 @@ impl AppShell { | crate::app::app_state::SettingItem::UiRounding { .. } | crate::app::app_state::SettingItem::SidebarWidth { .. } | crate::app::app_state::SettingItem::RightSidebarWidth { .. } - | crate::app::app_state::SettingItem::BottomPanelHeight { .. } => { + | crate::app::app_state::SettingItem::BottomPanelHeight { .. } + | crate::app::app_state::SettingItem::UiScale { .. } => { let changed = self.app_state.settings_begin_editing(); if changed { if let Ok(result) = self @@ -499,6 +530,27 @@ impl AppShell { changed = true; } } + crate::app::app_state::SettingsEditingKind::UiScale => { + let next = if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("auto") { + Some(None) + } else { + trimmed + .parse::() + .ok() + .map(|value| Some(value.clamp(0.5, 3.0))) + }; + if let Some(next) = next { + self.ui_config.window.scale_factor_override = next; + if let Some(state) = self.app_state.active_settings_buffer_mut() + && let Some(crate::app::app_state::SettingItem::UiScale { current }) = + state.selected_item_mut() + { + *current = next; + } + self.refresh_runtime_scale_from_window(); + changed = true; + } + } } if changed { diff --git a/src/app/event_loop/commands_terminal.rs b/src/app/event_loop/commands_terminal.rs index 595b790f..c8fb1a44 100644 --- a/src/app/event_loop/commands_terminal.rs +++ b/src/app/event_loop/commands_terminal.rs @@ -57,6 +57,27 @@ impl AppShell { } } + /// Scroll the terminal grid that currently owns focus (bottom panel, right + /// sidebar opencode chat, or center buffer terminal). `half_page` scrolls by + /// half the visible rows (Ctrl+U/Ctrl+D); otherwise it scrolls 3 lines. + fn scroll_focused_terminal(&mut self, up: bool, half_page: bool) -> bool { + let scrolled = if let Some(grid) = self.focused_terminal_grid_mut() { + let lines = if half_page { (grid.rows / 2).max(1) } else { 3 }; + if up { + grid.view_scroll_up(lines); + } else { + grid.view_scroll_down(lines); + } + true + } else { + false + }; + if scrolled { + self.mark_focused_terminal_layout_dirty(); + } + scrolled + } + pub(super) fn handle_terminal_and_focus_command(&mut self, command: &Command) -> Option { match command { Command::ToggleTerminal => { @@ -209,7 +230,9 @@ impl AppShell { changed |= focus_changed; if self.panel_state.right.active_tab_id() == Some(PanelTabId::Terminal) { self.ensure_right_opencode_terminal(); - if let Ok(result) = self.app_state.apply_mode_event(ModeEvent::FocusTerminal) { + if let Ok(result) = + self.app_state.apply_mode_event(ModeEvent::FocusTerminal) + { changed |= result.changed; } } @@ -223,7 +246,8 @@ impl AppShell { let terminal_has_focus = matches!( self.app_state.current_mode(), EditorMode::TerminalFocus | EditorMode::TerminalNormal - ) || self.focus_manager.current() == FocusTarget::BottomPanel; + ) || self.focus_manager.current() + == FocusTarget::BottomPanel; let mut changed = false; let mut focus_changed = false; @@ -288,32 +312,10 @@ impl AppShell { Some(false) } Command::TerminalPaste => Some(self.handle_terminal_paste()), - Command::TerminalScrollUp => { - if let Some(grid) = self.focused_terminal_grid_mut() { - grid.view_scroll_up(3); - if self.app_state.active_buffer_is_terminal() { - self.buffer_terminal_needs_layout = true; - } else { - self.terminal_needs_layout = true; - } - Some(true) - } else { - Some(false) - } - } - Command::TerminalScrollDown => { - if let Some(grid) = self.focused_terminal_grid_mut() { - grid.view_scroll_down(3); - if self.app_state.active_buffer_is_terminal() { - self.buffer_terminal_needs_layout = true; - } else { - self.terminal_needs_layout = true; - } - Some(true) - } else { - Some(false) - } - } + Command::TerminalScrollUp => Some(self.scroll_focused_terminal(true, false)), + Command::TerminalScrollDown => Some(self.scroll_focused_terminal(false, false)), + Command::TerminalScrollHalfPageUp => Some(self.scroll_focused_terminal(true, true)), + Command::TerminalScrollHalfPageDown => Some(self.scroll_focused_terminal(false, true)), Command::TerminalSearchOpen => { let report = dispatch_command(&mut self.app_state, Command::OpenInFileSearch); if report.success { diff --git a/src/app/event_loop/commands_tests.rs b/src/app/event_loop/commands_tests.rs index a91f74c7..9c7c12bc 100644 --- a/src/app/event_loop/commands_tests.rs +++ b/src/app/event_loop/commands_tests.rs @@ -1,5 +1,7 @@ use super::*; use crate::app::clipboard::ClipboardProvider; +use crate::app::input::{InputRouteOutcome, NormalizedInput}; +use winit::keyboard::{KeyCode, ModifiersState}; #[derive(Default)] struct MockClipboard { @@ -91,9 +93,11 @@ fn insert_edit_clears_stale_semantic_highlight_spans() { range: 0.."bootstrap".len(), category: crate::syntax::highlight::HighlightCategory::Variable, }]; - assert!(shell.app_state.set_semantic_symbol_highlights(vec![ - (0, "bootstrap".len()) - ])); + assert!( + shell + .app_state + .set_semantic_symbol_highlights(vec![(0, "bootstrap".len())]) + ); shell.editor_needs_layout = false; shell.editor_caret_needs_layout = false; @@ -104,6 +108,28 @@ fn insert_edit_clears_stale_semantic_highlight_spans() { assert!(shell.editor_needs_layout); } +#[test] +fn operator_delete_clears_stale_semantic_symbol_highlights() { + let mut shell = AppShell::new_for_tests().expect("create app shell"); + let text = "bootstrap.NewApp\nsecond line"; + shell.app_state = AppState::from_text(PathBuf::from("semantic-highlight-dd.ts"), text); + let _ = shell.app_state.apply_mode_event(ModeEvent::EnterNormal); + assert!( + shell + .app_state + .set_semantic_symbol_highlights(vec![(0, "bootstrap".len())]) + ); + let revision_before = shell.semantic_highlight_request_revision; + + assert!(shell.handle_command(Command::Operate { + op: crate::core::commands::Operator::Delete, + target: crate::core::commands::OperationTarget::CurrentLine, + })); + + assert!(shell.app_state.semantic_symbol_highlights().is_empty()); + assert!(shell.semantic_highlight_request_revision > revision_before); +} + #[test] fn move_to_last_line_uses_viewport_layout_path() { let mut shell = AppShell::new_for_tests().expect("create app shell"); @@ -235,6 +261,112 @@ fn settings_commit_ui_rounding_edit_clamps_to_24() { ); } +#[test] +fn settings_text_edit_works_after_opening_from_right_terminal_focus() { + let mut shell = AppShell::new_for_tests().expect("create app shell"); + shell.panel_state.right.visible = true; + shell.panel_state.right.switch_to_tab(PanelTabId::Terminal); + shell.focus_manager.set(FocusTarget::RightSidebar); + let _ = shell.app_state.apply_mode_event(ModeEvent::FocusTerminal); + + assert!(shell.handle_command(Command::OpenSettings)); + assert_eq!(shell.focus_manager.current(), FocusTarget::CenterEditor); + assert_eq!(shell.app_state.current_mode(), EditorMode::Normal); + + let settings = shell + .app_state + .active_settings_buffer_mut() + .expect("settings buffer"); + settings.selected_index = settings + .items + .iter() + .position(|item| { + matches!( + item, + crate::app::app_state::SettingItem::RightSidebarWidth { .. } + ) + }) + .expect("right sidebar width setting"); + + assert!(shell.handle_command(Command::SettingsActivate)); + assert_eq!(shell.app_state.current_mode(), EditorMode::Insert); + let context = shell.build_context(); + assert_eq!(context.focus, InputFocusContext::SettingsTab); + + let routed = shell.input_handler.route_normalized_input( + NormalizedInput { + physical_key: Some(KeyCode::Digit7), + named_key: None, + text: Some("7".to_string()), + modifiers: ModifiersState::empty(), + }, + &shell.input_map, + context, + std::time::Instant::now(), + ); + match routed { + Some(InputRouteOutcome::Dispatch(translated)) => { + assert_eq!( + translated.command, + Command::FilePickerAppendQuery("7".to_string()) + ); + assert!(shell.handle_command(translated.command)); + } + other => panic!("expected settings text append route, got {:?}", other), + } + + let settings = shell + .app_state + .active_settings_buffer() + .expect("settings buffer"); + let editing = settings.editing.as_ref().expect("editing state"); + assert!(editing.draft.ends_with('7')); +} + +#[test] +fn right_terminal_focus_cmd_comma_routes_to_settings() { + let mut shell = AppShell::new_for_tests().expect("create app shell"); + shell.app_state = AppState::from_text(PathBuf::from("main.rs"), "fn main() {}\n"); + shell.panel_state.right.visible = true; + shell.panel_state.right.switch_to_tab(PanelTabId::Terminal); + shell.focus_manager.set(FocusTarget::RightSidebar); + shell + .app_state + .apply_mode_event(ModeEvent::FocusTerminal) + .expect("focus terminal mode"); + let context = shell.build_context(); + assert_eq!(context.focus, InputFocusContext::Terminal); + assert_eq!(context.mode, EditorMode::TerminalFocus); + assert!(context.right_sidebar_terminal); + + let routed = shell.input_handler.route_normalized_input( + NormalizedInput { + physical_key: Some(KeyCode::Comma), + named_key: None, + text: Some(",".to_string()), + modifiers: ModifiersState::SUPER, + }, + &shell.input_map, + context, + std::time::Instant::now(), + ); + + match routed { + Some(InputRouteOutcome::Dispatch(translated)) => { + assert_eq!(translated.command, Command::OpenSettings); + assert!(shell.handle_command(translated.command)); + } + other => panic!( + "expected settings route from right terminal, got {:?}", + other + ), + } + + assert_eq!(shell.focus_manager.current(), FocusTarget::CenterEditor); + assert_eq!(shell.app_state.current_mode(), EditorMode::Normal); + assert!(shell.app_state.active_settings_buffer().is_some()); +} + #[test] fn scroll_half_page_down_uses_viewport_layout_path() { let mut shell = AppShell::new_for_tests().expect("create app shell"); @@ -365,6 +497,55 @@ fn ai_chat_toggle_closing_right_dock_returns_focus_to_editor() { assert_eq!(shell.focus_manager.current(), FocusTarget::CenterEditor); } +#[test] +fn ai_chat_focus_reenters_terminal_mode_when_right_sidebar_already_focused() { + let mut shell = AppShell::new_for_tests().expect("create app shell"); + shell.panel_state.right.visible = true; + shell.panel_state.right.switch_to_tab(PanelTabId::Terminal); + shell.focus_manager.set(FocusTarget::RightSidebar); + assert_eq!(shell.app_state.current_mode(), EditorMode::Normal); + + assert!(shell.handle_command(Command::AiChatFocus)); + + assert_eq!(shell.focus_manager.current(), FocusTarget::RightSidebar); + assert_eq!( + shell.panel_state.right.active_tab_id(), + Some(PanelTabId::Terminal) + ); + assert_eq!(shell.app_state.current_mode(), EditorMode::TerminalFocus); +} + +#[test] +fn right_terminal_output_preserves_manual_scrollback_view() { + let mut shell = AppShell::new_for_tests().expect("create app shell"); + shell.panel_state.right.visible = true; + shell.panel_state.right.switch_to_tab(PanelTabId::Terminal); + shell.focus_manager.set(FocusTarget::RightSidebar); + let _ = shell.app_state.apply_mode_event(ModeEvent::FocusTerminal); + shell.right_pty_session_id = Some(7); + shell.right_terminal_grid = TerminalGrid::new(5, 2); + shell + .right_terminal_grid + .feed_chunk("11111\r\n22222\r\n33333\r\n44444\r\n"); + shell.right_terminal_grid.view_scroll_up(1); + assert!(shell.right_terminal_grid.scroll_offset > 0); + + shell.on_worker_result(crate::async_runtime::message::WorkerResult { + request_id: 1, + revision_id: 0, + topic: crate::async_runtime::message::RequestTopic::TerminalPty, + payload: crate::async_runtime::message::WorkerResultPayload::PtyOutput { + session_id: 7, + chunk: b"55555\r\n".to_vec(), + }, + }); + + assert!( + shell.right_terminal_grid.scroll_offset > 0, + "right terminal output should not jump to bottom while user is viewing scrollback" + ); +} + #[test] fn explorer_filter_commands_update_workspace_state() { let mut shell = AppShell::new_for_tests().expect("create app shell"); @@ -911,7 +1092,6 @@ fn file_picker_confirm_submits_git_baseline_refresh_for_opened_file() { assert!(shell.handle_command(Command::FilePickerConfirmSelection)); assert!(shell.git_baseline_revision > revision_before); - } #[test] @@ -1508,6 +1688,22 @@ fn cached_ts_default_export(name: &str, source_path: &std::path::Path) -> crate: } } +fn cached_go_symbol(name: &str, source_path: &std::path::Path) -> crate::lsp::CachedSymbol { + crate::lsp::CachedSymbol { + name: name.to_string(), + kind: "Function".to_string(), + container_name: None, + file_path: source_path.to_path_buf(), + line: 0, + character: 5, + source_path: None, + import_path: None, + export_kind: None, + callable: Some(true), + has_parameters: Some(false), + } +} + fn cached_package_default_export( name: &str, source_path: &std::path::Path, @@ -1619,6 +1815,50 @@ fn member_access_completion_debounces_after_one_typed_character() { assert!(shell.pending_lsp_completion_after_debounce); } +#[test] +fn go_completion_debounces_after_two_typed_characters() { + let mut shell = AppShell::new_for_tests().expect("create app shell"); + let root = completion_temp_root("go_prefix_completion"); + let _path = open_completion_file(&mut shell, &root.join("main.go"), "Co"); + let _ = shell.app_state.jump_to_line_and_column(0, 2); + shell + .app_state + .apply_mode_event(ModeEvent::EnterInsert) + .expect("enter insert"); + shell.active_lsp_server = Some(ActiveLspServer { + server_name: "gopls".to_string(), + root_path: root, + }); + shell.lsp_completion_trigger_chars = vec!['.']; + + shell.queue_lsp_completion_after_debounce_if_needed(); + + assert!(shell.pending_lsp_completion_after_debounce); +} + +#[test] +fn go_member_access_completion_debounces_after_one_typed_character() { + let mut shell = AppShell::new_for_tests().expect("create app shell"); + let root = completion_temp_root("go_member_completion"); + let _path = open_completion_file(&mut shell, &root.join("main.go"), "client.G"); + let _ = shell + .app_state + .jump_to_line_and_column(0, "client.G".chars().count()); + shell + .app_state + .apply_mode_event(ModeEvent::EnterInsert) + .expect("enter insert"); + shell.active_lsp_server = Some(ActiveLspServer { + server_name: "gopls".to_string(), + root_path: root, + }); + shell.lsp_completion_trigger_chars = vec!['.']; + + shell.queue_lsp_completion_after_debounce_if_needed(); + + assert!(shell.pending_lsp_completion_after_debounce); +} + #[test] fn completion_accept_replaces_typed_prefix_instead_of_inserting_after_it() { let mut shell = AppShell::new_for_tests().expect("create app shell"); @@ -1687,12 +1927,19 @@ fn completion_accept_adds_call_parens_and_places_cursor_inside_for_params() { &cache, None, ); - assert!(shell.app_state.jump_to_line_and_column(0, "wai".chars().count())); + assert!( + shell + .app_state + .jump_to_line_and_column(0, "wai".chars().count()) + ); assert!(shell.app_state.set_completion(completion)); assert!(shell.handle_command(Command::CompletionAccept)); assert_eq!(shell.app_state.text_string(), "wait()"); - assert_eq!(shell.app_state.cursor_line_col(), (0, "wait(".chars().count())); + assert_eq!( + shell.app_state.cursor_line_col(), + (0, "wait(".chars().count()) + ); } #[test] @@ -1713,12 +1960,19 @@ fn completion_accept_adds_call_parens_and_keeps_cursor_after_no_param_call() { &cache, None, ); - assert!(shell.app_state.jump_to_line_and_column(0, "ini".chars().count())); + assert!( + shell + .app_state + .jump_to_line_and_column(0, "ini".chars().count()) + ); assert!(shell.app_state.set_completion(completion)); assert!(shell.handle_command(Command::CompletionAccept)); assert_eq!(shell.app_state.text_string(), "init()"); - assert_eq!(shell.app_state.cursor_line_col(), (0, "init()".chars().count())); + assert_eq!( + shell.app_state.cursor_line_col(), + (0, "init()".chars().count()) + ); } #[test] @@ -1739,12 +1993,19 @@ fn completion_accept_reuses_existing_call_parens_after_prefix() { &cache, None, ); - assert!(shell.app_state.jump_to_line_and_column(0, "wai".chars().count())); + assert!( + shell + .app_state + .jump_to_line_and_column(0, "wai".chars().count()) + ); assert!(shell.app_state.set_completion(completion)); assert!(shell.handle_command(Command::CompletionAccept)); assert_eq!(shell.app_state.text_string(), "wait(500)"); - assert_eq!(shell.app_state.cursor_line_col(), (0, "wait".chars().count())); + assert_eq!( + shell.app_state.cursor_line_col(), + (0, "wait".chars().count()) + ); } #[test] @@ -1765,7 +2026,11 @@ fn completion_accept_preserves_viewport_scroll_line() { &root.join("completion-scroll.ts"), &lines.join("\n"), ); - assert!(shell.app_state.jump_to_line_and_column(40, "wai".chars().count())); + assert!( + shell + .app_state + .jump_to_line_and_column(40, "wai".chars().count()) + ); shell.app_state.set_target_scroll_line(35); let mut item = test_completion_item("wait", "wait"); item.detail = Some("function wait(): void".to_string()); @@ -1786,11 +2051,7 @@ fn completion_accept_preserves_viewport_scroll_line() { assert_eq!(shell.app_state.scroll_line(), 35); assert_eq!( - shell - .app_state - .text_string() - .lines() - .nth(40), + shell.app_state.text_string().lines().nth(40), Some("wait()") ); } @@ -1813,12 +2074,19 @@ fn completion_accept_strips_lsp_empty_call_parens_when_source_already_has_call() &cache, None, ); - assert!(shell.app_state.jump_to_line_and_column(0, "wai".chars().count())); + assert!( + shell + .app_state + .jump_to_line_and_column(0, "wai".chars().count()) + ); assert!(shell.app_state.set_completion(completion)); assert!(shell.handle_command(Command::CompletionAccept)); assert_eq!(shell.app_state.text_string(), "wait(500)"); - assert_eq!(shell.app_state.cursor_line_col(), (0, "wait".chars().count())); + assert_eq!( + shell.app_state.cursor_line_col(), + (0, "wait".chars().count()) + ); } #[test] @@ -2117,11 +2385,7 @@ fn completion_accept_applies_lsp_additional_import_edits_in_one_undo() { let root = completion_temp_root("lsp_import_edit"); let _path = open_completion_file(&mut shell, &root.join("src/app.ts"), "con"); let mut item = test_completion_item("connect", "connect"); - item.additional_text_edits = vec![lsp_insert_edit( - 0, - 0, - "import { connect } from './api';\n", - )]; + item.additional_text_edits = vec![lsp_insert_edit(0, 0, "import { connect } from './api';\n")]; let cache = crate::lsp::WorkspaceSymbolCache::new(); let completion = crate::app::app_state::CompletionState::from_lsp_items( vec![item], @@ -2132,7 +2396,11 @@ fn completion_accept_applies_lsp_additional_import_edits_in_one_undo() { &cache, None, ); - assert!(shell.app_state.jump_to_line_and_column(0, "con".chars().count())); + assert!( + shell + .app_state + .jump_to_line_and_column(0, "con".chars().count()) + ); assert!(shell.app_state.set_completion(completion)); assert!(shell.handle_command(Command::CompletionAccept)); @@ -2166,7 +2434,11 @@ fn completion_accept_waits_for_lsp_resolve_before_inserting_unresolved_item() { &cache, None, ); - assert!(shell.app_state.jump_to_line_and_column(0, "con".chars().count())); + assert!( + shell + .app_state + .jump_to_line_and_column(0, "con".chars().count()) + ); assert!(shell.app_state.set_completion(completion)); assert!(shell.handle_command(Command::CompletionAccept)); @@ -2181,7 +2453,8 @@ fn completion_accept_waits_for_lsp_resolve_before_inserting_unresolved_item() { .completion_resolve_request_id .expect("resolve request id"); let mut resolved = test_completion_item("connect", "connect"); - resolved.additional_text_edits = vec![lsp_insert_edit(0, 0, "import { connect } from './api';\n")]; + resolved.additional_text_edits = + vec![lsp_insert_edit(0, 0, "import { connect } from './api';\n")]; shell.on_worker_result(crate::async_runtime::message::WorkerResult { request_id, revision_id: 0, @@ -2208,14 +2481,18 @@ fn workspace_completion_fallback_merges_existing_named_import() { let mut shell = AppShell::new_for_tests().expect("create app shell"); let root = completion_temp_root("merge_import"); let app_path = root.join("src/app.ts"); - let source_path = write_completion_file(&root.join("src/utils.ts"), "export function connect() {}\n"); + let source_path = + write_completion_file(&root.join("src/utils.ts"), "export function connect() {}\n"); let _path = open_completion_file( &mut shell, &app_path, "import { existing } from './utils';\n\ncon", ); let cache = crate::lsp::WorkspaceSymbolCache::new(); - cache.insert_symbols("typescript", vec![cached_ts_export("connect", &source_path)]); + cache.insert_symbols( + "typescript", + vec![cached_ts_export("connect", &source_path)], + ); let completion = crate::app::app_state::CompletionState::from_lsp_items( Vec::new(), 2, @@ -2241,10 +2518,14 @@ fn workspace_completion_fallback_inserts_new_named_import() { let mut shell = AppShell::new_for_tests().expect("create app shell"); let text = "const value = con"; let root = completion_temp_root("new_import"); - let source_path = write_completion_file(&root.join("src/api.ts"), "export function connect() {}\n"); + let source_path = + write_completion_file(&root.join("src/api.ts"), "export function connect() {}\n"); let _path = open_completion_file(&mut shell, &root.join("src/app.ts"), text); let cache = crate::lsp::WorkspaceSymbolCache::new(); - cache.insert_symbols("typescript", vec![cached_ts_export("connect", &source_path)]); + cache.insert_symbols( + "typescript", + vec![cached_ts_export("connect", &source_path)], + ); let cursor_col = text.chars().count(); let prefix_start = "const value = ".chars().count(); let completion = crate::app::app_state::CompletionState::from_lsp_items( @@ -2276,10 +2557,16 @@ fn workspace_completion_fallback_inserts_default_import() { let mut shell = AppShell::new_for_tests().expect("create app shell"); let text = "const value = wai"; let root = completion_temp_root("default_import"); - let source_path = write_completion_file(&root.join("src/wait.ts"), "export default function wait() {}\n"); + let source_path = write_completion_file( + &root.join("src/wait.ts"), + "export default function wait() {}\n", + ); let _path = open_completion_file(&mut shell, &root.join("src/app.ts"), text); let cache = crate::lsp::WorkspaceSymbolCache::new(); - cache.insert_symbols("typescript", vec![cached_ts_default_export("wait", &source_path)]); + cache.insert_symbols( + "typescript", + vec![cached_ts_default_export("wait", &source_path)], + ); let cursor_col = text.chars().count(); let prefix_start = "const value = ".chars().count(); let completion = crate::app::app_state::CompletionState::from_lsp_items( @@ -2305,12 +2592,19 @@ fn workspace_completion_fallback_inserts_default_import() { #[test] fn workspace_completion_symbols_merge_even_when_lsp_has_many_results() { let root = completion_temp_root("merge_cache_with_lsp"); - let source_path = write_completion_file(&root.join("src/api.ts"), "export function connect() {}\n"); + let source_path = + write_completion_file(&root.join("src/api.ts"), "export function connect() {}\n"); let cache = crate::lsp::WorkspaceSymbolCache::new(); - cache.insert_symbols("typescript", vec![cached_ts_export("connect", &source_path)]); + cache.insert_symbols( + "typescript", + vec![cached_ts_export("connect", &source_path)], + ); let lsp_items = vec![ - test_completion_item("ContentVisibilityAutoStateChangeEvent", "ContentVisibilityAutoStateChangeEvent"), + test_completion_item( + "ContentVisibilityAutoStateChangeEvent", + "ContentVisibilityAutoStateChangeEvent", + ), test_completion_item("CanvasGradient", "CanvasGradient"), test_completion_item("CSSPageDescriptors", "CSSPageDescriptors"), test_completion_item("CookieChangeEvent", "CookieChangeEvent"), @@ -2338,15 +2632,51 @@ fn workspace_completion_symbols_merge_even_when_lsp_has_many_results() { let _ = std::fs::remove_dir_all(root); } +#[test] +fn go_workspace_completion_symbols_merge_with_lsp_results() { + let root = completion_temp_root("go_merge_cache_with_lsp"); + let source_path = + write_completion_file(&root.join("api.go"), "package main\n\nfunc Connect() {}\n"); + let cache = crate::lsp::WorkspaceSymbolCache::new(); + cache.insert_symbols("go", vec![cached_go_symbol("Connect", &source_path)]); + + let completion = crate::app::app_state::CompletionState::from_lsp_items( + vec![test_completion_item("Context", "Context")], + 0, + 3, + 0, + "Con".to_string(), + &cache, + Some("go"), + ); + + let imported = completion + .filtered_items + .iter() + .find(|entry| entry.item.label == "Connect") + .expect("go workspace symbol should be merged"); + assert_eq!( + imported.source, + crate::app::app_state::CompletionItemSource::WorkspaceSymbol + ); + assert_eq!(imported.item.export_kind, None); + assert_eq!(imported.item.import_path, None); + let _ = std::fs::remove_dir_all(root); +} + #[test] fn workspace_completion_import_metadata_enriches_duplicate_lsp_item() { let mut shell = AppShell::new_for_tests().expect("create app shell"); let text = "const value = con"; let root = completion_temp_root("duplicate_lsp_import_metadata"); - let source_path = write_completion_file(&root.join("src/api.ts"), "export function connect() {}\n"); + let source_path = + write_completion_file(&root.join("src/api.ts"), "export function connect() {}\n"); let _path = open_completion_file(&mut shell, &root.join("src/app.ts"), text); let cache = crate::lsp::WorkspaceSymbolCache::new(); - cache.insert_symbols("typescript", vec![cached_ts_export("connect", &source_path)]); + cache.insert_symbols( + "typescript", + vec![cached_ts_export("connect", &source_path)], + ); let cursor_col = text.chars().count(); let prefix_start = "const value = ".chars().count(); let completion = crate::app::app_state::CompletionState::from_lsp_items( @@ -2376,10 +2706,16 @@ fn workspace_completion_fallback_inserts_commonjs_named_require() { let mut shell = AppShell::new_for_tests().expect("create app shell"); let text = "const value = con"; let root = completion_temp_root("commonjs_named_require"); - let source_path = write_completion_file(&root.join("src/api.cjs"), "exports.connect = function() {}\n"); + let source_path = write_completion_file( + &root.join("src/api.cjs"), + "exports.connect = function() {}\n", + ); let _path = open_completion_file(&mut shell, &root.join("src/app.cjs"), text); let cache = crate::lsp::WorkspaceSymbolCache::new(); - cache.insert_symbols("javascript", vec![cached_ts_export("connect", &source_path)]); + cache.insert_symbols( + "javascript", + vec![cached_ts_export("connect", &source_path)], + ); let cursor_col = text.chars().count(); let prefix_start = "const value = ".chars().count(); let completion = crate::app::app_state::CompletionState::from_lsp_items( @@ -2407,13 +2743,19 @@ fn workspace_completion_fallback_inserts_package_default_import() { let mut shell = AppShell::new_for_tests().expect("create app shell"); let text = "const client = axi"; let root = completion_temp_root("package_default_import"); - let package_type_path = - write_completion_file(&root.join("node_modules/axios/index.d.ts"), "declare const axios: AxiosStatic;\nexport default axios;\n"); + let package_type_path = write_completion_file( + &root.join("node_modules/axios/index.d.ts"), + "declare const axios: AxiosStatic;\nexport default axios;\n", + ); let _path = open_completion_file(&mut shell, &root.join("src/app.ts"), text); let cache = crate::lsp::WorkspaceSymbolCache::new(); cache.insert_symbols( "typescript", - vec![cached_package_default_export("axios", &package_type_path, "axios")], + vec![cached_package_default_export( + "axios", + &package_type_path, + "axios", + )], ); let cursor_col = text.chars().count(); let prefix_start = "const client = ".chars().count(); @@ -2442,13 +2784,19 @@ fn workspace_completion_fallback_inserts_package_default_require_for_commonjs() let mut shell = AppShell::new_for_tests().expect("create app shell"); let text = "const client = axi"; let root = completion_temp_root("package_default_require"); - let package_type_path = - write_completion_file(&root.join("node_modules/axios/index.d.ts"), "declare const axios: AxiosStatic;\nexport = axios;\n"); + let package_type_path = write_completion_file( + &root.join("node_modules/axios/index.d.ts"), + "declare const axios: AxiosStatic;\nexport = axios;\n", + ); let _path = open_completion_file(&mut shell, &root.join("src/app.cjs"), text); let cache = crate::lsp::WorkspaceSymbolCache::new(); cache.insert_symbols( "javascript", - vec![cached_package_default_export("axios", &package_type_path, "axios")], + vec![cached_package_default_export( + "axios", + &package_type_path, + "axios", + )], ); let cursor_col = text.chars().count(); let prefix_start = "const client = ".chars().count(); @@ -2478,7 +2826,10 @@ fn workspace_completion_fallback_same_file_symbol_has_no_import() { let root = completion_temp_root("same_file"); let active_path = open_completion_file(&mut shell, &root.join("src/app.ts"), "con"); let cache = crate::lsp::WorkspaceSymbolCache::new(); - cache.insert_symbols("typescript", vec![cached_ts_export("connect", &active_path)]); + cache.insert_symbols( + "typescript", + vec![cached_ts_export("connect", &active_path)], + ); let completion = crate::app::app_state::CompletionState::from_lsp_items( Vec::new(), 0, @@ -2496,6 +2847,40 @@ fn workspace_completion_fallback_same_file_symbol_has_no_import() { assert_eq!(shell.app_state.text_string(), "connect()"); } +#[test] +fn go_workspace_completion_accept_does_not_synthesize_imports() { + let mut shell = AppShell::new_for_tests().expect("create app shell"); + let root = completion_temp_root("go_no_synthetic_import"); + let source_path = + write_completion_file(&root.join("api.go"), "package main\n\nfunc Connect() {}\n"); + let text = "package main\n\nfunc main() {\n\tCon\n}\n"; + let _active_path = open_completion_file(&mut shell, &root.join("main.go"), text); + let cache = crate::lsp::WorkspaceSymbolCache::new(); + cache.insert_symbols("go", vec![cached_go_symbol("Connect", &source_path)]); + let completion = crate::app::app_state::CompletionState::from_lsp_items( + Vec::new(), + 3, + "\tCon".chars().count(), + "\t".chars().count(), + "Con".to_string(), + &cache, + Some("go"), + ); + assert!(!completion.filtered_items.is_empty()); + assert!( + shell + .app_state + .jump_to_line_and_column(3, "\tCon".chars().count()) + ); + assert!(shell.app_state.set_completion(completion)); + + assert!(shell.handle_command(Command::CompletionAccept)); + assert_eq!( + shell.app_state.text_string(), + "package main\n\nfunc main() {\n\tConnect()\n}\n" + ); +} + #[test] fn welcome_hides_while_command_palette_is_visible() { let mut shell = AppShell::new_for_tests().expect("create app shell"); @@ -2770,387 +3155,123 @@ fn resize_panel_and_ui_config_stay_in_sync() { ); } -// ── DAP / Debugger command tests ──────────────────────────────────────────── - #[test] -fn focus_dap_opens_left_sidebar_inspector_tab() { +fn focus_dap_opens_and_focuses_inspector() { let mut shell = AppShell::new_for_tests().expect("create app shell"); shell.panel_state.left.visible = false; - let changed = shell.handle_command(Command::FocusDap); - - assert!(changed); + assert!(shell.handle_command(Command::FocusDap)); assert!(shell.panel_state.left.visible); assert_eq!( shell.panel_state.left.active_tab_id(), Some(PanelTabId::Inspector) ); -} - -#[test] -fn focus_dap_sets_focus_to_left_sidebar() { - let mut shell = AppShell::new_for_tests().expect("create app shell"); - - shell.handle_command(Command::FocusDap); - assert_eq!(shell.focus_manager.current(), FocusTarget::LeftSidebar); } #[test] -fn focus_dap_idempotent_when_already_focused() { - let mut shell = AppShell::new_for_tests().expect("create app shell"); - shell.handle_command(Command::FocusDap); - let changed = shell.handle_command(Command::FocusDap); - // Should still report no change since already focused - assert!(!changed); -} - -#[test] -fn debug_start_without_session_returns_none_via_handler() { - let shell = AppShell::new_for_tests().expect("create app shell"); - // No DAP session — handler tries to launch but panics without tokio runtime. - // Test the handler directly: when there's no session, it enters the launch branch. - // We can't fully test launch without a tokio runtime, so verify the session is None. - assert!(shell.dap_session.is_none()); -} - -#[test] -fn debug_stop_without_session_returns_none() { - let mut shell = AppShell::new_for_tests().expect("create app shell"); - let result = shell.handle_dap_command(&Command::DebugStop); - assert!(result.is_none()); -} - -#[test] -fn debug_continue_without_session_returns_none() { - let mut shell = AppShell::new_for_tests().expect("create app shell"); - let result = shell.handle_dap_command(&Command::DebugContinue); - assert!(result.is_none()); -} - -#[test] -fn debug_step_over_without_session_returns_none() { - let mut shell = AppShell::new_for_tests().expect("create app shell"); - let result = shell.handle_dap_command(&Command::DebugStepOver); - assert!(result.is_none()); -} - -#[test] -fn debug_step_into_without_session_returns_none() { - let mut shell = AppShell::new_for_tests().expect("create app shell"); - let result = shell.handle_dap_command(&Command::DebugStepInto); - assert!(result.is_none()); -} - -#[test] -fn debug_step_out_without_session_returns_none() { - let mut shell = AppShell::new_for_tests().expect("create app shell"); - let result = shell.handle_dap_command(&Command::DebugStepOut); - assert!(result.is_none()); -} - -#[test] -fn debug_toggle_breakpoint_without_session_returns_none() { - let mut shell = AppShell::new_for_tests().expect("create app shell"); - let result = shell.handle_dap_command(&Command::DebugToggleBreakpoint); - assert!(result.is_none()); -} - -#[test] -fn debug_watch_add_without_session_returns_none() { - let mut shell = AppShell::new_for_tests().expect("create app shell"); - let result = shell.handle_dap_command(&Command::DebugWatchAdd); - assert!(result.is_none()); -} - -#[test] -fn debug_watch_remove_without_session_returns_none() { - let mut shell = AppShell::new_for_tests().expect("create app shell"); - let result = shell.handle_dap_command(&Command::DebugWatchRemove); - assert!(result.is_none()); -} - -#[test] -fn debug_goto_frame_without_session_returns_none() { - let mut shell = AppShell::new_for_tests().expect("create app shell"); - let result = shell.handle_dap_command(&Command::DebugGotoFrame); - assert!(result.is_none()); -} - -#[test] -fn dap_toggle_expand_works_on_inspector_panel() { - let mut shell = AppShell::new_for_tests().expect("create app shell"); - shell.handle_command(Command::FocusDap); - - // Variables section is expanded by default - let was_expanded = shell.dap_panel_state.sections[0].expanded; - let changed = shell.handle_command(Command::DapToggleExpand); - assert!(changed); - assert_eq!(shell.dap_panel_state.sections[0].expanded, !was_expanded); -} - -#[test] -fn dap_toggle_expand_noop_when_not_on_inspector() { - let mut shell = AppShell::new_for_tests().expect("create app shell"); - // Focus editor, not DAP panel - shell.handle_command(Command::FocusEditor); - - // DapToggleExpand returns None when not on inspector (falls through) - let result = shell.handle_dap_command(&Command::DapToggleExpand); - assert!(result.is_none()); -} - -#[test] -fn flutter_hot_reload_without_session_returns_none() { - let mut shell = AppShell::new_for_tests().expect("create app shell"); - // No DAP session — handler returns None - let result = shell.handle_dap_command(&Command::FlutterHotReload); - assert!(result.is_none()); -} - -#[test] -fn flutter_hot_restart_without_session_returns_none() { - let mut shell = AppShell::new_for_tests().expect("create app shell"); - let result = shell.handle_dap_command(&Command::FlutterHotRestart); - assert!(result.is_none()); -} - -#[test] -fn debug_toggle_breakpoint_noop_without_active_file() { - let mut shell = AppShell::new_for_tests().expect("create app shell"); - // No active file — toggle should return None - let result = shell.handle_dap_command(&Command::DebugToggleBreakpoint); - assert!(result.is_none()); -} - -#[test] -fn flutter_devices_in_command_palette() { - let mut shell = AppShell::new_for_tests().expect("create app shell"); - // Open command palette - shell.handle_command(Command::OpenCommandPalette); - assert!(shell.app_state.is_command_palette_visible()); - - // Verify flutter.devices is a valid command ID +fn dap_and_flutter_command_ids_survive_main_merge() { use crate::core::command_ids; - assert!(command_ids::is_valid("flutter.devices")); - assert!(command_ids::is_valid("flutter.hot_reload")); - assert!(command_ids::is_valid("flutter.hot_restart")); - - // Verify command parsing works - assert!(command_ids::parse("flutter.devices", None).is_some()); - assert!(command_ids::parse("flutter.hot_reload", None).is_some()); - assert!(command_ids::parse("flutter.hot_restart", None).is_some()); -} -struct TestTempDir { - path: PathBuf, -} - -impl TestTempDir { - fn new(prefix: &str) -> Self { - let path = std::env::temp_dir().join(format!("{}_{}", prefix, std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_micros())); - std::fs::create_dir_all(&path).unwrap(); - Self { path } + for id in [ + command_ids::FOCUS_DAP, + command_ids::DAP_TOGGLE_EXPAND, + command_ids::DEBUG_START, + command_ids::DEBUG_STOP, + command_ids::DEBUG_STEP_OVER, + command_ids::DEBUG_STEP_INTO, + command_ids::DEBUG_STEP_OUT, + command_ids::DEBUG_TOGGLE_BREAKPOINT, + command_ids::FLUTTER_DEVICES, + command_ids::FLUTTER_HOT_RELOAD, + command_ids::FLUTTER_HOT_RESTART, + ] { + assert!( + command_ids::ALL_IDS.contains(&id), + "missing command id: {id}" + ); + assert!( + command_ids::parse(id, None).is_some(), + "unparsed command id: {id}" + ); } } -impl Drop for TestTempDir { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.path); - } -} - -struct PathGuard { - old_path: String, -} - -impl Drop for PathGuard { - fn drop(&mut self) { - unsafe { - std::env::set_var("PATH", &self.old_path); - } - } -} -static ENV_LOCK: once_cell::sync::Lazy> = once_cell::sync::Lazy::new(|| std::sync::Mutex::new(())); - -fn setup_dummy_executable(name: &str) -> (TestTempDir, PathGuard) { - let temp = TestTempDir::new(name); - let bin_path = temp.path.join(name); - std::fs::write(&bin_path, "#!/bin/sh\nexit 0\n").unwrap(); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = std::fs::metadata(&bin_path).unwrap().permissions(); - perms.set_mode(0o755); - std::fs::set_permissions(&bin_path, perms).unwrap(); - } - let old_path = std::env::var("PATH").unwrap_or_default(); - let new_path = format!("{}:{}", temp.path.display(), old_path); - unsafe { - std::env::set_var("PATH", new_path); - } - (temp, PathGuard { old_path }) -} - -#[test] -fn debug_start_fallback_to_flutter_defaults() { - let _lock = ENV_LOCK.lock().unwrap(); - let _temp_exe = setup_dummy_executable("flutter"); - let mut shell = AppShell::new_for_tests().expect("create app shell"); - let temp_workspace = TestTempDir::new("workspace"); - shell.app_state.attach_workspace(temp_workspace.path.clone()).unwrap(); - - // Create an active file that is a .dart file - let main_dart = temp_workspace.path.join("lib").join("main.dart"); - std::fs::create_dir_all(main_dart.parent().unwrap()).unwrap(); - std::fs::write(&main_dart, "void main() {}").unwrap(); - shell.app_state.open_file(main_dart).unwrap(); - - let result = shell.handle_command(Command::DebugStart); - assert!(result); - assert!(shell.dap_session.is_some()); -} - -#[test] -fn debug_start_reads_launch_json() { - let _lock = ENV_LOCK.lock().unwrap(); - let _temp_exe = setup_dummy_executable("custom_type"); - let mut shell = AppShell::new_for_tests().expect("create app shell"); - let temp_workspace = TestTempDir::new("workspace_launch_json"); - shell.app_state.attach_workspace(temp_workspace.path.clone()).unwrap(); - - let vscode_dir = temp_workspace.path.join(".vscode"); - std::fs::create_dir_all(&vscode_dir).unwrap(); - let launch_json_path = vscode_dir.join("launch.json"); - let launch_json_content = r#"{ - "version": "0.2.0", - "configurations": [ - { - "name": "My Custom Debug", - "type": "custom_type", - "request": "launch", - "program": "bin/debug_target" - } - ] - }"#; - std::fs::write(&launch_json_path, launch_json_content).unwrap(); - - let result = shell.handle_command(Command::DebugStart); - assert!(result); - assert!(shell.dap_session.is_some()); -} - #[test] -fn debug_start_no_workspace_returns_false() { - let _lock = ENV_LOCK.lock().unwrap(); +fn manual_trigger_completion_dismisses_ghost_text_and_invalidates_inflight_ai() { let mut shell = AppShell::new_for_tests().expect("create app shell"); - shell.app_state.detach_workspace(); - // Explicitly make sure there is no active file or workspace root - assert!(shell.app_state.workspace_root_path().is_none()); - assert!(shell.app_state.active_file().is_none()); - - let result = shell.handle_command(Command::DebugStart); - assert!(!result); // Returns false - assert!(shell.transient_toast.is_some()); - let toast = shell.transient_toast.as_ref().unwrap(); - assert_eq!(toast.kind, crate::app::event_loop::ToastKind::Error); - assert!(toast.message.contains("No active workspace or file parent folder")); -} - -#[test] -fn debug_adapter_binary_not_found() { - let _lock = ENV_LOCK.lock().unwrap(); - let mut shell = AppShell::new_for_tests().expect("create app shell"); - let temp_workspace = TestTempDir::new("workspace_binary_not_found"); - shell.app_state.attach_workspace(temp_workspace.path.clone()).unwrap(); - - let vscode_dir = temp_workspace.path.join(".vscode"); - std::fs::create_dir_all(&vscode_dir).unwrap(); - let launch_json_path = vscode_dir.join("launch.json"); - let launch_json_content = r#"{ - "version": "0.2.0", - "configurations": [ - { - "name": "My Bad Custom Debug", - "type": "non_existent_adapter_binary", - "request": "launch", - "program": "bin/debug_target" - } - ] - }"#; - std::fs::write(&launch_json_path, launch_json_content).unwrap(); - - let result = shell.handle_command(Command::DebugStart); - assert!(!result); - assert!(shell.transient_toast.is_some()); - let toast = shell.transient_toast.as_ref().unwrap(); - assert_eq!(toast.kind, crate::app::event_loop::ToastKind::Error); - assert!(toast.message.contains("Binary 'non_existent_adapter_binary' not found")); -} - -#[test] -fn breakpoint_toggle_persists_across_session() { - let mut shell = AppShell::new_for_tests().expect("create app shell"); - let temp_workspace = TestTempDir::new("workspace_breakpoints_persist"); - shell.app_state.attach_workspace(temp_workspace.path.clone()).unwrap(); - - let main_dart = temp_workspace.path.join("lib").join("main.dart"); - std::fs::create_dir_all(main_dart.parent().unwrap()).unwrap(); - std::fs::write(&main_dart, "void main() {\n print('hello');\n}").unwrap(); - let main_dart = main_dart.canonicalize().unwrap(); - shell.app_state.open_file(main_dart.clone()).unwrap(); - - // Toggle breakpoint on line 1 - shell.app_state.jump_to_line_and_column(1, 0); - let result = shell.handle_command(Command::DebugToggleBreakpoint); - assert!(result); - assert_eq!(shell.breakpoints.get(&main_dart), Some(&vec![1])); - - // Now verify the file exists on disk - let bp_file = temp_workspace.path.join(".vscode").join("breakpoints.json"); - assert!(bp_file.exists()); - - // Create a new AppShell (simulating restart) and attach the same workspace - let mut new_shell = AppShell::new_for_tests().expect("create app shell"); - new_shell.app_state.attach_workspace(temp_workspace.path.clone()).unwrap(); - new_shell.load_breakpoints(); - - // Verify breakpoints are loaded - assert_eq!(new_shell.breakpoints.get(&main_dart), Some(&vec![1])); -} - -#[test] -fn test_breakpoint_and_cursor_movement() { - let mut shell = AppShell::new_for_tests().expect("create app shell"); - let temp_workspace = TestTempDir::new("workspace_breakpoints_movement"); - shell.app_state.attach_workspace(temp_workspace.path.clone()).unwrap(); + let root = std::env::temp_dir().join(format!( + "netherize_manual_completion_{}", + std::process::id() + )); + std::fs::create_dir_all(root.join("src")).expect("create workspace"); + let file_path = root.join("src/main.rs"); + std::fs::write(&file_path, "fn demo() {\n de\n}\n").expect("write file"); + shell + .app_state + .attach_workspace(root.clone()) + .expect("attach workspace"); + shell + .app_state + .open_file(file_path.clone()) + .expect("open file"); + shell.active_lsp_server = Some(ActiveLspServer { + server_name: "rust-analyzer".to_string(), + root_path: root.clone(), + }); + shell + .app_state + .apply_mode_event(ModeEvent::EnterInsert) + .expect("enter insert"); + shell.app_state.jump_to_line_and_column(1, 6); - let main_dart = temp_workspace.path.join("lib").join("main.dart"); - std::fs::create_dir_all(main_dart.parent().unwrap()).unwrap(); - std::fs::write(&main_dart, "void main() {\n print('hello');\n print('world');\n}").unwrap(); - let main_dart = main_dart.canonicalize().unwrap(); - shell.app_state.open_file(main_dart.clone()).unwrap(); + // Ghost text visible + an AI request in flight at the current caret. + shell + .app_state + .set_inline_suggestion(Some("mo()".to_string())); + shell.reanchor_ai_inline(); + shell.ai_inline_inflight = true; + shell.ai_inline_cancel_token = Some(CancellationToken::new()); + let revision_before = shell.ai_inline_revision; + + assert!(shell.handle_command(Command::TriggerCompletion)); + + // Ctrl+Space must dismiss the ghost, cancel AI, and submit an LSP request. + assert!(shell.app_state.inline_suggestion().is_none()); + assert!(shell.ai_inline_anchor.is_none()); + assert!(!shell.ai_inline_inflight); + assert!(shell.ai_inline_revision > revision_before); + assert!(shell.app_state.is_completion_loading()); + let request_id = shell + .active_lsp_completion_request_id + .expect("manual completion request should be active"); - // Toggle breakpoint on line 1 - shell.app_state.jump_to_line_and_column(1, 0); - let result = shell.handle_command(Command::DebugToggleBreakpoint); - assert!(result); + shell.on_worker_result(crate::async_runtime::message::WorkerResult { + request_id, + revision_id: 0, + topic: crate::async_runtime::message::RequestTopic::LspRequest, + payload: crate::async_runtime::message::WorkerResultPayload::LspCompletionResult { + items: vec![test_completion_item("demo", "demo")], + cursor_line: 1, + cursor_col: 6, + prefix_start_col: 4, + prefix: "de".to_string(), + }, + }); - // Verify breakpoint exists - let active_file = shell.app_state.active_file().unwrap(); - assert_eq!(active_file, main_dart); - let breakpoint_lines = shell.breakpoints.get(active_file).cloned().unwrap_or_default(); - assert_eq!(breakpoint_lines, vec![1]); + assert!(shell.app_state.completion().is_some()); - // Move cursor down - let moved = shell.handle_command(Command::MoveDown); - assert!(moved); + // A late result from the cancelled AI request must not replace the manual + // completion popup or restore ghost text. + shell.on_worker_result(crate::async_runtime::message::WorkerResult { + request_id: request_id + 1, + revision_id: revision_before, + topic: crate::async_runtime::message::RequestTopic::AiInlineCompletion, + payload: crate::async_runtime::message::WorkerResultPayload::AiInlineCompletionResult { + suggestion: "mo()".to_string(), + }, + }); - // Verify breakpoint STILL exists for the active file - let active_file_after = shell.app_state.active_file().unwrap(); - assert_eq!(active_file_after, main_dart); - let breakpoint_lines_after = shell.breakpoints.get(active_file_after).cloned().unwrap_or_default(); - assert_eq!(breakpoint_lines_after, vec![1]); + assert!(shell.app_state.inline_suggestion().is_none()); + assert!(shell.app_state.completion().is_some()); } - - diff --git a/src/app/event_loop/helpers.rs b/src/app/event_loop/helpers.rs index 55c89b57..e24d5342 100644 --- a/src/app/event_loop/helpers.rs +++ b/src/app/event_loop/helpers.rs @@ -98,32 +98,34 @@ pub(super) fn syntax_spans_to_styled( theme.syntax.keyword.as_u8() } crate::syntax::highlight::HighlightCategory::KeywordControl => { - theme.syntax.keyword_control.as_u8() - } - crate::syntax::highlight::HighlightCategory::KeywordStorage => { - theme.syntax.keyword_storage.as_u8() - } + theme.syntax.keyword_control.as_u8() + } + crate::syntax::highlight::HighlightCategory::KeywordStorage => { + theme.syntax.keyword_storage.as_u8() + } crate::syntax::highlight::HighlightCategory::String => theme.syntax.string.as_u8(), - crate::syntax::highlight::HighlightCategory::StringTemplate => theme.syntax.string.as_u8(), + crate::syntax::highlight::HighlightCategory::StringTemplate => { + theme.syntax.string.as_u8() + } crate::syntax::highlight::HighlightCategory::StringEscape => { - theme.syntax.string_escape.as_u8() - } + theme.syntax.string_escape.as_u8() + } crate::syntax::highlight::HighlightCategory::Comment => { theme.syntax.comment.as_u8() } crate::syntax::highlight::HighlightCategory::CommentDoc => { - theme.syntax.comment_doc.as_u8() - } + theme.syntax.comment_doc.as_u8() + } crate::syntax::highlight::HighlightCategory::Type => theme.syntax.r#type.as_u8(), crate::syntax::highlight::HighlightCategory::TypeBuiltin => { - theme.syntax.type_builtin.as_u8() - } + theme.syntax.type_builtin.as_u8() + } crate::syntax::highlight::HighlightCategory::Function => { theme.syntax.function.as_u8() } crate::syntax::highlight::HighlightCategory::FunctionBuiltin => { - theme.syntax.function_builtin.as_u8() - } + theme.syntax.function_builtin.as_u8() + } crate::syntax::highlight::HighlightCategory::Number => theme.syntax.number.as_u8(), crate::syntax::highlight::HighlightCategory::Boolean => { theme.syntax.boolean.as_u8() @@ -135,8 +137,8 @@ pub(super) fn syntax_spans_to_styled( theme.syntax.variable.as_u8() } crate::syntax::highlight::HighlightCategory::VariableBuiltin => { - theme.syntax.variable_builtin.as_u8() - } + theme.syntax.variable_builtin.as_u8() + } crate::syntax::highlight::HighlightCategory::Parameter => { theme.syntax.parameter.as_u8() } @@ -169,17 +171,17 @@ pub(super) fn syntax_spans_to_styled( } crate::syntax::highlight::HighlightCategory::Tag => theme.syntax.tag.as_u8(), crate::syntax::highlight::HighlightCategory::MarkupStrong => { - theme.syntax.markup_strong.as_u8() - } - crate::syntax::highlight::HighlightCategory::MarkupItalic => { - theme.syntax.markup_italic.as_u8() - } - crate::syntax::highlight::HighlightCategory::MarkupInlineCode => { - theme.syntax.markup_inline_code.as_u8() - } - crate::syntax::highlight::HighlightCategory::MarkupLink => { - theme.syntax.markup_link.as_u8() - } + theme.syntax.markup_strong.as_u8() + } + crate::syntax::highlight::HighlightCategory::MarkupItalic => { + theme.syntax.markup_italic.as_u8() + } + crate::syntax::highlight::HighlightCategory::MarkupInlineCode => { + theme.syntax.markup_inline_code.as_u8() + } + crate::syntax::highlight::HighlightCategory::MarkupLink => { + theme.syntax.markup_link.as_u8() + } }; StyledTextSpan::with_style( span.range.start, @@ -467,7 +469,8 @@ fn display_width(text: &str) -> usize { || (0x3040..=0x30FF).contains(&code) // Hiragana, Katakana || (0xAC00..=0xD7AF).contains(&code) // Hangul || (0xFF00..=0xFFEF).contains(&code) // Fullwidth - || (0x1F300..=0x1F9FF).contains(&code) // Emoji + || (0x1F300..=0x1F9FF).contains(&code) + // Emoji { 2 } else { @@ -1336,7 +1339,10 @@ fn compute_git_change_matches( children_map: &HashMap>, out: &mut HashMap, ) -> bool { - let file_type = node_types.get(path).copied().unwrap_or(WorkspaceNodeType::File); + let file_type = node_types + .get(path) + .copied() + .unwrap_or(WorkspaceNodeType::File); let is_dirty_path = app_state.is_dirty() && app_state .active_file() @@ -1348,7 +1354,8 @@ fn compute_git_change_matches( if let Some(children) = children_map.get(path) { for child in children { - subtree_match |= compute_git_change_matches(app_state, child, node_types, children_map, out); + subtree_match |= + compute_git_change_matches(app_state, child, node_types, children_map, out); } } @@ -1429,7 +1436,13 @@ pub(super) fn collect_explorer_entries(app_state: &AppState) -> Vec = HashMap::new(); if git_changes_only { - compute_git_change_matches(app_state, &root, &node_types, &children_map, &mut git_change_matches); + compute_git_change_matches( + app_state, + &root, + &node_types, + &children_map, + &mut git_change_matches, + ); } let mut entries = Vec::new(); @@ -1443,7 +1456,11 @@ pub(super) fn collect_explorer_entries(app_state: &AppState) -> Vec, + last_cursor_position: Option<(f32, f32)>, pending_right_pty_spawn: bool, /// When `Some`, this command string will be written into the right PTY /// immediately after `PtySpawned` is received for the right terminal. @@ -148,6 +149,9 @@ pub struct AppShell { dismissed_system_deps: bool, /// Toast window-relative ngắn hạn cho các action nền. transient_toast: Option, + /// True once the which-key redraw for the current pending chord was + /// scheduled; resets when the chord resolves or is abandoned. + whichkey_redraw_fired: bool, theme_picker_original_theme: Option, theme_picker_preview_profile: Option, base_theme: ThemeConfig, @@ -230,8 +234,24 @@ pub struct AppShell { ai_inline_revision: u64, pending_ai_inline_request: Option, ai_inline_cancel_token: Option, - last_ai_inline_queue_at: Option, last_ai_inline_submit_at: Option, + /// Set when the current typing command consumed the head of the visible + /// ghost text (prefix match) — the post-edit hook must keep the retained + /// suggestion instead of queueing a new request. + ai_inline_suggestion_retained: bool, + /// True between submitting an inline request and its result/failure; + /// drives the status-bar AI indicator. + ai_inline_inflight: bool, + /// Consecutive non-cancelled inline failures; at the toast threshold the + /// feature cools down instead of failing silently forever. + ai_inline_failure_streak: u32, + ai_inline_cooldown_until: Option, + /// (buffer, caret) the inline pipeline — pending debounce, in-flight + /// request, or visible ghost text — is valid for. When the caret leaves + /// this position other than by consuming the suggestion, the pipeline is + /// cancelled and the ghost cleared so a late result can't appear at (and + /// follow) the new caret position. + ai_inline_anchor: Option<(Option, usize)>, pending_lsp_document_sync: Option, last_editor_bounds: Option<[f32; 4]>, last_show_welcome: Option, @@ -257,7 +277,11 @@ pub struct AppShell { caret_blink_dirty: bool, last_caret_blink_tick: Instant, last_external_file_check: Instant, - last_external_file_check_times: std::collections::HashMap, + last_external_file_check_times: + std::collections::HashMap, + /// #5: thư mục cha của các file mở NGOÀI workspace root đã được gắn watcher + /// (non-recursive). Dùng để dedup, tránh spawn watcher trùng cho cùng một dir. + externally_watched_dirs: std::collections::HashSet, pre_markdown_preview_right_width: Option, /// Code actions từ lần request gần nhất, dùng để apply khi user chọn trong picker. pending_code_actions: Vec, @@ -317,7 +341,6 @@ enum PendingConfirmationAction { }, /// User confirmed or cancelled the opencode auto-install prompt. AiChatInstall, - } #[derive(Debug, Clone)] @@ -545,6 +568,11 @@ impl AppShell { if self.focus_manager.current() == FocusTarget::BottomPanel { return self.active_terminal_tab_mut().map(|tab| &mut tab.grid); } + if self.focus_manager.current() == FocusTarget::RightSidebar + && self.panel_state.right.active_tab_id() == Some(PanelTabId::Terminal) + { + return Some(&mut self.right_terminal_grid); + } None } diff --git a/src/app/event_loop/setup.rs b/src/app/event_loop/setup.rs index 7f22f352..345a61fb 100644 --- a/src/app/event_loop/setup.rs +++ b/src/app/event_loop/setup.rs @@ -153,6 +153,7 @@ impl AppShell { }, right_terminal_needs_layout: true, last_right_terminal_bounds: None, + last_cursor_position: None, pending_right_pty_spawn: false, right_pty_startup_command: None, terminal_buffer_grids: HashMap::new(), @@ -195,6 +196,7 @@ impl AppShell { active_system_dep_guide: None, dismissed_system_deps: false, transient_toast: None, + whichkey_redraw_fired: false, theme_picker_original_theme: None, theme_picker_preview_profile: None, base_theme, @@ -247,8 +249,12 @@ impl AppShell { ai_inline_revision: 0, pending_ai_inline_request: None, ai_inline_cancel_token: None, - last_ai_inline_queue_at: None, last_ai_inline_submit_at: None, + ai_inline_suggestion_retained: false, + ai_inline_inflight: false, + ai_inline_failure_streak: 0, + ai_inline_cooldown_until: None, + ai_inline_anchor: None, pending_lsp_document_sync: None, last_editor_bounds: None, last_show_welcome: None, @@ -273,6 +279,7 @@ impl AppShell { last_caret_blink_tick: now, last_external_file_check: now, last_external_file_check_times: std::collections::HashMap::new(), + externally_watched_dirs: std::collections::HashSet::new(), pre_markdown_preview_right_width: None, pending_code_actions: Vec::new(), selected_python_env: None, @@ -296,6 +303,7 @@ impl AppShell { topic: RequestTopic::WorkspaceWatch, payload: WorkerRequestPayload::StartFileWatch { root_path: workspace_root.clone(), + recursive: true, }, }); @@ -516,13 +524,12 @@ impl AppShell { } pub(super) fn update_runtime_scaling_for_window(&mut self, scale_factor: f64) { - // let raw_scale = if let Some(over) = self.ui_config.window.scale_factor_override { - // over as f64 - // } else { - // scale_factor - // }; - // let dpi_scale = (raw_scale as f32).max(0.25); - let dpi_scale = (scale_factor as f32).max(0.25); + let raw_scale = if let Some(over) = self.ui_config.window.scale_factor_override { + over as f64 + } else { + scale_factor + }; + let dpi_scale = (raw_scale as f32).max(0.25); let logical_width = self.window_size.width as f32 / dpi_scale; let logical_height = self.window_size.height as f32 / dpi_scale; @@ -602,6 +609,7 @@ impl AppShell { self.panel_state.right.size_px = scaled_ui.docks.right.size_px; self.panel_state.bottom.size_px = scaled_ui.docks.bottom.size_px; if let Some(renderer) = self.renderer.as_mut() { + renderer.set_ui_scale(self.runtime_scale); renderer.apply_theme(scaled_theme); renderer.apply_ui_config(&scaled_ui); } @@ -797,7 +805,9 @@ impl AppShell { FocusTarget::CenterEditor if self.app_state.active_buffer_is_help() => { InputFocusContext::Help } - FocusTarget::CenterEditor if self.app_state.active_buffer_is_extensions_manager() => { + FocusTarget::CenterEditor + if self.app_state.active_buffer_is_extensions_manager() => + { InputFocusContext::ExtensionsManager } _ => InputFocusContext::Editor, @@ -810,8 +820,11 @@ impl AppShell { command_palette_mode: self.app_state.command_palette_mode(), welcome_visible, completion_visible: self.app_state.has_completion(), + inline_suggestion_visible: self.app_state.inline_suggestion().is_some(), hover_overlay_visible: self.app_state.has_scrollable_floating_overlay(), zen_mode_active: self.panel_state.maximized_region.is_some(), + right_sidebar_terminal: self.focus_manager.current() == FocusTarget::RightSidebar + && self.panel_state.right.active_tab_id() == Some(PanelTabId::Terminal), } } @@ -1099,7 +1112,9 @@ impl AppShell { Ok(tree) if tree.root_node().end_byte() <= text_snapshot.len() => Ok(tree), Ok(_) => engine.parse_source(&text_snapshot, buffer_revision), Err(err) => { - eprintln!("[AppShell] incremental tree-sitter parse failed: {err}; retrying full parse"); + eprintln!( + "[AppShell] incremental tree-sitter parse failed: {err}; retrying full parse" + ); engine.parse_source(&text_snapshot, buffer_revision) } }; @@ -1151,6 +1166,48 @@ impl AppShell { self.pending_ai_inline_request = None; if let Some(token) = self.ai_inline_cancel_token.take() { token.cancel(); + self.ai_inline_inflight = false; + } + } + + /// True while the caret is still exactly where the inline pipeline was + /// anchored: same buffer, same char index, still in Insert mode. + pub(super) fn ai_inline_anchor_is_current(&self) -> bool { + let Some((file, cursor)) = self.ai_inline_anchor.as_ref() else { + return false; + }; + self.app_state.current_mode() == EditorMode::Insert + && *cursor == self.app_state.cursor_char_idx() + && file.as_deref() == self.app_state.active_file() + } + + /// Re-anchor the pipeline to the caret's current position. Called after + /// edits that legitimately move the caret while keeping the suggestion + /// (prefix-retained typing, partial word accept). + pub(super) fn reanchor_ai_inline(&mut self) { + self.ai_inline_anchor = Some(( + self.app_state.active_file().map(PathBuf::from), + self.app_state.cursor_char_idx(), + )); + } + + /// Watchdog run every event-loop pass, before the debounce flush: if the + /// caret moved away from the anchor (cursor motion, mouse click, mode or + /// buffer switch) the whole pipeline is torn down — the pending debounced + /// request is dropped, the in-flight request is cancelled and its late + /// result invalidated via a revision bump, and any visible ghost text is + /// cleared so it can't follow the caret to the new position. + pub(super) fn enforce_ai_inline_anchor(&mut self) { + if self.ai_inline_anchor.is_none() || self.ai_inline_anchor_is_current() { + return; + } + self.ai_inline_anchor = None; + self.ai_inline_revision = self.ai_inline_revision.saturating_add(1); + self.cancel_ai_inline_completion(); + if self.app_state.clear_inline_suggestion() { + self.editor_needs_layout = true; + self.editor_caret_needs_layout = false; + self.request_redraw(); } } @@ -1168,30 +1225,32 @@ impl AppShell { return; }; let now = Instant::now(); - if !self.should_queue_ai_inline_completion(cfg, now) { + if let Some(until) = self.ai_inline_cooldown_until { + if now < until { + self.cancel_ai_inline_completion(); + return; + } + self.ai_inline_cooldown_until = None; + } + if !self.should_queue_ai_inline_completion(cfg) { self.pending_ai_inline_request = None; - self.last_ai_inline_queue_at = Some(now); return; } - self.last_ai_inline_queue_at = Some(now); + // Every typing edit (re-)queues; the debounce in flush coalesces fast + // keystrokes so the request fires once the user pauses. Rate limiting + // (min_interval) is enforced at flush time by delaying, not dropping. self.ai_inline_revision = self.ai_inline_revision.saturating_add(1); self.pending_ai_inline_request = Some(PendingAiInlineRequest { revision: self.ai_inline_revision, queued_at: now, }); + self.reanchor_ai_inline(); } fn should_queue_ai_inline_completion( &self, cfg: &crate::config::ai_config::InlineCompletionConfig, - now: Instant, ) -> bool { - if let Some(last_submit) = self.last_ai_inline_submit_at - && last_submit.elapsed() < Duration::from_millis(cfg.min_interval_ms()) - { - return false; - } - let cursor = self.app_state.cursor_char_idx(); if cursor < cfg.min_prefix_chars() { return false; @@ -1209,13 +1268,7 @@ impl AppShell { return false; } - if before.is_some_and(|ch| cfg.trigger_chars().contains(&ch)) { - return true; - } - - self.last_ai_inline_queue_at - .map(|last| now.duration_since(last) >= Duration::from_millis(cfg.idle_trigger_ms())) - .unwrap_or(true) + true } pub(super) fn flush_pending_ai_inline_completion(&mut self) { @@ -1226,9 +1279,23 @@ impl AppShell { self.cancel_ai_inline_completion(); return; }; + // The caret moved away (or the mode/buffer changed) since this request + // was queued: a completion for the old spot is useless — cancel instead + // of requesting at whatever position the caret happens to be at now. + if !self.ai_inline_anchor_is_current() { + self.enforce_ai_inline_anchor(); + return; + } if pending.queued_at.elapsed() < Duration::from_millis(cfg.debounce_ms()) { return; } + // Rate limit: keep the pending request and retry on the next wake + // (next_ai_inline_flush_deadline accounts for this) instead of dropping. + if let Some(last_submit) = self.last_ai_inline_submit_at + && last_submit.elapsed() < Duration::from_millis(cfg.min_interval_ms()) + { + return; + } let revision = pending.revision; self.pending_ai_inline_request = None; if let Some(token) = self.ai_inline_cancel_token.take() { @@ -1240,6 +1307,7 @@ impl AppShell { let api_key = cfg.provider.api_key.clone(); let model = cfg.provider.model.clone(); let endpoint_kind = cfg.provider.endpoint_kind.clone(); + let reasoning_effort = cfg.provider.reasoning_effort.clone(); let max_tokens = cfg.max_tokens(); let text = self.app_state.text_string(); @@ -1259,6 +1327,8 @@ impl AppShell { } let language_id = self.app_state.active_file().map(language_id_for_path); self.last_ai_inline_submit_at = Some(Instant::now()); + self.ai_inline_inflight = true; + self.request_redraw(); self.submit(RequestSpec { revision_id: revision, topic: RequestTopic::AiInlineCompletion, @@ -1267,6 +1337,7 @@ impl AppShell { api_key, model, endpoint_kind, + reasoning_effort, prefix, suffix, language_id, @@ -1280,7 +1351,14 @@ impl AppShell { pub(super) fn next_ai_inline_flush_deadline(&self) -> Option { let pending = self.pending_ai_inline_request.as_ref()?; let cfg = self.ai_config.inline_completion()?; - Some(pending.queued_at + Duration::from_millis(cfg.debounce_ms())) + let debounce_deadline = pending.queued_at + Duration::from_millis(cfg.debounce_ms()); + let min_interval_deadline = self + .last_ai_inline_submit_at + .map(|last| last + Duration::from_millis(cfg.min_interval_ms())); + Some(match min_interval_deadline { + Some(interval_deadline) => debounce_deadline.max(interval_deadline), + None => debounce_deadline, + }) } pub(super) fn submit_active_palette_fzf_search(&mut self) { @@ -1430,14 +1508,22 @@ impl AppShell { } let custom_bin_path = if desired.server_name.contains('/') { - Path::new(&desired.server_name).parent().map(Path::to_path_buf) + Path::new(&desired.server_name) + .parent() + .map(Path::to_path_buf) } else { if let Some(path) = self.app_state.active_file() { if let Some(profile) = crate::lsp::registry::language_profile_for_path(path) { if profile.key == "python" { - self.selected_python_env.as_ref().and_then(|p| p.parent()).map(Path::to_path_buf) + self.selected_python_env + .as_ref() + .and_then(|p| p.parent()) + .map(Path::to_path_buf) } else if profile.key == "dart" { - self.selected_dart_env.as_ref().and_then(|p| p.parent()).map(Path::to_path_buf) + self.selected_dart_env + .as_ref() + .and_then(|p| p.parent()) + .map(Path::to_path_buf) } else { None } @@ -1491,9 +1577,13 @@ impl AppShell { let mut server_name = profile.lsp_binary.to_string(); let root_path = crate::lsp::registry::find_project_root(path, profile.root_markers); - if profile.key == "python" && let Some(python_path) = &self.selected_python_env { + if profile.key == "python" + && let Some(python_path) = &self.selected_python_env + { let local_pylsp = python_path.parent().map(|p| p.join("pylsp")); - if let Some(local_pylsp) = local_pylsp && local_pylsp.try_exists().unwrap_or(false) { + if let Some(local_pylsp) = local_pylsp + && local_pylsp.try_exists().unwrap_or(false) + { server_name = local_pylsp.to_string_lossy().to_string(); } } else if profile.key == "dart" { @@ -1521,14 +1611,18 @@ impl AppShell { if let Ok(entries) = std::fs::read_dir(&versions_dir) { for entry in entries.flatten() { if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { - let dart_bin = entry.path() + let dart_bin = entry + .path() .join("bin") .join("cache") .join("dart-sdk") .join("bin") .join("dart"); if dart_bin.try_exists().unwrap_or(false) { - found.push((entry.file_name().to_string_lossy().to_string(), dart_bin)); + found.push(( + entry.file_name().to_string_lossy().to_string(), + dart_bin, + )); } } } @@ -1566,7 +1660,55 @@ impl AppShell { }) } + /// #5: file mở NGOÀI workspace root không được recursive watcher của root bao phủ. + /// Gắn thêm một watcher non-recursive cho thư mục cha của nó (có dedup) để + /// vẫn nhận update tức thì thay vì chỉ dựa vào polling 1s. + pub(super) fn ensure_external_file_watch_for_active(&mut self) { + let Some(path) = self.app_state.active_file().map(PathBuf::from) else { + return; + }; + if let Some(root) = self.app_state.workspace_root_path().map(PathBuf::from) + && path.starts_with(&root) + { + return; + } + let Some(parent) = path.parent().map(|p| p.to_path_buf()) else { + return; + }; + if !self.externally_watched_dirs.insert(parent.clone()) { + return; + } + self.submit(RequestSpec { + revision_id: 0, + topic: RequestTopic::WorkspaceWatch, + payload: WorkerRequestPayload::StartFileWatch { + root_path: parent, + recursive: false, + }, + }); + } + + /// Submit an async workspace tree rescan; the result comes back as + /// `WorkspaceRescanned` and is applied via `apply_workspace_rescan`. + pub(super) fn submit_workspace_rescan(&mut self) { + let Some((root_path, ignore_rules, options)) = + self.app_state.workspace_rescan_request_params() + else { + return; + }; + self.submit(RequestSpec { + revision_id: 0, + topic: RequestTopic::WorkspaceWatch, + payload: WorkerRequestPayload::RescanWorkspace { + root_path, + ignore_rules, + options, + }, + }); + } + pub(super) fn submit_lsp_did_open_for_active_file(&mut self) { + self.ensure_external_file_watch_for_active(); self.pending_lsp_document_sync = None; let Some(path) = self.app_state.active_file() else { return; @@ -1648,6 +1790,53 @@ impl AppShell { true } + /// Re-sync an externally reloaded document (usually an inactive buffer) + /// with the running LSP server. Without this, the server keeps the stale + /// didOpen overlay for that file, which SHADOWS the new on-disk content — + /// cross-file diagnostics in the active file are then computed against old + /// code until the user manually revisits the reloaded buffer. + /// + /// Never starts a server on behalf of an inactive buffer: if no server for + /// the file's language is running, the sync is skipped (the server will + /// read fresh content from disk when it starts later). + pub(super) fn submit_lsp_sync_for_externally_reloaded_path(&mut self, path: &Path) { + let Some(profile) = crate::lsp::registry::language_profile_for_path(path) else { + return; + }; + if profile.lsp_binary.is_empty() { + return; + } + let Some(active) = self.active_lsp_server.as_ref() else { + return; + }; + // server_name may be a bare binary or a resolved absolute path + // (e.g. venv-local pylsp, FVM dart). + let matches_running_server = active.server_name == profile.lsp_binary + || active + .server_name + .ends_with(&format!("/{}", profile.lsp_binary)); + if !matches_running_server { + return; + } + let Some(text) = self.app_state.buffer_text_for_path(path) else { + return; + }; + + let version = self.app_state.revision().min(i32::MAX as u64) as i32; + // LspDidOpen payload handles both "not yet open -> didOpen" and + // "already open -> didChange" in the worker. + self.submit(RequestSpec { + revision_id: self.app_state.revision(), + topic: RequestTopic::LspClient, + payload: WorkerRequestPayload::LspDidOpen { + uri: path_to_lsp_uri(path), + language_id: language_id_for_path(path), + version, + text, + }, + }); + } + pub(super) fn queue_lsp_did_change_for_active_file(&mut self) { let Some(path) = self.app_state.active_file().map(PathBuf::from) else { self.pending_lsp_document_sync = None; diff --git a/src/app/input/handler.rs b/src/app/input/handler.rs index b1d1b2a3..906a24cb 100644 --- a/src/app/input/handler.rs +++ b/src/app/input/handler.rs @@ -64,6 +64,17 @@ impl InputHandler { self.modifiers } + /// Pending chord sequence (for the which-key overlay) and when it started. + /// `None` while no multi-step chord is in flight (operator/leap pendings + /// have no sequence and are excluded on purpose). + pub fn pending_chord_sequence( + &self, + ) -> Option<(&crate::app::input_map::PendingSequence, Instant)> { + let pending = self.pending_input.as_ref()?; + let sequence = pending.sequence.as_ref()?; + Some((sequence, pending.started_at)) + } + pub fn get_pending_keys(&self) -> String { let pending_state = self.pending_input.as_ref().map(|pending| &pending.state); let sequence = self @@ -191,18 +202,50 @@ impl InputHandler { let normalized = NormalizedInput::from_key_event(key_event, self.modifiers); let is_f5 = normalized.named_key == Some(NamedKey::F5); if is_f5 { - eprintln!("[DAP LOG] [F5 Input Handler] translate_key_event: normalized F5 key_event, modifiers: {:?}", self.modifiers); + eprintln!( + "[DAP LOG] [F5 Input Handler] translate_key_event: normalized F5 key_event, modifiers: {:?}", + self.modifiers + ); } if key_event.repeat { return self.route_repeated_normalized_input(normalized, input_map, context); } let outcome = self.route_normalized_input(normalized, input_map, context, Instant::now()); if is_f5 { - eprintln!("[DAP LOG] [F5 Input Handler] translate_key_event F5 outcome: {:?}", outcome); + eprintln!( + "[DAP LOG] [F5 Input Handler] translate_key_event F5 outcome: {:?}", + outcome + ); } outcome } + /// In the right-sidebar opencode chat, Ctrl+U/Ctrl+D scroll the chat history + /// half a page. opencode is a full-screen TUI that keeps its history inside + /// its own viewport — nothing ever reaches our scrollback — so local grid + /// scrolling can never reveal it. Instead, forward opencode's own default + /// half-page keybinds (`ctrl+alt+u` / `ctrl+alt+d`, encoded as ESC + ^U/^D) + /// so opencode scrolls its message list itself. + /// Scoped by `right_sidebar_terminal`, so the bottom-panel shell keeps ^U/^D + /// intact even if the right dock's editor mode has drifted out of TerminalFocus. + fn right_chat_scroll_command( + normalized: &NormalizedInput, + context: KeybindingContext, + ) -> Option { + if !(context.right_sidebar_terminal + && normalized.modifiers.control_key() + && !normalized.modifiers.alt_key() + && !normalized.modifiers.super_key()) + { + return None; + } + match normalized.physical_key { + Some(KeyCode::KeyU) => Some(Command::TerminalWriteInput("\u{1b}\u{15}".to_string())), + Some(KeyCode::KeyD) => Some(Command::TerminalWriteInput("\u{1b}\u{4}".to_string())), + _ => None, + } + } + pub(crate) fn route_repeated_normalized_input( &mut self, normalized: NormalizedInput, @@ -226,6 +269,21 @@ impl InputHandler { return self.route_ai_chat_input(normalized, input_debug, context); } + if let Some(scroll) = Self::right_chat_scroll_command(&normalized, context) { + self.clear_pending_counts(); + return Some(InputRouteOutcome::Dispatch(Self::translate_dispatch( + input_debug, + format!( + "mode={} focus={} -> right chat scroll (held Ctrl+U/D)", + context.mode.as_str(), + context.focus.as_str() + ), + scroll, + 1, + false, + ))); + } + if matches!( context.focus, InputFocusContext::BufferTerminal | InputFocusContext::Terminal @@ -389,6 +447,46 @@ impl InputHandler { ))); } + // Right-sidebar opencode chat: Ctrl+U/Ctrl+D scroll the chat history + // instead of being sent to the PTY as raw control bytes. + if let Some(scroll) = Self::right_chat_scroll_command(&normalized, context) { + self.clear_pending_counts(); + return Some(InputRouteOutcome::Dispatch(Self::translate_dispatch( + input_debug, + format!( + "mode={} focus={} -> right chat scroll (Ctrl+U/D)", + context.mode.as_str(), + context.focus.as_str() + ), + scroll, + 1, + false, + ))); + } + + // Zen Mode: Esc must reach the terminal app (Vim, etc.) as a raw ESC + // instead of leaving terminal focus. In Zen Mode the editor is hidden, so + // dropping terminal focus would strand the user — they'd have to press F12 + // to resume typing. Forwarding ESC keeps the typing flow intact. + if context.zen_mode_active + && context.focus == InputFocusContext::Terminal + && context.mode == EditorMode::TerminalFocus + && normalized.named_key == Some(NamedKey::Escape) + { + self.clear_pending_counts(); + return Some(InputRouteOutcome::Dispatch(Self::translate_dispatch( + input_debug, + format!( + "mode={} focus={} -> zen terminal raw Esc", + context.mode.as_str(), + context.focus.as_str() + ), + Command::TerminalWriteInput("\u{1b}".to_string()), + 1, + false, + ))); + } + if context.focus == InputFocusContext::Terminal && context.mode == EditorMode::TerminalFocus && !zen_mode_allows_leader @@ -856,6 +954,36 @@ impl InputHandler { }); } + PendingState::FindCharMotion { kind } => { + let kind = *kind; + if let Some(ch) = replace_char_from_input(&normalized) { + let command = Command::MoveFindChar(kind, ch); + self.clear_pending_input(); + let (repeat_count, count_ignored) = + self.consume_repeat_count_for_command(&command, Some(&pending.state)); + return Some(InputRouteOutcome::Dispatch(Self::translate_dispatch( + input_debug, + format!( + "mode={} focus={} -> find-char motion resolved ({ch:?})", + context.mode.as_str(), + context.focus.as_str() + ), + command, + repeat_count, + count_ignored, + ))); + } + self.reset_prefix(); + return Some(InputRouteOutcome::NoDispatch { + input_debug, + route_debug: format!( + "mode={} focus={} -> find-char motion reset (not a printable char)", + context.mode.as_str(), + context.focus.as_str() + ), + }); + } + PendingState::OperatorFindChar { op, kind } => { if let Some(ch) = replace_char_from_input(&normalized) { let command = Command::Operate { @@ -905,6 +1033,25 @@ impl InputHandler { }); }; + // Esc cancels the pending chord outright (closing the which-key + // overlay) instead of falling through to a mode-level escape. + if normalized.named_key == Some(NamedKey::Escape) { + self.reset_prefix(); + return Some(InputRouteOutcome::NoDispatch { + input_debug, + route_debug: format!( + "mode={} focus={} -> chord cancelled by Esc", + context.mode.as_str(), + context.focus.as_str(), + ), + }); + } + + // The chord keeps its original start time across steps so the + // which-key overlay (delay measured from the first key) stays + // visible instead of re-arming its delay on every step. + let chord_started_at = pending.started_at; + if let Some(matched) = input_map.resolve_sequence_next(sequence, &normalized, context) { match matched { SequenceMatch::Dispatch(resolved) => { @@ -933,7 +1080,7 @@ impl InputHandler { self.pending_input = Some(PendingInput { state: next_state.clone(), sequence: Some(next_sequence), - started_at: now, + started_at: chord_started_at, }); return Some(InputRouteOutcome::NoDispatch { input_debug, @@ -1065,6 +1212,34 @@ impl InputHandler { } } + // Bare f/F/t/T motion trong Normal/Visual mode -> chờ target char. + // Phải đứng trước resolve_sequence_start để không bị keymap chord nuốt. + if context.focus == InputFocusContext::Editor + && matches!(context.mode, EditorMode::Normal | EditorMode::Visual) + && !normalized.has_command_modifier() + && !normalized.modifiers.control_key() + && !normalized.modifiers.alt_key() + { + if let Some(kind) = find_motion_prefix_from_input(&normalized) { + if self.operator_count.is_none() { + self.operator_count = self.pending_count.take(); + } + self.pending_input = Some(PendingInput { + sequence: None, + state: PendingState::FindCharMotion { kind }, + started_at: now, + }); + return Some(InputRouteOutcome::NoDispatch { + input_debug, + route_debug: format!( + "mode={} focus={} -> find-char motion start ({kind:?}), waiting target char", + context.mode.as_str(), + context.focus.as_str(), + ), + }); + } + } + if context.focus == InputFocusContext::Editor && context.mode == EditorMode::Visual { if let Some(modifier) = inner_or_around_from_input(&normalized) { self.pending_input = Some(PendingInput { @@ -1154,7 +1329,9 @@ impl InputHandler { || normalized.named_key == Some(NamedKey::F12); if !is_global_layout_toggle { - if normalized.has_command_modifier() && normalized.physical_key == Some(KeyCode::KeyV) { + if normalized.has_command_modifier() + && normalized.physical_key == Some(KeyCode::KeyV) + { self.clear_pending_counts(); return Some(InputRouteOutcome::Dispatch(Self::translate_dispatch( input_debug, @@ -1306,6 +1483,10 @@ impl InputHandler { } } + // Ghost text is accepted only via Ctrl+J / Ctrl+L (see resolved_keymap + + // config keymap). Tab is intentionally NOT an accept key — it inserts + // indentation — so there is no Tab/accept ambiguity here. + if let Some(resolved) = input_map.resolve(&normalized, context) { let (repeat_count, count_ignored) = self.consume_repeat_count_for_command(&resolved.command, None); @@ -1403,6 +1584,13 @@ impl InputHandler { ) { return; } + // Chord sequences never expire: their state is visible (statusbar + // pending keys + which-key overlay), so a silent reset would make + // the next key fire a surprise normal-mode command (e.g. paste) + // while the overlay still invites a continuation. Esc cancels. + if pending.sequence.is_some() { + return; + } if now.duration_since(pending.started_at) > self.prefix_timeout { self.reset_prefix(); } @@ -1444,8 +1632,12 @@ impl InputHandler { input_debug: String, context: KeybindingContext, ) -> Option { - // Ctrl+N / Ctrl+P → cycle suggestion popup; Ctrl+U/D → scroll history; Ctrl+C → clear input - if normalized.has_command_modifier() { + // Ctrl+N / Ctrl+P → cycle suggestion popup; Ctrl+U/D → scroll history; Ctrl+C → clear input. + // On macOS, `has_command_modifier()` means Cmd, so check Control explicitly here. + let control_shortcut = normalized.modifiers.control_key() + && !normalized.modifiers.alt_key() + && !normalized.modifiers.super_key(); + if control_shortcut { match normalized.physical_key { Some(KeyCode::KeyC) => { return Some(InputRouteOutcome::Dispatch(Self::translate_dispatch( diff --git a/src/app/input/helpers.rs b/src/app/input/helpers.rs index f82dd06f..28929406 100644 --- a/src/app/input/helpers.rs +++ b/src/app/input/helpers.rs @@ -109,7 +109,11 @@ pub(super) fn should_start_replace_pending( if context.focus != InputFocusContext::Editor || context.mode != EditorMode::Normal { return false; } - if input.has_command_modifier() || input.modifiers.control_key() || input.modifiers.alt_key() || input.modifiers.shift_key() { + if input.has_command_modifier() + || input.modifiers.control_key() + || input.modifiers.alt_key() + || input.modifiers.shift_key() + { return false; } input.physical_key == Some(KeyCode::KeyR) || input.text.as_deref() == Some("r") diff --git a/src/app/input/pending.rs b/src/app/input/pending.rs index c00c4257..00fdf2a4 100644 --- a/src/app/input/pending.rs +++ b/src/app/input/pending.rs @@ -77,6 +77,10 @@ pub(super) enum PendingState { op: Operator, kind: FindMotionKind, }, + /// Bare f/F/t/T motion (không có operator) -> chờ target char. + FindCharMotion { + kind: FindMotionKind, + }, ReplaceChar, Leader, Sequence, @@ -121,6 +125,7 @@ impl PendingState { Self::PendingOperator { .. } | Self::OperatorG { .. } | Self::OperatorFindChar { .. } + | Self::FindCharMotion { .. } | Self::OperatorWithObject { .. } ) } @@ -166,6 +171,12 @@ impl PendingState { (Operator::Yank, FindMotionKind::BackwardTill) => "pending yank till-char backward", (Operator::Visual, _) => "pending visual find-char", }, + Self::FindCharMotion { kind } => match kind { + FindMotionKind::ForwardTo => "pending find-char forward", + FindMotionKind::ForwardTill => "pending till-char forward", + FindMotionKind::BackwardTo => "pending find-char backward", + FindMotionKind::BackwardTill => "pending till-char backward", + }, Self::ReplaceChar => "pending replace char", Self::Leader => "pending leader", Self::Sequence => "pending sequence", diff --git a/src/app/input/tests.rs b/src/app/input/tests.rs index cec9f590..05f8fa73 100644 --- a/src/app/input/tests.rs +++ b/src/app/input/tests.rs @@ -276,6 +276,138 @@ fn zen_mode_active_routes_space_to_terminal_focus_input() { } } +#[test] +fn zen_mode_active_forwards_escape_to_terminal_pty() { + let mut handler = InputHandler::new(); + let map = make_map(); + let mut context = + KeybindingContext::with_focus(EditorMode::TerminalFocus, InputFocusContext::Terminal); + context.zen_mode_active = true; + let t0 = std::time::Instant::now(); + + let resolved = handler.route_normalized_input( + named_input(NamedKey::Escape, Some(KeyCode::Escape)), + &map, + context, + t0, + ); + match resolved { + Some(InputRouteOutcome::Dispatch(translated)) => { + assert_eq!( + translated.command, + Command::TerminalWriteInput("\u{1b}".to_string()) + ); + } + other => panic!("expected raw Esc dispatch in zen terminal, got {:?}", other), + } +} + +#[test] +fn non_zen_terminal_escape_does_not_forward_raw_pty() { + let mut handler = InputHandler::new(); + let map = make_map(); + let context = + KeybindingContext::with_focus(EditorMode::TerminalFocus, InputFocusContext::Terminal); + let t0 = std::time::Instant::now(); + + let resolved = handler.route_normalized_input( + named_input(NamedKey::Escape, Some(KeyCode::Escape)), + &map, + context, + t0, + ); + // Outside Zen Mode, Esc keeps its focus-leaving semantics (FocusBack), never a + // raw ESC into the PTY. + if let Some(InputRouteOutcome::Dispatch(translated)) = resolved { + assert_ne!( + translated.command, + Command::TerminalWriteInput("\u{1b}".to_string()) + ); + } +} + +#[test] +fn right_sidebar_terminal_ctrl_u_d_scroll_half_page() { + let mut handler = InputHandler::new(); + let map = make_map(); + let mut context = + KeybindingContext::with_focus(EditorMode::TerminalFocus, InputFocusContext::Terminal); + context.right_sidebar_terminal = true; + let t0 = std::time::Instant::now(); + + // Forwarded as opencode's default half-page keybinds (ctrl+alt+u / ctrl+alt+d) + // because the chat history lives inside opencode's own viewport, not in our + // grid scrollback. + let up = handler.route_normalized_input(ctrl_input('u', KeyCode::KeyU), &map, context, t0); + match up { + Some(InputRouteOutcome::Dispatch(translated)) => { + assert_eq!( + translated.command, + Command::TerminalWriteInput("\u{1b}\u{15}".to_string()) + ); + } + other => panic!("expected scroll up dispatch, got {:?}", other), + } + + let down = handler.route_normalized_input(ctrl_input('d', KeyCode::KeyD), &map, context, t0); + match down { + Some(InputRouteOutcome::Dispatch(translated)) => { + assert_eq!( + translated.command, + Command::TerminalWriteInput("\u{1b}\u{4}".to_string()) + ); + } + other => panic!("expected scroll down dispatch, got {:?}", other), + } +} + +#[test] +fn right_sidebar_terminal_ctrl_u_d_scroll_even_when_mode_drifted() { + let mut handler = InputHandler::new(); + let map = make_map(); + let mut context = + KeybindingContext::with_focus(EditorMode::Normal, InputFocusContext::Inspector); + context.right_sidebar_terminal = true; + let t0 = std::time::Instant::now(); + + let up = handler.route_normalized_input(ctrl_input('u', KeyCode::KeyU), &map, context, t0); + match up { + Some(InputRouteOutcome::Dispatch(translated)) => { + assert_eq!( + translated.command, + Command::TerminalWriteInput("\u{1b}\u{15}".to_string()) + ); + } + other => panic!("expected right-sidebar scroll up dispatch, got {:?}", other), + } +} + +#[test] +fn bottom_terminal_ctrl_u_d_still_forward_raw_to_pty() { + let mut handler = InputHandler::new(); + let map = make_map(); + // right_sidebar_terminal defaults to false -> bottom-panel shell keeps ^U/^D. + let context = + KeybindingContext::with_focus(EditorMode::TerminalFocus, InputFocusContext::Terminal); + let t0 = std::time::Instant::now(); + + let up = handler.route_normalized_input(ctrl_input('u', KeyCode::KeyU), &map, context, t0); + match up { + Some(InputRouteOutcome::Dispatch(translated)) => match &translated.command { + Command::TerminalWriteInput(payload) => { + assert!( + !payload.starts_with('\u{1b}'), + "bottom terminal Ctrl+U must stay raw ^U, not the right-dock \ + ctrl+alt+u forward, got {:?}", + payload + ); + } + other => panic!("bottom terminal Ctrl+U should forward raw, got {:?}", other), + }, + other => panic!("expected raw write dispatch, got {:?}", other), + } +} + #[test] fn zen_mode_active_allows_leader_z_m_from_terminal_normal_mode() { let mut handler = InputHandler::new(); @@ -829,7 +961,10 @@ fn interrupted_prefix_falls_back_and_does_not_stick() { } #[test] -fn prefix_timeout_resets_safely() { +fn pending_chord_survives_timeout_window() { + // Chord sequences must NOT silently expire: their state is visible via the + // which-key overlay, so a continuation key pressed after a long pause must + // still resolve the chord instead of firing a normal-mode command. let mut handler = InputHandler::new(); let map = make_map(); let context = KeybindingContext::for_mode(EditorMode::Normal); @@ -841,15 +976,24 @@ fn prefix_timeout_resets_safely() { context, t0, ); + assert!(handler.pending_chord_sequence().is_some()); + // Well past the old 1.5s prefix timeout — the chord must still be alive. let after_timeout = t0 + Duration::from_millis(1_700); - let expired = handler.route_normalized_input( - char_input('f', KeyCode::KeyF), + + // 'q' has no continuation in the chord: it cancels the chord and falls + // back to normal resolution ('q' is unbound -> NoDispatch, not None). + let interrupted = handler.route_normalized_input( + char_input('q', KeyCode::KeyQ), &map, context, after_timeout, ); - assert!(expired.is_none()); + assert!(matches!( + interrupted, + Some(InputRouteOutcome::NoDispatch { .. }) + )); + assert!(handler.pending_chord_sequence().is_none()); let next = handler.route_normalized_input( char_input('j', KeyCode::KeyJ), @@ -861,10 +1005,39 @@ fn prefix_timeout_resets_safely() { Some(InputRouteOutcome::Dispatch(translated)) => { assert_eq!(translated.command, Command::MoveDown); } - other => panic!("expected router recovered after timeout, got {:?}", other), + other => panic!("expected router recovered after interrupt, got {:?}", other), } } +#[test] +fn escape_cancels_pending_chord_without_fallback_dispatch() { + let mut handler = InputHandler::new(); + let map = make_map(); + let context = KeybindingContext::for_mode(EditorMode::Normal); + let t0 = std::time::Instant::now(); + + let _ = handler.route_normalized_input( + named_input(NamedKey::Space, Some(KeyCode::Space)), + &map, + context, + t0, + ); + assert!(handler.pending_chord_sequence().is_some()); + + let cancelled = handler.route_normalized_input( + named_input(NamedKey::Escape, Some(KeyCode::Escape)), + &map, + context, + t0 + Duration::from_millis(500), + ); + assert!(matches!( + cancelled, + Some(InputRouteOutcome::NoDispatch { .. }) + )); + assert!(handler.pending_chord_sequence().is_none()); + assert_eq!(handler.get_pending_keys(), ""); +} + #[test] fn focus_loss_resets_prefix_safely() { let mut handler = InputHandler::new(); @@ -880,8 +1053,9 @@ fn focus_loss_resets_prefix_safely() { ); handler.on_focus_changed(false); + // 'q' không được bind trong Normal mode ('f' giờ mở find-char pending). let after_focus_lost = - handler.route_normalized_input(char_input('f', KeyCode::KeyF), &map, context, now); + handler.route_normalized_input(char_input('q', KeyCode::KeyQ), &map, context, now); assert!(after_focus_lost.is_none()); let next = handler.route_normalized_input(char_input('j', KeyCode::KeyJ), &map, context, now); @@ -896,6 +1070,58 @@ fn focus_loss_resets_prefix_safely() { } } +#[test] +fn bare_find_char_motion_waits_then_dispatches_move_find_char() { + let mut handler = InputHandler::new(); + let map = make_map(); + let context = KeybindingContext::for_mode(EditorMode::Normal); + let now = std::time::Instant::now(); + + let pending = + handler.route_normalized_input(char_input('f', KeyCode::KeyF), &map, context, now); + assert!(matches!( + pending, + Some(InputRouteOutcome::NoDispatch { .. }) + )); + + let resolved = + handler.route_normalized_input(char_input('x', KeyCode::KeyX), &map, context, now); + match resolved { + Some(InputRouteOutcome::Dispatch(translated)) => { + assert_eq!( + translated.command, + Command::MoveFindChar(crate::core::commands::FindMotionKind::ForwardTo, 'x') + ); + } + other => panic!("expected find-char dispatch, got {:?}", other), + } +} + +#[test] +fn bare_find_char_motion_cancelled_by_escape() { + let mut handler = InputHandler::new(); + let map = make_map(); + let context = KeybindingContext::for_mode(EditorMode::Normal); + let now = std::time::Instant::now(); + + let _ = handler.route_normalized_input(char_input('t', KeyCode::KeyT), &map, context, now); + let _ = handler.route_normalized_input( + named_input(NamedKey::Escape, Some(KeyCode::Escape)), + &map, + context, + now, + ); + + // Sau Esc, 'j' phải dispatch motion bình thường (pending đã bị huỷ). + let next = handler.route_normalized_input(char_input('j', KeyCode::KeyJ), &map, context, now); + match next { + Some(InputRouteOutcome::Dispatch(translated)) => { + assert_eq!(translated.command, Command::MoveDown); + } + other => panic!("expected recovery after Esc, got {:?}", other), + } +} + #[test] fn repeated_motion_key_dispatches_while_holding() { let mut handler = InputHandler::new(); @@ -1284,7 +1510,8 @@ fn settings_focus_text_input_routes_to_editing_append() { fn palette_focus_text_input_routes_to_query_append() { let mut handler = InputHandler::new(); let map = make_map(); - let mut context = KeybindingContext::with_focus(EditorMode::PaletteFocus, InputFocusContext::Editor); + let mut context = + KeybindingContext::with_focus(EditorMode::PaletteFocus, InputFocusContext::Editor); context.command_palette_visible = true; let now = std::time::Instant::now(); @@ -1710,6 +1937,30 @@ fn ai_chat_text_input_keeps_full_unicode_payload() { } } +#[test] +fn ai_chat_ctrl_u_d_scroll_history() { + let mut handler = InputHandler::new(); + let map = make_map(); + let context = KeybindingContext::with_focus(EditorMode::Normal, InputFocusContext::AiChat); + let now = std::time::Instant::now(); + + let up = handler.route_normalized_input(ctrl_input('u', KeyCode::KeyU), &map, context, now); + match up { + Some(InputRouteOutcome::Dispatch(translated)) => { + assert_eq!(translated.command, Command::AiChatScrollHalfPageUp); + } + other => panic!("expected ai chat scroll up dispatch, got {:?}", other), + } + + let down = handler.route_normalized_input(ctrl_input('d', KeyCode::KeyD), &map, context, now); + match down { + Some(InputRouteOutcome::Dispatch(translated)) => { + assert_eq!(translated.command, Command::AiChatScrollHalfPageDown); + } + other => panic!("expected ai chat scroll down dispatch, got {:?}", other), + } +} + #[test] fn buffer_terminal_f12_maps_to_focus_terminal() { let mut handler = InputHandler::new(); @@ -1724,7 +1975,10 @@ fn buffer_terminal_f12_maps_to_focus_terminal() { Some(InputRouteOutcome::Dispatch(translated)) => { assert_eq!(translated.command, Command::FocusTerminal); } - other => panic!("expected F12 dispatch in buffer terminal focus, got {:?}", other), + other => panic!( + "expected F12 dispatch in buffer terminal focus, got {:?}", + other + ), } } @@ -1748,6 +2002,9 @@ fn buffer_terminal_cmd_r_maps_to_focus_inspector() { Some(InputRouteOutcome::Dispatch(translated)) => { assert_eq!(translated.command, Command::FocusInspector); } - other => panic!("expected Cmd-R dispatch in buffer terminal focus, got {:?}", other), + other => panic!( + "expected Cmd-R dispatch in buffer terminal focus, got {:?}", + other + ), } } diff --git a/src/app/input_map/focus.rs b/src/app/input_map/focus.rs index 57c29b0a..d0b46c0a 100644 --- a/src/app/input_map/focus.rs +++ b/src/app/input_map/focus.rs @@ -495,7 +495,9 @@ impl InputMap { }); } - if let Some(command) = resolved_keymap::resolve_command(&self.keymap, input, "normal", &self.open_file_path) { + if let Some(command) = + resolved_keymap::resolve_command(&self.keymap, input, "normal", &self.open_file_path) + { return Some(KeybindingMatch { command, reason: "inspector: keymap binding", @@ -569,7 +571,6 @@ impl InputMap { }); } - if input.modifiers.control_key() && !input.modifiers.super_key() && input.physical_key == Some(KeyD) @@ -1043,8 +1044,9 @@ impl InputMap { pub(super) fn resolve_terminal_focus( &self, input: &NormalizedInput, - mode: EditorMode, + context: KeybindingContext, ) -> Option { + let mode = context.mode; // Strict terminal mode: only consult terminal-mode bindings here. // Unmapped keys must fall through as raw PTY input instead of triggering globals. if let Some(command) = resolved_keymap::resolve_command_mode_only( @@ -1059,11 +1061,20 @@ impl InputMap { }); } - if let Some(command) = resolved_keymap::resolve_global_command( - &self.keymap, - input, - &self.open_file_path, - ) { + if context.right_sidebar_terminal + && input.modifiers.super_key() + && !input.modifiers.shift_key() + && input.physical_key == Some(KeyCode::Comma) + { + return Some(KeybindingMatch { + command: Command::OpenSettings, + reason: "right terminal focus: Cmd+, -> OpenSettings", + }); + } + + if let Some(command) = + resolved_keymap::resolve_global_command(&self.keymap, input, &self.open_file_path) + { let is_allowed = match &command { Command::FocusTerminal | Command::ToggleBottomDock @@ -1083,8 +1094,10 @@ impl InputMap { | Command::DebugStepInto | Command::DebugStepOut => true, Command::DebugToggleBreakpoint => { - // Only allow if NOT Shift+B - !(input.modifiers.shift_key() && !input.modifiers.control_key() && !input.modifiers.super_key() && !input.modifiers.alt_key()) + !(input.modifiers.shift_key() + && !input.modifiers.control_key() + && !input.modifiers.super_key() + && !input.modifiers.alt_key()) } _ => false, }; diff --git a/src/app/input_map/mod.rs b/src/app/input_map/mod.rs index d63c1aeb..9a4d4aec 100644 --- a/src/app/input_map/mod.rs +++ b/src/app/input_map/mod.rs @@ -99,8 +99,15 @@ pub struct KeybindingContext { pub command_palette_mode: Option, pub welcome_visible: bool, pub completion_visible: bool, + /// True when AI ghost text is showing at the caret. Lets the input layer + /// route Tab to accept the suggestion (completion menu still wins). + pub inline_suggestion_visible: bool, pub hover_overlay_visible: bool, pub zen_mode_active: bool, + /// True when the focused terminal is the right-sidebar opencode chat. Lets + /// the input layer rebind Ctrl+U/Ctrl+D to scroll instead of forwarding them + /// as raw control bytes, without affecting the bottom-panel shell. + pub right_sidebar_terminal: bool, } impl KeybindingContext { @@ -116,8 +123,10 @@ impl KeybindingContext { command_palette_mode: None, welcome_visible: false, completion_visible: false, + inline_suggestion_visible: false, hover_overlay_visible: false, zen_mode_active: false, + right_sidebar_terminal: false, } } @@ -139,8 +148,10 @@ impl KeybindingContext { command_palette_mode: None, welcome_visible: false, completion_visible: false, + inline_suggestion_visible: false, hover_overlay_visible: false, zen_mode_active: false, + right_sidebar_terminal: false, } } } @@ -162,6 +173,28 @@ pub enum SequenceMatch { Pending(PendingSequence), } +/// One row of the which-key overlay: the next key of a pending chord plus a +/// human-readable description of what it does. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WhichKeyEntry { + /// Display token (e.g. "f", ""). + pub key: String, + /// Humanized command label, or "+more" for a deeper chord group. + pub label: String, + pub is_group: bool, +} + +/// "editor.move_word_forward" -> "Move word forward". +fn humanize_command_id(id: &str) -> String { + let tail = id.rsplit('.').next().unwrap_or(id); + let label = tail.replace('_', " "); + let mut chars = label.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => label, + } +} + #[derive(Debug, Clone)] pub struct InputMap { open_file_path: PathBuf, @@ -256,7 +289,7 @@ impl InputMap { return self.resolve_settings_focus(input, context); } if context.focus == InputFocusContext::Terminal { - return self.resolve_terminal_focus(input, context.mode); + return self.resolve_terminal_focus(input, context); } if context.focus == InputFocusContext::Explorer { return self.resolve_explorer_focus(input, context.welcome_visible); @@ -271,7 +304,8 @@ impl InputMap { return self.resolve_help_focus(input); } if context.focus == InputFocusContext::ExtensionsManager { - return self.resolve_extensions_manager_focus(input, context.mode == EditorMode::Insert); + return self + .resolve_extensions_manager_focus(input, context.mode == EditorMode::Insert); } if context.focus == InputFocusContext::BottomPanel { return self.resolve_bottom_panel_focus(input); @@ -472,6 +506,36 @@ impl InputMap { true } + /// Continuations of a pending chord, ready for the which-key overlay. + /// Entries whose command id cannot resolve to a real command are hidden, + /// mirroring dispatch behavior. + pub fn whichkey_entries( + &self, + pending: &PendingSequence, + context: KeybindingContext, + ) -> Vec { + let mode_str = self.sequence_mode_str(context); + self.keymap + .sequence_continuations(&pending.steps, mode_str) + .into_iter() + .filter_map(|cont| match cont.command_id { + Some(id) => { + command_ids::parse(&id, Some(&self.open_file_path))?; + Some(WhichKeyEntry { + key: cont.key, + label: humanize_command_id(&id), + is_group: false, + }) + } + None => Some(WhichKeyEntry { + key: cont.key, + label: "+more".to_string(), + is_group: true, + }), + }) + .collect() + } + fn sequence_mode_str(&self, context: KeybindingContext) -> &'static str { match context.focus { InputFocusContext::Editor if context.welcome_visible => "normal", diff --git a/src/app/input_map/tests.rs b/src/app/input_map/tests.rs index 78e63299..0653ee97 100644 --- a/src/app/input_map/tests.rs +++ b/src/app/input_map/tests.rs @@ -147,6 +147,15 @@ fn table_driven_keybinding_resolution() { }, expected: None, }, + Case { + name: "terminal focus F5 -> DebugStart", + context: KeybindingContext::with_focus( + EditorMode::TerminalFocus, + InputFocusContext::Terminal, + ), + input: input_from_named(NamedKey::F5), + expected: Some(Command::DebugStart), + }, Case { name: "terminal focus cmd+v -> TerminalPaste", context: KeybindingContext::with_focus( @@ -249,6 +258,20 @@ fn table_driven_keybinding_resolution() { }, expected: Some(Command::TerminalPaste), }, + Case { + name: "terminal normal cmd+3 -> SwitchTerminalTab", + context: KeybindingContext::with_focus( + EditorMode::TerminalNormal, + InputFocusContext::Terminal, + ), + input: NormalizedInput { + physical_key: Some(KeyCode::Digit3), + named_key: None, + text: Some("3".to_string()), + modifiers: ModifiersState::SUPER, + }, + expected: Some(Command::SwitchTerminalTab(2)), + }, Case { name: "terminal normal Shift+V -> EnterVisualLine", context: KeybindingContext::with_focus( @@ -357,6 +380,12 @@ fn table_driven_keybinding_resolution() { input: input_from_named(NamedKey::F12), expected: Some(Command::FocusTerminal), }, + Case { + name: "global F9 -> DebugToggleBreakpoint", + context: KeybindingContext::for_mode(EditorMode::Normal), + input: input_from_named(NamedKey::F9), + expected: Some(Command::DebugToggleBreakpoint), + }, Case { name: "global cmd+backslash -> ToggleBottomDock", context: KeybindingContext::for_mode(EditorMode::Normal), diff --git a/src/app/match_ranges.rs b/src/app/match_ranges.rs index 67fe1a3f..60026167 100644 --- a/src/app/match_ranges.rs +++ b/src/app/match_ranges.rs @@ -219,7 +219,9 @@ mod tests { #[test] fn score_prefers_tight_prefix_completion_matches() { let put = score_label_match("put", "p").expect("put should match").0; - let patch = score_label_match("patch", "p").expect("patch should match").0; + let patch = score_label_match("patch", "p") + .expect("patch should match") + .0; let page = score_label_match("CSSPageDescriptors", "p") .expect("camel boundary should match") .0; diff --git a/src/app/persistence.rs b/src/app/persistence.rs index eb38d1fb..22f9e07a 100644 --- a/src/app/persistence.rs +++ b/src/app/persistence.rs @@ -1,4 +1,8 @@ -use std::{collections::HashMap, path::{Path, PathBuf}, time::{SystemTime, UNIX_EPOCH}}; +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; use serde::{Deserialize, Serialize}; @@ -12,6 +16,9 @@ pub struct AppPersistentState { pub recent_project_meta: HashMap, #[serde(default)] pub theme_profile: Option, + /// True after the one-time first-run key-hint toast was shown. + #[serde(default)] + pub first_run_tour_shown: bool, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -87,7 +94,8 @@ impl AppPersistentState { self.recent_projects.retain(|p| p != &path); self.recent_projects.insert(0, path.clone()); let keep: std::collections::HashSet<_> = self.recent_projects.iter().cloned().collect(); - self.recent_project_meta.retain(|path, _| keep.contains(path)); + self.recent_project_meta + .retain(|path, _| keep.contains(path)); let last_opened_unix_secs = SystemTime::now() .duration_since(UNIX_EPOCH) .ok() diff --git a/src/app/resolved_keymap.rs b/src/app/resolved_keymap.rs index 1c6eaf42..0801f303 100644 --- a/src/app/resolved_keymap.rs +++ b/src/app/resolved_keymap.rs @@ -105,6 +105,7 @@ fn named_key_display(named: NamedKey) -> String { NamedKey::ArrowDown => "".to_string(), NamedKey::ArrowLeft => "".to_string(), NamedKey::ArrowRight => "".to_string(), + NamedKey::F1 => "".to_string(), NamedKey::F12 => "".to_string(), _ => format!("<{named:?}>"), } @@ -354,6 +355,16 @@ pub enum SequenceLookup<'a> { Exact(&'a str), } +/// One possible continuation of a pending chord prefix (which-key overlay). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SequenceContinuation { + /// Display token of the next key (e.g. "f", ""). + pub key: String, + /// Command id when prefix+key is an exact binding; `None` for a group + /// where deeper chords continue. + pub command_id: Option, +} + /// The fully resolved keymap after all layers have been applied. /// /// Maps (mode, KeySpec) → command_id string. @@ -489,6 +500,56 @@ impl ResolvedKeymap { SequenceLookup::None } + /// Enumerate every distinct next key continuing `prefix` in the given mode, + /// for the which-key overlay. `command_id` is `Some` when prefix+key is an + /// exact binding (mode-specific wins over global, mirroring + /// `lookup_sequence`), `None` when only deeper chords follow (+group). + pub fn sequence_continuations( + &self, + prefix: &[KeySpec], + mode_str: &str, + ) -> Vec { + #[derive(Default)] + struct Acc { + mode_exact: Option, + global_exact: Option, + } + let mut by_key: HashMap = HashMap::new(); + for (key, id) in &self.sequences { + let scope = key.mode.as_deref(); + let in_mode_scope = scope == Some(mode_str); + if !in_mode_scope && scope.is_some() { + continue; + } + if key.steps.len() <= prefix.len() || !key.steps.starts_with(prefix) { + continue; + } + let token = key.steps[prefix.len()].display_token(); + let acc = by_key.entry(token).or_default(); + if key.steps.len() == prefix.len() + 1 { + if in_mode_scope { + acc.mode_exact = Some(id.clone()); + } else { + acc.global_exact = Some(id.clone()); + } + } + } + let mut out: Vec = by_key + .into_iter() + .map(|(key, acc)| SequenceContinuation { + key, + command_id: acc.mode_exact.or(acc.global_exact), + }) + .collect(); + out.sort_by(|a, b| { + a.key + .to_ascii_lowercase() + .cmp(&b.key.to_ascii_lowercase()) + .then_with(|| a.key.cmp(&b.key)) + }); + out + } + /// Build a `ResolvedKeymap` from a list of `KeyBinding` entries loaded /// from TOML. Invalid key strings are logged and skipped. pub fn from_bindings(bindings: &[KeyBinding]) -> Self { @@ -619,13 +680,20 @@ pub fn builtin_defaults() -> ResolvedKeymap { km.insert(None, nk(NamedKey::F11), DEBUG_STEP_INTO); km.insert(None, KeySpec::ShiftPlus(KeyCode::F11), DEBUG_STEP_OUT); km.insert(None, nk(NamedKey::F9), DEBUG_TOGGLE_BREAKPOINT); - km.insert(None, KeySpec::ShiftPlus(KeyCode::KeyB), DEBUG_TOGGLE_BREAKPOINT); + km.insert( + None, + KeySpec::ShiftPlus(KeyCode::KeyB), + DEBUG_TOGGLE_BREAKPOINT, + ); // ── Insert mode ─────────────────────────────────────────────────────────── km.insert(Some("insert"), nk(NamedKey::Escape), ENTER_NORMAL); km.insert(Some("insert"), nk(NamedKey::Backspace), BACKSPACE); km.insert(Some("insert"), nk(NamedKey::Enter), NEWLINE); - km.insert(Some("insert"), nk(NamedKey::Tab), AI_ACCEPT_INLINE); + // Tab inserts indentation; AI ghost text is accepted only via Ctrl+J + // (and Ctrl+L for word-by-word). Keeping Tab off the accept path avoids the + // overlap/ambiguity with the LSP completion menu. + km.insert(Some("insert"), nk(NamedKey::Tab), INSERT_TAB); km.insert(Some("insert"), nk(NamedKey::ArrowLeft), MOVE_LEFT); km.insert(Some("insert"), nk(NamedKey::ArrowRight), MOVE_RIGHT); km.insert(Some("insert"), nk(NamedKey::ArrowUp), MOVE_UP); @@ -696,8 +764,16 @@ pub fn builtin_defaults() -> ResolvedKeymap { ); km.insert(Some("normal"), ch('{'), MOVE_PARAGRAPH_UP); km.insert(Some("normal"), ch('}'), MOVE_PARAGRAPH_DOWN); - km.insert(Some("normal"), KeySpec::CtrlPlus(KeyCode::KeyH), BUFFER_PREV); - km.insert(Some("normal"), KeySpec::CtrlPlus(KeyCode::KeyL), BUFFER_NEXT); + km.insert( + Some("normal"), + KeySpec::CtrlPlus(KeyCode::KeyH), + BUFFER_PREV, + ); + km.insert( + Some("normal"), + KeySpec::CtrlPlus(KeyCode::KeyL), + BUFFER_NEXT, + ); km.insert(Some("normal"), KeySpec::CtrlPlus(KeyCode::KeyR), REDO); km.insert(Some("normal"), ch('n'), SEARCH_NEXT); km.insert(Some("normal"), ch('N'), SEARCH_PREV); @@ -750,8 +826,16 @@ pub fn builtin_defaults() -> ResolvedKeymap { nk(NamedKey::ArrowDown), OVERLAY_SELECT_NEXT, ); - km.insert(Some("palette"), KeySpec::CtrlPlus(KeyCode::KeyP), OVERLAY_SELECT_PREV); - km.insert(Some("palette"), KeySpec::CtrlPlus(KeyCode::KeyN), OVERLAY_SELECT_NEXT); + km.insert( + Some("palette"), + KeySpec::CtrlPlus(KeyCode::KeyP), + OVERLAY_SELECT_PREV, + ); + km.insert( + Some("palette"), + KeySpec::CtrlPlus(KeyCode::KeyN), + OVERLAY_SELECT_NEXT, + ); km.insert( Some("palette"), nk(NamedKey::Backspace), @@ -771,6 +855,51 @@ pub fn builtin_defaults() -> ResolvedKeymap { km.insert(Some("terminal"), cmd(KeyCode::KeyV), TERMINAL_PASTE); km.insert(Some("terminal"), cmd(KeyCode::KeyT), TERMINAL_TAB_NEW); km.insert(Some("terminal"), cmd(KeyCode::KeyW), TERMINAL_TAB_CLOSE); + km.insert( + Some("terminal"), + cmd(KeyCode::Digit1), + TERMINAL_TAB_SWITCH_1, + ); + km.insert( + Some("terminal"), + cmd(KeyCode::Digit2), + TERMINAL_TAB_SWITCH_2, + ); + km.insert( + Some("terminal"), + cmd(KeyCode::Digit3), + TERMINAL_TAB_SWITCH_3, + ); + km.insert( + Some("terminal"), + cmd(KeyCode::Digit4), + TERMINAL_TAB_SWITCH_4, + ); + km.insert( + Some("terminal"), + cmd(KeyCode::Digit5), + TERMINAL_TAB_SWITCH_5, + ); + km.insert( + Some("terminal"), + cmd(KeyCode::Digit6), + TERMINAL_TAB_SWITCH_6, + ); + km.insert( + Some("terminal"), + cmd(KeyCode::Digit7), + TERMINAL_TAB_SWITCH_7, + ); + km.insert( + Some("terminal"), + cmd(KeyCode::Digit8), + TERMINAL_TAB_SWITCH_8, + ); + km.insert( + Some("terminal"), + cmd(KeyCode::Digit9), + TERMINAL_TAB_SWITCH_9, + ); // ── Terminal normal mode bindings (copy mode / virtual cursor) ────────── km.insert( @@ -821,12 +950,61 @@ pub fn builtin_defaults() -> ResolvedKeymap { km.insert(Some("terminal_normal"), ch('V'), ENTER_VISUAL_LINE); km.insert(Some("terminal_normal"), ph(KeyCode::KeyY), YANK_SELECTION); km.insert(Some("terminal_normal"), cmd(KeyCode::KeyV), TERMINAL_PASTE); - km.insert(Some("terminal_normal"), cmd(KeyCode::KeyT), TERMINAL_TAB_NEW); + km.insert( + Some("terminal_normal"), + cmd(KeyCode::KeyT), + TERMINAL_TAB_NEW, + ); km.insert( Some("terminal_normal"), cmd(KeyCode::KeyW), TERMINAL_TAB_CLOSE, ); + km.insert( + Some("terminal_normal"), + cmd(KeyCode::Digit1), + TERMINAL_TAB_SWITCH_1, + ); + km.insert( + Some("terminal_normal"), + cmd(KeyCode::Digit2), + TERMINAL_TAB_SWITCH_2, + ); + km.insert( + Some("terminal_normal"), + cmd(KeyCode::Digit3), + TERMINAL_TAB_SWITCH_3, + ); + km.insert( + Some("terminal_normal"), + cmd(KeyCode::Digit4), + TERMINAL_TAB_SWITCH_4, + ); + km.insert( + Some("terminal_normal"), + cmd(KeyCode::Digit5), + TERMINAL_TAB_SWITCH_5, + ); + km.insert( + Some("terminal_normal"), + cmd(KeyCode::Digit6), + TERMINAL_TAB_SWITCH_6, + ); + km.insert( + Some("terminal_normal"), + cmd(KeyCode::Digit7), + TERMINAL_TAB_SWITCH_7, + ); + km.insert( + Some("terminal_normal"), + cmd(KeyCode::Digit8), + TERMINAL_TAB_SWITCH_8, + ); + km.insert( + Some("terminal_normal"), + cmd(KeyCode::Digit9), + TERMINAL_TAB_SWITCH_9, + ); km.insert(Some("terminal_normal"), ch('/'), TERMINAL_SEARCH_OPEN); km.insert(Some("terminal_normal"), ch('n'), SEARCH_NEXT); km.insert(Some("terminal_normal"), ch('N'), SEARCH_PREV); @@ -1147,6 +1325,10 @@ mod tests { parse_key_spec("ArrowUp"), Some(KeySpec::Named(NamedKey::ArrowUp)) ); + assert_eq!(parse_key_spec("F2"), Some(KeySpec::Named(NamedKey::F2))); + assert_eq!(parse_key_spec("F5"), Some(KeySpec::Named(NamedKey::F5))); + assert_eq!(parse_key_spec("F11"), Some(KeySpec::Named(NamedKey::F11))); + assert_eq!(parse_key_spec("F12"), Some(KeySpec::Named(NamedKey::F12))); } #[test] @@ -1196,6 +1378,52 @@ mod tests { assert!(parse_key_spec("XF86AudioPlay").is_none()); } + #[test] + fn sequence_continuations_lists_exacts_groups_and_mode_priority() { + let mut km = ResolvedKeymap::new(); + // f -> file picker (global) + km.insert_sequence( + None, + vec![KeySpec::Leader, KeySpec::Physical(KeyCode::KeyF)], + "app.open_file_picker", + ); + // g d -> deeper chord => "g" is a group after + km.insert_sequence( + None, + vec![ + KeySpec::Leader, + KeySpec::Physical(KeyCode::KeyG), + KeySpec::Physical(KeyCode::KeyD), + ], + "lsp.goto_definition", + ); + // mode-specific override of f in visual mode + km.insert_sequence( + Some("visual"), + vec![KeySpec::Leader, KeySpec::Physical(KeyCode::KeyF)], + "editor.format_selection", + ); + + let prefix = vec![KeySpec::Leader]; + let normal = km.sequence_continuations(&prefix, "normal"); + let f = normal.iter().find(|c| c.key == "f").expect("f present"); + assert_eq!(f.command_id.as_deref(), Some("app.open_file_picker")); + let g = normal.iter().find(|c| c.key == "g").expect("g present"); + assert_eq!(g.command_id, None, "deeper chord shows as group"); + + let visual = km.sequence_continuations(&prefix, "visual"); + let f = visual.iter().find(|c| c.key == "f").expect("f present"); + assert_eq!( + f.command_id.as_deref(), + Some("editor.format_selection"), + "mode-specific exact wins over global" + ); + + // Non-matching prefix yields nothing. + let none = km.sequence_continuations(&[KeySpec::Physical(KeyCode::KeyZ)], "normal"); + assert!(none.is_empty()); + } + #[test] fn parse_character_key() { assert_eq!(parse_key_spec(":"), Some(KeySpec::Char(':'))); diff --git a/src/async_runtime/dart_env.rs b/src/async_runtime/dart_env.rs index de774054..77c558f6 100644 --- a/src/async_runtime/dart_env.rs +++ b/src/async_runtime/dart_env.rs @@ -1,5 +1,5 @@ -use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; #[derive(Debug, Clone, Serialize, Deserialize)] pub enum DartEnvKind { @@ -45,7 +45,8 @@ pub async fn scan_dart_environments(workspace_root: &Path) -> Vec { while let Some(Ok(entry)) = entries.next() { if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { let version_name = entry.file_name().to_string_lossy().to_string(); - let dart_bin = entry.path() + let dart_bin = entry + .path() .join("bin") .join("cache") .join("dart-sdk") diff --git a/src/async_runtime/flutter_device.rs b/src/async_runtime/flutter_device.rs index f7b8e4af..4da3f917 100644 --- a/src/async_runtime/flutter_device.rs +++ b/src/async_runtime/flutter_device.rs @@ -1,5 +1,5 @@ -use std::process::Stdio; use serde::{Deserialize, Serialize}; +use std::process::Stdio; use tokio::process::Command; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -25,7 +25,10 @@ fn resolve_sdk_dir() -> Option { } } if let Ok(home) = std::env::var("HOME") { - let p = std::path::Path::new(&home).join("Library").join("Android").join("sdk"); + let p = std::path::Path::new(&home) + .join("Library") + .join("Android") + .join("sdk"); if p.exists() { return Some(p); } @@ -40,7 +43,11 @@ fn resolve_adb_path() -> String { return p.to_string_lossy().to_string(); } } - for p in &["/opt/homebrew/bin/adb", "/usr/local/bin/adb", "/usr/bin/adb"] { + for p in &[ + "/opt/homebrew/bin/adb", + "/usr/local/bin/adb", + "/usr/bin/adb", + ] { if std::path::Path::new(p).exists() { return p.to_string().to_string(); } @@ -55,7 +62,11 @@ fn resolve_emulator_path() -> String { return p.to_string_lossy().to_string(); } } - for p in &["/opt/homebrew/bin/emulator", "/usr/local/bin/emulator", "/usr/bin/emulator"] { + for p in &[ + "/opt/homebrew/bin/emulator", + "/usr/local/bin/emulator", + "/usr/bin/emulator", + ] { if std::path::Path::new(p).exists() { return p.to_string().to_string(); } @@ -117,17 +128,25 @@ pub async fn scan_flutter_devices(flutter_path: Option) -> V let handle_flutter_devices = tokio::spawn(async move { let mut list = Vec::new(); eprintln!("[scan_flutter_devices] Spawning 'flutter devices --machine'..."); - let res = tokio::time::timeout(timeout_duration, make_flutter_command(&f1_cmd) - .args(&["devices", "--machine"]) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .output() - ).await; + let res = tokio::time::timeout( + timeout_duration, + make_flutter_command(&f1_cmd) + .args(&["devices", "--machine"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output(), + ) + .await; match res { Ok(Ok(output)) if output.status.success() => { - if let Ok(raw_devices) = serde_json::from_slice::>(&output.stdout) { - eprintln!("[scan_flutter_devices] 'flutter devices' returned {} devices", raw_devices.len()); + if let Ok(raw_devices) = + serde_json::from_slice::>(&output.stdout) + { + eprintln!( + "[scan_flutter_devices] 'flutter devices' returned {} devices", + raw_devices.len() + ); for dev in raw_devices { list.push(FlutterDevice { id: dev.id, @@ -142,10 +161,16 @@ pub async fn scan_flutter_devices(flutter_path: Option) -> V } } Ok(Ok(output)) => { - eprintln!("[scan_flutter_devices] 'flutter devices' failed with status: {:?}", output.status); + eprintln!( + "[scan_flutter_devices] 'flutter devices' failed with status: {:?}", + output.status + ); } Ok(Err(e)) => { - eprintln!("[scan_flutter_devices] Failed to run 'flutter devices': {:?}", e); + eprintln!( + "[scan_flutter_devices] Failed to run 'flutter devices': {:?}", + e + ); } Err(_) => { eprintln!("[scan_flutter_devices] 'flutter devices' timed out after 5s"); @@ -159,17 +184,25 @@ pub async fn scan_flutter_devices(flutter_path: Option) -> V let handle_flutter_emulators = tokio::spawn(async move { let mut list = Vec::new(); eprintln!("[scan_flutter_devices] Spawning 'flutter emulators --machine'..."); - let res = tokio::time::timeout(timeout_duration, make_flutter_command(&f2_cmd) - .args(&["emulators", "--machine"]) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .output() - ).await; + let res = tokio::time::timeout( + timeout_duration, + make_flutter_command(&f2_cmd) + .args(&["emulators", "--machine"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output(), + ) + .await; match res { Ok(Ok(output)) if output.status.success() => { - if let Ok(raw_emulators) = serde_json::from_slice::>(&output.stdout) { - eprintln!("[scan_flutter_devices] 'flutter emulators' returned {} emulators", raw_emulators.len()); + if let Ok(raw_emulators) = + serde_json::from_slice::>(&output.stdout) + { + eprintln!( + "[scan_flutter_devices] 'flutter emulators' returned {} emulators", + raw_emulators.len() + ); for emu in raw_emulators { list.push(FlutterDevice { id: emu.id, @@ -184,10 +217,16 @@ pub async fn scan_flutter_devices(flutter_path: Option) -> V } } Ok(Ok(output)) => { - eprintln!("[scan_flutter_devices] 'flutter emulators' failed with status: {:?}", output.status); + eprintln!( + "[scan_flutter_devices] 'flutter emulators' failed with status: {:?}", + output.status + ); } Ok(Err(e)) => { - eprintln!("[scan_flutter_devices] Failed to run 'flutter emulators': {:?}", e); + eprintln!( + "[scan_flutter_devices] Failed to run 'flutter emulators': {:?}", + e + ); } Err(_) => { eprintln!("[scan_flutter_devices] 'flutter emulators' timed out after 5s"); @@ -200,13 +239,16 @@ pub async fn scan_flutter_devices(flutter_path: Option) -> V let handle_simctl = tokio::spawn(async move { let mut list = Vec::new(); eprintln!("[scan_flutter_devices] Spawning 'xcrun simctl list devices --json'..."); - let res = tokio::time::timeout(timeout_duration, Command::new("xcrun") - .args(&["simctl", "list", "devices", "--json"]) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .output() - ).await; + let res = tokio::time::timeout( + timeout_duration, + Command::new("xcrun") + .args(&["simctl", "list", "devices", "--json"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output(), + ) + .await; match res { Ok(Ok(output)) if output.status.success() => { if let Ok(simctl_data) = serde_json::from_slice::(&output.stdout) { @@ -215,7 +257,9 @@ pub async fn scan_flutter_devices(flutter_path: Option) -> V for dev in devs { let is_avail = match &dev.is_available { serde_json::Value::Bool(b) => *b, - serde_json::Value::String(s) => s.to_lowercase() == "yes" || s.to_lowercase() == "true", + serde_json::Value::String(s) => { + s.to_lowercase() == "yes" || s.to_lowercase() == "true" + } _ => true, }; if is_avail { @@ -231,16 +275,25 @@ pub async fn scan_flutter_devices(flutter_path: Option) -> V } } } - eprintln!("[scan_flutter_devices] 'xcrun simctl' returned {} available simulators", count); + eprintln!( + "[scan_flutter_devices] 'xcrun simctl' returned {} available simulators", + count + ); } else { eprintln!("[scan_flutter_devices] Failed to parse 'xcrun simctl' JSON"); } } Ok(Ok(output)) => { - eprintln!("[scan_flutter_devices] 'xcrun simctl' failed with status: {:?}", output.status); + eprintln!( + "[scan_flutter_devices] 'xcrun simctl' failed with status: {:?}", + output.status + ); } Ok(Err(e)) => { - eprintln!("[scan_flutter_devices] Failed to run 'xcrun simctl': {:?}", e); + eprintln!( + "[scan_flutter_devices] Failed to run 'xcrun simctl': {:?}", + e + ); } Err(_) => { eprintln!("[scan_flutter_devices] 'xcrun simctl' timed out after 5s"); @@ -257,13 +310,16 @@ pub async fn scan_flutter_devices(flutter_path: Option) -> V let handle_emulator = tokio::spawn(async move { let mut list = Vec::new(); eprintln!("[scan_flutter_devices] Spawning '{} -list-avds'...", e_cmd); - let res = tokio::time::timeout(timeout_duration, Command::new(&e_cmd) - .arg("-list-avds") - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .output() - ).await; + let res = tokio::time::timeout( + timeout_duration, + Command::new(&e_cmd) + .arg("-list-avds") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output(), + ) + .await; match res { Ok(Ok(output)) if output.status.success() => { let stdout_str = String::from_utf8_lossy(&output.stdout); @@ -281,13 +337,22 @@ pub async fn scan_flutter_devices(flutter_path: Option) -> V }); } } - eprintln!("[scan_flutter_devices] Android emulator list returned {} AVDs", count); + eprintln!( + "[scan_flutter_devices] Android emulator list returned {} AVDs", + count + ); } Ok(Ok(output)) => { - eprintln!("[scan_flutter_devices] Android emulator list failed with status: {:?}", output.status); + eprintln!( + "[scan_flutter_devices] Android emulator list failed with status: {:?}", + output.status + ); } Ok(Err(e)) => { - eprintln!("[scan_flutter_devices] Failed to run Android emulator list: {:?}", e); + eprintln!( + "[scan_flutter_devices] Failed to run Android emulator list: {:?}", + e + ); } Err(_) => { eprintln!("[scan_flutter_devices] Android emulator list timed out after 5s"); @@ -301,13 +366,16 @@ pub async fn scan_flutter_devices(flutter_path: Option) -> V let handle_adb = tokio::spawn(async move { let mut list = Vec::new(); eprintln!("[scan_flutter_devices] Spawning '{} devices'...", a_cmd); - let res = tokio::time::timeout(timeout_duration, Command::new(&a_cmd) - .arg("devices") - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .output() - ).await; + let res = tokio::time::timeout( + timeout_duration, + Command::new(&a_cmd) + .arg("devices") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output(), + ) + .await; match res { Ok(Ok(output)) if output.status.success() => { let stdout_str = String::from_utf8_lossy(&output.stdout); @@ -326,10 +394,16 @@ pub async fn scan_flutter_devices(flutter_path: Option) -> V }); } } - eprintln!("[scan_flutter_devices] adb devices returned {} active devices", count); + eprintln!( + "[scan_flutter_devices] adb devices returned {} active devices", + count + ); } Ok(Ok(output)) => { - eprintln!("[scan_flutter_devices] adb devices failed with status: {:?}", output.status); + eprintln!( + "[scan_flutter_devices] adb devices failed with status: {:?}", + output.status + ); } Ok(Err(e)) => { eprintln!("[scan_flutter_devices] Failed to run adb devices: {:?}", e); @@ -352,26 +426,38 @@ pub async fn scan_flutter_devices(flutter_path: Option) -> V // Populate flutter devices and emulators for d in flutter_devs { - if !devices.iter().any(|existing: &FlutterDevice| existing.id == d.id) { + if !devices + .iter() + .any(|existing: &FlutterDevice| existing.id == d.id) + { devices.push(d); } } for d in flutter_emus { - if !devices.iter().any(|existing: &FlutterDevice| existing.id == d.id) { + if !devices + .iter() + .any(|existing: &FlutterDevice| existing.id == d.id) + { devices.push(d); } } // Populate iOS simulators for d in simctl_devs { - if !devices.iter().any(|existing: &FlutterDevice| existing.id == d.id) { + if !devices + .iter() + .any(|existing: &FlutterDevice| existing.id == d.id) + { devices.push(d); } } // Populate Android AVDs for d in avd_emus { - if !devices.iter().any(|existing: &FlutterDevice| existing.id == d.id) { + if !devices + .iter() + .any(|existing: &FlutterDevice| existing.id == d.id) + { devices.push(d); } } @@ -380,7 +466,9 @@ pub async fn scan_flutter_devices(flutter_path: Option) -> V for d in adb_devs { let mut found = false; for existing in &mut devices { - if existing.id == d.id || (d.id.starts_with("emulator-") && existing.platform == "android") { + if existing.id == d.id + || (d.id.starts_with("emulator-") && existing.platform == "android") + { existing.is_active = true; found = true; } @@ -390,7 +478,10 @@ pub async fn scan_flutter_devices(flutter_path: Option) -> V } } - eprintln!("[scan_flutter_devices] Scan completed. Found {} total devices", devices.len()); + eprintln!( + "[scan_flutter_devices] Scan completed. Found {} total devices", + devices.len() + ); devices } @@ -401,7 +492,10 @@ pub async fn launch_flutter_emulator( // 1. iOS Simulator boot via xcrun simctl let is_ios = emulator_id.contains('-') && emulator_id.len() >= 36; if is_ios { - eprintln!("[launch_flutter_emulator] Booting iOS Simulator: {}", emulator_id); + eprintln!( + "[launch_flutter_emulator] Booting iOS Simulator: {}", + emulator_id + ); let status = Command::new("xcrun") .args(&["simctl", "boot", emulator_id]) .stdin(Stdio::null()) @@ -463,7 +557,10 @@ pub async fn launch_flutter_emulator( } } - eprintln!("[launch_flutter_emulator] Falling back to native: {} -avd {}", emulator_cmd, emulator_id); + eprintln!( + "[launch_flutter_emulator] Falling back to native: {} -avd {}", + emulator_cmd, emulator_id + ); let spawn_result = Command::new(&emulator_cmd) .args(&["-avd", emulator_id]) .stdin(Stdio::null()) @@ -477,7 +574,10 @@ pub async fn launch_flutter_emulator( Ok(()) } Err(e) => { - eprintln!("[launch_flutter_emulator] Fallback emulator spawn failed: {:?}", e); + eprintln!( + "[launch_flutter_emulator] Fallback emulator spawn failed: {:?}", + e + ); Err(format!( "Both flutter emulator launch and fallback emulator launch failed. Fallback error: {}", e diff --git a/src/async_runtime/message.rs b/src/async_runtime/message.rs index b290a4f7..3ad4a621 100644 --- a/src/async_runtime/message.rs +++ b/src/async_runtime/message.rs @@ -89,7 +89,7 @@ pub struct FilePreviewLine { pub is_target: bool, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum FileSystemChangeKind { Create, Delete, @@ -97,13 +97,21 @@ pub enum FileSystemChangeKind { Rename, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct FileSystemEvent { pub kind: FileSystemChangeKind, pub path: PathBuf, pub new_path: Option, } +/// One externally changed file fetched by the worker for the UI to apply. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExternalFileRead { + pub path: PathBuf, + pub content: Option, + pub modified_time: Option, +} + /// Request từ UI/main thread sang worker. /// revision_id là bắt buộc để detect stale result. #[derive(Debug, Clone)] @@ -161,6 +169,9 @@ pub enum WorkerRequestPayload { }, StartFileWatch { root_path: PathBuf, + /// `true` cho workspace root (quét cả cây), `false` cho file mở ngoài root + /// (chỉ watch thư mục cha — tránh dựng recursive watcher nặng trên /tmp, /etc…). + recursive: bool, }, SpawnPtyShell { shell: Option, @@ -336,6 +347,7 @@ pub enum WorkerRequestPayload { api_key: Option, model: String, endpoint_kind: Option, + reasoning_effort: Option, prefix: String, suffix: String, language_id: Option, @@ -400,6 +412,19 @@ pub enum WorkerRequestPayload { source_path: PathBuf, target_path: PathBuf, }, + /// Read contents of externally changed files off the UI thread. + /// Phase 2 of the external-change pipeline: the main thread only decides + /// WHICH paths need reloading; the worker does the actual disk reads. + ReadExternalFiles { + paths: Vec, + }, + /// Re-scan the workspace tree off the UI thread (external create/delete/ + /// rename). The worker walks the tree and returns fresh nodes to swap in. + RescanWorkspace { + root_path: PathBuf, + ignore_rules: crate::workspace::model::WorkspaceIgnoreRules, + options: crate::workspace::scanner::WorkspaceScanOptions, + }, /// workspace/symbol request — index all symbols in the workspace. WorkspaceSymbolRequest { language_id: String, @@ -770,6 +795,16 @@ pub enum WorkerResultPayload { success: bool, error_message: Option, }, + /// Contents of externally changed files, read off the UI thread. + /// `content: None` means the read failed (deleted/permission/binary). + ExternalFilesRead { + files: Vec, + }, + /// Fresh workspace tree from an async rescan. + WorkspaceRescanned { + root_path: PathBuf, + nodes: Vec, + }, /// workspace/symbol response — all symbols in the workspace. WorkspaceSymbols { language_id: String, diff --git a/src/async_runtime/mod.rs b/src/async_runtime/mod.rs index 70d32b69..ddc6b466 100644 --- a/src/async_runtime/mod.rs +++ b/src/async_runtime/mod.rs @@ -1,5 +1,5 @@ +pub mod dart_env; +pub mod flutter_device; pub mod message; pub mod python_env; -pub mod dart_env; pub mod scheduler; -pub mod flutter_device; diff --git a/src/async_runtime/scheduler.rs b/src/async_runtime/scheduler.rs index 0196b649..10ad3a12 100644 --- a/src/async_runtime/scheduler.rs +++ b/src/async_runtime/scheduler.rs @@ -29,6 +29,7 @@ mod syntax_jobs; mod tests; pub use runtime::AsyncScheduler; +pub(crate) use syntax_jobs::resolve_system_path; /// When a file is below both thresholds the async worker highlights the full /// buffer (parse once, generate spans for everything). Above the thresholds diff --git a/src/async_runtime/scheduler/ai.rs b/src/async_runtime/scheduler/ai.rs index 2e9074ad..8c3f5597 100644 --- a/src/async_runtime/scheduler/ai.rs +++ b/src/async_runtime/scheduler/ai.rs @@ -18,6 +18,7 @@ pub(super) async fn execute_ai_inline_request( api_key, model, endpoint_kind, + reasoning_effort, prefix, suffix, language_id, @@ -56,7 +57,7 @@ pub(super) async fn execute_ai_inline_request( ); let stream_response = worker_tx.is_some() && endpoint_kind.as_deref() != Some("responses"); - let body = serde_json::json!({ + let mut body = serde_json::json!({ "model": model, "messages": [ {"role": "system", "content": system}, @@ -66,6 +67,12 @@ pub(super) async fn execute_ai_inline_request( "max_tokens": max_tokens, "stream": stream_response }); + if let Some(effort) = reasoning_effort + .as_ref() + .filter(|effort| !effort.is_empty()) + { + body["reasoning_effort"] = serde_json::Value::String(effort.clone()); + } let mut req = client.post(endpoint).json(&body); if let Some(key) = api_key.as_ref().filter(|key| !key.is_empty()) { diff --git a/src/async_runtime/scheduler/dispatch.rs b/src/async_runtime/scheduler/dispatch.rs index 28ed724a..c3f7c517 100644 --- a/src/async_runtime/scheduler/dispatch.rs +++ b/src/async_runtime/scheduler/dispatch.rs @@ -75,8 +75,9 @@ pub(super) async fn dispatch_loop( if matches!(request.payload, WorkerRequestPayload::StartFileWatch { .. }) { let worker_tx = result_tx.clone(); + let event_proxy = event_proxy.clone(); tokio::spawn(async move { - run_file_watch_request(request, worker_tx).await; + run_file_watch_request(request, worker_tx, event_proxy).await; }); continue; } @@ -114,6 +115,7 @@ pub(super) async fn dispatch_loop( | WorkerRequestPayload::LspFormattingRequest { .. } | WorkerRequestPayload::LspCompletionRequest { .. } | WorkerRequestPayload::LspCompletionResolveRequest { .. } + | WorkerRequestPayload::LspCompletionVirtualHoverRequest { .. } | WorkerRequestPayload::LspCodeActionRequest { .. } | WorkerRequestPayload::WorkspaceSymbolRequest { .. } | WorkerRequestPayload::StopLspServer @@ -391,7 +393,8 @@ pub(super) async fn dispatch_loop( let worker_tx = result_tx.clone(); let event_proxy = event_proxy.clone(); tokio::spawn(async move { - let devices = crate::async_runtime::flutter_device::scan_flutter_devices(flutter_path).await; + let devices = + crate::async_runtime::flutter_device::scan_flutter_devices(flutter_path).await; emit_message_and_wake( &worker_tx, &event_proxy, @@ -411,13 +414,20 @@ pub(super) async fn dispatch_loop( WorkerRequestPayload::LaunchFlutterEmulator { .. } ) { let (flutter_path, emulator_id) = match request.payload { - WorkerRequestPayload::LaunchFlutterEmulator { flutter_path, emulator_id } => (flutter_path, emulator_id), + WorkerRequestPayload::LaunchFlutterEmulator { + flutter_path, + emulator_id, + } => (flutter_path, emulator_id), _ => unreachable!(), }; let worker_tx = result_tx.clone(); let event_proxy = event_proxy.clone(); tokio::spawn(async move { - let _ = crate::async_runtime::flutter_device::launch_flutter_emulator(flutter_path, &emulator_id).await; + let _ = crate::async_runtime::flutter_device::launch_flutter_emulator( + flutter_path, + &emulator_id, + ) + .await; emit_message_and_wake( &worker_tx, &event_proxy, @@ -476,6 +486,80 @@ pub(super) async fn dispatch_loop( continue; } + if matches!( + request.payload, + WorkerRequestPayload::ReadExternalFiles { .. } + ) { + let WorkerRequestPayload::ReadExternalFiles { paths } = request.payload else { + unreachable!() + }; + let worker_tx = result_tx.clone(); + let event_proxy = event_proxy.clone(); + tokio::spawn(async move { + let mut files = Vec::with_capacity(paths.len()); + for path in paths { + let content = tokio::fs::read_to_string(&path).await.ok(); + let modified_time = tokio::fs::metadata(&path) + .await + .ok() + .and_then(|metadata| metadata.modified().ok()); + files.push(crate::async_runtime::message::ExternalFileRead { + path, + content, + modified_time, + }); + } + emit_message_and_wake( + &worker_tx, + &event_proxy, + WorkerMessage::Result(WorkerResult { + request_id: request.request_id, + revision_id: request.revision_id, + topic: request.topic, + payload: WorkerResultPayload::ExternalFilesRead { files }, + }), + ); + }); + continue; + } + + if matches!( + request.payload, + WorkerRequestPayload::RescanWorkspace { .. } + ) { + let WorkerRequestPayload::RescanWorkspace { + root_path, + ignore_rules, + options, + } = request.payload + else { + unreachable!() + }; + let worker_tx = result_tx.clone(); + let event_proxy = event_proxy.clone(); + // Tree walk là blocking I/O thuần — spawn_blocking thay vì async task. + tokio::task::spawn_blocking(move || { + let scanner = + crate::workspace::scanner::WorkspaceScanner::new(ignore_rules, options); + // Lỗi scan (root tạm mất, permission) -> bỏ qua kết quả thay vì + // swap cây rỗng vào và xoá sạch explorer. + let Ok(nodes) = scanner.scan(&root_path) else { + return; + }; + emit_message_and_wake( + &worker_tx, + &event_proxy, + WorkerMessage::Result(WorkerResult { + request_id: request.request_id, + revision_id: request.revision_id, + topic: request.topic, + payload: WorkerResultPayload::WorkspaceRescanned { root_path, nodes }, + }), + ); + }); + continue; + } + if matches!(request.payload, WorkerRequestPayload::CopyFile { .. }) { let (source_path, target_path) = match request.payload { WorkerRequestPayload::CopyFile { diff --git a/src/async_runtime/scheduler/file_watch.rs b/src/async_runtime/scheduler/file_watch.rs index e0480b86..e3dc53c7 100644 --- a/src/async_runtime/scheduler/file_watch.rs +++ b/src/async_runtime/scheduler/file_watch.rs @@ -1,9 +1,16 @@ -use std::{path::PathBuf, sync::mpsc as std_mpsc}; +use std::{ + collections::HashSet, + path::PathBuf, + sync::mpsc as std_mpsc, + time::{Duration, Instant}, +}; use notify::{ Event as NotifyEvent, EventKind as NotifyEventKind, RecursiveMode, Watcher, event::ModifyKind, }; +use winit::event_loop::EventLoopProxy; +use crate::app::event_loop::AppEvent; use crate::async_runtime::message::{ FileSystemChangeKind, FileSystemEvent, WorkerEvent, WorkerEventKind, WorkerFailure, WorkerFailureKind, WorkerMessage, WorkerRequest, WorkerRequestPayload, WorkerResult, @@ -13,12 +20,25 @@ use crate::workspace::model::WorkspaceIgnoreRules; use super::{ FILE_WATCH_BATCH_WINDOW, async_trace, - emit::{emit_message, failure_from_join_error}, + emit::{emit_message, emit_message_and_wake, failure_from_join_error}, }; +/// Sau số lần restart này, báo cho UI biết watcher đang degraded (toast). +/// Watcher KHÔNG bỏ cuộc — mất live-update tree-level là mất vĩnh viễn khả năng +/// thấy file mới do agent/IDE khác tạo, trong khi poll 3s chỉ cứu buffer đang mở. +const FILE_WATCH_DEGRADED_THRESHOLD: u32 = 5; +/// Backoff giữa các lần restart watcher: 2s, 4s, 8s, 16s, rồi cap 30s. +pub(super) fn file_watch_restart_backoff(restarts: u32) -> Duration { + Duration::from_secs((1u64 << restarts.min(5)).clamp(2, 30)) +} +/// Watcher chạy ổn định ít nhất chừng này thì coi như lần chết kế tiếp là sự cố +/// mới, reset backoff về đầu thay vì leo tiếp lên cap. +const FILE_WATCH_STABLE_RUN: Duration = Duration::from_secs(60); + pub(super) async fn run_file_watch_request( request: WorkerRequest, worker_tx: std_mpsc::Sender, + event_proxy: EventLoopProxy, ) { emit_message( &worker_tx, @@ -35,79 +55,102 @@ pub(super) async fn run_file_watch_request( request.revision_id ); - let watcher_request = request.clone(); - let watcher_tx = worker_tx.clone(); - let worker_handle = - tokio::task::spawn_blocking(move || execute_file_watch_loop(&watcher_request, &watcher_tx)); + // #4: Watcher có thể chết giữa chừng (channel disconnect, FSEvents reset). + // Tự dựng lại với backoff lũy tiến, KHÔNG bao giờ bỏ cuộc; qua ngưỡng degraded + // thì emit Failed một lần duy nhất để UI toast cho user biết. + let mut restarts = 0u32; + let mut degraded_notified = false; + loop { + let watcher_request = request.clone(); + let watcher_tx = worker_tx.clone(); + let watcher_proxy = event_proxy.clone(); + let run_started = Instant::now(); + let worker_handle = tokio::task::spawn_blocking(move || { + execute_file_watch_loop(&watcher_request, &watcher_tx, &watcher_proxy) + }); - match worker_handle.await { - Ok(Ok(())) => { - emit_message( - &worker_tx, - WorkerMessage::Event(WorkerEvent { - request_id: request.request_id, - revision_id: request.revision_id, - topic: request.topic, - kind: WorkerEventKind::Completed, - }), - ); - async_trace!( - "[Worker] completed watcher request_id={} revision={}", - request.request_id, - request.revision_id - ); - } - Ok(Err(message)) => { - emit_message( - &worker_tx, - WorkerMessage::Event(WorkerEvent { - request_id: request.request_id, - revision_id: request.revision_id, - topic: request.topic, - kind: WorkerEventKind::Failed { - error: WorkerFailure { - kind: WorkerFailureKind::Execution, - message, - }, - }, - }), - ); - async_trace!( - "[Worker] file watcher failed request_id={} revision={}", - request.request_id, - request.revision_id - ); + let failure = match worker_handle.await { + Ok(Ok(())) => { + emit_message( + &worker_tx, + WorkerMessage::Event(WorkerEvent { + request_id: request.request_id, + revision_id: request.revision_id, + topic: request.topic, + kind: WorkerEventKind::Completed, + }), + ); + async_trace!( + "[Worker] completed watcher request_id={} revision={}", + request.request_id, + request.revision_id + ); + return; + } + Ok(Err(message)) => { + async_trace!( + "[Worker] file watcher loop failed request_id={} attempt={} err={}", + request.request_id, + restarts, + message + ); + WorkerFailure { + kind: WorkerFailureKind::Execution, + message: format!("file watcher degraded (restart #{restarts}): {message}"), + } + } + Err(join_error) => { + async_trace!( + "[Worker] file watcher panicked/cancelled request_id={} attempt={}", + request.request_id, + restarts + ); + failure_from_join_error(join_error) + } + }; + + if run_started.elapsed() >= FILE_WATCH_STABLE_RUN { + restarts = 0; } - Err(join_error) => { - emit_message( + restarts += 1; + + if restarts >= FILE_WATCH_DEGRADED_THRESHOLD && !degraded_notified { + degraded_notified = true; + emit_message_and_wake( &worker_tx, + &event_proxy, WorkerMessage::Event(WorkerEvent { request_id: request.request_id, revision_id: request.revision_id, topic: request.topic, - kind: WorkerEventKind::Failed { - error: failure_from_join_error(join_error), - }, + kind: WorkerEventKind::Failed { error: failure }, }), ); - async_trace!( - "[Worker] file watcher failed (panic/cancelled) request_id={} revision={}", - request.request_id, - request.revision_id - ); } + + tokio::time::sleep(file_watch_restart_backoff(restarts)).await; } } fn execute_file_watch_loop( request: &WorkerRequest, worker_tx: &std_mpsc::Sender, + event_proxy: &EventLoopProxy, ) -> Result<(), String> { - let WorkerRequestPayload::StartFileWatch { root_path } = &request.payload else { + let WorkerRequestPayload::StartFileWatch { + root_path, + recursive, + } = &request.payload + else { return Err("file watch loop received non-watch payload".to_string()); }; let root_path = root_path.clone(); + let recursive_mode = if *recursive { + RecursiveMode::Recursive + } else { + RecursiveMode::NonRecursive + }; let ignore_rules = WorkspaceIgnoreRules::default(); let (notify_tx, notify_rx) = std_mpsc::channel::>(); @@ -117,7 +160,7 @@ fn execute_file_watch_loop( .map_err(|err| format!("create file watcher failed: {err}"))?; watcher - .watch(&root_path, RecursiveMode::Recursive) + .watch(&root_path, recursive_mode) .map_err(|err| format!("watch {:?} failed: {err}", root_path))?; async_trace!( @@ -130,8 +173,12 @@ fn execute_file_watch_loop( match notify_rx.recv() { Ok(Ok(event)) => { let mut events = Vec::new(); - extend_unique_file_events( + // HashSet dedup: một đợt git checkout/agent sửa hàng loạt có thể + // dồn hàng nghìn event vào một batch — Vec::contains là O(n²). + let mut seen = HashSet::new(); + extend_unique_file_events_with_seen( &mut events, + &mut seen, filter_file_watch_events(event, &ignore_rules), ); @@ -139,8 +186,9 @@ fn execute_file_watch_loop( loop { match notify_rx.recv_timeout(FILE_WATCH_BATCH_WINDOW) { Ok(Ok(event)) => { - extend_unique_file_events( + extend_unique_file_events_with_seen( &mut events, + &mut seen, filter_file_watch_events(event, &ignore_rules), ); } @@ -154,8 +202,12 @@ fn execute_file_watch_loop( } if !events.is_empty() { - emit_message( + // #1: PHẢI wake event loop — nếu không, FileSystemEvents nằm im + // trong channel cho tới khi có event khác (gõ phím/chuột) đánh thức + // ControlFlow::Wait. Đây là nguyên nhân "IDE không update theo gì cả". + emit_message_and_wake( worker_tx, + event_proxy, WorkerMessage::Result(WorkerResult { request_id: request.request_id, revision_id: request.revision_id, @@ -194,12 +246,22 @@ fn filter_file_watch_events( .collect() } +#[cfg(test)] pub(super) fn extend_unique_file_events( target: &mut Vec, incoming: impl IntoIterator, +) { + let mut seen: HashSet = target.iter().cloned().collect(); + extend_unique_file_events_with_seen(target, &mut seen, incoming); +} + +fn extend_unique_file_events_with_seen( + target: &mut Vec, + seen: &mut HashSet, + incoming: impl IntoIterator, ) { for event in incoming { - if !target.contains(&event) { + if seen.insert(event.clone()) { target.push(event); } } diff --git a/src/async_runtime/scheduler/lsp_parse.rs b/src/async_runtime/scheduler/lsp_parse.rs index 2c4badf2..ea43c830 100644 --- a/src/async_runtime/scheduler/lsp_parse.rs +++ b/src/async_runtime/scheduler/lsp_parse.rs @@ -818,7 +818,10 @@ pub(super) fn handle_lsp_references( LSP_REFERENCES_TIMEOUT_SECS, )?; if let Some(error) = response.get("error") { - let msg = error.get("message").and_then(|m| m.as_str()).unwrap_or("unknown error"); + let msg = error + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("unknown error"); let code = error.get("code").and_then(|c| c.as_i64()).unwrap_or(0); return Err(format!("references: LSP error {code}: {msg}")); } @@ -1453,7 +1456,10 @@ pub(super) fn handle_workspace_symbol( )?; if let Some(error) = response.get("error") { - let msg = error.get("message").and_then(|m| m.as_str()).unwrap_or("unknown error"); + let msg = error + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("unknown error"); let code = error.get("code").and_then(|c| c.as_i64()).unwrap_or(0); return Err(format!("workspace/symbol: LSP error {code}: {msg}")); } diff --git a/src/async_runtime/scheduler/syntax_jobs.rs b/src/async_runtime/scheduler/syntax_jobs.rs index 77711d6e..912e263a 100644 --- a/src/async_runtime/scheduler/syntax_jobs.rs +++ b/src/async_runtime/scheduler/syntax_jobs.rs @@ -251,6 +251,7 @@ pub(super) async fn execute_virtual_job( "rust-analyzer", "typescript-language-server", "gopls", + "dart", "pylsp", "jdtls", "sqls", @@ -465,7 +466,7 @@ fn cpu_burn_checksum(busy_millis: u64) -> u64 { } /// Xây dựng $PATH đầy đủ cho GUI bundle — bổ sung các thư mục mà app GUI thường thiếu. -pub(super) fn resolve_system_path() -> String { +pub(crate) fn resolve_system_path() -> String { let current = std::env::var("PATH").unwrap_or_default(); let home = std::env::var("HOME").unwrap_or_default(); @@ -479,7 +480,38 @@ pub(super) fn resolve_system_path() -> String { "/sbin", ]; - let home_extras = [format!("{home}/.cargo/bin"), format!("{home}/.local/bin")]; + let mut home_extras = Vec::new(); + push_unique_path(&mut home_extras, format!("{home}/.cargo/bin")); + let gvm_root = std::env::var("GVM_ROOT").unwrap_or_else(|_| format!("{home}/.gvm")); + for path in crate::lsp::client::resolve_gvm_paths(&gvm_root) { + push_unique_path(&mut home_extras, path); + } + push_unique_path(&mut home_extras, format!("{home}/go/bin")); + // nvm has no "current" symlink by default — scan installed versions and add + // the newest ones so node-based language servers are detected. + for path in resolve_nvm_bin_paths(&home) { + push_unique_path(&mut home_extras, path); + } + push_unique_path(&mut home_extras, format!("{home}/.npm-global/bin")); + push_unique_path(&mut home_extras, format!("{home}/.jenv/shims")); + push_unique_path(&mut home_extras, format!("{home}/.jenv/bin")); + push_unique_path(&mut home_extras, format!("{home}/.pyenv/shims")); + push_unique_path(&mut home_extras, format!("{home}/.pyenv/bin")); + push_unique_path(&mut home_extras, format!("{home}/.rbenv/shims")); + push_unique_path(&mut home_extras, format!("{home}/.rbenv/bin")); + push_unique_path(&mut home_extras, format!("{home}/.volta/bin")); + push_unique_path(&mut home_extras, format!("{home}/.asdf/shims")); + push_unique_path(&mut home_extras, format!("{home}/.bun/bin")); + push_unique_path(&mut home_extras, format!("{home}/.deno/bin")); + // Dart/Flutter: pub global executables + FVM-managed SDK + push_unique_path(&mut home_extras, format!("{home}/.pub-cache/bin")); + push_unique_path(&mut home_extras, format!("{home}/fvm/default/bin")); + push_unique_path(&mut home_extras, format!("{home}/.fvm/default/bin")); + push_unique_path( + &mut home_extras, + format!("{home}/.local/share/netherize/bin"), + ); + push_unique_path(&mut home_extras, format!("{home}/.local/bin")); let mut dirs: Vec<&str> = current.split(':').filter(|s| !s.is_empty()).collect(); for extra in extras { @@ -495,6 +527,48 @@ pub(super) fn resolve_system_path() -> String { dirs.join(":") } +/// Collect bin dirs of installed nvm node versions, newest first (capped to 3). +/// Standard nvm has no stable "current" symlink, so enumerate ~/.nvm/versions/node/*. +fn resolve_nvm_bin_paths(home: &str) -> Vec { + let versions_root = std::path::Path::new(home).join(".nvm/versions/node"); + let Ok(entries) = std::fs::read_dir(&versions_root) else { + return Vec::new(); + }; + let mut versions: Vec = entries + .filter_map(|entry| entry.ok()) + .filter(|entry| entry.path().join("bin").is_dir()) + .filter_map(|entry| entry.file_name().into_string().ok()) + .collect(); + // Sort semver-ish descending: v20.11.1 > v18.19.0 + versions.sort_by(|a, b| { + let parse = |v: &str| -> Vec { + v.trim_start_matches('v') + .split('.') + .filter_map(|part| part.parse::().ok()) + .collect() + }; + parse(b).cmp(&parse(a)) + }); + versions + .into_iter() + .take(3) + .map(|version| { + versions_root + .join(version) + .join("bin") + .to_string_lossy() + .into_owned() + }) + .collect() +} + +fn push_unique_path(paths: &mut Vec, path: String) { + if path.is_empty() || paths.iter().any(|existing| existing == &path) { + return; + } + paths.push(path); +} + /// Cài đặt từng tool một, gửi progress message về main thread sau mỗi bước. pub(super) async fn run_extension_command( binary: String, diff --git a/src/async_runtime/scheduler/tests.rs b/src/async_runtime/scheduler/tests.rs index bd4d4244..a9e0c1ca 100644 --- a/src/async_runtime/scheduler/tests.rs +++ b/src/async_runtime/scheduler/tests.rs @@ -5,7 +5,9 @@ use notify::{Event as NotifyEvent, EventKind as NotifyEventKind, event::ModifyKi use crate::async_runtime::{ message::{FileSystemChangeKind, FileSystemEvent}, scheduler::{ - file_watch::{extend_unique_file_events, normalize_notify_event}, + file_watch::{ + extend_unique_file_events, file_watch_restart_backoff, normalize_notify_event, + }, fzf::{build_file_preview_lines, build_fzf_find_file_script, build_fzf_live_grep_script}, git::parse_git_blame_summary, runtime::build_worker_runtime, @@ -71,6 +73,18 @@ fn extend_unique_file_events_deduplicates_burst_entries() { assert_eq!(target, vec![event]); } +#[test] +fn file_watch_restart_backoff_grows_exponentially_and_caps_at_30s() { + use std::time::Duration; + + assert_eq!(file_watch_restart_backoff(1), Duration::from_secs(2)); + assert_eq!(file_watch_restart_backoff(2), Duration::from_secs(4)); + assert_eq!(file_watch_restart_backoff(3), Duration::from_secs(8)); + assert_eq!(file_watch_restart_backoff(4), Duration::from_secs(16)); + assert_eq!(file_watch_restart_backoff(5), Duration::from_secs(30)); + assert_eq!(file_watch_restart_backoff(100), Duration::from_secs(30)); +} + #[test] fn worker_runtime_enables_io_for_tokio_process() { let runtime = build_worker_runtime().expect("worker runtime"); diff --git a/src/config/ai_config.rs b/src/config/ai_config.rs index 45ff54f9..a8de6305 100644 --- a/src/config/ai_config.rs +++ b/src/config/ai_config.rs @@ -30,6 +30,11 @@ pub struct AiProviderConfig { pub model: String, pub api_key: Option, pub endpoint_kind: Option, + /// Sent as `reasoning_effort` in the request body when present. Reasoning + /// models burn the token budget on thinking and return empty content for + /// inline completion; "low" keeps them usable when no non-reasoning model + /// is available. + pub reasoning_effort: Option, } impl AiConfig { @@ -137,6 +142,7 @@ fn candidate_paths() -> Vec { if let Ok(cwd) = std::env::current_dir() { paths.push(cwd.join("config").join("ai.toml")); } + paths.push(user_config_root().join("config").join("ai.toml")); paths.push(user_config_root().join("ai.toml")); paths } diff --git a/src/config/keymap_loader.rs b/src/config/keymap_loader.rs index 52ceadba..eea2e4d2 100644 --- a/src/config/keymap_loader.rs +++ b/src/config/keymap_loader.rs @@ -152,6 +152,24 @@ fn load_validated_file(path: &Path) -> Option> { mod tests { use super::*; + #[test] + fn default_keymap_has_no_unknown_commands() { + let path = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("config/keymaps/default.toml"); + let content = std::fs::read_to_string(&path).expect("read default keymap"); + let file: KeymapFile = toml::from_str(&content).expect("parse default keymap"); + let unknown: Vec = file + .bindings + .iter() + .filter(|b| !command_ids::is_valid(&b.command)) + .map(|b| format!("{} (key='{}')", b.command, b.key)) + .collect(); + assert!( + unknown.is_empty(), + "default.toml references unknown command ids (their keys are silently dead): {unknown:?}" + ); + } + #[test] fn invalid_toml_returns_none() { let dir = std::env::temp_dir(); diff --git a/src/config/theme_config/loader.rs b/src/config/theme_config/loader.rs index ebff00bc..2d7d63a1 100644 --- a/src/config/theme_config/loader.rs +++ b/src/config/theme_config/loader.rs @@ -642,12 +642,16 @@ fn parse_syntax(raw: &RawSyntax) -> Result { keyword_control: parse_color( "syntax", "keyword_control", - raw.keyword_control.as_deref().unwrap_or(raw.keyword.as_str()), + raw.keyword_control + .as_deref() + .unwrap_or(raw.keyword.as_str()), )?, keyword_storage: parse_color( "syntax", "keyword_storage", - raw.keyword_storage.as_deref().unwrap_or(raw.keyword.as_str()), + raw.keyword_storage + .as_deref() + .unwrap_or(raw.keyword.as_str()), )?, string: parse_color("syntax", "string", &raw.string)?, string_escape: parse_color( @@ -663,7 +667,9 @@ fn parse_syntax(raw: &RawSyntax) -> Result { function_builtin: parse_color( "syntax", "function_builtin", - raw.function_builtin.as_deref().unwrap_or(raw.function.as_str()), + raw.function_builtin + .as_deref() + .unwrap_or(raw.function.as_str()), )?, comment: parse_color("syntax", "comment", &raw.comment)?, comment_doc: parse_color( @@ -803,7 +809,9 @@ fn parse_syntax(raw: &RawSyntax) -> Result { markup_inline_code: parse_color( "syntax", "markup_inline_code", - raw.markup_inline_code.as_deref().unwrap_or(raw.string.as_str()), + raw.markup_inline_code + .as_deref() + .unwrap_or(raw.string.as_str()), )?, markup_link: parse_color( "syntax", @@ -912,24 +920,64 @@ fn parse_icons(raw: &RawIcons, ui: &UiThemeTokens) -> Result Result Result Result Result Option<&'static str> { return Some(icon); } - if filename == "dockerfile" || filename.starts_with("dockerfile.") || filename.ends_with(".dockerfile") { + if filename == "dockerfile" + || filename.starts_with("dockerfile.") + || filename.ends_with(".dockerfile") + { return Some("built_in:docker"); } if filename == ".dockerignore" { return Some("built_in:dockerignore"); } - if matches!(filename, "docker-compose.yml" | "docker-compose.yaml" | "compose.yml" | "compose.yaml") { + if matches!( + filename, + "docker-compose.yml" | "docker-compose.yaml" | "compose.yml" | "compose.yaml" + ) { return Some("built_in:docker"); } @@ -403,7 +409,10 @@ fn special_icon_for_filename(filename: &str) -> Option<&'static str> { if filename.starts_with("vitest.config.") { return Some("built_in:vitest"); } - if filename.starts_with("jest.config.") || filename == ".jestrc" || filename.starts_with(".jestrc.") { + if filename.starts_with("jest.config.") + || filename == ".jestrc" + || filename.starts_with(".jestrc.") + { return Some("built_in:jest"); } if filename.starts_with("cypress.config.ts") { @@ -428,16 +437,25 @@ fn special_icon_for_filename(filename: &str) -> Option<&'static str> { if filename == ".eslintignore" { return Some("built_in:eslintignore"); } - if filename.starts_with("eslint.config.") || filename == ".eslintrc" || filename.starts_with(".eslintrc.") { + if filename.starts_with("eslint.config.") + || filename == ".eslintrc" + || filename.starts_with(".eslintrc.") + { return Some("built_in:eslint"); } if filename == ".prettierignore" { return Some("built_in:prettierignore"); } - if filename.starts_with("prettier.config.") || filename == ".prettierrc" || filename.starts_with(".prettierrc.") { + if filename.starts_with("prettier.config.") + || filename == ".prettierrc" + || filename.starts_with(".prettierrc.") + { return Some("built_in:prettier"); } - if filename.starts_with("babel.config.") || filename == ".babelrc" || filename.starts_with(".babelrc.") { + if filename.starts_with("babel.config.") + || filename == ".babelrc" + || filename.starts_with(".babelrc.") + { return Some("built_in:babel"); } if filename.starts_with("webpack.config.") { @@ -906,28 +924,88 @@ mod tests { assert_eq!(theme.get_icon_for_file("Dockerfile", false), "🐳"); assert_eq!(theme.get_icon_for_file("README.md", false), "📘"); assert_eq!(theme.get_icon_for_file("schema.SQL", false), "🗄️"); - assert_eq!(theme.get_icon_for_file("user.service.ts", false), "built_in:nestjsservice"); - assert_eq!(theme.get_icon_for_file("user-service.ts", false), "built_in:nestjsservice"); - assert_eq!(theme.get_icon_for_file("auth.service", false), "built_in:nestjsservice"); - assert_eq!(theme.get_icon_for_file("auth-service", false), "built_in:nestjsservice"); - assert_eq!(theme.get_icon_for_file("user.controller.ts", false), "built_in:nestjscontroller"); - assert_eq!(theme.get_icon_for_file("auth-module.ts", false), "built_in:nestjsmodule"); - assert_eq!(theme.get_icon_for_file("create-user.dto.ts", false), "built_in:nestjsdto"); - assert_eq!(theme.get_icon_for_file("jwt.guard.ts", false), "built_in:nestjsguard"); - assert_eq!(theme.get_icon_for_file("user.test.ts", false), "built_in:testts"); - assert_eq!(theme.get_icon_for_file("button.stories.tsx", false), "built_in:storybook"); + assert_eq!( + theme.get_icon_for_file("user.service.ts", false), + "built_in:nestjsservice" + ); + assert_eq!( + theme.get_icon_for_file("user-service.ts", false), + "built_in:nestjsservice" + ); + assert_eq!( + theme.get_icon_for_file("auth.service", false), + "built_in:nestjsservice" + ); + assert_eq!( + theme.get_icon_for_file("auth-service", false), + "built_in:nestjsservice" + ); + assert_eq!( + theme.get_icon_for_file("user.controller.ts", false), + "built_in:nestjscontroller" + ); + assert_eq!( + theme.get_icon_for_file("auth-module.ts", false), + "built_in:nestjsmodule" + ); + assert_eq!( + theme.get_icon_for_file("create-user.dto.ts", false), + "built_in:nestjsdto" + ); + assert_eq!( + theme.get_icon_for_file("jwt.guard.ts", false), + "built_in:nestjsguard" + ); + assert_eq!( + theme.get_icon_for_file("user.test.ts", false), + "built_in:testts" + ); + assert_eq!( + theme.get_icon_for_file("button.stories.tsx", false), + "built_in:storybook" + ); assert_eq!(theme.get_icon_for_file("Dockerfile", false), "🐳"); - assert_eq!(theme.get_icon_for_file("Dockerfile.dev", false), "built_in:docker"); - assert_eq!(theme.get_icon_for_file("src/Dockerfile", false), "built_in:docker"); - assert_eq!(theme.get_icon_for_file("pnpm-lock.yaml", false), "built_in:pnpmlock"); - assert_eq!(theme.get_icon_for_file("vite.config.ts", false), "built_in:vite"); - assert_eq!(theme.get_icon_for_file(".eslintrc.json", false), "built_in:eslint"); - assert_eq!(theme.get_icon_for_file("schema.prisma", false), "built_in:prisma"); + assert_eq!( + theme.get_icon_for_file("Dockerfile.dev", false), + "built_in:docker" + ); + assert_eq!( + theme.get_icon_for_file("src/Dockerfile", false), + "built_in:docker" + ); + assert_eq!( + theme.get_icon_for_file("pnpm-lock.yaml", false), + "built_in:pnpmlock" + ); + assert_eq!( + theme.get_icon_for_file("vite.config.ts", false), + "built_in:vite" + ); + assert_eq!( + theme.get_icon_for_file(".eslintrc.json", false), + "built_in:eslint" + ); + assert_eq!( + theme.get_icon_for_file("schema.prisma", false), + "built_in:prisma" + ); assert_eq!(theme.get_icon_for_file("README.md", false), "📘"); - assert_eq!(theme.get_icon_for_file("LICENSE", false), "built_in:license"); - assert_eq!(theme.get_icon_for_file("index.ts", false), "built_in:typescript"); - assert_eq!(theme.get_icon_for_file("slot-random-service.js", false), "built_in:nestjsservice"); + assert_eq!( + theme.get_icon_for_file("LICENSE", false), + "built_in:license" + ); + assert_eq!( + theme.get_icon_for_file("index.ts", false), + "built_in:typescript" + ); + assert_eq!( + theme.get_icon_for_file("slot-random-service.js", false), + "built_in:nestjsservice" + ); assert_eq!(theme.get_icon_for_file("notes.md", false), "📝"); - assert_eq!(theme.get_icon_for_file("unknown.bin", false), "built_in:file"); + assert_eq!( + theme.get_icon_for_file("unknown.bin", false), + "built_in:file" + ); } } diff --git a/src/config/ui_config.rs b/src/config/ui_config.rs index 3b1f4168..b5a326a7 100644 --- a/src/config/ui_config.rs +++ b/src/config/ui_config.rs @@ -58,7 +58,7 @@ pub struct WindowUiConfig { pub auto_scale: bool, pub min_content_scale: f32, pub max_content_scale: f32, - // pub scale_factor_override: Option, + pub scale_factor_override: Option, } #[derive(Debug, Clone, Copy)] @@ -194,7 +194,7 @@ impl UiConfig { auto_scale: true, min_content_scale: 1.0, max_content_scale: 1.0, - // scale_factor_override: None, + scale_factor_override: None, }, layout: WorkbenchLayoutConfig { outer_gap: 14.0, @@ -304,11 +304,11 @@ impl UiConfig { .max_content_scale .unwrap_or(fallback.window.max_content_scale), )?, - // scale_factor_override: if let Some(val) = raw.window.scale_factor_override { - // Some(parse_positive_f32("window", "scale_factor_override", val)?) - // } else { - // None - // }, + scale_factor_override: if let Some(val) = raw.window.scale_factor_override { + Some(parse_positive_f32("window", "scale_factor_override", val)?) + } else { + None + }, }, layout: WorkbenchLayoutConfig { outer_gap: parse_non_negative_f32( @@ -629,9 +629,9 @@ impl UiConfig { if raw.editor.font_family.is_some() { self.editor.font_family = raw.editor.font_family.clone(); } - // if let Some(over) = raw.window.scale_factor_override { - // self.window.scale_factor_override = Some(over.max(0.1)); - // } + if let Some(over) = raw.window.scale_factor_override { + self.window.scale_factor_override = Some(over.max(0.1)); + } if let Some(auto) = raw.window.auto_scale { self.window.auto_scale = auto; } @@ -745,6 +745,11 @@ fn find_profile_path(name: &str) -> Option { } } + let user_path = user_config_root().join("config").join("ui").join(&filename); + if user_path.exists() { + return Some(user_path); + } + if let Ok(exe) = std::env::current_exe() && let Some(parent) = exe.parent() { @@ -790,7 +795,7 @@ struct RawWindow { auto_scale: Option, min_content_scale: Option, max_content_scale: Option, - // scale_factor_override: Option, + scale_factor_override: Option, } #[derive(Debug, Default, Deserialize)] @@ -889,7 +894,8 @@ struct UserUiWindow { auto_scale: bool, min_content_scale: f32, max_content_scale: f32, - // scale_factor_override: Option, + #[serde(skip_serializing_if = "Option::is_none")] + scale_factor_override: Option, } #[derive(Debug, Serialize)] @@ -928,7 +934,7 @@ impl From<&UiConfig> for UserUiConfigFile { auto_scale: value.window.auto_scale, min_content_scale: value.window.min_content_scale, max_content_scale: value.window.max_content_scale, - // scale_factor_override: value.window.scale_factor_override, + scale_factor_override: value.window.scale_factor_override, }, docks: UserUiDocks { left_visible: value.docks.left.visible, diff --git a/src/core/command_dispatch/common.rs b/src/core/command_dispatch/common.rs index 5bc24695..4582041f 100644 --- a/src/core/command_dispatch/common.rs +++ b/src/core/command_dispatch/common.rs @@ -73,13 +73,28 @@ impl<'state, 'clipboard, 'terminal> DispatchCtx<'state, 'clipboard, 'terminal> { } pub(super) fn open_file(&mut self, path: PathBuf) -> DispatchReport { + // Mở sang file khác là một jump (vim :e semantics) -> ghi lại vị trí + // xuất phát để Ctrl+O quay về được. Mở lại chính file đang active thì bỏ qua. + let origin = self + .app_state + .active_file() + .filter(|active| *active != path.as_path()) + .map(|active| { + let (line, col) = self.app_state.cursor_line_col(); + (active.to_path_buf(), line, col) + }); match self.app_state.open_file(path.clone()) { - Ok(()) => DispatchReport { - message: format!("Dispatch: open trigger succeeded -> {}", path.display()), - request_redraw: true, - success: true, - state_changed: true, - }, + Ok(()) => { + if let Some((origin_path, line, col)) = origin { + self.app_state.push_jump_entry(origin_path, line, col); + } + DispatchReport { + message: format!("Dispatch: open trigger succeeded -> {}", path.display()), + request_redraw: true, + success: true, + state_changed: true, + } + } Err(err) => DispatchReport::failure(format!("Dispatch: open trigger failed -> {err}")), } } diff --git a/src/core/command_dispatch/editing.rs b/src/core/command_dispatch/editing.rs index 76c75cca..6ce2639f 100644 --- a/src/core/command_dispatch/editing.rs +++ b/src/core/command_dispatch/editing.rs @@ -151,10 +151,7 @@ pub(super) fn dispatch(ctx: &mut DispatchCtx<'_, '_, '_>, command: Command) -> D // Check if in VisualBlock mode first if ctx.app_state.current_mode() == EditorMode::VisualBlock { let changed = ctx.app_state.visual_block_insert_before(); - return DispatchReport::success( - "Dispatch: visual block insert before", - changed, - ); + return DispatchReport::success("Dispatch: visual block insert before", changed); } match ctx.enter_insert_mode_if_needed() { Ok(mode_changed) => { @@ -169,19 +166,16 @@ pub(super) fn dispatch(ctx: &mut DispatchCtx<'_, '_, '_>, command: Command) -> D changed, ) } - Err(err) => { - DispatchReport::failure(format!("Dispatch: insert at line start rejected ({err})")) - } + Err(err) => DispatchReport::failure(format!( + "Dispatch: insert at line start rejected ({err})" + )), } - }, + } Command::AppendAtLineEnd => { // Check if in VisualBlock mode first if ctx.app_state.current_mode() == EditorMode::VisualBlock { let changed = ctx.app_state.visual_block_append_after(); - return DispatchReport::success( - "Dispatch: visual block append after", - changed, - ); + return DispatchReport::success("Dispatch: visual block append after", changed); } match ctx.enter_insert_mode_if_needed() { Ok(mode_changed) => { @@ -196,11 +190,11 @@ pub(super) fn dispatch(ctx: &mut DispatchCtx<'_, '_, '_>, command: Command) -> D changed, ) } - Err(err) => { - DispatchReport::failure(format!("Dispatch: append at line end rejected ({err})")) - } + Err(err) => DispatchReport::failure(format!( + "Dispatch: append at line end rejected ({err})" + )), } - }, + } Command::AppendAfterCursor => match ctx.enter_insert_mode_if_needed() { Ok(mode_changed) => { let cursor_changed = ctx.app_state.append_after_cursor(); @@ -398,7 +392,8 @@ pub(super) fn dispatch(ctx: &mut DispatchCtx<'_, '_, '_>, command: Command) -> D let changed = ctx.app_state.visual_block_change(); return DispatchReport::success( if changed { - "Dispatch: changed visual block selection and entered multi-insert".to_string() + "Dispatch: changed visual block selection and entered multi-insert" + .to_string() } else { "Dispatch: visual block change ignored".to_string() }, diff --git a/src/core/command_dispatch/mod.rs b/src/core/command_dispatch/mod.rs index 9e71f6c7..b6d2a3d3 100644 --- a/src/core/command_dispatch/mod.rs +++ b/src/core/command_dispatch/mod.rs @@ -269,6 +269,7 @@ fn dispatch_command_with_clipboard_once( | Command::MoveToLastLine | Command::MoveParagraphUp | Command::MoveParagraphDown + | Command::MoveFindChar(..) | Command::ScrollHalfPageUp | Command::ScrollHalfPageDown | Command::CenterCursorLine @@ -360,6 +361,8 @@ fn dispatch_command_with_clipboard_once( | Command::TerminalPaste | Command::TerminalScrollUp | Command::TerminalScrollDown + | Command::TerminalScrollHalfPageUp + | Command::TerminalScrollHalfPageDown | Command::TerminalTabNew | Command::TerminalTabClose | Command::CloseSidebars diff --git a/src/core/command_dispatch/navigation.rs b/src/core/command_dispatch/navigation.rs index 3dc498cd..e3fafd2b 100644 --- a/src/core/command_dispatch/navigation.rs +++ b/src/core/command_dispatch/navigation.rs @@ -5,6 +5,25 @@ use crate::core::{ use super::common::{DispatchCtx, DispatchReport}; +/// Chụp lại (file, line, col) hiện tại trước một jump-motion (gg/G/{}/n/N). +fn jump_origin(ctx: &DispatchCtx<'_, '_, '_>) -> Option<(std::path::PathBuf, usize, usize)> { + ctx.app_state.active_file().map(|active| { + let (line, col) = ctx.app_state.cursor_line_col(); + (active.to_path_buf(), line, col) + }) +} + +/// Push origin vào jump stack nếu motion thực sự di chuyển cursor (vim jumplist). +fn record_jump_if_moved( + ctx: &mut DispatchCtx<'_, '_, '_>, + origin: Option<(std::path::PathBuf, usize, usize)>, + moved: bool, +) { + if moved && let Some((path, line, col)) = origin { + ctx.app_state.push_jump_entry(path, line, col); + } +} + pub(super) fn dispatch(ctx: &mut DispatchCtx<'_, '_, '_>, command: Command) -> DispatchReport { if let Some(report) = dispatch_terminal_normal(ctx, &command) { return report; @@ -130,19 +149,27 @@ pub(super) fn dispatch(ctx: &mut DispatchCtx<'_, '_, '_>, command: Command) -> D ) } Command::MoveToFirstLine => { + let origin = jump_origin(ctx); let changed = ctx.app_state.move_to_first_line(); + record_jump_if_moved(ctx, origin, changed); DispatchReport::success("Dispatch: move to first line", changed) } Command::MoveToLastLine => { + let origin = jump_origin(ctx); let changed = ctx.app_state.move_to_last_line(); + record_jump_if_moved(ctx, origin, changed); DispatchReport::success("Dispatch: move to last line", changed) } Command::MoveParagraphUp => { + let origin = jump_origin(ctx); let changed = ctx.app_state.move_paragraph_up(); + record_jump_if_moved(ctx, origin, changed); DispatchReport::success("Dispatch: move paragraph up", changed) } Command::MoveParagraphDown => { + let origin = jump_origin(ctx); let changed = ctx.app_state.move_paragraph_down(); + record_jump_if_moved(ctx, origin, changed); DispatchReport::success("Dispatch: move paragraph down", changed) } Command::ScrollHalfPageUp | Command::ScrollHalfPageDown | Command::CenterCursorLine => { @@ -160,8 +187,21 @@ pub(super) fn dispatch(ctx: &mut DispatchCtx<'_, '_, '_>, command: Command) -> D true, false, ), + Command::MoveFindChar(kind, target) => { + let changed = ctx.app_state.find_char_motion_and_highlight(kind, target); + DispatchReport::success( + if changed { + format!("Dispatch: find char '{target}' ({kind:?})") + } else { + format!("Dispatch: find char '{target}' not found on line") + }, + changed, + ) + } Command::SearchNext => { + let origin = jump_origin(ctx); let changed = ctx.app_state.search_next(); + record_jump_if_moved(ctx, origin, changed); DispatchReport::success( if changed { "Dispatch: search next".to_string() @@ -172,7 +212,9 @@ pub(super) fn dispatch(ctx: &mut DispatchCtx<'_, '_, '_>, command: Command) -> D ) } Command::SearchPrev => { + let origin = jump_origin(ctx); let changed = ctx.app_state.search_prev(); + record_jump_if_moved(ctx, origin, changed); DispatchReport::success( if changed { "Dispatch: search previous".to_string() diff --git a/src/core/command_dispatch/palette.rs b/src/core/command_dispatch/palette.rs index 474c6623..40cbb602 100644 --- a/src/core/command_dispatch/palette.rs +++ b/src/core/command_dispatch/palette.rs @@ -170,11 +170,17 @@ pub(super) fn dispatch(ctx: &mut DispatchCtx<'_, '_, '_>, command: Command) -> D }, Command::ToggleLiveGrepCaseSensitive => { let changed = ctx.app_state.toggle_live_grep_case_sensitive(); - DispatchReport::success("Dispatch: toggled live grep case sensitivity".to_string(), changed) + DispatchReport::success( + "Dispatch: toggled live grep case sensitivity".to_string(), + changed, + ) } Command::ToggleInFileSearchCaseSensitive => { let changed = ctx.app_state.toggle_search_case_sensitive(); - DispatchReport::success("Dispatch: toggled in-file search case sensitivity".to_string(), changed) + DispatchReport::success( + "Dispatch: toggled in-file search case sensitivity".to_string(), + changed, + ) } Command::FilePickerBackspaceQuery => match ctx.app_state.file_picker_backspace_query() { Ok(changed) => DispatchReport::success( @@ -377,7 +383,14 @@ fn confirm_selection(ctx: &mut DispatchCtx<'_, '_, '_>) -> DispatchReport { ctx.app_state.command_palette_mode(), Some(CommandPaletteMode::InFileSearch) ) { + let origin = ctx.app_state.active_file().map(|active| { + let (line, col) = ctx.app_state.cursor_line_col(); + (active.to_path_buf(), line, col) + }); let moved = ctx.app_state.search_next(); + if moved && let Some((origin_path, line, col)) = origin { + ctx.app_state.push_jump_entry(origin_path, line, col); + } let changed = moved || ctx.close_palette_and_exit_focus(); return DispatchReport::success_with_flags( if moved { @@ -437,6 +450,12 @@ fn confirm_selection(ctx: &mut DispatchCtx<'_, '_, '_>) -> DispatchReport { open_report } CommandPaletteAction::OpenSearchMatch { path, line, column } => { + // Ghi vị trí xuất phát để Ctrl+O quay về (kể cả khi match cùng file — + // ctx.open_file chỉ push khi đổi file; push trùng được dedup). + let origin = ctx.app_state.active_file().map(|active| { + let (origin_line, origin_col) = ctx.app_state.cursor_line_col(); + (active.to_path_buf(), origin_line, origin_col) + }); let changed = ctx.close_palette_and_exit_focus(); let mut open_report = ctx.open_file(path.clone()); if !open_report.success { @@ -444,6 +463,10 @@ fn confirm_selection(ctx: &mut DispatchCtx<'_, '_, '_>) -> DispatchReport { return open_report; } + if let Some((origin_path, origin_line, origin_col)) = origin { + ctx.app_state + .push_jump_entry(origin_path, origin_line, origin_col); + } let jumped = ctx.app_state.jump_to_line_and_column( line.saturating_sub(1) as usize, column.saturating_sub(1) as usize, diff --git a/src/core/command_dispatch/session.rs b/src/core/command_dispatch/session.rs index ad3abd92..d2cb526e 100644 --- a/src/core/command_dispatch/session.rs +++ b/src/core/command_dispatch/session.rs @@ -131,6 +131,8 @@ pub(super) fn dispatch(ctx: &mut DispatchCtx<'_, '_, '_>, command: Command) -> D | Command::PrevPanelTab | Command::TerminalScrollUp | Command::TerminalScrollDown + | Command::TerminalScrollHalfPageUp + | Command::TerminalScrollHalfPageDown | Command::TerminalTabNew | Command::TerminalTabClose | Command::SwitchTerminalTab(_) diff --git a/src/core/command_dispatch/tests.rs b/src/core/command_dispatch/tests.rs index 4e71594d..0fb7b133 100644 --- a/src/core/command_dispatch/tests.rs +++ b/src/core/command_dispatch/tests.rs @@ -645,6 +645,154 @@ fn operate_delete_find_forward_includes_target_char() { assert_eq!(app_state.text_string(), "ef ghi"); } +#[test] +fn move_find_char_jumps_to_char_and_highlights_all_matches() { + let mut app_state = AppState::from_text(unique_temp_path("move_find_char"), "abc abc\nabc"); + let _ = dispatch_command(&mut app_state, Command::SwitchMode(ModeEvent::EnterNormal)); + + let report = dispatch_command( + &mut app_state, + Command::MoveFindChar(FindMotionKind::ForwardTo, 'b'), + ); + + assert!(report.success); + assert_eq!(app_state.cursor_line_col(), (0, 1)); + assert_eq!(app_state.last_search_query(), "b"); + assert_eq!(app_state.search_highlights().len(), 3); + + // n (SearchNext) nhảy tới match kế tiếp — kể cả ở dòng khác. + let _ = dispatch_command(&mut app_state, Command::SearchNext); + assert_eq!(app_state.cursor_line_col(), (0, 5)); + let _ = dispatch_command(&mut app_state, Command::SearchNext); + assert_eq!(app_state.cursor_line_col(), (1, 1)); +} + +#[test] +fn move_find_char_till_stops_before_target() { + let mut app_state = AppState::from_text(unique_temp_path("move_till_char"), "abc def"); + let _ = dispatch_command(&mut app_state, Command::SwitchMode(ModeEvent::EnterNormal)); + + let report = dispatch_command( + &mut app_state, + Command::MoveFindChar(FindMotionKind::ForwardTill, 'd'), + ); + + assert!(report.success); + assert_eq!(app_state.cursor_line_col(), (0, 3)); +} + +#[test] +fn move_find_char_backward_jumps_to_previous_occurrence() { + let mut app_state = AppState::from_text(unique_temp_path("move_find_back"), "abc abc"); + let _ = dispatch_command(&mut app_state, Command::SwitchMode(ModeEvent::EnterNormal)); + let _ = dispatch_command(&mut app_state, Command::MoveToLineEnd); + + let report = dispatch_command( + &mut app_state, + Command::MoveFindChar(FindMotionKind::BackwardTo, 'a'), + ); + + assert!(report.success); + assert_eq!(app_state.cursor_line_col(), (0, 4)); +} + +#[test] +fn move_find_char_missing_target_keeps_cursor_but_still_highlights() { + let mut app_state = AppState::from_text(unique_temp_path("move_find_miss"), "abc\nxyz"); + let _ = dispatch_command(&mut app_state, Command::SwitchMode(ModeEvent::EnterNormal)); + + // 'x' không có trên dòng 1 -> cursor đứng yên (Vim), nhưng highlight vẫn set + // để n nhảy tới occurrence ở dòng khác. + let _ = dispatch_command( + &mut app_state, + Command::MoveFindChar(FindMotionKind::ForwardTo, 'x'), + ); + assert_eq!(app_state.cursor_line_col(), (0, 0)); + assert_eq!(app_state.search_highlights().len(), 1); + + let _ = dispatch_command(&mut app_state, Command::SearchNext); + assert_eq!(app_state.cursor_line_col(), (1, 0)); +} + +#[test] +fn yank_to_line_end_id_parses_to_yank_operate() { + let command = crate::core::command_ids::parse("editor.yank_to_line_end", None); + assert_eq!( + command, + Some(Command::Operate { + op: Operator::Yank, + target: OperationTarget::Motion(Motion::LineEnd), + }) + ); +} + +#[test] +fn move_to_last_line_pushes_jump_origin() { + let path = unique_temp_path("jump_origin_g"); + std::fs::write(&path, "one\ntwo\nthree").expect("write temp file"); + let mut app_state = AppState::from_text(path.clone(), ""); + let _ = dispatch_command(&mut app_state, Command::OpenFile(path.clone())); + let canonical = app_state.active_file().expect("file opened").to_path_buf(); + let _ = dispatch_command(&mut app_state, Command::SwitchMode(ModeEvent::EnterNormal)); + + let _ = dispatch_command(&mut app_state, Command::MoveToLastLine); + assert_eq!(app_state.cursor_line_col(), (2, 0)); + + let back = app_state.pop_jump_back(); + assert_eq!(back, Some((canonical, 0, 0))); + let _ = std::fs::remove_file(path); +} + +#[test] +fn search_next_pushes_jump_origin_for_ctrl_o() { + let path = unique_temp_path("jump_origin_n"); + std::fs::write(&path, "alpha\nbeta\nalpha").expect("write temp file"); + let mut app_state = AppState::from_text(path.clone(), ""); + let _ = dispatch_command(&mut app_state, Command::OpenFile(path.clone())); + let canonical = app_state.active_file().expect("file opened").to_path_buf(); + let _ = dispatch_command(&mut app_state, Command::SwitchMode(ModeEvent::EnterNormal)); + + // * đặt query + nhảy tới match kế tiếp (đã push origin từ trước). + let _ = dispatch_command(&mut app_state, Command::SearchWordUnderCursor); + let after_star = app_state.cursor_line_col(); + assert_eq!(after_star, (2, 0)); + + // n tiếp tục: phải push origin (line 2) để Ctrl+O quay về. + let _ = dispatch_command(&mut app_state, Command::SearchNext); + let back = app_state.pop_jump_back(); + assert_eq!(back, Some((canonical, 2, 0))); + let _ = std::fs::remove_file(path); +} + +#[test] +fn jump_stack_dedups_same_line_and_caps_at_100() { + let path = unique_temp_path("jump_stack_cap"); + let mut app_state = AppState::from_text(path.clone(), "x"); + + // Dedup: cùng (file, line) chỉ giữ một entry, cột được cập nhật. + app_state.push_jump_entry(path.clone(), 5, 1); + app_state.push_jump_entry(path.clone(), 5, 9); + assert_eq!(app_state.pop_jump_back(), Some((path.clone(), 5, 9))); + assert!(app_state.pop_jump_back().is_none()); + + // Cap: 150 entry khác nhau -> chỉ giữ 100 entry mới nhất. + for line in 0..150 { + app_state.push_jump_entry(path.clone(), line, 0); + } + let mut count = 0; + let mut last = None; + while let Some(entry) = app_state.pop_jump_back() { + last = Some(entry); + count += 1; + // pop_jump_back đẩy current pos sang forward stack, không ảnh hưởng back stack. + if count > 200 { + panic!("jump stack not capped"); + } + } + assert_eq!(count, 100); + assert_eq!(last, Some((path, 50, 0))); +} + #[test] fn change_to_line_end_alias_enters_insert_and_removes_suffix() { let mut app_state = AppState::from_text(unique_temp_path("change_to_eol"), "alpha beta"); diff --git a/src/core/command_ids.rs b/src/core/command_ids.rs index d19b5c5b..428b0d9d 100644 --- a/src/core/command_ids.rs +++ b/src/core/command_ids.rs @@ -48,6 +48,7 @@ pub const CHANGE_SELECTION: &str = "editor.change_selection"; pub const CHANGE_WORD_FORWARD: &str = "editor.change_word_forward"; pub const CHANGE_WORD_BACKWARD: &str = "editor.change_word_backward"; pub const CHANGE_TO_LINE_END: &str = "editor.change_to_line_end"; +pub const YANK_TO_LINE_END: &str = "editor.yank_to_line_end"; pub const JOIN_LINES: &str = "editor.join_lines"; pub const PASTE_AFTER: &str = "editor.paste_after"; pub const PASTE_BEFORE: &str = "editor.paste_before"; @@ -488,6 +489,18 @@ pub const ALL_IDS: &[&str] = &[ TOGGLE_MAXIMIZE_FOCUS, TOGGLE_FOLD, TOGGLE_FOLD_ALL, + NEW_INSTANCE, + ENTER_VISUAL_BLOCK, + CHANGE_TO_LINE_END, + DELETE_TO_LINE_END, + YANK_TO_LINE_END, + JOIN_LINES, + MOVE_PARAGRAPH_UP, + MOVE_PARAGRAPH_DOWN, + AI_CHAT_STOP, + OPEN_HELP, + MARKDOWN_PREVIEW_SCROLL_TOP, + MARKDOWN_PREVIEW_SCROLL_BOTTOM, FOCUS_DAP, DAP_TOGGLE_EXPAND, DEBUG_START, @@ -504,8 +517,12 @@ pub const ALL_IDS: &[&str] = &[ FLUTTER_HOT_RESTART, ]; +/// A command id is valid if it is registered in ALL_IDS or resolvable by +/// `parse` — the fallback keeps keymap validation in sync with the dispatcher +/// so an id added to `parse` but forgotten here no longer silently disables +/// its keybinding. pub fn is_valid(id: &str) -> bool { - ALL_IDS.contains(&id) + ALL_IDS.contains(&id) || parse(id, None).is_some() } pub fn parse(id: &str, open_file_path: Option<&std::path::Path>) -> Option { @@ -555,6 +572,12 @@ pub fn parse(id: &str, open_file_path: Option<&std::path::Path>) -> Option Some(Command::ChangeWordForward), CHANGE_WORD_BACKWARD => Some(Command::ChangeWordBackward), CHANGE_TO_LINE_END => Some(Command::ChangeToLineEnd), + YANK_TO_LINE_END => Some(Command::Operate { + op: crate::core::commands::Operator::Yank, + target: crate::core::commands::OperationTarget::Motion( + crate::core::commands::Motion::LineEnd, + ), + }), JOIN_LINES => Some(Command::JoinLines), PASTE_AFTER => Some(Command::PasteAfter), PASTE_BEFORE => Some(Command::PasteBefore), diff --git a/src/core/commands.rs b/src/core/commands.rs index 16bd9a27..1a0fec03 100644 --- a/src/core/commands.rs +++ b/src/core/commands.rs @@ -111,6 +111,9 @@ pub enum Command { MoveToLastLine, MoveParagraphUp, MoveParagraphDown, + /// Vim f/F/t/T motion: nhảy tới ký tự trên dòng hiện tại, đồng thời set + /// search highlight cho ký tự đó để n/N nhảy tiếp giữa các match. + MoveFindChar(FindMotionKind, char), ScrollHalfPageUp, ScrollHalfPageDown, CenterCursorLine, @@ -217,6 +220,10 @@ pub enum Command { TerminalScrollUp, /// Scroll terminal viewport down (towards live output). TerminalScrollDown, + /// Scroll terminal viewport up half a page (Ctrl+U in the right chat). + TerminalScrollHalfPageUp, + /// Scroll terminal viewport down half a page (Ctrl+D in the right chat). + TerminalScrollHalfPageDown, /// Create a new terminal tab in the bottom panel. TerminalTabNew, /// Close the current terminal tab in the bottom panel. @@ -486,6 +493,7 @@ impl Command { | Self::MoveToLastLine | Self::MoveParagraphUp | Self::MoveParagraphDown + | Self::MoveFindChar(..) | Self::ScrollHalfPageUp | Self::ScrollHalfPageDown | Self::CenterCursorLine @@ -561,6 +569,8 @@ impl Command { | Self::ExplorerExpandOrChild | Self::TerminalScrollUp | Self::TerminalScrollDown + | Self::TerminalScrollHalfPageUp + | Self::TerminalScrollHalfPageDown | Self::MarkdownPreviewScrollUp | Self::MarkdownPreviewScrollDown | Self::MarkdownPreviewScrollHalfPageUp diff --git a/src/dap/client.rs b/src/dap/client.rs index 6c2d4b4c..bc13a682 100644 --- a/src/dap/client.rs +++ b/src/dap/client.rs @@ -7,7 +7,7 @@ use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt, BufReader}; use tokio::process::{Child, ChildStdin, Command}; use tokio::sync::{mpsc, oneshot}; -use crate::dap::types::{Request, Response, Event}; +use crate::dap::types::{Event, Request, Response}; pub struct DapClient { _child: Child, @@ -33,7 +33,10 @@ impl DapClient { event_tx: mpsc::UnboundedSender, env_vars: Option>, ) -> Result, std::io::Error> { - eprintln!("[DAP LOG] [DAP Client] DapClient::launch called for program: {}, args: {:?}", program, args); + eprintln!( + "[DAP LOG] [DAP Client] DapClient::launch called for program: {}, args: {:?}", + program, args + ); let path_buf = std::path::Path::new(program); let exists = if path_buf.is_absolute() || path_buf.components().count() > 1 { path_buf.exists() @@ -41,7 +44,12 @@ impl DapClient { let mut found = false; for dir in std::env::split_paths(&path) { let joined = dir.join(program); - eprintln!("[DAP LOG] exists check: program={}, checking joined={:?}, exists={}", program, joined, joined.exists()); + eprintln!( + "[DAP LOG] exists check: program={}, checking joined={:?}, exists={}", + program, + joined, + joined.exists() + ); if joined.exists() { found = true; break; @@ -54,7 +62,10 @@ impl DapClient { if !exists { return Err(std::io::Error::new( std::io::ErrorKind::NotFound, - format!("Binary '{}' not found in PATH or at specified path", program), + format!( + "Binary '{}' not found in PATH or at specified path", + program + ), )); } @@ -83,7 +94,8 @@ impl DapClient { std::io::Error::new(std::io::ErrorKind::Other, "Failed to capture stdout") })?; - let pending_requests: Arc>>> = Arc::new(Mutex::new(HashMap::new())); + let pending_requests: Arc>>> = + Arc::new(Mutex::new(HashMap::new())); let pending_requests_clone = pending_requests.clone(); // Spawn background stdout reader thread @@ -225,7 +237,10 @@ async fn read_message( reader.read_exact(buffer).await?; let json_str = String::from_utf8(buffer.clone()).map_err(|e| { - std::io::Error::new(std::io::ErrorKind::InvalidData, format!("Invalid UTF-8: {}", e)) + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Invalid UTF-8: {}", e), + ) })?; Ok(Some(json_str)) @@ -279,17 +294,24 @@ mod tests { #[tokio::test] async fn read_message_parses_multiple_frames_sequentially() { let json1 = r#"{"seq":1,"type":"event","event":"initialized"}"#; - let json2 = r#"{"seq":2,"type":"response","request_seq":0,"success":true,"command":"initialize"}"#; + let json2 = + r#"{"seq":2,"type":"response","request_seq":0,"success":true,"command":"initialize"}"#; let mut data = make_dap_message(json1); data.extend_from_slice(&make_dap_message(json2)); let mut reader = BufReader::new(&data[..]); let mut buffer = Vec::new(); - let r1 = read_message(&mut reader, &mut buffer).await.unwrap().unwrap(); + let r1 = read_message(&mut reader, &mut buffer) + .await + .unwrap() + .unwrap(); assert_eq!(r1, json1); - let r2 = read_message(&mut reader, &mut buffer).await.unwrap().unwrap(); + let r2 = read_message(&mut reader, &mut buffer) + .await + .unwrap() + .unwrap(); assert_eq!(r2, json2); } @@ -318,17 +340,24 @@ mod tests { #[tokio::test] async fn read_message_reuses_buffer_across_calls() { let json1 = r#"{"short":true}"#; - let json2 = r#"{"a_much_longer_field_name":"with a longer value that exceeds the first buffer"}"#; + let json2 = + r#"{"a_much_longer_field_name":"with a longer value that exceeds the first buffer"}"#; let mut data = make_dap_message(json1); data.extend_from_slice(&make_dap_message(json2)); let mut reader = BufReader::new(&data[..]); let mut buffer = Vec::new(); - let r1 = read_message(&mut reader, &mut buffer).await.unwrap().unwrap(); + let r1 = read_message(&mut reader, &mut buffer) + .await + .unwrap() + .unwrap(); assert_eq!(r1, json1); - let r2 = read_message(&mut reader, &mut buffer).await.unwrap().unwrap(); + let r2 = read_message(&mut reader, &mut buffer) + .await + .unwrap() + .unwrap(); assert_eq!(r2, json2); // Buffer should have been resized for the larger message assert!(buffer.len() >= json2.len()); diff --git a/src/dap/launch_config.rs b/src/dap/launch_config.rs index 3a3564a0..6d8c2b61 100644 --- a/src/dap/launch_config.rs +++ b/src/dap/launch_config.rs @@ -1,5 +1,5 @@ -use std::path::{Path, PathBuf}; use serde::Deserialize; +use std::path::{Path, PathBuf}; #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -54,7 +54,11 @@ impl LaunchConfiguration { let program = self.program.clone().unwrap_or_default(); let cwd = self.cwd.as_ref().map(|c| { let p = PathBuf::from(c); - if p.is_absolute() { p } else { workspace_root.join(p) } + if p.is_absolute() { + p + } else { + workspace_root.join(p) + } }); ResolvedLaunchConfig { name: self.name.clone(), diff --git a/src/dap/mod.rs b/src/dap/mod.rs index b04d4748..1664ed11 100644 --- a/src/dap/mod.rs +++ b/src/dap/mod.rs @@ -1,8 +1,8 @@ pub mod client; -pub mod types; -pub mod session; pub mod launch_config; +pub mod session; +pub mod types; pub use client::DapClient; -pub use types::{Breakpoint, Event, Request, Response, StackFrame, Thread, Variable}; pub use session::DapSession; +pub use types::{Breakpoint, Event, Request, Response, StackFrame, Thread, Variable}; diff --git a/src/dap/session.rs b/src/dap/session.rs index b6419c98..7b8db82b 100644 --- a/src/dap/session.rs +++ b/src/dap/session.rs @@ -1,9 +1,12 @@ +use serde_json::json; use std::path::Path; use std::sync::{Arc, Mutex}; -use serde_json::json; use crate::dap::client::DapClient; -use crate::dap::types::{Breakpoint as DapBreakpoint, StackFrame as DapStackFrame, Variable as DapVariable, Scope as DapScope, SourceBreakpoint}; +use crate::dap::types::{ + Breakpoint as DapBreakpoint, Scope as DapScope, SourceBreakpoint, StackFrame as DapStackFrame, + Variable as DapVariable, +}; use crate::workbench::debug_state::{ Breakpoint as EditorBreakpoint, DebugSharedState, DebugVariable, SourceLocation, StackFrame as EditorStackFrame, @@ -49,11 +52,13 @@ impl DapSession { self.client.send_request("launch", Some(args)).await?; // Standard configurationDone to let the adapter know initialization is finished self.client.send_request("configurationDone", None).await?; - + let mut state_guard = self.state.lock().await; state_guard.paused = false; - state_guard.console_messages.push("Flutter application launching...".to_string()); - + state_guard + .console_messages + .push("Flutter application launching...".to_string()); + Ok(()) } @@ -75,10 +80,15 @@ impl DapSession { "breakpoints": breakpoints }); - let resp = self.client.send_request("setBreakpoints", Some(args)).await?; + let resp = self + .client + .send_request("setBreakpoints", Some(args)) + .await?; if resp.success { if let Some(body) = resp.body { - if let Ok(dap_bps) = serde_json::from_value::>(body["breakpoints"].clone()) { + if let Ok(dap_bps) = + serde_json::from_value::>(body["breakpoints"].clone()) + { let mut state_guard = self.state.lock().await; state_guard.breakpoints.clear(); for (i, dap_bp) in dap_bps.into_iter().enumerate() { @@ -91,7 +101,9 @@ impl DapSession { column: 0, }, enabled: true, - path: path.canonicalize().unwrap_or_else(|_| path.to_path_buf()), + path: path + .canonicalize() + .unwrap_or_else(|_| path.to_path_buf()), }); } } @@ -146,7 +158,9 @@ impl DapSession { let mut state_guard = self.state.lock().await; state_guard.paused = true; state_guard.execution_location = None; - state_guard.console_messages.push("Debugger session stopped.".to_string()); + state_guard + .console_messages + .push("Debugger session stopped.".to_string()); Ok(()) } @@ -154,9 +168,13 @@ impl DapSession { let args = json!({ "request": "hotReload" }); - self.client.send_request("customRequest", Some(args)).await?; + self.client + .send_request("customRequest", Some(args)) + .await?; let mut state_guard = self.state.lock().await; - state_guard.console_messages.push("⚡ Hot Reload triggered...".to_string()); + state_guard + .console_messages + .push("⚡ Hot Reload triggered...".to_string()); Ok(()) } @@ -164,16 +182,24 @@ impl DapSession { let args = json!({ "request": "hotRestart" }); - self.client.send_request("customRequest", Some(args)).await?; + self.client + .send_request("customRequest", Some(args)) + .await?; let mut state_guard = self.state.lock().await; - state_guard.console_messages.push("↻ Hot Restart triggered...".to_string()); + state_guard + .console_messages + .push("↻ Hot Restart triggered...".to_string()); Ok(()) } pub async fn evaluate_watch_expressions(&self) -> Result<(), String> { let expressions: Vec = { let state_guard = self.state.lock().await; - state_guard.watch.iter().map(|w| w.expression.clone()).collect() + state_guard + .watch + .iter() + .map(|w| w.expression.clone()) + .collect() }; let mut results = Vec::new(); @@ -185,7 +211,8 @@ impl DapSession { if let Ok(resp) = self.client.send_request("evaluate", Some(args)).await { if resp.success { if let Some(body) = resp.body { - let value = body.get("result") + let value = body + .get("result") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); @@ -217,8 +244,12 @@ impl DapSession { let mut current_loc = None; let mut top_frame_id = None; - if resp.success && let Some(body) = resp.body { - if let Ok(frames) = serde_json::from_value::>(body["stackFrames"].clone()) { + if resp.success + && let Some(body) = resp.body + { + if let Ok(frames) = + serde_json::from_value::>(body["stackFrames"].clone()) + { for (idx, frame) in frames.into_iter().enumerate() { let line = (frame.line - 1).max(0) as usize; // 1-indexed to 0-indexed let col = (frame.column - 1).max(0) as usize; @@ -258,15 +289,26 @@ impl DapSession { if let Some(frame_id) = top_frame_id { let args = json!({ "frameId": frame_id }); let resp = self.client.send_request("scopes", Some(args)).await?; - if resp.success && let Some(body) = resp.body { - if let Ok(scopes) = serde_json::from_value::>(body["scopes"].clone()) { + if resp.success + && let Some(body) = resp.body + { + if let Ok(scopes) = serde_json::from_value::>(body["scopes"].clone()) + { // Iterate scopes (usually Local, Global) for scope in scopes { if scope.variables_reference > 0 { - let var_args = json!({ "variablesReference": scope.variables_reference }); - let var_resp = self.client.send_request("variables", Some(var_args)).await?; - if var_resp.success && let Some(var_body) = var_resp.body { - if let Ok(dap_vars) = serde_json::from_value::>(var_body["variables"].clone()) { + let var_args = + json!({ "variablesReference": scope.variables_reference }); + let var_resp = self + .client + .send_request("variables", Some(var_args)) + .await?; + if var_resp.success + && let Some(var_body) = var_resp.body + { + if let Ok(dap_vars) = serde_json::from_value::>( + var_body["variables"].clone(), + ) { for d_var in dap_vars { variables.push(self.fetch_variable_tree(d_var).await); } @@ -296,8 +338,12 @@ impl DapSession { if var.variables_reference > 0 { let args = json!({ "variablesReference": var.variables_reference }); if let Ok(resp) = self.client.send_request("variables", Some(args)).await { - if resp.success && let Some(body) = resp.body { - if let Ok(dap_vars) = serde_json::from_value::>(body["variables"].clone()) { + if resp.success + && let Some(body) = resp.body + { + if let Ok(dap_vars) = + serde_json::from_value::>(body["variables"].clone()) + { for d_var in dap_vars { // Don't recursively fetch indefinitely (fetch 1 level down) children.push(DebugVariable { diff --git a/src/dap/types.rs b/src/dap/types.rs index a55fe6bc..e7f31202 100644 --- a/src/dap/types.rs +++ b/src/dap/types.rs @@ -277,7 +277,10 @@ mod tests { assert_eq!(deserialized.id, 100); assert_eq!(deserialized.line, 42); assert_eq!(deserialized.column, 8); - assert_eq!(deserialized.source.as_ref().unwrap().name.as_deref(), Some("main.dart")); + assert_eq!( + deserialized.source.as_ref().unwrap().name.as_deref(), + Some("main.dart") + ); } #[test] @@ -384,8 +387,14 @@ mod tests { path: Some("/lib/main.dart".to_string()), }, breakpoints: vec![ - SourceBreakpoint { line: 10, column: None }, - SourceBreakpoint { line: 20, column: Some(5) }, + SourceBreakpoint { + line: 10, + column: None, + }, + SourceBreakpoint { + line: 20, + column: Some(5), + }, ], }; let json = serde_json::to_string(&args).unwrap(); diff --git a/src/lib.rs b/src/lib.rs index 04397179..413d8f3f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,4 +15,4 @@ pub mod workspace; /// Phiên bản hiển thị trong UI (Welcome screen, status bar…). /// Đổi ở đây là xong — đồng bộ toàn bộ code. /// Lưu ý: config/ui/default.toml cũng có `[welcome] version` cần cập nhật cùng. -pub const APP_VERSION: &str = "v1.0.4-alpha"; +pub const APP_VERSION: &str = "v1.0.5-alpha"; diff --git a/src/lsp/client.rs b/src/lsp/client.rs index a779c747..70d6f177 100644 --- a/src/lsp/client.rs +++ b/src/lsp/client.rs @@ -120,12 +120,12 @@ fn parse_go_version(name: &str) -> (u32, u32, u32) { /// installed `gopls@latest` (in the newest pkgset) takes priority over an /// older version installed in an older pkgset. /// The caller resolves `gvm_root` from `$GVM_ROOT` or the `~/.gvm` fallback. -fn resolve_gvm_paths(gvm_root: &str) -> Vec { +pub(crate) fn resolve_gvm_paths(gvm_root: &str) -> Vec { if !std::path::Path::new(gvm_root).exists() { return vec![]; } - let mut paths = vec![format!("{gvm_root}/bin")]; + let mut paths = Vec::new(); // Collect and sort Go version names (newest first) so that the latest // pkgset bin — where `gopls@latest` lives — appears first in PATH. @@ -150,7 +150,7 @@ fn resolve_gvm_paths(gvm_root: &str) -> Vec { for ver in sorted_versions("pkgsets") { let bin = format!("{gvm_root}/pkgsets/{ver}/global/bin"); if std::path::Path::new(&bin).is_dir() { - paths.push(bin); + push_unique_path(&mut paths, bin); } } @@ -158,13 +158,24 @@ fn resolve_gvm_paths(gvm_root: &str) -> Vec { for ver in sorted_versions("gos") { let bin = format!("{gvm_root}/gos/{ver}/bin"); if std::path::Path::new(&bin).is_dir() { - paths.push(bin); + push_unique_path(&mut paths, bin); } } + // gvm's own command wrappers. Keep this after concrete pkgset/gos bins so + // a directly installed `gopls` wins over the generic gvm shim directory. + push_unique_path(&mut paths, format!("{gvm_root}/bin")); + paths } +fn push_unique_path(paths: &mut Vec, path: String) { + if path.is_empty() || paths.iter().any(|existing| existing == &path) { + return; + } + paths.push(path); +} + /// Probes the user's login shell **once per process** and caches the result. /// /// Spawning an interactive login shell (`-ilc`) sources `~/.zshrc` and @@ -256,14 +267,23 @@ pub fn patched_env_path() -> String { // Primary: one-shot login-shell extraction (nvm, gvm, cargo, etc. all active). if let Some(live_path) = extract_path_from_login_shell() { + // Even when the login shell returns a PATH, version-manager directories + // (gvm, nvm, pyenv, jenv, …) may still be missing if the user's shell + // init files guard them behind interactive-only conditions that don't + // execute under `-ilc`. Merge the static fallback paths so that + // binaries installed via version managers are always discoverable. + let static_fallback = static_patched_env_path(); + let mut merged = live_path; + for seg in static_fallback.split(':') { + if !seg.is_empty() && !merged.split(':').any(|existing| existing == seg) { + merged = format!("{merged}:{seg}"); + } + } // Inject Netherize-managed LSP binaries at front — not present in the user shell. - return if live_path - .split(':') - .any(|seg| seg == netherize_bin.as_str()) - { - live_path + return if merged.split(':').any(|seg| seg == netherize_bin.as_str()) { + merged } else { - format!("{netherize_bin}:{live_path}") + format!("{netherize_bin}:{merged}") }; } @@ -285,38 +305,44 @@ fn static_patched_env_path() -> String { let mut candidates: Vec = vec![]; // Cargo — rust-analyzer and other Rust tools. - candidates.push(format!("{home}/.cargo/bin")); + push_unique_path(&mut candidates, format!("{home}/.cargo/bin")); // gvm — newest pkgset/gos bin first (gopls@latest lives in pkgset). let gvm_root = std::env::var("GVM_ROOT").unwrap_or_else(|_| format!("{home}/.gvm")); - candidates.extend(resolve_gvm_paths(&gvm_root)); - // gvm bare bin directory (shell wrappers: go, gofmt, gvm itself). - candidates.push(format!("{home}/.gvm/bin")); + for path in resolve_gvm_paths(&gvm_root) { + push_unique_path(&mut candidates, path); + } // Plain ~/go/bin fallback for Go installs without gvm. - candidates.push(format!("{home}/go/bin")); + push_unique_path(&mut candidates, format!("{home}/go/bin")); // nvm — smart alias resolution first, then belt-and-suspenders current symlink. if let Some(nvm_bin) = resolve_nvm_bin(&home) { - candidates.push(nvm_bin); + push_unique_path(&mut candidates, nvm_bin); } - candidates.push(format!("{home}/.nvm/versions/node/current/bin")); + push_unique_path( + &mut candidates, + format!("{home}/.nvm/versions/node/current/bin"), + ); // npm global with custom prefix. - candidates.push(format!("{home}/.npm-global/bin")); + push_unique_path(&mut candidates, format!("{home}/.npm-global/bin")); // jenv — Java version manager (shims for java, javac, jdtls, etc.). - candidates.push(format!("{home}/.jenv/shims")); - candidates.push(format!("{home}/.jenv/bin")); + push_unique_path(&mut candidates, format!("{home}/.jenv/shims")); + push_unique_path(&mut candidates, format!("{home}/.jenv/bin")); // Homebrew (system fallback — lower priority than user-managed tools). - candidates.push("/opt/homebrew/bin".to_string()); - candidates.push("/opt/homebrew/sbin".to_string()); - candidates.push("/usr/local/bin".to_string()); - candidates.push("/usr/local/sbin".to_string()); + push_unique_path(&mut candidates, "/opt/homebrew/bin".to_string()); + push_unique_path(&mut candidates, "/opt/homebrew/sbin".to_string()); + push_unique_path(&mut candidates, "/usr/local/bin".to_string()); + push_unique_path(&mut candidates, "/usr/local/sbin".to_string()); // Netherize-managed LSP binaries. - candidates.push(format!("{home}/.local/share/netherize/bin")); + push_unique_path( + &mut candidates, + format!("{home}/.local/share/netherize/bin"), + ); let mut extra: Vec = candidates .into_iter() @@ -1639,6 +1665,32 @@ mod tests { let paths = resolve_gvm_paths(&gvm_root.to_string_lossy()); + let newest_pkgset = paths + .iter() + .position(|p| p.contains("pkgsets/go1.25.4/global/bin")) + .expect("newest pkgset path"); + let older_pkgset = paths + .iter() + .position(|p| p.contains("pkgsets/go1.24.11/global/bin")) + .expect("older pkgset path"); + let newest_go = paths + .iter() + .position(|p| p.contains("gos/go1.25.4/bin")) + .expect("newest go path"); + let older_go = paths + .iter() + .position(|p| p.contains("gos/go1.24.11/bin")) + .expect("older go path"); + let gvm_bin_path = gvm_root.join("bin").to_string_lossy().to_string(); + let gvm_bin = paths + .iter() + .position(|p| p == &gvm_bin_path) + .expect("gvm bin path"); + + assert!(newest_pkgset < older_pkgset, "{paths:?}"); + assert!(older_pkgset < newest_go, "{paths:?}"); + assert!(newest_go < older_go, "{paths:?}"); + assert!(older_go < gvm_bin, "{paths:?}"); assert!( paths.iter().any(|p| p.contains("go1.24.11/bin")), "{paths:?}" diff --git a/src/lsp/registry.rs b/src/lsp/registry.rs index dfa81be3..783c4be8 100644 --- a/src/lsp/registry.rs +++ b/src/lsp/registry.rs @@ -300,13 +300,11 @@ pub fn language_profile_for_binary(binary: &str) -> Option<&'static LanguageProf let path = Path::new(binary); let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(binary); let file_name = path.file_name().and_then(|f| f.to_str()).unwrap_or(binary); - LANGUAGE_REGISTRY - .iter() - .find(|profile| { - profile.lsp_binary == binary - || profile.lsp_binary == stem - || profile.lsp_binary == file_name - }) + LANGUAGE_REGISTRY.iter().find(|profile| { + profile.lsp_binary == binary + || profile.lsp_binary == stem + || profile.lsp_binary == file_name + }) } pub fn find_project_root(file_path: &Path, markers: &[&str]) -> PathBuf { diff --git a/src/lsp/symbol_cache.rs b/src/lsp/symbol_cache.rs index c9954fbb..d2c9b485 100644 --- a/src/lsp/symbol_cache.rs +++ b/src/lsp/symbol_cache.rs @@ -233,16 +233,17 @@ fn index_ts_js_package_exports(workspace_root: &Path) -> Vec { continue; }; - let mut package_symbols = extract_ts_js_exports_from_text(&type_entry_path, &package_dir, &text) - .into_iter() - .filter(|symbol| symbol.export_kind.is_some()) - .map(|mut symbol| { - symbol.source_path = Some(type_entry_path.clone()); - symbol.file_path = type_entry_path.clone(); - symbol.import_path = Some(package_name.clone()); - symbol - }) - .collect::>(); + let mut package_symbols = + extract_ts_js_exports_from_text(&type_entry_path, &package_dir, &text) + .into_iter() + .filter(|symbol| symbol.export_kind.is_some()) + .map(|mut symbol| { + symbol.source_path = Some(type_entry_path.clone()); + symbol.file_path = type_entry_path.clone(); + symbol.import_path = Some(package_name.clone()); + symbol + }) + .collect::>(); if !package_symbols .iter() @@ -424,8 +425,8 @@ fn package_default_export_name(text: &str) -> Option { return Some(name); } } - if let Some(rest) = - strip_leading_word(trimmed, "export").and_then(|rest| rest.trim_start().strip_prefix('=')) + if let Some(rest) = strip_leading_word(trimmed, "export") + .and_then(|rest| rest.trim_start().strip_prefix('=')) { if let Some(name) = read_identifier(rest.trim_start()) { return Some(name); @@ -548,7 +549,8 @@ pub fn extract_ts_js_exports_from_text( continue; } - let is_cjs_start = (trimmed.starts_with("module.exports") || trimmed.starts_with("exports")) + let is_cjs_start = (trimmed.starts_with("module.exports") + || trimmed.starts_with("exports")) && trimmed.contains('{') && !trimmed.contains('}'); if is_cjs_start { @@ -654,7 +656,7 @@ fn extract_commonjs_candidates_from_line( import_path: Option<&str>, ) -> Vec { let mut candidates = Vec::new(); - + // Pattern 1: module.exports = { name1, name2 } or exports = { name1, name2 } if (trimmed.starts_with("module.exports") || trimmed.starts_with("exports")) && trimmed.contains('{') @@ -722,7 +724,7 @@ fn extract_commonjs_candidates_from_line( } } } - + candidates } @@ -737,7 +739,6 @@ fn is_valid_ts_js_identifier(s: &str) -> bool { chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '$') } - fn collect_ts_js_files(root: &Path, out: &mut Vec) { let Ok(entries) = fs::read_dir(root) else { return; @@ -1196,7 +1197,9 @@ fn ts_js_parameters_after_named_call(raw_text: &str, name: &str) -> Option .or_else(|| rest.strip_prefix('!')) .unwrap_or(rest) .trim_start(); - rest = strip_leading_type_arguments(rest).unwrap_or(rest).trim_start(); + rest = strip_leading_type_arguments(rest) + .unwrap_or(rest) + .trim_start(); if let Some(content) = parenthesized_prefix_content(rest) { return Some(!content.trim().is_empty()); } @@ -1271,7 +1274,12 @@ fn parenthesized_prefix_content(text: &str) -> Option<&str> { Some(&trimmed[1..close]) } -fn find_matching_delimiter(text: &str, open: usize, open_ch: char, close_ch: char) -> Option { +fn find_matching_delimiter( + text: &str, + open: usize, + open_ch: char, + close_ch: char, +) -> Option { let mut depth = 0usize; let mut quote = None; let mut escape = false; @@ -1981,8 +1989,14 @@ enum Role { assert!(has("Role", "Enum", None)); assert!(has("Admin", "EnumMember", Some("Role"))); assert!(has("User", "EnumMember", Some("Role"))); - assert_eq!(call_shape("localHelper", None), Some((Some(true), Some(false)))); - assert_eq!(call_shape("calculate", None), Some((Some(true), Some(false)))); + assert_eq!( + call_shape("localHelper", None), + Some((Some(true), Some(false))) + ); + assert_eq!( + call_shape("calculate", None), + Some((Some(true), Some(false))) + ); assert_eq!( call_shape("constructor", Some("UserService")), Some((Some(true), Some(true))) diff --git a/src/render/icon_pipeline.rs b/src/render/icon_pipeline.rs index a3064f59..87b3a18a 100644 --- a/src/render/icon_pipeline.rs +++ b/src/render/icon_pipeline.rs @@ -124,7 +124,9 @@ pub fn canonical_icon_id(icon: &str) -> Option<&'static str> { "nestjsentity" | "nestjs_entity" | "nest_entity" => "built_in:nestjsentity", "nestjsfilter" | "nestjs_filter" | "nest_filter" => "built_in:nestjsfilter", "nestjsguard" | "nestjs_guard" | "nest_guard" => "built_in:nestjsguard", - "nestjsinterceptor" | "nestjs_interceptor" | "nest_interceptor" => "built_in:nestjsinterceptor", + "nestjsinterceptor" | "nestjs_interceptor" | "nest_interceptor" => { + "built_in:nestjsinterceptor" + } "nestjsmodule" | "nestjs_module" | "nest_module" => "built_in:nestjsmodule", "nestjsrepository" | "nestjs_repository" | "nest_repository" => "built_in:nestjsrepository", "nestjsresolver" | "nestjs_resolver" | "nest_resolver" => "built_in:nestjsresolver", @@ -215,7 +217,11 @@ pub struct IconPipeline { } impl IconPipeline { - pub fn new(device: &wgpu::Device, queue: &wgpu::Queue, surface_format: wgpu::TextureFormat) -> Self { + pub fn new( + device: &wgpu::Device, + queue: &wgpu::Queue, + surface_format: wgpu::TextureFormat, + ) -> Self { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("Netherize Icon Shader"), source: wgpu::ShaderSource::Wgsl(include_str!("shaders/icon.wgsl").into()), @@ -359,10 +365,26 @@ impl IconPipeline { let v1 = (entry.y + entry.h) as f32 / s; let base = vertices.len() as u32; vertices.extend_from_slice(&[ - IconVertex { position: [x0, y0], uv: [u0, v1], color: instance.tint }, - IconVertex { position: [x1, y0], uv: [u1, v1], color: instance.tint }, - IconVertex { position: [x1, y1], uv: [u1, v0], color: instance.tint }, - IconVertex { position: [x0, y1], uv: [u0, v0], color: instance.tint }, + IconVertex { + position: [x0, y0], + uv: [u0, v1], + color: instance.tint, + }, + IconVertex { + position: [x1, y0], + uv: [u1, v1], + color: instance.tint, + }, + IconVertex { + position: [x1, y1], + uv: [u1, v0], + color: instance.tint, + }, + IconVertex { + position: [x0, y1], + uv: [u0, v0], + color: instance.tint, + }, ]); indices.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]); } @@ -374,16 +396,20 @@ impl IconPipeline { return; } - self.vertex_buffer = Some(device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("Netherize Icon Vertex Buffer"), - contents: bytemuck::cast_slice(&vertices), - usage: wgpu::BufferUsages::VERTEX, - })); - self.index_buffer = Some(device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("Netherize Icon Index Buffer"), - contents: bytemuck::cast_slice(&indices), - usage: wgpu::BufferUsages::INDEX, - })); + self.vertex_buffer = Some( + device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("Netherize Icon Vertex Buffer"), + contents: bytemuck::cast_slice(&vertices), + usage: wgpu::BufferUsages::VERTEX, + }), + ); + self.index_buffer = Some( + device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("Netherize Icon Index Buffer"), + contents: bytemuck::cast_slice(&indices), + usage: wgpu::BufferUsages::INDEX, + }), + ); self.index_count = indices.len() as u32; } @@ -391,7 +417,9 @@ impl IconPipeline { if self.index_count == 0 { return; } - let (Some(vb), Some(ib), Some(bg)) = (&self.vertex_buffer, &self.index_buffer, &self.bind_group) else { + let (Some(vb), Some(ib), Some(bg)) = + (&self.vertex_buffer, &self.index_buffer, &self.bind_group) + else { return; }; pass.set_pipeline(&self.render_pipeline); @@ -410,162 +438,630 @@ struct BuiltAtlas { fn build_bearded_atlas() -> BuiltAtlas { const ICONS: &[(&str, &[u8])] = &[ - ("built_in:ansible", include_bytes!("../../assets/bearded-icons/conf.svg")), - ("built_in:astro", include_bytes!("../../assets/bearded-icons/astro.svg")), - ("built_in:astroconfig", include_bytes!("../../assets/bearded-icons/astroconfig.svg")), - ("built_in:babel", include_bytes!("../../assets/bearded-icons/babel.svg")), - ("built_in:biome", include_bytes!("../../assets/bearded-icons/biome.svg")), - ("built_in:browserslist", include_bytes!("../../assets/bearded-icons/browserslist.svg")), - ("built_in:bunlock", include_bytes!("../../assets/bearded-icons/bunlock.svg")), - ("built_in:c", include_bytes!("../../assets/bearded-icons/c.svg")), - ("built_in:cargo", include_bytes!("../../assets/bearded-icons/cargo.svg")), - ("built_in:cargolock", include_bytes!("../../assets/bearded-icons/cargolock.svg")), - ("built_in:clojure", include_bytes!("../../assets/bearded-icons/clojure.svg")), - ("built_in:cmake", include_bytes!("../../assets/bearded-icons/cmake.svg")), - ("built_in:codeworkspace", include_bytes!("../../assets/bearded-icons/codeworkspace.svg")), - ("built_in:commitlint", include_bytes!("../../assets/bearded-icons/commitlint.svg")), - ("built_in:composerlock", include_bytes!("../../assets/bearded-icons/composerlock.svg")), - ("built_in:conf", include_bytes!("../../assets/bearded-icons/conf.svg")), - ("built_in:cpp", include_bytes!("../../assets/bearded-icons/cpp.svg")), - ("built_in:csharp", include_bytes!("../../assets/bearded-icons/csharp.svg")), - ("built_in:css", include_bytes!("../../assets/bearded-icons/css.svg")), - ("built_in:dart", include_bytes!("../../assets/bearded-icons/dartlang.svg")), - ("built_in:docker", include_bytes!("../../assets/bearded-icons/docker.svg")), - ("built_in:dockerignore", include_bytes!("../../assets/bearded-icons/dockerignore.svg")), - ("built_in:drizzle", include_bytes!("../../assets/bearded-icons/drizzle.svg")), - ("built_in:editorconfig", include_bytes!("../../assets/bearded-icons/editorconfig.svg")), - ("built_in:elm", include_bytes!("../../assets/bearded-icons/elm.svg")), - ("built_in:error", include_bytes!("../../assets/bearded-icons/error.svg")), - ("built_in:eslint", include_bytes!("../../assets/bearded-icons/eslint.svg")), - ("built_in:eslintignore", include_bytes!("../../assets/bearded-icons/eslintignore.svg")), - ("built_in:file", include_bytes!("../../assets/bearded-icons/file.svg")), - ("built_in:flakelock", include_bytes!("../../assets/bearded-icons/flakelock.svg")), - ("built_in:flutterlock", include_bytes!("../../assets/bearded-icons/flutterlock.svg")), - ("built_in:folder", include_bytes!("../../assets/bearded-icons/folder.svg")), - ("built_in:folder_open", include_bytes!("../../assets/bearded-icons/folder_open.svg")), - ("built_in:root_folder", include_bytes!("../../assets/bearded-icons/root_folder.svg")), - ("built_in:fsharp", include_bytes!("../../assets/bearded-icons/fsharp.svg")), - ("built_in:git", include_bytes!("../../assets/bearded-icons/git.svg")), - ("built_in:go", include_bytes!("../../assets/bearded-icons/go.svg")), - ("built_in:gitlab", include_bytes!("../../assets/bearded-icons/gitlab.svg")), - ("built_in:gradle", include_bytes!("../../assets/bearded-icons/gradle.svg")), - ("built_in:graphql", include_bytes!("../../assets/bearded-icons/graphql.svg")), - ("built_in:haskell", include_bytes!("../../assets/bearded-icons/haskell.svg")), - ("built_in:hash", include_bytes!("../../assets/bearded-icons/hash.svg")), - ("built_in:html", include_bytes!("../../assets/bearded-icons/html.svg")), - ("built_in:identifier", include_bytes!("../../assets/bearded-icons/identifier.svg")), - ("built_in:image", include_bytes!("../../assets/bearded-icons/image.svg")), - ("built_in:info", include_bytes!("../../assets/bearded-icons/info.svg")), - ("built_in:symbol-class", include_bytes!("../../assets/bearded-icons/symbol-class.svg")), - ("built_in:symbol-constant", include_bytes!("../../assets/bearded-icons/symbol-constant.svg")), - ("built_in:symbol-constructor", include_bytes!("../../assets/bearded-icons/symbol-constructor.svg")), - ("built_in:symbol-enum", include_bytes!("../../assets/bearded-icons/symbol-enum.svg")), - ("built_in:symbol-event", include_bytes!("../../assets/bearded-icons/symbol-event.svg")), - ("built_in:symbol-field", include_bytes!("../../assets/bearded-icons/symbol-field.svg")), - ("built_in:symbol-function", include_bytes!("../../assets/bearded-icons/symbol-function.svg")), - ("built_in:symbol-interface", include_bytes!("../../assets/bearded-icons/symbol-interface.svg")), - ("built_in:symbol-keyword", include_bytes!("../../assets/bearded-icons/symbol-keyword.svg")), - ("built_in:symbol-method", include_bytes!("../../assets/bearded-icons/symbol-method.svg")), - ("built_in:symbol-module", include_bytes!("../../assets/bearded-icons/symbol-module.svg")), - ("built_in:symbol-namespace", include_bytes!("../../assets/bearded-icons/symbol-namespace.svg")), - ("built_in:symbol-operator", include_bytes!("../../assets/bearded-icons/symbol-operator.svg")), - ("built_in:symbol-property", include_bytes!("../../assets/bearded-icons/symbol-property.svg")), - ("built_in:symbol-reference", include_bytes!("../../assets/bearded-icons/symbol-reference.svg")), - ("built_in:symbol-struct", include_bytes!("../../assets/bearded-icons/symbol-struct.svg")), - ("built_in:symbol-type-parameter", include_bytes!("../../assets/bearded-icons/symbol-type-parameter.svg")), - ("built_in:symbol-variable", include_bytes!("../../assets/bearded-icons/symbol-variable.svg")), - ("built_in:java", include_bytes!("../../assets/bearded-icons/java.svg")), - ("built_in:javascript", include_bytes!("../../assets/bearded-icons/js.svg")), - ("built_in:jest", include_bytes!("../../assets/bearded-icons/jest.svg")), - ("built_in:keep", include_bytes!("../../assets/bearded-icons/keep.svg")), - ("built_in:knex", include_bytes!("../../assets/bearded-icons/knex.svg")), - ("built_in:license", include_bytes!("../../assets/bearded-icons/license.svg")), - ("built_in:node", include_bytes!("../../assets/bearded-icons/node.svg")), - ("built_in:json", include_bytes!("../../assets/bearded-icons/json.svg")), - ("built_in:key", include_bytes!("../../assets/bearded-icons/key.svg")), - ("built_in:kotlin", include_bytes!("../../assets/bearded-icons/kotlin.svg")), - ("built_in:lock", include_bytes!("../../assets/bearded-icons/lock.svg")), - ("built_in:lua", include_bytes!("../../assets/bearded-icons/lua.svg")), - ("built_in:makefile", include_bytes!("../../assets/bearded-icons/makefile.svg")), - ("built_in:manifest", include_bytes!("../../assets/bearded-icons/manifest.svg")), - ("built_in:markdown", include_bytes!("../../assets/bearded-icons/markdown.svg")), - ("built_in:mixlock", include_bytes!("../../assets/bearded-icons/mixlock.svg")), - ("built_in:netlify", include_bytes!("../../assets/bearded-icons/netlify.svg")), - ("built_in:nextconfig", include_bytes!("../../assets/bearded-icons/nextconfig.svg")), - ("built_in:nestjs", include_bytes!("../../assets/bearded-icons/nestjs.svg")), - ("built_in:nestjscontroller", include_bytes!("../../assets/bearded-icons/nestjscontroller.svg")), - ("built_in:nestjsdecorator", include_bytes!("../../assets/bearded-icons/nestjsdecorator.svg")), - ("built_in:nestjsdto", include_bytes!("../../assets/bearded-icons/nestjsdto.svg")), - ("built_in:nestjsentity", include_bytes!("../../assets/bearded-icons/nestjsentity.svg")), - ("built_in:nestjsfilter", include_bytes!("../../assets/bearded-icons/nestjsfilter.svg")), - ("built_in:nestjsguard", include_bytes!("../../assets/bearded-icons/nestjsguard.svg")), - ("built_in:nestjsinterceptor", include_bytes!("../../assets/bearded-icons/nestjsinterceptor.svg")), - ("built_in:nestjsmodule", include_bytes!("../../assets/bearded-icons/nestjsmodule.svg")), - ("built_in:nestjsrepository", include_bytes!("../../assets/bearded-icons/nestjsrepository.svg")), - ("built_in:nestjsresolver", include_bytes!("../../assets/bearded-icons/nestjsresolver.svg")), - ("built_in:nestjsservice", include_bytes!("../../assets/bearded-icons/nestjsservice.svg")), - ("built_in:nestscheduler", include_bytes!("../../assets/bearded-icons/nestscheduler.svg")), - ("built_in:nginx", include_bytes!("../../assets/bearded-icons/nginx.svg")), - ("built_in:nodemon", include_bytes!("../../assets/bearded-icons/nodemon.svg")), - ("built_in:npmlock", include_bytes!("../../assets/bearded-icons/npmlock.svg")), - ("built_in:nvm", include_bytes!("../../assets/bearded-icons/nvm.svg")), - ("built_in:nx", include_bytes!("../../assets/bearded-icons/nx.svg")), - ("built_in:nuxt", include_bytes!("../../assets/bearded-icons/nuxt.svg")), - ("built_in:nim", include_bytes!("../../assets/bearded-icons/nim.svg")), - ("built_in:npm", include_bytes!("../../assets/bearded-icons/npm.svg")), - ("built_in:ocaml", include_bytes!("../../assets/bearded-icons/ocaml.svg")), - ("built_in:perl", include_bytes!("../../assets/bearded-icons/perl.svg")), - ("built_in:php", include_bytes!("../../assets/bearded-icons/php.svg")), - ("built_in:playright", include_bytes!("../../assets/bearded-icons/playright.svg")), - ("built_in:pnpmlock", include_bytes!("../../assets/bearded-icons/pnpmlock.svg")), - ("built_in:poetrylock", include_bytes!("../../assets/bearded-icons/poetrylock.svg")), - ("built_in:postcssconfig", include_bytes!("../../assets/bearded-icons/postcssconfig.svg")), - ("built_in:precommit", include_bytes!("../../assets/bearded-icons/precommit.svg")), - ("built_in:prettier", include_bytes!("../../assets/bearded-icons/prettier.svg")), - ("built_in:prettierignore", include_bytes!("../../assets/bearded-icons/prettierignore.svg")), - ("built_in:prisma", include_bytes!("../../assets/bearded-icons/prisma.svg")), - ("built_in:proto", include_bytes!("../../assets/bearded-icons/proto.svg")), - ("built_in:python", include_bytes!("../../assets/bearded-icons/python.svg")), - ("built_in:r", include_bytes!("../../assets/bearded-icons/r.svg")), - ("built_in:reactjs", include_bytes!("../../assets/bearded-icons/reactjs.svg")), - ("built_in:readme", include_bytes!("../../assets/bearded-icons/readme.svg")), - ("built_in:remix", include_bytes!("../../assets/bearded-icons/remix.svg")), - ("built_in:ruby", include_bytes!("../../assets/bearded-icons/ruby.svg")), - ("built_in:rust", include_bytes!("../../assets/bearded-icons/rust.svg")), - ("built_in:sass", include_bytes!("../../assets/bearded-icons/sass.svg")), - ("built_in:scala", include_bytes!("../../assets/bearded-icons/scala.svg")), - ("built_in:sequelize", include_bytes!("../../assets/bearded-icons/sequelize.svg")), - ("built_in:shell", include_bytes!("../../assets/bearded-icons/shell.svg")), - ("built_in:sol", include_bytes!("../../assets/bearded-icons/sol.svg")), - ("built_in:sql", include_bytes!("../../assets/bearded-icons/sql.svg")), - ("built_in:storybook", include_bytes!("../../assets/bearded-icons/storybook.svg")), - ("built_in:svelte", include_bytes!("../../assets/bearded-icons/svelte.svg")), - ("built_in:svelteconfig", include_bytes!("../../assets/bearded-icons/svelteconfig.svg")), - ("built_in:swift", include_bytes!("../../assets/bearded-icons/swift.svg")), - ("built_in:tailwind", include_bytes!("../../assets/bearded-icons/tailwind.svg")), - ("built_in:tauri", include_bytes!("../../assets/bearded-icons/tauri.svg")), - ("built_in:terraform", include_bytes!("../../assets/bearded-icons/terraform.svg")), - ("built_in:testjs", include_bytes!("../../assets/bearded-icons/testjs.svg")), - ("built_in:testts", include_bytes!("../../assets/bearded-icons/testts.svg")), - ("built_in:todo", include_bytes!("../../assets/bearded-icons/todo.svg")), - ("built_in:toml", include_bytes!("../../assets/bearded-icons/toml.svg")), - ("built_in:tsconfig", include_bytes!("../../assets/bearded-icons/tsconfig.svg")), - ("built_in:tsx", include_bytes!("../../assets/bearded-icons/tsx.svg")), - ("built_in:turbo", include_bytes!("../../assets/bearded-icons/turbo.svg")), - ("built_in:typescript", include_bytes!("../../assets/bearded-icons/typescript.svg")), - ("built_in:unocss", include_bytes!("../../assets/bearded-icons/unocss.svg")), - ("built_in:vercel", include_bytes!("../../assets/bearded-icons/vercel.svg")), - ("built_in:vite", include_bytes!("../../assets/bearded-icons/vite.svg")), - ("built_in:vitest", include_bytes!("../../assets/bearded-icons/vitest.svg")), - ("built_in:vue", include_bytes!("../../assets/bearded-icons/vue.svg")), - ("built_in:vueconfig", include_bytes!("../../assets/bearded-icons/vueconfig.svg")), - ("built_in:warning", include_bytes!("../../assets/bearded-icons/warning.svg")), - ("built_in:webpack", include_bytes!("../../assets/bearded-icons/webpack.svg")), - ("built_in:windi", include_bytes!("../../assets/bearded-icons/windi.svg")), - ("built_in:xml", include_bytes!("../../assets/bearded-icons/xml.svg")), - ("built_in:yaml", include_bytes!("../../assets/bearded-icons/yaml.svg")), - ("built_in:yarnlock", include_bytes!("../../assets/bearded-icons/yarnlock.svg")), - ("built_in:zig", include_bytes!("../../assets/bearded-icons/zig.svg")), + ( + "built_in:ansible", + include_bytes!("../../assets/bearded-icons/conf.svg"), + ), + ( + "built_in:astro", + include_bytes!("../../assets/bearded-icons/astro.svg"), + ), + ( + "built_in:astroconfig", + include_bytes!("../../assets/bearded-icons/astroconfig.svg"), + ), + ( + "built_in:babel", + include_bytes!("../../assets/bearded-icons/babel.svg"), + ), + ( + "built_in:biome", + include_bytes!("../../assets/bearded-icons/biome.svg"), + ), + ( + "built_in:browserslist", + include_bytes!("../../assets/bearded-icons/browserslist.svg"), + ), + ( + "built_in:bunlock", + include_bytes!("../../assets/bearded-icons/bunlock.svg"), + ), + ( + "built_in:c", + include_bytes!("../../assets/bearded-icons/c.svg"), + ), + ( + "built_in:cargo", + include_bytes!("../../assets/bearded-icons/cargo.svg"), + ), + ( + "built_in:cargolock", + include_bytes!("../../assets/bearded-icons/cargolock.svg"), + ), + ( + "built_in:clojure", + include_bytes!("../../assets/bearded-icons/clojure.svg"), + ), + ( + "built_in:cmake", + include_bytes!("../../assets/bearded-icons/cmake.svg"), + ), + ( + "built_in:codeworkspace", + include_bytes!("../../assets/bearded-icons/codeworkspace.svg"), + ), + ( + "built_in:commitlint", + include_bytes!("../../assets/bearded-icons/commitlint.svg"), + ), + ( + "built_in:composerlock", + include_bytes!("../../assets/bearded-icons/composerlock.svg"), + ), + ( + "built_in:conf", + include_bytes!("../../assets/bearded-icons/conf.svg"), + ), + ( + "built_in:cpp", + include_bytes!("../../assets/bearded-icons/cpp.svg"), + ), + ( + "built_in:csharp", + include_bytes!("../../assets/bearded-icons/csharp.svg"), + ), + ( + "built_in:css", + include_bytes!("../../assets/bearded-icons/css.svg"), + ), + ( + "built_in:dart", + include_bytes!("../../assets/bearded-icons/dartlang.svg"), + ), + ( + "built_in:docker", + include_bytes!("../../assets/bearded-icons/docker.svg"), + ), + ( + "built_in:dockerignore", + include_bytes!("../../assets/bearded-icons/dockerignore.svg"), + ), + ( + "built_in:drizzle", + include_bytes!("../../assets/bearded-icons/drizzle.svg"), + ), + ( + "built_in:editorconfig", + include_bytes!("../../assets/bearded-icons/editorconfig.svg"), + ), + ( + "built_in:elm", + include_bytes!("../../assets/bearded-icons/elm.svg"), + ), + ( + "built_in:error", + include_bytes!("../../assets/bearded-icons/error.svg"), + ), + ( + "built_in:eslint", + include_bytes!("../../assets/bearded-icons/eslint.svg"), + ), + ( + "built_in:eslintignore", + include_bytes!("../../assets/bearded-icons/eslintignore.svg"), + ), + ( + "built_in:file", + include_bytes!("../../assets/bearded-icons/file.svg"), + ), + ( + "built_in:flakelock", + include_bytes!("../../assets/bearded-icons/flakelock.svg"), + ), + ( + "built_in:flutterlock", + include_bytes!("../../assets/bearded-icons/flutterlock.svg"), + ), + ( + "built_in:folder", + include_bytes!("../../assets/bearded-icons/folder.svg"), + ), + ( + "built_in:folder_open", + include_bytes!("../../assets/bearded-icons/folder_open.svg"), + ), + ( + "built_in:root_folder", + include_bytes!("../../assets/bearded-icons/root_folder.svg"), + ), + ( + "built_in:fsharp", + include_bytes!("../../assets/bearded-icons/fsharp.svg"), + ), + ( + "built_in:git", + include_bytes!("../../assets/bearded-icons/git.svg"), + ), + ( + "built_in:go", + include_bytes!("../../assets/bearded-icons/go.svg"), + ), + ( + "built_in:gitlab", + include_bytes!("../../assets/bearded-icons/gitlab.svg"), + ), + ( + "built_in:gradle", + include_bytes!("../../assets/bearded-icons/gradle.svg"), + ), + ( + "built_in:graphql", + include_bytes!("../../assets/bearded-icons/graphql.svg"), + ), + ( + "built_in:haskell", + include_bytes!("../../assets/bearded-icons/haskell.svg"), + ), + ( + "built_in:hash", + include_bytes!("../../assets/bearded-icons/hash.svg"), + ), + ( + "built_in:html", + include_bytes!("../../assets/bearded-icons/html.svg"), + ), + ( + "built_in:identifier", + include_bytes!("../../assets/bearded-icons/identifier.svg"), + ), + ( + "built_in:image", + include_bytes!("../../assets/bearded-icons/image.svg"), + ), + ( + "built_in:info", + include_bytes!("../../assets/bearded-icons/info.svg"), + ), + ( + "built_in:symbol-class", + include_bytes!("../../assets/bearded-icons/symbol-class.svg"), + ), + ( + "built_in:symbol-constant", + include_bytes!("../../assets/bearded-icons/symbol-constant.svg"), + ), + ( + "built_in:symbol-constructor", + include_bytes!("../../assets/bearded-icons/symbol-constructor.svg"), + ), + ( + "built_in:symbol-enum", + include_bytes!("../../assets/bearded-icons/symbol-enum.svg"), + ), + ( + "built_in:symbol-event", + include_bytes!("../../assets/bearded-icons/symbol-event.svg"), + ), + ( + "built_in:symbol-field", + include_bytes!("../../assets/bearded-icons/symbol-field.svg"), + ), + ( + "built_in:symbol-function", + include_bytes!("../../assets/bearded-icons/symbol-function.svg"), + ), + ( + "built_in:symbol-interface", + include_bytes!("../../assets/bearded-icons/symbol-interface.svg"), + ), + ( + "built_in:symbol-keyword", + include_bytes!("../../assets/bearded-icons/symbol-keyword.svg"), + ), + ( + "built_in:symbol-method", + include_bytes!("../../assets/bearded-icons/symbol-method.svg"), + ), + ( + "built_in:symbol-module", + include_bytes!("../../assets/bearded-icons/symbol-module.svg"), + ), + ( + "built_in:symbol-namespace", + include_bytes!("../../assets/bearded-icons/symbol-namespace.svg"), + ), + ( + "built_in:symbol-operator", + include_bytes!("../../assets/bearded-icons/symbol-operator.svg"), + ), + ( + "built_in:symbol-property", + include_bytes!("../../assets/bearded-icons/symbol-property.svg"), + ), + ( + "built_in:symbol-reference", + include_bytes!("../../assets/bearded-icons/symbol-reference.svg"), + ), + ( + "built_in:symbol-struct", + include_bytes!("../../assets/bearded-icons/symbol-struct.svg"), + ), + ( + "built_in:symbol-type-parameter", + include_bytes!("../../assets/bearded-icons/symbol-type-parameter.svg"), + ), + ( + "built_in:symbol-variable", + include_bytes!("../../assets/bearded-icons/symbol-variable.svg"), + ), + ( + "built_in:java", + include_bytes!("../../assets/bearded-icons/java.svg"), + ), + ( + "built_in:javascript", + include_bytes!("../../assets/bearded-icons/js.svg"), + ), + ( + "built_in:jest", + include_bytes!("../../assets/bearded-icons/jest.svg"), + ), + ( + "built_in:keep", + include_bytes!("../../assets/bearded-icons/keep.svg"), + ), + ( + "built_in:knex", + include_bytes!("../../assets/bearded-icons/knex.svg"), + ), + ( + "built_in:license", + include_bytes!("../../assets/bearded-icons/license.svg"), + ), + ( + "built_in:node", + include_bytes!("../../assets/bearded-icons/node.svg"), + ), + ( + "built_in:json", + include_bytes!("../../assets/bearded-icons/json.svg"), + ), + ( + "built_in:key", + include_bytes!("../../assets/bearded-icons/key.svg"), + ), + ( + "built_in:kotlin", + include_bytes!("../../assets/bearded-icons/kotlin.svg"), + ), + ( + "built_in:lock", + include_bytes!("../../assets/bearded-icons/lock.svg"), + ), + ( + "built_in:lua", + include_bytes!("../../assets/bearded-icons/lua.svg"), + ), + ( + "built_in:makefile", + include_bytes!("../../assets/bearded-icons/makefile.svg"), + ), + ( + "built_in:manifest", + include_bytes!("../../assets/bearded-icons/manifest.svg"), + ), + ( + "built_in:markdown", + include_bytes!("../../assets/bearded-icons/markdown.svg"), + ), + ( + "built_in:mixlock", + include_bytes!("../../assets/bearded-icons/mixlock.svg"), + ), + ( + "built_in:netlify", + include_bytes!("../../assets/bearded-icons/netlify.svg"), + ), + ( + "built_in:nextconfig", + include_bytes!("../../assets/bearded-icons/nextconfig.svg"), + ), + ( + "built_in:nestjs", + include_bytes!("../../assets/bearded-icons/nestjs.svg"), + ), + ( + "built_in:nestjscontroller", + include_bytes!("../../assets/bearded-icons/nestjscontroller.svg"), + ), + ( + "built_in:nestjsdecorator", + include_bytes!("../../assets/bearded-icons/nestjsdecorator.svg"), + ), + ( + "built_in:nestjsdto", + include_bytes!("../../assets/bearded-icons/nestjsdto.svg"), + ), + ( + "built_in:nestjsentity", + include_bytes!("../../assets/bearded-icons/nestjsentity.svg"), + ), + ( + "built_in:nestjsfilter", + include_bytes!("../../assets/bearded-icons/nestjsfilter.svg"), + ), + ( + "built_in:nestjsguard", + include_bytes!("../../assets/bearded-icons/nestjsguard.svg"), + ), + ( + "built_in:nestjsinterceptor", + include_bytes!("../../assets/bearded-icons/nestjsinterceptor.svg"), + ), + ( + "built_in:nestjsmodule", + include_bytes!("../../assets/bearded-icons/nestjsmodule.svg"), + ), + ( + "built_in:nestjsrepository", + include_bytes!("../../assets/bearded-icons/nestjsrepository.svg"), + ), + ( + "built_in:nestjsresolver", + include_bytes!("../../assets/bearded-icons/nestjsresolver.svg"), + ), + ( + "built_in:nestjsservice", + include_bytes!("../../assets/bearded-icons/nestjsservice.svg"), + ), + ( + "built_in:nestscheduler", + include_bytes!("../../assets/bearded-icons/nestscheduler.svg"), + ), + ( + "built_in:nginx", + include_bytes!("../../assets/bearded-icons/nginx.svg"), + ), + ( + "built_in:nodemon", + include_bytes!("../../assets/bearded-icons/nodemon.svg"), + ), + ( + "built_in:npmlock", + include_bytes!("../../assets/bearded-icons/npmlock.svg"), + ), + ( + "built_in:nvm", + include_bytes!("../../assets/bearded-icons/nvm.svg"), + ), + ( + "built_in:nx", + include_bytes!("../../assets/bearded-icons/nx.svg"), + ), + ( + "built_in:nuxt", + include_bytes!("../../assets/bearded-icons/nuxt.svg"), + ), + ( + "built_in:nim", + include_bytes!("../../assets/bearded-icons/nim.svg"), + ), + ( + "built_in:npm", + include_bytes!("../../assets/bearded-icons/npm.svg"), + ), + ( + "built_in:ocaml", + include_bytes!("../../assets/bearded-icons/ocaml.svg"), + ), + ( + "built_in:perl", + include_bytes!("../../assets/bearded-icons/perl.svg"), + ), + ( + "built_in:php", + include_bytes!("../../assets/bearded-icons/php.svg"), + ), + ( + "built_in:playright", + include_bytes!("../../assets/bearded-icons/playright.svg"), + ), + ( + "built_in:pnpmlock", + include_bytes!("../../assets/bearded-icons/pnpmlock.svg"), + ), + ( + "built_in:poetrylock", + include_bytes!("../../assets/bearded-icons/poetrylock.svg"), + ), + ( + "built_in:postcssconfig", + include_bytes!("../../assets/bearded-icons/postcssconfig.svg"), + ), + ( + "built_in:precommit", + include_bytes!("../../assets/bearded-icons/precommit.svg"), + ), + ( + "built_in:prettier", + include_bytes!("../../assets/bearded-icons/prettier.svg"), + ), + ( + "built_in:prettierignore", + include_bytes!("../../assets/bearded-icons/prettierignore.svg"), + ), + ( + "built_in:prisma", + include_bytes!("../../assets/bearded-icons/prisma.svg"), + ), + ( + "built_in:proto", + include_bytes!("../../assets/bearded-icons/proto.svg"), + ), + ( + "built_in:python", + include_bytes!("../../assets/bearded-icons/python.svg"), + ), + ( + "built_in:r", + include_bytes!("../../assets/bearded-icons/r.svg"), + ), + ( + "built_in:reactjs", + include_bytes!("../../assets/bearded-icons/reactjs.svg"), + ), + ( + "built_in:readme", + include_bytes!("../../assets/bearded-icons/readme.svg"), + ), + ( + "built_in:remix", + include_bytes!("../../assets/bearded-icons/remix.svg"), + ), + ( + "built_in:ruby", + include_bytes!("../../assets/bearded-icons/ruby.svg"), + ), + ( + "built_in:rust", + include_bytes!("../../assets/bearded-icons/rust.svg"), + ), + ( + "built_in:sass", + include_bytes!("../../assets/bearded-icons/sass.svg"), + ), + ( + "built_in:scala", + include_bytes!("../../assets/bearded-icons/scala.svg"), + ), + ( + "built_in:sequelize", + include_bytes!("../../assets/bearded-icons/sequelize.svg"), + ), + ( + "built_in:shell", + include_bytes!("../../assets/bearded-icons/shell.svg"), + ), + ( + "built_in:sol", + include_bytes!("../../assets/bearded-icons/sol.svg"), + ), + ( + "built_in:sql", + include_bytes!("../../assets/bearded-icons/sql.svg"), + ), + ( + "built_in:storybook", + include_bytes!("../../assets/bearded-icons/storybook.svg"), + ), + ( + "built_in:svelte", + include_bytes!("../../assets/bearded-icons/svelte.svg"), + ), + ( + "built_in:svelteconfig", + include_bytes!("../../assets/bearded-icons/svelteconfig.svg"), + ), + ( + "built_in:swift", + include_bytes!("../../assets/bearded-icons/swift.svg"), + ), + ( + "built_in:tailwind", + include_bytes!("../../assets/bearded-icons/tailwind.svg"), + ), + ( + "built_in:tauri", + include_bytes!("../../assets/bearded-icons/tauri.svg"), + ), + ( + "built_in:terraform", + include_bytes!("../../assets/bearded-icons/terraform.svg"), + ), + ( + "built_in:testjs", + include_bytes!("../../assets/bearded-icons/testjs.svg"), + ), + ( + "built_in:testts", + include_bytes!("../../assets/bearded-icons/testts.svg"), + ), + ( + "built_in:todo", + include_bytes!("../../assets/bearded-icons/todo.svg"), + ), + ( + "built_in:toml", + include_bytes!("../../assets/bearded-icons/toml.svg"), + ), + ( + "built_in:tsconfig", + include_bytes!("../../assets/bearded-icons/tsconfig.svg"), + ), + ( + "built_in:tsx", + include_bytes!("../../assets/bearded-icons/tsx.svg"), + ), + ( + "built_in:turbo", + include_bytes!("../../assets/bearded-icons/turbo.svg"), + ), + ( + "built_in:typescript", + include_bytes!("../../assets/bearded-icons/typescript.svg"), + ), + ( + "built_in:unocss", + include_bytes!("../../assets/bearded-icons/unocss.svg"), + ), + ( + "built_in:vercel", + include_bytes!("../../assets/bearded-icons/vercel.svg"), + ), + ( + "built_in:vite", + include_bytes!("../../assets/bearded-icons/vite.svg"), + ), + ( + "built_in:vitest", + include_bytes!("../../assets/bearded-icons/vitest.svg"), + ), + ( + "built_in:vue", + include_bytes!("../../assets/bearded-icons/vue.svg"), + ), + ( + "built_in:vueconfig", + include_bytes!("../../assets/bearded-icons/vueconfig.svg"), + ), + ( + "built_in:warning", + include_bytes!("../../assets/bearded-icons/warning.svg"), + ), + ( + "built_in:webpack", + include_bytes!("../../assets/bearded-icons/webpack.svg"), + ), + ( + "built_in:windi", + include_bytes!("../../assets/bearded-icons/windi.svg"), + ), + ( + "built_in:xml", + include_bytes!("../../assets/bearded-icons/xml.svg"), + ), + ( + "built_in:yaml", + include_bytes!("../../assets/bearded-icons/yaml.svg"), + ), + ( + "built_in:yarnlock", + include_bytes!("../../assets/bearded-icons/yarnlock.svg"), + ), + ( + "built_in:zig", + include_bytes!("../../assets/bearded-icons/zig.svg"), + ), ]; let icon_size = 96u32; let padding = 8u32; @@ -581,12 +1077,25 @@ fn build_bearded_atlas() -> BuiltAtlas { let row = idx as u32 / cols; let x = col * cell + padding; let y = row * cell + padding; - let icon = rasterize_svg(svg, icon_size, icon_size).unwrap_or_else(|| vec![0; (icon_size * icon_size * 4) as usize]); + let icon = rasterize_svg(svg, icon_size, icon_size) + .unwrap_or_else(|| vec![0; (icon_size * icon_size * 4) as usize]); blit_rgba(&mut rgba, size, x, y, icon_size, icon_size, &icon); - entries.insert(*id, AtlasEntry { x, y, w: icon_size, h: icon_size }); + entries.insert( + *id, + AtlasEntry { + x, + y, + w: icon_size, + h: icon_size, + }, + ); } - BuiltAtlas { size, rgba, entries } + BuiltAtlas { + size, + rgba, + entries, + } } fn rasterize_svg(svg: &[u8], width: u32, height: u32) -> Option> { diff --git a/src/render/renderer.rs b/src/render/renderer.rs index 7daf9070..8bc92fd0 100644 --- a/src/render/renderer.rs +++ b/src/render/renderer.rs @@ -113,6 +113,32 @@ pub(super) struct TextScissorBatch { pub(super) range: InstanceDrawRange, } +/// Status-bar LSP indicator for the active file's language server. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LspStatusIndicator { + /// Active file has no language profile — hide the indicator entirely. + NotApplicable, + /// A profile exists but no server is running. + Inactive, + /// Server process is being spawned/initialized. + Starting(String), + /// Server is alive and serving this workspace. + Running(String), + /// Server binary is missing — install guide shown. + Missing(String), +} + +/// Status-bar chip state for AI inline completion. None (not passed) hides the chip. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AiInlineStatus { + /// Provider configured and idle. + Ready, + /// A completion request is in flight. + Loading, + /// Circuit breaker tripped — completions paused after repeated failures. + Error, +} + #[derive(Debug, Clone, PartialEq)] pub(super) struct StatusbarLayoutKey { pub(super) mode: EditorMode, @@ -129,6 +155,8 @@ pub(super) struct StatusbarLayoutKey { pub(super) lsp_loading: bool, pub(super) lsp_loading_frame: u8, pub(super) lsp_progress: Option, + pub(super) lsp_indicator: LspStatusIndicator, + pub(super) ai_status: Option, pub(super) bounds: [f32; 4], pub(super) venv_name: Option, pub(super) python_version: Option, @@ -289,6 +317,9 @@ pub struct Renderer { pub(super) panel_padding: f32, pub(super) panel_corner_radius: f32, pub(super) round_ui: bool, + /// Effective runtime UI scale (dpi_scale × content_scale). Single source for + /// scaling hardcoded px chrome (paddings, radii) so they track text size. + pub(super) ui_scale: f32, pub(super) sidebar_base_padding: f32, pub(super) sidebar_indent_per_depth: f32, pub(super) topbar_padding_x: f32, @@ -321,6 +352,13 @@ pub struct Renderer { pub(super) toast_chrome_instances: Vec, pub(super) toast_scissor: Option<[u32; 4]>, + // ── Which-key overlay (pending chord hints) ────────────────────────────── + pub(super) whichkey_text_system: TextSystem, + pub(super) whichkey_text_pipeline: TextPipeline, + pub(super) whichkey_glyph_instances: Vec, + pub(super) whichkey_chrome_instances: Vec, + pub(super) whichkey_scissor: Option<[u32; 4]>, + // ── AI Chat (RightSidebar) ───────────────────────────────────────────── pub(super) ai_chat_text_system: TextSystem, pub(super) ai_chat_text_pipeline: TextPipeline, @@ -357,7 +395,12 @@ impl Renderer { center_bounds: [f32; 4], down: bool, ) -> Option { - crate::render::renderer::editor::soft_wrap_visual_move_target(self, app_state, center_bounds, down) + crate::render::renderer::editor::soft_wrap_visual_move_target( + self, + app_state, + center_bounds, + down, + ) } pub fn editor_chrome_instances(&self) -> &[RegionDrawInstance] { diff --git a/src/render/renderer/components.rs b/src/render/renderer/components.rs index d2f4c992..b2316c38 100644 --- a/src/render/renderer/components.rs +++ b/src/render/renderer/components.rs @@ -1,13 +1,11 @@ - -mod highlight_chip; mod help_keycaps; +mod highlight_chip; mod prefix_icon_badge; mod shortcut_hint; - -pub(super) use highlight_chip::{push_centered_highlight_chip, HighlightChipStyle}; pub(super) use help_keycaps::{estimate_help_keycaps_width, layout_help_keycaps}; +pub(super) use highlight_chip::{HighlightChipStyle, push_centered_highlight_chip}; pub(super) use prefix_icon_badge::{ - layout_prefix_icon_badge, PrefixIconBadge, PrefixIconBadgeChrome, + PrefixIconBadge, PrefixIconBadgeChrome, layout_prefix_icon_badge, }; -pub(super) use shortcut_hint::{layout_shortcut_hint, ShortcutHintSegment}; +pub(super) use shortcut_hint::{ShortcutHintSegment, layout_shortcut_hint}; diff --git a/src/render/renderer/components/help_keycaps.rs b/src/render/renderer/components/help_keycaps.rs index 4d8f6344..72c6bce8 100644 --- a/src/render/renderer/components/help_keycaps.rs +++ b/src/render/renderer/components/help_keycaps.rs @@ -46,10 +46,7 @@ pub fn help_keycap_palette( .trim() .to_ascii_lowercase(); - let (tone, text) = if matches!( - normalized.as_str(), - "cmd" | "⌘" | "option" | "opt" | "alt" - ) { + let (tone, text) = if matches!(normalized.as_str(), "cmd" | "⌘" | "option" | "opt" | "alt") { (accent, fg) } else if matches!(normalized.as_str(), "spc" | "space" | "leader") { (warning, fg) @@ -101,7 +98,11 @@ pub fn layout_help_keycaps( let mut cursor_x = origin_x; let scale = (font_size / 14.0).max(0.5); let key_gap = (font_size * 0.44).max(4.0 * scale); - let key_height = layout_clamp(font_size + 12.0 * scale, 24.0 * scale, row_height - 6.0 * scale); + let key_height = layout_clamp( + font_size + 12.0 * scale, + 24.0 * scale, + row_height - 6.0 * scale, + ); let key_radius = layout_clamp(key_height * 0.20, 4.0 * scale, 12.0 * scale); let border_thickness = layout_clamp(key_height * 0.06, 1.0, 1.5 * scale); let key_padding_x = layout_clamp(font_size * 0.8, 8.0 * scale, 24.0 * scale); diff --git a/src/render/renderer/components/prefix_icon_badge.rs b/src/render/renderer/components/prefix_icon_badge.rs index c12a42f2..f302691c 100644 --- a/src/render/renderer/components/prefix_icon_badge.rs +++ b/src/render/renderer/components/prefix_icon_badge.rs @@ -62,7 +62,15 @@ pub fn layout_prefix_icon_badge( let icon_y = y + (h - icon_line_h) * 0.5 + icon_size * badge.y_nudge_scale; text_system.set_metrics(cosmic_text::Metrics::new(icon_size, icon_line_h)); text_system.set_size(Some(w), Some(icon_line_h)); - layout_panel_text_bold(badge.icon, text_system, atlas, queue, icon_x, icon_y, icon_color) + layout_panel_text_bold( + badge.icon, + text_system, + atlas, + queue, + icon_x, + icon_y, + icon_color, + ) } fn blend_icon_badge_color(base: [f32; 4], tint: [f32; 4], amount: f32) -> [f32; 4] { @@ -74,4 +82,3 @@ fn blend_icon_badge_color(base: [f32; 4], tint: [f32; 4], amount: f32) -> [f32; 1.0, ] } - diff --git a/src/render/renderer/editor.rs b/src/render/renderer/editor.rs index 3839c389..e87aad59 100644 --- a/src/render/renderer/editor.rs +++ b/src/render/renderer/editor.rs @@ -2,9 +2,9 @@ mod buffers; mod completion; +mod extensions; mod fuzzy; mod help; -mod extensions; mod overlays; mod selections; mod settings; @@ -173,8 +173,7 @@ pub(super) fn soft_wrap_visual_move_target( && cursor_byte <= run.glyphs.last().map(|g| g.end).unwrap_or(usize::MAX) })?; let target_run_index = runs.iter().position(|run| { - let top = geometry.origin_y - + run.line_top + let top = geometry.origin_y + run.line_top - app_state.folded_visual_y_offset_before(run.line_i, run.line_height); let bottom = top + run.line_height.max(1.0); target_y >= top && target_y < bottom @@ -184,7 +183,11 @@ pub(super) fn soft_wrap_visual_move_target( } let target_run = &runs[target_run_index]; - let mut target_byte = target_run.glyphs.last().map(|g| g.end).unwrap_or(cursor_byte); + let mut target_byte = target_run + .glyphs + .last() + .map(|g| g.end) + .unwrap_or(cursor_byte); for glyph in target_run.glyphs { let glyph_mid = geometry.origin_x + glyph.x + glyph.w * 0.5; if target_x <= glyph_mid { @@ -209,11 +212,12 @@ pub(super) fn editor_viewport_geometry( let gutter_width = gutter_width_for_editor(gutter_digits, font_size, line_height); let scroll_x = app_state.scroll_column as f32 * (font_size * 0.6).max(1.0); let top_inset = EDITOR_BREADCRUMB_TOP_INSET; - let breadcrumb_height = if renderer.editor_breadcrumb_segments.is_empty() && !renderer.dap_is_active { - 0.0 - } else { - line_height + EDITOR_BREADCRUMB_PAD_Y * 2.0 + EDITOR_BREADCRUMB_GAP_Y - }; + let breadcrumb_height = + if renderer.editor_breadcrumb_segments.is_empty() && !renderer.dap_is_active { + 0.0 + } else { + line_height + EDITOR_BREADCRUMB_PAD_Y * 2.0 + EDITOR_BREADCRUMB_GAP_Y + }; let viewport_text_left = center_bounds[0] + left_inset + gutter_width; let origin_x = viewport_text_left - scroll_x; let viewport_text_width = diff --git a/src/render/renderer/editor/buffers.rs b/src/render/renderer/editor/buffers.rs index 1bee5873..efd0473c 100644 --- a/src/render/renderer/editor/buffers.rs +++ b/src/render/renderer/editor/buffers.rs @@ -5,8 +5,8 @@ mod diagnostics; use crate::{ app::app_state::{ AppState, CompletionDisplayItem, DiagnosticsState, EditorOverlay, FloatingBoxBlock, - FloatingBoxStyle, HelpState, OverlayColorToken, ReferencesBufferItem, ReferencesBufferState, - SettingItem, SettingsState, + FloatingBoxStyle, HelpState, OverlayColorToken, ReferencesBufferItem, + ReferencesBufferState, SettingItem, SettingsState, }, async_runtime::message::LspDiagnostic, config::theme_config::ThemeConfig, @@ -45,25 +45,30 @@ impl Renderer { return; } + // Scale hardcoded chrome px so paddings/offsets track the runtime-scaled + // text metrics across monitors (same pattern as extensions.rs). + let s = self.ui_scale.max(0.5); let font_size = self.theme.editor.font_size; - let line_height = self.theme.editor.line_height.max(font_size + 4.0); + let line_height = self.theme.editor.line_height.max(font_size + 4.0 * s); self.editor_overlay_text_system .set_metrics(Metrics::new(font_size, line_height)); self.editor_overlay_scissor = rect_to_scissor(center_bounds); - let pad_x = self.editor_padding_x.max(14.0); - let pad_y = self.editor_padding_y.max(14.0); + let pad_x = self.editor_padding_x.max(14.0 * s); + let pad_y = self.editor_padding_y.max(14.0 * s); let panel_x = center_bounds[0] + pad_x; let panel_y = center_bounds[1] + pad_y; let panel_w = (center_bounds[2] - pad_x * 2.0).max(1.0); let panel_h = (center_bounds[3] - pad_y * 2.0).max(1.0); let gap = 0.0; - let left_w = (panel_w * 0.56).clamp(420.0, 720.0).min(panel_w * 0.62); + let left_w = (panel_w * 0.56) + .clamp(420.0 * s, 720.0 * s) + .min(panel_w * 0.62); let right_w = (panel_w - left_w - gap).max(1.0); let left_x = panel_x; let right_x = left_x + left_w + gap; - let header_h = line_height + 14.0; - let footer_h = line_height + 10.0; + let header_h = line_height + 14.0 * s; + let footer_h = line_height + 10.0 * s; let content_top = panel_y + header_h; let content_bottom = (panel_y + panel_h - footer_h).max(content_top + line_height); let content_h = (content_bottom - content_top).max(line_height); @@ -88,9 +93,13 @@ impl Renderer { RegionDrawInstance::new([right_x, panel_y + header_h - 1.0, right_w, 1.0], divider), ]; - let header_y = panel_y + 6.0; + let header_y = panel_y + 6.0 * s; let unique_file_count = references.path_counts.len(); - let left_title = if references.loading { "References · loading..." } else { "References" }; + let left_title = if references.loading { + "References · loading..." + } else { + "References" + }; let count_label = &mut self.temp_string_buffer; count_label.clear(); let _ = write!( @@ -99,15 +108,16 @@ impl Renderer { references.items.len(), unique_file_count ); - let header_count_w = estimate_monospace_width(" 999 refs · 999 files", font_size).min(left_w * 0.55); + let header_count_w = + estimate_monospace_width(" 999 refs · 999 files", font_size).min(left_w * 0.55); self.editor_overlay_text_system - .set_size(Some((left_w - 20.0).max(1.0)), Some(line_height)); + .set_size(Some((left_w - 20.0 * s).max(1.0)), Some(line_height)); glyphs.extend(layout_panel_text_bold( left_title, &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - left_x + 10.0, + left_x + 10.0 * s, header_y, fg, )); @@ -116,7 +126,7 @@ impl Renderer { &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - (left_x + left_w - header_count_w - 10.0).max(left_x + 100.0), + (left_x + left_w - header_count_w - 10.0 * s).max(left_x + 100.0 * s), header_y, fg_dim, )); @@ -140,31 +150,31 @@ impl Renderer { .unwrap_or("No reference selected") }; self.editor_overlay_text_system - .set_size(Some((right_w - 20.0).max(1.0)), Some(line_height)); + .set_size(Some((right_w - 20.0 * s).max(1.0)), Some(line_height)); glyphs.extend(layout_panel_text( - &clamp_monospace_text(&right_header, (right_w - 20.0).max(1.0), font_size), + &clamp_monospace_text(&right_header, (right_w - 20.0 * s).max(1.0), font_size), &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - right_x + 10.0, + right_x + 10.0 * s, header_y, fg, )); let help_text = "J/K / Ctrl+N/P / Up/Down to navigate | Enter to open | Esc/Q to close"; self.editor_overlay_text_system - .set_size(Some((panel_w - 20.0).max(1.0)), Some(line_height)); + .set_size(Some((panel_w - 20.0 * s).max(1.0)), Some(line_height)); glyphs.extend(layout_panel_text( - &clamp_monospace_text(help_text, (panel_w - 20.0).max(1.0), font_size), + &clamp_monospace_text(help_text, (panel_w - 20.0 * s).max(1.0), font_size), &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - panel_x + 10.0, - panel_y + panel_h - footer_h + 4.0, + panel_x + 10.0 * s, + panel_y + panel_h - footer_h + 4.0 * s, fg_ghost, )); - let left_text_width = (left_w - 20.0).max(1.0); + let left_text_width = (left_w - 20.0 * s).max(1.0); if references.items.is_empty() { let status = references .status_message @@ -177,13 +187,13 @@ impl Renderer { &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - left_x + 14.0, - content_top + 4.0, + left_x + 14.0 * s, + content_top + 4.0 * s, fg_ghost, )); } else { - let row_h = line_height + 8.0; - let group_header_h = line_height + 7.0; + let row_h = line_height + 8.0 * s; + let group_header_h = line_height + 7.0 * s; let visible_rows = ((content_h / row_h).floor() as usize).max(1); let mut start_idx = references.selected_index.saturating_sub(visible_rows / 2); if start_idx + visible_rows > references.items.len() { @@ -206,32 +216,49 @@ impl Renderer { let mut group_bg = self.theme.ui.overlay_bg.as_f32(); group_bg[3] = (group_bg[3] * 0.85).clamp(0.18, 0.45); chrome.push(RegionDrawInstance::new( - [left_x + 8.0, draw_y + 2.0, (left_w - 16.0).max(1.0), (line_height + 3.0).max(12.0)], + [ + left_x + 8.0 * s, + draw_y + 2.0 * s, + (left_w - 16.0 * s).max(1.0), + (line_height + 3.0 * s).max(12.0 * s), + ], group_bg, )); - let group_count = references.path_counts.get(&item.relative_path).copied().unwrap_or(0); + let group_count = references + .path_counts + .get(&item.relative_path) + .copied() + .unwrap_or(0); count_label.clear(); let _ = write!(count_label, "{}", group_count); - let count_w = estimate_monospace_width(count_label, font_size).max(16.0); + let count_w = estimate_monospace_width(count_label, font_size).max(16.0 * s); right_header_buffer.clear(); let _ = write!(right_header_buffer, "▾ {}", file_name); glyphs.extend(layout_panel_text_bold( - &clamp_monospace_text(right_header_buffer, left_text_width * 0.50, font_size), + &clamp_monospace_text( + right_header_buffer, + left_text_width * 0.50, + font_size, + ), &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - left_x + 16.0, - draw_y + 4.0, + left_x + 16.0 * s, + draw_y + 4.0 * s, self.theme.ui.accent.as_f32(), )); let folder_x = left_x + left_w * 0.54; glyphs.extend(layout_panel_text( - &clamp_monospace_text(&folder_label, (left_x + left_w - folder_x - count_w - 26.0).max(1.0), font_size), + &clamp_monospace_text( + &folder_label, + (left_x + left_w - folder_x - count_w - 26.0 * s).max(1.0), + font_size, + ), &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, folder_x, - draw_y + 4.0, + draw_y + 4.0 * s, fg_ghost, )); glyphs.extend(layout_panel_text_bold( @@ -239,8 +266,8 @@ impl Renderer { &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - left_x + left_w - count_w - 18.0, - draw_y + 4.0, + left_x + left_w - count_w - 18.0 * s, + draw_y + 4.0 * s, fg, )); draw_y += group_header_h; @@ -251,29 +278,34 @@ impl Renderer { let is_selected = item_idx == references.selected_index; if is_selected { chrome.push(RegionDrawInstance::new( - [left_x + 6.0, row_y, (left_w - 12.0).max(1.0), row_h - 4.0], + [ + left_x + 6.0 * s, + row_y, + (left_w - 12.0 * s).max(1.0), + row_h - 4.0 * s, + ], selection_bg, )); chrome.push(RegionDrawInstance::new( - [left_x + 6.0, row_y, 3.0, row_h - 4.0], + [left_x + 6.0 * s, row_y, 3.0 * s, row_h - 4.0 * s], accent, )); } count_label.clear(); let _ = write!(count_label, "{}", item.line + 1); - let line_w = estimate_monospace_width("9999", font_size).max(34.0); + let line_w = estimate_monospace_width("9999", font_size).max(34.0 * s); glyphs.extend(layout_panel_text( count_label, &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - left_x + 22.0, - row_y + 4.0, + left_x + 22.0 * s, + row_y + 4.0 * s, if is_selected { fg } else { fg_ghost }, )); - let snippet_x = left_x + 22.0 + line_w; - let snippet_w = (left_x + left_w - snippet_x - 10.0).max(1.0); + let snippet_x = left_x + 22.0 * s + line_w; + let snippet_w = (left_x + left_w - snippet_x - 10.0 * s).max(1.0); let snippet = reference_row_summary(item, right_header_buffer); glyphs.extend(layout_panel_text( &clamp_monospace_text(snippet, snippet_w, font_size), @@ -281,7 +313,7 @@ impl Renderer { &mut self.atlas, &self.queue, snippet_x, - row_y + 4.0, + row_y + 4.0 * s, if is_selected { fg } else { fg_dim }, )); @@ -301,8 +333,8 @@ impl Renderer { .map(|line| line.line_number) .unwrap_or(1) ); - let line_number_width = estimate_monospace_width(count_label, font_size) + 14.0; - let preview_text_width = (right_w - 20.0 - line_number_width).max(1.0); + let line_number_width = estimate_monospace_width(count_label, font_size) + 14.0 * s; + let preview_text_width = (right_w - 20.0 * s - line_number_width).max(1.0); let preview_rows = ((content_h / line_height).floor() as usize).max(1); let mut preview_start = 0usize; if let Some(target_idx) = references @@ -326,11 +358,16 @@ impl Renderer { let row_y = content_top + slot as f32 * line_height; if line.is_target { chrome.push(RegionDrawInstance::new( - [right_x + 6.0, row_y, (right_w - 12.0).max(1.0), line_height], + [ + right_x + 6.0 * s, + row_y, + (right_w - 12.0 * s).max(1.0), + line_height, + ], selection_bg, )); chrome.push(RegionDrawInstance::new( - [right_x + 6.0, row_y, 3.0, line_height], + [right_x + 6.0 * s, row_y, 3.0 * s, line_height], accent, )); } @@ -344,7 +381,7 @@ impl Renderer { &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - right_x + 10.0, + right_x + 10.0 * s, row_y, if line.is_target { warning } else { fg_ghost }, )); @@ -381,7 +418,7 @@ impl Renderer { &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - right_x + 10.0 + line_number_width, + right_x + 10.0 * s + line_number_width, row_y, )); } @@ -397,14 +434,14 @@ impl Renderer { "Loading preview..." }; self.editor_overlay_text_system - .set_size(Some((right_w - 20.0).max(1.0)), Some(line_height)); + .set_size(Some((right_w - 20.0 * s).max(1.0)), Some(line_height)); glyphs.extend(layout_panel_text( empty_message, &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - right_x + 10.0, - content_top + 4.0, + right_x + 10.0 * s, + content_top + 4.0 * s, fg_ghost, )); } @@ -441,10 +478,6 @@ fn compact_reference_path(path: &str) -> String { } } - - - - fn reference_row_summary<'a>(item: &'a ReferencesBufferItem, buffer: &'a mut String) -> &'a str { let summary = item.summary.trim(); if summary.is_empty() || summary.starts_with("Ln ") { diff --git a/src/render/renderer/editor/buffers/diagnostics.rs b/src/render/renderer/editor/buffers/diagnostics.rs index 5124386e..51d5a99d 100644 --- a/src/render/renderer/editor/buffers/diagnostics.rs +++ b/src/render/renderer/editor/buffers/diagnostics.rs @@ -11,9 +11,9 @@ use crate::{ core::mode::EditorMode, render::{ glyph_instance::GlyphInstance, + icon_pipeline::{IconDrawInstance, canonical_icon_id}, region_pipeline::RegionDrawInstance, renderer::Renderer, - icon_pipeline::{IconDrawInstance, canonical_icon_id}, }, text::layout_sync::{compute_caret_layout, compute_cursor_overlay, rebuild_layout_projection}, }; @@ -22,8 +22,7 @@ use cosmic_text::Metrics; use super::super::super::helpers::{ caret_rect_for_mode, clamp_monospace_text, estimate_monospace_width, gutter_width_for_editor, layout_panel_rich_text, layout_panel_text, layout_panel_text_bold, layout_panel_text_italic, - rect_to_scissor, - should_draw_block_cursor, + rect_to_scissor, should_draw_block_cursor, }; use super::super::{cursor_diagnostic, editor_viewport_geometry, run_x_for_byte, wrap_text_lines}; use crate::text::text_system::StyledTextSpan; @@ -84,11 +83,31 @@ impl Renderer { ]; let header_y = panel_y + 6.0; - let error_count = diagnostics.results.iter().filter(|item| item.severity == Some(1)).count(); - let warning_count = diagnostics.results.iter().filter(|item| item.severity == Some(2)).count(); - let info_count = diagnostics.results.iter().filter(|item| item.severity == Some(3)).count(); - let hint_count = diagnostics.results.iter().filter(|item| item.severity == Some(4)).count(); - let none_count = diagnostics.results.iter().filter(|item| item.severity.is_none()).count(); + let error_count = diagnostics + .results + .iter() + .filter(|item| item.severity == Some(1)) + .count(); + let warning_count = diagnostics + .results + .iter() + .filter(|item| item.severity == Some(2)) + .count(); + let info_count = diagnostics + .results + .iter() + .filter(|item| item.severity == Some(3)) + .count(); + let hint_count = diagnostics + .results + .iter() + .filter(|item| item.severity == Some(4)) + .count(); + let none_count = diagnostics + .results + .iter() + .filter(|item| item.severity.is_none()) + .count(); let unique_file_count = count_diagnostic_files(&diagnostics.results); let count_label = format!( @@ -96,7 +115,8 @@ impl Renderer { diagnostics.results.len(), unique_file_count ); - let header_count_w = estimate_monospace_width(" 999 problems · 999 files", font_size).min(left_w * 0.58); + let header_count_w = + estimate_monospace_width(" 999 problems · 999 files", font_size).min(left_w * 0.58); self.editor_overlay_text_system .set_size(Some((left_w - 20.0).max(1.0)), Some(line_height)); glyphs.extend(layout_panel_text_bold( @@ -181,7 +201,10 @@ impl Renderer { } let footer_y = panel_y + panel_h - footer_h; - chrome.push(RegionDrawInstance::new([left_x, footer_y, left_w, 1.0], divider)); + chrome.push(RegionDrawInstance::new( + [left_x, footer_y, left_w, 1.0], + divider, + )); let footer_text = format!( "↑↓ navigate | Enter open diagnostic | Esc/Q close • ×{} ▲{} i{} •{} ?{}", error_count, warning_count, info_count, hint_count, none_count @@ -224,14 +247,26 @@ impl Renderer { let mut group_bg = self.theme.ui.overlay_bg.as_f32(); group_bg[3] = (group_bg[3] * 0.85).clamp(0.18, 0.45); chrome.push(RegionDrawInstance::new( - [left_x + 8.0, draw_y + 2.0, (left_w - 16.0).max(1.0), (line_height + 3.0).max(12.0)], + [ + left_x + 8.0, + draw_y + 2.0, + (left_w - 16.0).max(1.0), + (line_height + 3.0).max(12.0), + ], group_bg, )); - let group_count = count_diagnostics_for_path(&diagnostics.results, &item.file_path.display().to_string()); + let group_count = count_diagnostics_for_path( + &diagnostics.results, + &item.file_path.display().to_string(), + ); let count_label = format!("{}", group_count); let count_w = estimate_monospace_width(&count_label, font_size).max(16.0); glyphs.extend(layout_panel_text_bold( - &clamp_monospace_text(&format!("▾ {}", file_name), left_text_width * 0.50, font_size), + &clamp_monospace_text( + &format!("▾ {}", file_name), + left_text_width * 0.50, + font_size, + ), &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, @@ -241,7 +276,11 @@ impl Renderer { )); let folder_x = left_x + left_w * 0.54; glyphs.extend(layout_panel_text( - &clamp_monospace_text(&folder_label, (left_x + left_w - folder_x - count_w - 26.0).max(1.0), font_size), + &clamp_monospace_text( + &folder_label, + (left_x + left_w - folder_x - count_w - 26.0).max(1.0), + font_size, + ), &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, @@ -440,7 +479,10 @@ impl Renderer { self.editor_overlay_icon_pipeline.upload_instances( &self.device, &self.editor_overlay_icon_instances, - [self.surface_state.config.width, self.surface_state.config.height], + [ + self.surface_state.config.width, + self.surface_state.config.height, + ], ); self.editor_overlay_text_pipeline.upload_instances( &self.device, diff --git a/src/render/renderer/editor/extensions.rs b/src/render/renderer/editor/extensions.rs index 1d52ff52..a8d459e5 100644 --- a/src/render/renderer/editor/extensions.rs +++ b/src/render/renderer/editor/extensions.rs @@ -26,14 +26,22 @@ impl Renderer { return; } - let font_size = self.theme.editor.font_size.max(13.0); - let line_height = self.theme.editor.line_height.max(font_size + 6.0).max(21.0); + // Single scale source for hardcoded chrome px so paddings/radii track the + // (already runtime-scaled) text metrics across monitors. + let s = self.ui_scale.max(0.5); + let font_size = self.theme.editor.font_size.max(13.0 * s); + let line_height = self + .theme + .editor + .line_height + .max(font_size + 6.0 * s) + .max(21.0 * s); self.editor_overlay_text_system .set_metrics(Metrics::new(font_size, line_height)); self.editor_overlay_scissor = rect_to_scissor(center_bounds); - let pad_x = self.editor_padding_x.max(18.0); - let pad_y = self.editor_padding_y.max(18.0); + let pad_x = self.editor_padding_x.max(18.0 * s); + let pad_y = self.editor_padding_y.max(18.0 * s); let panel_x = center_bounds[0] + pad_x; let panel_y = center_bounds[1] + pad_y; let panel_w = (center_bounds[2] - pad_x * 2.0).max(1.0); @@ -52,15 +60,15 @@ impl Renderer { let mut divider = ghost; divider[3] = divider[3].clamp(0.28, 0.42); - let titlebar_h = line_height + 10.0; - let header_h = titlebar_h + line_height * 4.0 + 72.0; + let titlebar_h = line_height + 10.0 * s; + let header_h = titlebar_h + line_height * 4.0 + 72.0 * s; let content_top = panel_y + header_h; let content_bottom = panel_y + panel_h; let rows_bottom = content_bottom.max(content_top + line_height); let content_h = (rows_bottom - content_top).max(line_height); - let inner_x = panel_x + 14.0; - let inner_w = (panel_w - 28.0).max(1.0); + let inner_x = panel_x + 14.0 * s; + let inner_w = (panel_w - 28.0 * s).max(1.0); let text_w = inner_w.max(1.0); if let Some(cmd_state) = &state.command { @@ -69,7 +77,10 @@ impl Renderer { let mut chrome = vec![ RegionDrawInstance::new([panel_x, panel_y, panel_w, panel_h], panel_bg), RegionDrawInstance::new([panel_x, panel_y, panel_w, titlebar_h], panel_bg), - RegionDrawInstance::new([panel_x, panel_y + titlebar_h - 1.0, panel_w, 1.0], divider), + RegionDrawInstance::new( + [panel_x, panel_y + titlebar_h - 1.0, panel_w, 1.0], + divider, + ), ]; let mut glyphs = Vec::new(); @@ -85,9 +96,12 @@ impl Renderer { fg, )); - let runtime_scale = (font_size / 14.0).max(0.5); - let popup_w = ((panel_w * 0.82).max(380.0 * runtime_scale)).min(panel_w - 16.0).max(180.0); - let popup_h = ((panel_h * 0.72).max(260.0 * runtime_scale)).min(panel_h - 16.0).max(120.0); + let popup_w = ((panel_w * 0.82).max(380.0 * s)) + .min(panel_w - 16.0 * s) + .max(180.0); + let popup_h = ((panel_h * 0.72).max(260.0 * s)) + .min(panel_h - 16.0 * s) + .max(120.0); let popup_x = panel_x + (panel_w - popup_w) * 0.5; let popup_y = panel_y + (panel_h - popup_h) * 0.5; @@ -104,21 +118,45 @@ impl Renderer { }; // Draw solid popup border and body card - chrome.push(RegionDrawInstance::new([popup_x - 1.5, popup_y - 1.5, popup_w + 3.0, popup_h + 3.0], border_color).with_radius(6.0)); - chrome.push(RegionDrawInstance::new([popup_x, popup_y, popup_w, popup_h], card_bg).with_radius(5.5)); + chrome.push( + RegionDrawInstance::new( + [ + popup_x - 1.5 * s, + popup_y - 1.5 * s, + popup_w + 3.0 * s, + popup_h + 3.0 * s, + ], + border_color, + ) + .with_radius(6.0 * s), + ); + chrome.push( + RegionDrawInstance::new([popup_x, popup_y, popup_w, popup_h], card_bg) + .with_radius(5.5 * s), + ); - let header_divider_h = line_height + 14.0; + let header_divider_h = line_height + 14.0 * s; let mut divider_color = ghost; divider_color[3] = 0.25; - chrome.push(RegionDrawInstance::new([popup_x, popup_y + header_divider_h, popup_w, 1.0], divider_color)); + chrome.push(RegionDrawInstance::new( + [popup_x, popup_y + header_divider_h, popup_w, 1.0], + divider_color, + )); - let action_txt = if cmd_state.uninstall { "Uninstalling" } else { "Installing" }; + let action_txt = if cmd_state.uninstall { + "Uninstalling" + } else { + "Installing" + }; let status_label = if cmd_state.running { format!("⚡ {} {}...", action_txt, cmd_state.binary) } else if cmd_state.success == Some(true) { format!("✓ {} complete for {}", action_txt, cmd_state.binary) } else { - format!("✗ {} failed for {} (code {:?})", action_txt, cmd_state.binary, cmd_state.exit_code) + format!( + "✗ {} failed for {} (code {:?})", + action_txt, cmd_state.binary, cmd_state.exit_code + ) }; glyphs.extend(layout_panel_text_bold( @@ -126,25 +164,30 @@ impl Renderer { &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - popup_x + 16.0, - popup_y + 8.0, + popup_x + 16.0 * s, + popup_y + 8.0 * s, border_color, )); - let log_box_x = popup_x + 16.0; - let log_box_y = popup_y + header_divider_h + 12.0; - let log_box_w = popup_w - 32.0; - let log_box_h = (popup_h - header_divider_h - line_height - 36.0).max(40.0); + let log_box_x = popup_x + 16.0 * s; + let log_box_y = popup_y + header_divider_h + 12.0 * s; + let log_box_w = popup_w - 32.0 * s; + let log_box_h = (popup_h - header_divider_h - line_height - 36.0 * s).max(40.0 * s); // Make the logs container box also COMPLETELY solid (alpha = 1.0) using solid panel bg let mut inner_box_bg = panel_bg; inner_box_bg[3] = 1.0; - chrome.push(RegionDrawInstance::new([log_box_x, log_box_y, log_box_w, log_box_h], inner_box_bg).with_radius(4.0)); + chrome.push( + RegionDrawInstance::new([log_box_x, log_box_y, log_box_w, log_box_h], inner_box_bg) + .with_radius(4.0 * s), + ); - let log_font_size = (font_size * 0.85).max(11.0); - let log_line_h = (line_height * 0.85).max(18.0); - self.editor_overlay_text_system.set_metrics(Metrics::new(log_font_size, log_line_h)); - self.editor_overlay_text_system.set_size(Some(log_box_w - 16.0), Some(log_line_h)); + let log_font_size = (font_size * 0.85).max(11.0 * s); + let log_line_h = (line_height * 0.85).max(18.0 * s); + self.editor_overlay_text_system + .set_metrics(Metrics::new(log_font_size, log_line_h)); + self.editor_overlay_text_system + .set_size(Some(log_box_w - 16.0 * s), Some(log_line_h)); if cmd_state.logs.is_empty() { glyphs.extend(layout_panel_text( @@ -152,18 +195,19 @@ impl Renderer { &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - log_box_x + 10.0, - log_box_y + 8.0, + log_box_x + 10.0 * s, + log_box_y + 8.0 * s, ghost, )); } else { let char_width = (log_font_size * 0.6).max(1.0); - let max_chars = ((log_box_w - 20.0) / char_width).floor() as usize; + let max_chars = ((log_box_w - 20.0 * s) / char_width).floor() as usize; let max_chars = max_chars.max(10); let mut wrapped_lines = Vec::new(); for line in &cmd_state.logs { - let is_err = line.contains("npm error") || line.to_lowercase().contains("error"); + let is_err = + line.contains("npm error") || line.to_lowercase().contains("error"); let chars: Vec = line.chars().collect(); if chars.is_empty() { wrapped_lines.push((String::new(), is_err)); @@ -180,7 +224,7 @@ impl Renderer { let max_lines = ((log_box_h / log_line_h).floor() as usize).max(1); let start_idx = wrapped_lines.len().saturating_sub(max_lines); - let mut cur_y = log_box_y + 6.0; + let mut cur_y = log_box_y + 6.0 * s; for (line_text, is_err) in &wrapped_lines[start_idx..] { let color = if *is_err { warning } else { dim }; glyphs.extend(layout_panel_text( @@ -188,7 +232,7 @@ impl Renderer { &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - log_box_x + 10.0, + log_box_x + 10.0 * s, cur_y, color, )); @@ -196,11 +240,13 @@ impl Renderer { } } - self.editor_overlay_text_system.set_metrics(Metrics::new(font_size, line_height)); - self.editor_overlay_text_system.set_size(Some(text_w), Some(line_height)); + self.editor_overlay_text_system + .set_metrics(Metrics::new(font_size, line_height)); + self.editor_overlay_text_system + .set_size(Some(text_w), Some(line_height)); let helper_text = if cmd_state.running { - "Logs are streaming in real-time. Press Esc to hide popup." + "Logs are streaming in real-time. Please wait for the operation to finish..." } else { "Operation finished. Press Esc to dismiss this popup." }; @@ -209,8 +255,8 @@ impl Renderer { &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - popup_x + 16.0, - popup_y + popup_h - line_height - 10.0, + popup_x + 16.0 * s, + popup_y + popup_h - line_height - 10.0 * s, ghost, )); @@ -232,8 +278,8 @@ impl Renderer { ]; let mut glyphs = Vec::new(); - let inner_x = panel_x + 14.0; - let inner_w = (panel_w - 28.0).max(1.0); + let inner_x = panel_x + 14.0 * s; + let inner_w = (panel_w - 28.0 * s).max(1.0); let text_w = inner_w.max(1.0); self.editor_overlay_text_system @@ -244,24 +290,37 @@ impl Renderer { &mut self.atlas, &self.queue, inner_x, - panel_y + 5.0, + panel_y + 5.0 * s, fg, )); - let shortcuts_y = panel_y + titlebar_h + 8.0; - let shortcut_h = (line_height * 0.82).max(16.0); + let shortcuts_y = panel_y + titlebar_h + 8.0 * s; + let shortcut_h = (line_height * 0.82).max(16.0 * s); chrome.push( - RegionDrawInstance::new([inner_x, shortcuts_y - 2.0, inner_w, shortcut_h + 6.0], overlay) - .with_radius(4.0), + RegionDrawInstance::new( + [ + inner_x, + shortcuts_y - 2.0 * s, + inner_w, + shortcut_h + 6.0 * s, + ], + overlay, + ) + .with_radius(4.0 * s), ); - let shortcut_font = (font_size * 0.72).max(10.0); - let shortcut_line_h = (line_height * 0.72).max(14.0); + let shortcut_font = (font_size * 0.72).max(10.0 * s); + let shortcut_line_h = (line_height * 0.72).max(14.0 * s); self.editor_overlay_text_system .set_metrics(Metrics::new(shortcut_font, shortcut_line_h)); self.editor_overlay_text_system .set_size(None, Some(shortcut_line_h)); - let shortcuts: &[(&str, &str)] = if inner_w < 520.0 { - &[("Ctrl-N/P", "nav"), ("i", "install"), ("u", "uninstall"), ("/", "filter")] + let shortcuts: &[(&str, &str)] = if inner_w < 520.0 * s { + &[ + ("Ctrl-N/P", "nav"), + ("i", "install"), + ("u", "uninstall"), + ("/", "filter"), + ] } else { &[ ("Ctrl-N/P", "nav"), @@ -271,25 +330,36 @@ impl Renderer { ("/", "filter"), ] }; - let mut shortcut_x = inner_x + 10.0; - let key_text_gap = 14.0; - let group_gap = 28.0; - let key_pad_x = 7.0; - let key_h = shortcut_h.max(shortcut_line_h + 7.0); + let mut shortcut_x = inner_x + 10.0 * s; + let key_text_gap = 14.0 * s; + let group_gap = 28.0 * s; + let key_pad_x = 7.0 * s; + let key_h = shortcut_h.max(shortcut_line_h + 7.0 * s); for (key, label) in shortcuts { let key_w = estimate_monospace_width(key, shortcut_font) + key_pad_x * 2.0; let label_w = estimate_monospace_width(label, shortcut_font); - if shortcut_x + key_w + key_text_gap + label_w > inner_x + inner_w - 8.0 { + if shortcut_x + key_w + key_text_gap + label_w > inner_x + inner_w - 8.0 * s { break; } let mut key_outline = accent; key_outline[3] = key_outline[3].max(0.82); let mut key_fill = editor_bg; key_fill[3] = key_fill[3].max(0.92); - chrome.push(RegionDrawInstance::new([shortcut_x, shortcuts_y - 1.0, key_w, key_h], key_outline).with_radius(4.0)); chrome.push( - RegionDrawInstance::new([shortcut_x + 1.5, shortcuts_y + 0.5, key_w - 3.0, key_h - 3.0], key_fill) - .with_radius(3.0), + RegionDrawInstance::new([shortcut_x, shortcuts_y - 1.0, key_w, key_h], key_outline) + .with_radius(4.0 * s), + ); + chrome.push( + RegionDrawInstance::new( + [ + shortcut_x + 1.5 * s, + shortcuts_y + 0.5 * s, + key_w - 3.0 * s, + key_h - 3.0 * s, + ], + key_fill, + ) + .with_radius(3.0 * s), ); glyphs.extend(layout_panel_text_bold( key, @@ -316,22 +386,40 @@ impl Renderer { self.editor_overlay_text_system .set_size(Some(text_w), Some(line_height)); - let tabs_y = panel_y + titlebar_h + line_height + 24.0; - let tab_gap = 8.0; + let tabs_y = panel_y + titlebar_h + line_height + 24.0 * s; + let tab_gap = 8.0 * s; let tabs = [ (ExtensionsTab::All, format!("All {}", state.items.len())), - (ExtensionsTab::Installed, format!("Installed {}", state.installed_count())), - (ExtensionsTab::Available, format!("Available {}", state.available_count())), + ( + ExtensionsTab::Installed, + format!("Installed {}", state.installed_count()), + ), + ( + ExtensionsTab::Available, + format!("Available {}", state.available_count()), + ), ]; let mut tab_x = inner_x; for (tab_kind, label) in tabs { let active = state.tab == tab_kind; - let tab_w = estimate_monospace_width(&label, font_size) + 22.0; + let tab_w = estimate_monospace_width(&label, font_size) + 22.0 * s; let mut tab_bg = if active { selection } else { overlay }; tab_bg[3] = tab_bg[3].max(if active { 0.95 } else { 0.62 }); - chrome.push(RegionDrawInstance::new([tab_x, tabs_y - 5.0, tab_w, line_height + 10.0], tab_bg).with_radius(5.0)); + chrome.push( + RegionDrawInstance::new( + [tab_x, tabs_y - 5.0 * s, tab_w, line_height + 10.0 * s], + tab_bg, + ) + .with_radius(5.0 * s), + ); if active { - chrome.push(RegionDrawInstance::new([tab_x, tabs_y + line_height + 3.0, tab_w, 2.0], accent).with_radius(1.0)); + chrome.push( + RegionDrawInstance::new( + [tab_x, tabs_y + line_height + 3.0 * s, tab_w, 2.0 * s], + accent, + ) + .with_radius(1.0 * s), + ); } glyphs.extend(if active { layout_panel_text_bold( @@ -339,7 +427,7 @@ impl Renderer { &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - tab_x + 11.0, + tab_x + 11.0 * s, tabs_y, accent, ) @@ -349,7 +437,7 @@ impl Renderer { &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - tab_x + 11.0, + tab_x + 11.0 * s, tabs_y, ghost, ) @@ -357,9 +445,9 @@ impl Renderer { tab_x += tab_w + tab_gap; } - let summary_y = tabs_y + line_height + 12.0; + let summary_y = tabs_y + line_height + 12.0 * s; chrome.push(RegionDrawInstance::new( - [inner_x, summary_y, inner_w, line_height + 18.0], + [inner_x, summary_y, inner_w, line_height + 18.0 * s], overlay, )); let installed_label = format!("⚡ {} installed", state.installed_count()); @@ -368,11 +456,12 @@ impl Renderer { &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - inner_x + 14.0, - summary_y + 8.0, + inner_x + 14.0 * s, + summary_y + 8.0 * s, success, )); - let available_x = inner_x + 14.0 + estimate_monospace_width(&installed_label, font_size) + 28.0; + let available_x = + inner_x + 14.0 * s + estimate_monospace_width(&installed_label, font_size) + 28.0 * s; let available_label = format!("↓ {} available", state.available_count()); glyphs.extend(layout_panel_text_bold( &available_label, @@ -380,47 +469,56 @@ impl Renderer { &mut self.atlas, &self.queue, available_x, - summary_y + 8.0, + summary_y + 8.0 * s, warning, )); let platform_label = format!("Platform: {}", state.platform); - let platform_x = available_x + estimate_monospace_width(&available_label, font_size) + 34.0; + let platform_x = + available_x + estimate_monospace_width(&available_label, font_size) + 34.0 * s; let total_label = format!("{} total extensions", state.items.len()); let total_w = estimate_monospace_width(&total_label, font_size); - let total_x = inner_x + inner_w - total_w - 14.0; - if platform_x + estimate_monospace_width(&platform_label, font_size) + 18.0 < total_x { + let total_x = inner_x + inner_w - total_w - 14.0 * s; + if platform_x + estimate_monospace_width(&platform_label, font_size) + 18.0 * s < total_x { glyphs.extend(layout_panel_text( &platform_label, &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, platform_x, - summary_y + 8.0, + summary_y + 8.0 * s, dim, )); } - if total_x > available_x + estimate_monospace_width(&available_label, font_size) + 18.0 { + if total_x > available_x + estimate_monospace_width(&available_label, font_size) + 18.0 * s + { glyphs.extend(layout_panel_text( &total_label, &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, total_x, - summary_y + 8.0, + summary_y + 8.0 * s, ghost, )); } - let filter_y = summary_y + line_height + 30.0; - let filter_bg = if state.filter_focused { selection } else { editor_bg }; + let filter_y = summary_y + line_height + 30.0 * s; + let filter_bg = if state.filter_focused { + selection + } else { + editor_bg + }; chrome.push(RegionDrawInstance::new( - [inner_x, filter_y, inner_w, line_height + 16.0], + [inner_x, filter_y, inner_w, line_height + 16.0 * s], filter_bg, )); if state.filter_focused { - chrome.push(RegionDrawInstance::new([inner_x, filter_y, inner_w, 2.0], accent)); chrome.push(RegionDrawInstance::new( - [inner_x, filter_y + line_height + 14.0, inner_w, 2.0], + [inner_x, filter_y, inner_w, 2.0 * s], + accent, + )); + chrome.push(RegionDrawInstance::new( + [inner_x, filter_y + line_height + 14.0 * s, inner_w, 2.0 * s], accent, )); } @@ -430,19 +528,19 @@ impl Renderer { format!("ext› {}", state.filter) }; glyphs.extend(layout_panel_text( - &clamp_monospace_text(&filter_text, inner_w - 24.0, font_size), + &clamp_monospace_text(&filter_text, inner_w - 24.0 * s, font_size), &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - inner_x + 12.0, - filter_y + 8.0, + inner_x + 12.0 * s, + filter_y + 8.0 * s, if state.filter.is_empty() { ghost } else { fg }, )); let entries = build_extension_entries(state); - let section_h = line_height + 10.0; - let row_h = (line_height * 2.0 + 16.0).max(54.0); - let detail_h = (line_height * 4.0 + 42.0).max(124.0); + let section_h = line_height + 10.0 * s; + let row_h = (line_height * 2.0 + 16.0 * s).max(54.0 * s); + let detail_h = (line_height * 4.0 + 42.0 * s).max(124.0 * s); let mut virtual_y = 0.0f32; let mut selected_virtual_y = 0.0f32; for entry in &entries { @@ -453,27 +551,43 @@ impl Renderer { selected_virtual_y = virtual_y; } virtual_y += row_h; - if state.items.get(abs_idx).is_some_and(|item| state.expanded_binary.as_deref() == Some(item.binary.as_str())) { + if state.items.get(abs_idx).is_some_and(|item| { + state.expanded_binary.as_deref() == Some(item.binary.as_str()) + }) { virtual_y += detail_h; } } } } let total_virtual_h = virtual_y; - let scroll_y = (selected_virtual_y + row_h - content_h + 8.0).max(0.0); + let scroll_y = (selected_virtual_y + row_h - content_h + 8.0 * s).max(0.0); let scroll_y = scroll_y.min((total_virtual_h - content_h).max(0.0)); if total_virtual_h > content_h { - let track_x = panel_x + panel_w - 7.0; - let track_top = content_top + 4.0; - let track_h = (rows_bottom - track_top - 4.0).max(8.0); - let thumb_h = (content_h / total_virtual_h * track_h).max(18.0).min(track_h); + let track_x = panel_x + panel_w - 7.0 * s; + let track_top = content_top + 4.0 * s; + let track_h = (rows_bottom - track_top - 4.0 * s).max(8.0 * s); + let thumb_h = (content_h / total_virtual_h * track_h) + .max(18.0 * s) + .min(track_h); let thumb_y = track_top + (scroll_y / total_virtual_h * track_h).min(track_h - thumb_h); - chrome.push(RegionDrawInstance::new([track_x, track_top, 3.0, track_h], with_alpha(ghost, 0.12)).with_radius(1.5)); - chrome.push(RegionDrawInstance::new([track_x, thumb_y, 3.0, thumb_h], with_alpha(ghost, 0.45)).with_radius(1.5)); + chrome.push( + RegionDrawInstance::new( + [track_x, track_top, 3.0 * s, track_h], + with_alpha(ghost, 0.12), + ) + .with_radius(1.5 * s), + ); + chrome.push( + RegionDrawInstance::new( + [track_x, thumb_y, 3.0 * s, thumb_h], + with_alpha(ghost, 0.45), + ) + .with_radius(1.5 * s), + ); } - let mut entry_y = content_top + 8.0 - scroll_y; + let mut entry_y = content_top + 8.0 * s - scroll_y; let selected_abs = state.selected_item_index(); for entry in entries { match entry { @@ -490,7 +604,7 @@ impl Renderer { &mut self.atlas, &self.queue, inner_x, - entry_y + 4.0, + entry_y + 4.0 * s, fg, )); let count = format!("{installed}/{total} installed"); @@ -500,7 +614,7 @@ impl Renderer { &mut self.atlas, &self.queue, inner_x + inner_w - estimate_monospace_width(&count, font_size), - entry_y + 4.0, + entry_y + 4.0 * s, success, )); } @@ -514,43 +628,67 @@ impl Renderer { if entry_y + row_h > content_top && entry_y < rows_bottom { let is_selected = selected_abs == Some(abs_idx); chrome.push(RegionDrawInstance::new( - [inner_x, entry_y, inner_w, row_h - 4.0], + [inner_x, entry_y, inner_w, row_h - 4.0 * s], if is_selected { selection } else { overlay }, )); if is_selected { - chrome.push(RegionDrawInstance::new([inner_x, entry_y, 3.0, row_h - 4.0], accent)); + chrome.push(RegionDrawInstance::new( + [inner_x, entry_y, 3.0 * s, row_h - 4.0 * s], + accent, + )); } - let status_color = if item.installed { success } else { warning }; - let status = if item.installed { "✓ Installed" } else { "⇩ Not installed" }; - let compact_row = inner_w < 480.0; - let right_pad = 14.0; - + let status_color = if !state.deps_checked { + ghost + } else if item.installed { + success + } else { + warning + }; + let status = if !state.deps_checked { + "… Checking" + } else if item.installed { + "✓ Installed" + } else { + "⇩ Not installed" + }; + let compact_row = inner_w < 480.0 * s; + let right_pad = 14.0 * s; + // Dynamically scale column widths using screen percentage clamped to optimal premium ranges. // This prevents columns from expanding excessively and squishing everything on 2K/High-DPI displays. let status_w = if compact_row { - (inner_w * 0.22).clamp(100.0, 140.0) + (inner_w * 0.22).clamp(100.0 * s, 140.0 * s) } else { - (inner_w * 0.16).clamp(130.0, 180.0) + (inner_w * 0.16).clamp(130.0 * s, 180.0 * s) }; let tag_w = if compact_row { 0.0 } else { - (inner_w * 0.12).clamp(90.0, 140.0) + (inner_w * 0.12).clamp(90.0 * s, 140.0 * s) }; - + let status_x = inner_x + inner_w - status_w - right_pad; - let tag_x = status_x - tag_w - 18.0; - let name_right = if compact_row { status_x - 14.0 } else { tag_x - 18.0 }; - let name_x = inner_x + 36.0; - let name_w = (name_right - name_x).max(40.0); - let chevron = if state.expanded_binary.as_deref() == Some(item.binary.as_str()) { "▾" } else { "▸" }; + let tag_x = status_x - tag_w - 18.0 * s; + let name_right = if compact_row { + status_x - 14.0 * s + } else { + tag_x - 18.0 * s + }; + let name_x = inner_x + 36.0 * s; + let name_w = (name_right - name_x).max(40.0 * s); + let chevron = + if state.expanded_binary.as_deref() == Some(item.binary.as_str()) { + "▾" + } else { + "▸" + }; glyphs.extend(layout_panel_text_bold( chevron, &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - inner_x + 16.0, - entry_y + 8.0, + inner_x + 16.0 * s, + entry_y + 8.0 * s, if is_selected { accent } else { ghost }, )); glyphs.extend(layout_panel_text_bold( @@ -559,7 +697,7 @@ impl Renderer { &mut self.atlas, &self.queue, name_x, - entry_y + 8.0, + entry_y + 8.0 * s, if is_selected { fg } else { dim }, )); glyphs.extend(layout_panel_text( @@ -568,7 +706,7 @@ impl Renderer { &mut self.atlas, &self.queue, name_x, - entry_y + 8.0 + line_height, + entry_y + 8.0 * s + line_height, dim, )); if !compact_row { @@ -578,7 +716,7 @@ impl Renderer { &mut self.atlas, &self.queue, tag_x, - entry_y + 8.0 + line_height * 0.5, + entry_y + 8.0 * s + line_height * 0.5, ghost, )); } @@ -588,7 +726,7 @@ impl Renderer { &mut self.atlas, &self.queue, status_x, - entry_y + 8.0 + line_height * 0.5, + entry_y + 8.0 * s + line_height * 0.5, status_color, )); } @@ -596,46 +734,91 @@ impl Renderer { if state.expanded_binary.as_deref() == Some(item.binary.as_str()) { let detail_y = entry_y; let detail_color = with_alpha(selection, 0.56); - chrome.push(RegionDrawInstance::new([inner_x + 12.0, detail_y, inner_w - 24.0, detail_h - 8.0], detail_color).with_radius(6.0)); - chrome.push(RegionDrawInstance::new([inner_x + 12.0, detail_y, 3.0, detail_h - 8.0], accent).with_radius(1.5)); - let install = if state.platform == "macOS" { &item.macos_install } else { &item.linux_install }; - let uninstall = if state.platform == "macOS" { &item.macos_uninstall } else { &item.linux_uninstall }; - let files = if item.extensions.is_empty() { "files: any".to_string() } else { format!("files: {}", item.extensions.join(", ")) }; + chrome.push( + RegionDrawInstance::new( + [ + inner_x + 12.0 * s, + detail_y, + inner_w - 24.0 * s, + detail_h - 8.0 * s, + ], + detail_color, + ) + .with_radius(6.0 * s), + ); + chrome.push( + RegionDrawInstance::new( + [inner_x + 12.0 * s, detail_y, 3.0 * s, detail_h - 8.0 * s], + accent, + ) + .with_radius(1.5 * s), + ); + let install = if state.platform == "macOS" { + &item.macos_install + } else { + &item.linux_install + }; + let uninstall = if state.platform == "macOS" { + &item.macos_uninstall + } else { + &item.linux_uninstall + }; + let files = if item.extensions.is_empty() { + "files: any".to_string() + } else { + format!("files: {}", item.extensions.join(", ")) + }; glyphs.extend(layout_panel_text_bold( - &clamp_monospace_text(&format!("{} details", item.name), inner_w - 52.0, font_size), + &clamp_monospace_text( + &format!("{} details", item.name), + inner_w - 52.0 * s, + font_size, + ), &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - inner_x + 28.0, - detail_y + 12.0, + inner_x + 28.0 * s, + detail_y + 12.0 * s, fg, )); glyphs.extend(layout_panel_text( - &clamp_monospace_text(&format!("binary: {} • {}", item.binary, files), inner_w - 52.0, font_size), + &clamp_monospace_text( + &format!("binary: {} • {}", item.binary, files), + inner_w - 52.0 * s, + font_size, + ), &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - inner_x + 28.0, - detail_y + 12.0 + line_height, + inner_x + 28.0 * s, + detail_y + 12.0 * s + line_height, dim, )); glyphs.extend(layout_panel_text( - &clamp_monospace_text(&format!("install: {install}"), inner_w - 52.0, font_size), + &clamp_monospace_text( + &format!("install: {install}"), + inner_w - 52.0 * s, + font_size, + ), &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - inner_x + 28.0, - detail_y + 12.0 + line_height * 2.0, + inner_x + 28.0 * s, + detail_y + 12.0 * s + line_height * 2.0, if item.installed { dim } else { warning }, )); if !uninstall.trim().is_empty() { glyphs.extend(layout_panel_text( - &clamp_monospace_text(&format!("uninstall: {uninstall}"), inner_w - 52.0, font_size), + &clamp_monospace_text( + &format!("uninstall: {uninstall}"), + inner_w - 52.0 * s, + font_size, + ), &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - inner_x + 28.0, - detail_y + 12.0 + line_height * 3.0, + inner_x + 28.0 * s, + detail_y + 12.0 * s + line_height * 3.0, if item.installed { success } else { ghost }, )); } diff --git a/src/render/renderer/editor/fuzzy.rs b/src/render/renderer/editor/fuzzy.rs index a5e1fa5a..9c513b4d 100644 --- a/src/render/renderer/editor/fuzzy.rs +++ b/src/render/renderer/editor/fuzzy.rs @@ -36,23 +36,29 @@ impl Renderer { return; } + // Scale hardcoded chrome px so paddings/offsets track the runtime-scaled + // text metrics across monitors (same pattern as extensions.rs). + let s = self.ui_scale.max(0.5); let font_size = self.theme.editor.font_size; - let line_height = self.theme.editor.line_height.max(font_size + 4.0); + let line_height = self.theme.editor.line_height.max(font_size + 4.0 * s); self.editor_overlay_text_system .set_metrics(Metrics::new(font_size, line_height)); self.editor_overlay_scissor = rect_to_scissor(center_bounds); - let pad_x = self.editor_padding_x.max(14.0); - let pad_y = self.editor_padding_y.max(14.0); + let pad_x = self.editor_padding_x.max(14.0 * s); + let pad_y = self.editor_padding_y.max(14.0 * s); let panel_x = center_bounds[0] + pad_x; let panel_y = center_bounds[1] + pad_y; let panel_w = (center_bounds[2] - pad_x * 2.0).max(1.0); let panel_h = (center_bounds[3] - pad_y * 2.0).max(1.0); - let is_live_grep = fuzzy_state.mode == crate::app::command_palette::CommandPaletteMode::LiveGrep; - let gap = if is_live_grep { 0.0 } else { 16.0 }; + let is_live_grep = + fuzzy_state.mode == crate::app::command_palette::CommandPaletteMode::LiveGrep; + let gap = if is_live_grep { 0.0 } else { 16.0 * s }; let left_w = if is_live_grep { - (panel_w * 0.56).clamp(420.0, 720.0).min(panel_w * 0.62) + (panel_w * 0.56) + .clamp(420.0 * s, 720.0 * s) + .min(panel_w * 0.62) } else { (panel_w * 0.5).max(1.0) }; @@ -61,11 +67,11 @@ impl Renderer { let right_x = left_x + left_w + gap; let header_h = if is_live_grep { - line_height * 2.0 + 30.0 + line_height * 2.0 + 30.0 * s } else { - line_height + 14.0 + line_height + 14.0 * s }; - let footer_h = line_height + 10.0; + let footer_h = line_height + 10.0 * s; let content_top = panel_y + header_h; let content_bottom = (panel_y + panel_h - footer_h).max(content_top + line_height); let content_h = (content_bottom - content_top).max(line_height); @@ -95,7 +101,11 @@ impl Renderer { RegionDrawInstance::new([right_x, panel_y + header_h - 1.0, right_w, 1.0], divider), ]; - let header_y = if is_live_grep { panel_y + 12.0 } else { panel_y + 6.0 }; + let header_y = if is_live_grep { + panel_y + 12.0 * s + } else { + panel_y + 6.0 * s + }; let title = match fuzzy_state.mode { crate::app::command_palette::CommandPaletteMode::FilePicker => "File Picker", @@ -106,7 +116,11 @@ impl Renderer { let unique_file_count = if is_live_grep { let mut paths = Vec::::new(); for item in &fuzzy_state.results { - if let crate::app::command_palette::CommandPaletteAction::OpenSearchMatch { path, .. } = &item.action { + if let crate::app::command_palette::CommandPaletteAction::OpenSearchMatch { + path, + .. + } = &item.action + { let path_label = path.display().to_string(); if !paths.iter().any(|existing| existing == &path_label) { paths.push(path_label); @@ -118,51 +132,61 @@ impl Renderer { 0 }; let left_header = if is_live_grep { - format!("{} {} results · {} files", title, fuzzy_state.results.len(), unique_file_count) + format!( + "{} {} results · {} files", + title, + fuzzy_state.results.len(), + unique_file_count + ) } else { format!("{} > {}", title, fuzzy_state.query) }; self.editor_overlay_text_system - .set_size(Some((left_w - 20.0).max(1.0)), Some(line_height)); + .set_size(Some((left_w - 20.0 * s).max(1.0)), Some(line_height)); if is_live_grep { - let header_count_w = estimate_monospace_width(" 999 results · 999 files", font_size).min(left_w * 0.55); + let header_count_w = + estimate_monospace_width(" 999 results · 999 files", font_size).min(left_w * 0.55); glyphs.extend(layout_panel_text( title, &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - left_x + 10.0, + left_x + 10.0 * s, header_y, fg, )); - let count_label = format!("{} results · {} files", fuzzy_state.results.len(), unique_file_count); + let count_label = format!( + "{} results · {} files", + fuzzy_state.results.len(), + unique_file_count + ); glyphs.extend(layout_panel_text( &clamp_monospace_text(&count_label, header_count_w, font_size), &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - (left_x + left_w - header_count_w - 10.0).max(left_x + 90.0), + (left_x + left_w - header_count_w - 10.0 * s).max(left_x + 90.0 * s), header_y, fg_dim, )); } else { glyphs.extend(layout_panel_text( - &clamp_monospace_text(&left_header, (left_w - 20.0).max(1.0), font_size), + &clamp_monospace_text(&left_header, (left_w - 20.0 * s).max(1.0), font_size), &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - left_x + 10.0, + left_x + 10.0 * s, header_y, fg, )); } if is_live_grep { - let search_box_x = left_x + 14.0; - let search_box_y = header_y + line_height + 6.0; - let search_box_w = (left_w - 28.0).max(1.0); - let search_box_h = line_height + 8.0; + let search_box_x = left_x + 14.0 * s; + let search_box_y = header_y + line_height + 6.0 * s; + let search_box_w = (left_w - 28.0 * s).max(1.0); + let search_box_h = line_height + 8.0 * s; chrome.push(RegionDrawInstance::new( [search_box_x, search_box_y, search_box_w, search_box_h], self.theme.ui.overlay_bg.as_f32(), @@ -174,34 +198,47 @@ impl Renderer { }; let aa_text = "Aa"; let aa_text_w = estimate_monospace_width(aa_text, font_size); - let option_w = aa_text_w + 22.0; + let option_w = aa_text_w + 22.0 * s; glyphs.extend(layout_panel_text( - &clamp_monospace_text(&search_text, (search_box_w - option_w - 18.0).max(1.0), font_size), + &clamp_monospace_text( + &search_text, + (search_box_w - option_w - 18.0 * s).max(1.0), + font_size, + ), &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - search_box_x + 10.0, - search_box_y + 4.0, - if fuzzy_state.query.trim().is_empty() { fg_ghost } else { fg_dim }, + search_box_x + 10.0 * s, + search_box_y + 4.0 * s, + if fuzzy_state.query.trim().is_empty() { + fg_ghost + } else { + fg_dim + }, )); let aa_active = fuzzy_state.live_grep_case_sensitive; if aa_active { let mut aa_bg = self.theme.ui.accent.as_f32(); aa_bg[3] = aa_bg[3].clamp(0.35, 0.70); chrome.push(RegionDrawInstance::new( - [search_box_x + search_box_w - option_w - 8.0, search_box_y + 3.0, option_w, (line_height + 2.0).max(12.0)], + [ + search_box_x + search_box_w - option_w - 8.0 * s, + search_box_y + 3.0 * s, + option_w, + (line_height + 2.0 * s).max(12.0 * s), + ], aa_bg, )); } - let aa_box_x = search_box_x + search_box_w - option_w - 8.0; - let aa_box_h = (line_height + 2.0).max(12.0); + let aa_box_x = search_box_x + search_box_w - option_w - 8.0 * s; + let aa_box_h = (line_height + 2.0 * s).max(12.0 * s); glyphs.extend(layout_panel_text( aa_text, &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, aa_box_x + ((option_w - aa_text_w) * 0.5).max(0.0), - search_box_y + 3.0 + ((aa_box_h - line_height) * 0.5).max(0.0), + search_box_y + 3.0 * s + ((aa_box_h - line_height) * 0.5).max(0.0), if aa_active { fg } else { fg_ghost }, )); } @@ -211,8 +248,17 @@ impl Renderer { .map(|item| { if is_live_grep { match &item.action { - crate::app::command_palette::CommandPaletteAction::OpenSearchMatch { path, line, column } => { - format!("{}:{}:{}", compact_search_path(&path.display().to_string()), line, column) + crate::app::command_palette::CommandPaletteAction::OpenSearchMatch { + path, + line, + column, + } => { + format!( + "{}:{}:{}", + compact_search_path(&path.display().to_string()), + line, + column + ) } _ => item.label.clone(), } @@ -223,13 +269,13 @@ impl Renderer { .unwrap_or_else(|| "No file selected".to_string()); self.editor_overlay_text_system - .set_size(Some((right_w - 20.0).max(1.0)), Some(line_height)); + .set_size(Some((right_w - 20.0 * s).max(1.0)), Some(line_height)); glyphs.extend(layout_panel_text( - &clamp_monospace_text(&right_header, (right_w - 20.0).max(1.0), font_size), + &clamp_monospace_text(&right_header, (right_w - 20.0 * s).max(1.0), font_size), &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - right_x + 10.0, + right_x + 10.0 * s, header_y, fg, )); @@ -242,30 +288,34 @@ impl Renderer { _ => "Type to search | Ctrl+N/P / Up/Down to navigate | Enter to open", }; self.editor_overlay_text_system - .set_size(Some((panel_w - 20.0).max(1.0)), Some(line_height)); + .set_size(Some((panel_w - 20.0 * s).max(1.0)), Some(line_height)); glyphs.extend(layout_panel_text( - &clamp_monospace_text(help_text, (panel_w - 20.0).max(1.0), font_size), + &clamp_monospace_text(help_text, (panel_w - 20.0 * s).max(1.0), font_size), &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - panel_x + 10.0, - panel_y + panel_h - footer_h + 4.0, + panel_x + 10.0 * s, + panel_y + panel_h - footer_h + 4.0 * s, fg_ghost, )); } let row_h = if is_live_grep { - line_height + 8.0 + line_height + 8.0 * s + } else { + line_height * 2.0 + 8.0 * s + }; + let group_header_h = if is_live_grep { + line_height + 7.0 * s } else { - line_height * 2.0 + 8.0 + 0.0 }; - let group_header_h = if is_live_grep { line_height + 7.0 } else { 0.0 }; let visible_rows = ((content_h / row_h).floor() as usize).max(1); let mut start_idx = fuzzy_state.selected_index.saturating_sub(visible_rows / 2); if start_idx + visible_rows > fuzzy_state.results.len() { start_idx = fuzzy_state.results.len().saturating_sub(visible_rows); } - let left_text_width = (left_w - 20.0).max(1.0); + let left_text_width = (left_w - 20.0 * s).max(1.0); let mut draw_y = content_top; let mut previous_group_path: Option = None; let mut rendered_rows = 0usize; @@ -276,9 +326,10 @@ impl Renderer { let item = &fuzzy_state.results[item_idx]; let current_group_path = if is_live_grep { match &item.action { - crate::app::command_palette::CommandPaletteAction::OpenSearchMatch { path, .. } => { - Some(path.display().to_string()) - } + crate::app::command_palette::CommandPaletteAction::OpenSearchMatch { + path, + .. + } => Some(path.display().to_string()), _ => None, } } else { @@ -295,29 +346,43 @@ impl Renderer { let mut group_bg = self.theme.ui.overlay_bg.as_f32(); group_bg[3] = (group_bg[3] * 0.85).clamp(0.18, 0.45); chrome.push(RegionDrawInstance::new( - [left_x + 8.0, draw_y + 2.0, (left_w - 16.0).max(1.0), (line_height + 3.0).max(12.0)], + [ + left_x + 8.0 * s, + draw_y + 2.0 * s, + (left_w - 16.0 * s).max(1.0), + (line_height + 3.0 * s).max(12.0 * s), + ], group_bg, )); - let group_count = count_search_matches_for_path(&fuzzy_state.results, path_label); + let group_count = + count_search_matches_for_path(&fuzzy_state.results, path_label); let count_label = format!("{}", group_count); - let count_w = estimate_monospace_width(&count_label, font_size).max(16.0); + let count_w = estimate_monospace_width(&count_label, font_size).max(16.0 * s); glyphs.extend(layout_panel_text_bold( - &clamp_monospace_text(&format!("▾ {}", file_name), left_text_width * 0.50, font_size), + &clamp_monospace_text( + &format!("▾ {}", file_name), + left_text_width * 0.50, + font_size, + ), &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - left_x + 16.0, - draw_y + 4.0, + left_x + 16.0 * s, + draw_y + 4.0 * s, self.theme.ui.accent.as_f32(), )); let folder_x = left_x + left_w * 0.54; glyphs.extend(layout_panel_text( - &clamp_monospace_text(&folder_label, (left_x + left_w - folder_x - count_w - 26.0).max(1.0), font_size), + &clamp_monospace_text( + &folder_label, + (left_x + left_w - folder_x - count_w - 26.0 * s).max(1.0), + font_size, + ), &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, folder_x, - draw_y + 4.0, + draw_y + 4.0 * s, fg_ghost, )); glyphs.extend(layout_panel_text_bold( @@ -325,8 +390,8 @@ impl Renderer { &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - left_x + left_w - count_w - 18.0, - draw_y + 4.0, + left_x + left_w - count_w - 18.0 * s, + draw_y + 4.0 * s, fg, )); draw_y += group_header_h; @@ -337,11 +402,16 @@ impl Renderer { let is_selected = item_idx == fuzzy_state.selected_index; if is_selected { chrome.push(RegionDrawInstance::new( - [left_x + 6.0, row_y, (left_w - 12.0).max(1.0), row_h - 4.0], + [ + left_x + 6.0 * s, + row_y, + (left_w - 12.0 * s).max(1.0), + row_h - 4.0 * s, + ], selection_bg, )); chrome.push(RegionDrawInstance::new( - [left_x + 6.0, row_y, 3.0, row_h - 4.0], + [left_x + 6.0 * s, row_y, 3.0 * s, row_h - 4.0 * s], accent, )); } @@ -374,8 +444,8 @@ impl Renderer { &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - left_x + 14.0, - row_y + 4.0, + left_x + 14.0 * s, + row_y + 4.0 * s, row_label_color, )); } @@ -383,21 +453,25 @@ impl Renderer { let summary = item.secondary_label.clone().unwrap_or_default(); if !summary.is_empty() { let summary_x = if is_live_grep { - left_x + 22.0 + left_x + 22.0 * s } else { - left_x + 14.0 + left_x + 14.0 * s }; - let summary_w = (left_x + left_w - summary_x - 10.0).max(1.0); + let summary_w = (left_x + left_w - summary_x - 10.0 * s).max(1.0); self.editor_overlay_text_system .set_size(Some(summary_w), Some(line_height)); let summary_y = if is_live_grep { - row_y + 4.0 + row_y + 4.0 * s } else { - row_y + line_height + 2.0 + row_y + line_height + 2.0 * s }; if is_live_grep && !fuzzy_state.query.trim().is_empty() { let clamped_summary = clamp_monospace_text(&summary, summary_w, font_size); - let spans = search_summary_spans(&clamped_summary, &fuzzy_state.query, self.theme.ui.warning.as_u8()); + let spans = search_summary_spans( + &clamped_summary, + &fuzzy_state.query, + self.theme.ui.warning.as_u8(), + ); glyphs.extend(layout_panel_rich_text( &clamped_summary, &spans, @@ -426,21 +500,24 @@ impl Renderer { if is_live_grep { let footer_y = panel_y + panel_h - footer_h; - chrome.push(RegionDrawInstance::new([left_x, footer_y, left_w, 1.0], divider)); + chrome.push(RegionDrawInstance::new( + [left_x, footer_y, left_w, 1.0], + divider, + )); let footer_text = format!( "↑↓ navigate | Enter open match | Esc/Q close • {} results {} files", fuzzy_state.results.len(), unique_file_count ); self.editor_overlay_text_system - .set_size(Some((left_w - 20.0).max(1.0)), Some(line_height)); + .set_size(Some((left_w - 20.0 * s).max(1.0)), Some(line_height)); glyphs.extend(layout_panel_text( - &clamp_monospace_text(&footer_text, (left_w - 20.0).max(1.0), font_size), + &clamp_monospace_text(&footer_text, (left_w - 20.0 * s).max(1.0), font_size), &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - left_x + 10.0, - footer_y + 4.0, + left_x + 10.0 * s, + footer_y + 4.0 * s, fg_ghost, )); } @@ -456,8 +533,8 @@ impl Renderer { .unwrap_or(1) ), font_size, - ) + 14.0; - let preview_text_width = (right_w - 20.0 - line_number_width).max(1.0); + ) + 14.0 * s; + let preview_text_width = (right_w - 20.0 * s - line_number_width).max(1.0); let preview_rows = ((content_h / line_height).floor() as usize).max(1); let mut preview_start = 0usize; if let Some(target_idx) = fuzzy_state @@ -488,18 +565,28 @@ impl Renderer { }; if let Some(bg) = line_bg { chrome.push(RegionDrawInstance::new( - [right_x + 6.0, row_y, (right_w - 12.0).max(1.0), line_height], + [ + right_x + 6.0 * s, + row_y, + (right_w - 12.0 * s).max(1.0), + line_height, + ], bg, )); } else if line.is_target { chrome.push(RegionDrawInstance::new( - [right_x + 6.0, row_y, (right_w - 12.0).max(1.0), line_height], + [ + right_x + 6.0 * s, + row_y, + (right_w - 12.0 * s).max(1.0), + line_height, + ], selection_bg, )); } if line.is_target { chrome.push(RegionDrawInstance::new( - [right_x + 6.0, row_y, 3.0, line_height], + [right_x + 6.0 * s, row_y, 3.0 * s, line_height], accent, )); } @@ -511,7 +598,7 @@ impl Renderer { &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - right_x + 10.0, + right_x + 10.0 * s, row_y, if line.is_target { warning } else { fg_ghost }, )); @@ -548,7 +635,7 @@ impl Renderer { &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - right_x + 10.0 + line_number_width, + right_x + 10.0 * s + line_number_width, row_y, )); } @@ -563,14 +650,14 @@ impl Renderer { "Loading preview..." }; self.editor_overlay_text_system - .set_size(Some((right_w - 20.0).max(1.0)), Some(line_height)); + .set_size(Some((right_w - 20.0 * s).max(1.0)), Some(line_height)); glyphs.extend(layout_panel_text( empty_message, &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - right_x + 10.0, - content_top + 4.0, + right_x + 10.0 * s, + content_top + 4.0 * s, fg_ghost, )); } @@ -607,8 +694,6 @@ fn compact_search_path(path: &str) -> String { } } - - fn count_search_matches_for_path( results: &[crate::app::command_palette::CommandPaletteItem], path_label: &str, diff --git a/src/render/renderer/editor/help.rs b/src/render/renderer/editor/help.rs index ab4c123d..b6dd34d6 100644 --- a/src/render/renderer/editor/help.rs +++ b/src/render/renderer/editor/help.rs @@ -53,10 +53,13 @@ impl Renderer { return; } - let font_size = self.theme.editor.font_size.max(14.0); - let title_size = (font_size * 2.2).max(28.0); - let small_size = (font_size * 0.82).max(11.0); - let line_height = self.theme.editor.line_height.max(font_size + 5.0); + // Scale hardcoded chrome px so layout tracks the runtime-scaled text + // metrics across monitors (same pattern as extensions.rs). + let s = self.ui_scale.max(0.5); + let font_size = self.theme.editor.font_size.max(14.0 * s); + let title_size = (font_size * 2.2).max(28.0 * s); + let small_size = (font_size * 0.82).max(11.0 * s); + let line_height = self.theme.editor.line_height.max(font_size + 5.0 * s); self.editor_overlay_text_system .set_metrics(Metrics::new(font_size, line_height)); self.editor_overlay_scissor = rect_to_scissor(center_bounds); @@ -65,8 +68,8 @@ impl Renderer { // both special buffer tabs should occupy the center editor region with // identical inset behavior instead of using independent full-screen // margins. - let pad_x = self.editor_padding_x.max(18.0); - let pad_y = self.editor_padding_y.max(18.0); + let pad_x = self.editor_padding_x.max(18.0 * s); + let pad_y = self.editor_padding_y.max(18.0 * s); let panel_x = center_bounds[0] + pad_x; let panel_y = center_bounds[1] + pad_y - help.scroll_y; let panel_w = (center_bounds[2] - pad_x * 2.0).max(1.0); @@ -84,42 +87,42 @@ impl Renderer { let mut card_border = divider; card_border[3] = 0.72; - // ── sizing constants (tweak here) ────────────────────────────────────── - let hdr_logo_x = 176.0; - let hdr_accent_x = 24.0; - let hdr_accent_y = 24.0; - let hdr_accent_size = 120.0; - let hdr_subtitle_gap = 24.0; - let hdr_meta_col_w = 420.0; - let hdr_meta_lh_gap = 28.0; - let hdr_wrap_margin = 56.0; - let hdr_title_gap = 64.0; - let hdr_min_h = 208.0; - let legend_gap_top = 56.0; - let legend_h = 116.0; - let legend_text_x = 40.0; - let legend_text_y = 36.0; - let grid_gap_top = 56.0; - let col_gap = 40.0; - let card_h_min = 440.0; - let card_h_max = 720.0; - let card_title_div_y = 92.0; - let card_title_x = 36.0; - let card_title_y = 24.0; - let card_hint_rx = 292.0; - let hint_clamp_w = 264.0; - let entry_row_h = 104.0; + // ── sizing constants (tweak here; all device px scaled by ui_scale) ──── + let hdr_logo_x = 176.0 * s; + let hdr_accent_x = 24.0 * s; + let hdr_accent_y = 24.0 * s; + let hdr_accent_size = 120.0 * s; + let hdr_subtitle_gap = 24.0 * s; + let hdr_meta_col_w = 420.0 * s; + let hdr_meta_lh_gap = 28.0 * s; + let hdr_wrap_margin = 56.0 * s; + let hdr_title_gap = 64.0 * s; + let hdr_min_h = 208.0 * s; + let legend_gap_top = 56.0 * s; + let legend_h = 116.0 * s; + let legend_text_x = 40.0 * s; + let legend_text_y = 36.0 * s; + let grid_gap_top = 56.0 * s; + let col_gap = 40.0 * s; + let card_h_min = 440.0 * s; + let card_h_max = 720.0 * s; + let card_title_div_y = 92.0 * s; + let card_title_x = 36.0 * s; + let card_title_y = 24.0 * s; + let card_hint_rx = 292.0 * s; + let hint_clamp_w = 264.0 * s; + let entry_row_h = 104.0 * s; let key_col_ratio = 0.52_f32; - let key_col_min = 440.0; - let key_col_max = 680.0; - let label_col_extra = 120.0; - let label_col_min = 220.0; - let entry_top_pad = 184.0; - let entry_y_start = 136.0; - let entry_key_x = 36.0; + let key_col_min = 440.0 * s; + let key_col_max = 680.0 * s; + let label_col_extra = 120.0 * s; + let label_col_min = 220.0 * s; + let entry_top_pad = 184.0 * s; + let entry_y_start = 136.0 * s; + let entry_key_x = 36.0 * s; // let entry_key_y_adj = 6.0; let key_fs_ratio = 0.72_f32; - let key_fs_min = 10.0; + let key_fs_min = 10.0 * s; // ─────────────────────────────────────────────────────────────────────── let mut glyphs = Vec::new(); @@ -165,7 +168,7 @@ impl Renderer { } self.editor_overlay_text_system - .set_metrics(Metrics::new(title_size, title_size + 8.0)); + .set_metrics(Metrics::new(title_size, title_size + 8.0 * s)); self.editor_overlay_text_system.set_size( Some((panel_w - hdr_wrap_margin).max(1.0)), Some(line_height), @@ -191,7 +194,7 @@ impl Renderer { accent, )); self.editor_overlay_text_system - .set_metrics(Metrics::new(small_size, small_size + 8.0)); + .set_metrics(Metrics::new(small_size, small_size + 8.0 * s)); let meta_x = panel_x + panel_w - hdr_meta_col_w; for (idx, line) in [ "leader = space".to_string(), @@ -249,15 +252,15 @@ impl Renderer { )); let grid_top = legend_y + legend_h + grid_gap_top; - let columns = if panel_w > 1680.0 { + let columns = if panel_w > 1680.0 * s { 4 - } else if panel_w > 1180.0 { + } else if panel_w > 1180.0 * s { 3 } else { 2 }; let card_w = (panel_w - col_gap * (columns as f32 - 1.0)) / columns as f32; - let available_grid_h = (panel_y + panel_h - grid_top).max(180.0); + let available_grid_h = (panel_y + panel_h - grid_top).max(180.0 * s); let visible_rows = ((help.sections.len() + columns - 1) / columns).max(1) as f32; let card_h = ((available_grid_h - col_gap * (visible_rows - 1.0)) / visible_rows) .clamp(card_h_min, card_h_max); @@ -266,7 +269,7 @@ impl Renderer { let row = idx / columns; let x = panel_x + col as f32 * (card_w + col_gap); let y = grid_top + row as f32 * (card_h + col_gap) - help.scroll_y; - if y + card_h < panel_y + 40.0 { + if y + card_h < panel_y + 40.0 * s { // card is entirely above the visible area continue; } @@ -321,7 +324,7 @@ impl Renderer { &key_refs, (font_size * key_fs_ratio).max(key_fs_min), ); - let label_x = x + entry_key_x + key_col_w.max(keycaps_w + 20.0); + let label_x = x + entry_key_x + key_col_w.max(keycaps_w + 20.0 * s); glyphs.extend(layout_help_keycaps( &key_refs, &mut self.editor_overlay_text_system, diff --git a/src/render/renderer/editor/overlays.rs b/src/render/renderer/editor/overlays.rs index 2701bb46..40dded62 100644 --- a/src/render/renderer/editor/overlays.rs +++ b/src/render/renderer/editor/overlays.rs @@ -15,9 +15,9 @@ use crate::{ glyph_instance::GlyphInstance, region_pipeline::RegionDrawInstance, renderer::Renderer, }, text::layout_sync::{ - compute_caret_layout, compute_caret_layout_with_folds, compute_cursor_overlay, - rebuild_layout_projection, - }, + compute_caret_layout, compute_caret_layout_with_folds, compute_cursor_overlay, + rebuild_layout_projection, + }, }; use cosmic_text::Metrics; @@ -26,9 +26,9 @@ use super::super::helpers::{ gutter_width_for_editor, layout_panel_rich_text, layout_panel_text, layout_panel_text_bold, layout_panel_text_italic, rect_to_scissor, should_draw_block_cursor, }; -use crate::render::icon_pipeline::{canonical_icon_id, IconDrawInstance}; use super::completion::{completion_kind_badge, completion_label_spans}; use super::{EDITOR_BREADCRUMB_GAP_Y, EDITOR_BREADCRUMB_PAD_Y, EDITOR_BREADCRUMB_TOP_INSET}; +use crate::render::icon_pipeline::{IconDrawInstance, canonical_icon_id}; const DIAGNOSTIC_SEVERITY_ERROR: u32 = 1; const DIAGNOSTIC_SEVERITY_WARNING: u32 = 2; @@ -42,6 +42,9 @@ impl Renderer { .active_file() .and_then(|path| app_state.diagnostics_for_path(path)); let geometry = editor_viewport_geometry(self, app_state, center_bounds); + // Scale hardcoded popup/chrome px so they track runtime-scaled text + // metrics across monitors (same pattern as extensions.rs). + let ui_s = self.ui_scale.max(0.5); let viewport_top = geometry.viewport_text_top; let viewport_bottom = viewport_top + geometry.viewport_text_height.max(1.0); let viewport_right = center_bounds[0] + center_bounds[2] - self.editor_padding_x; @@ -124,7 +127,11 @@ impl Renderer { } if self.dap_is_active { - let status_text = if self.dap_is_paused { "⏸ PAUSED " } else { "▶ RUNNING " }; + let status_text = if self.dap_is_paused { + "⏸ PAUSED " + } else { + "▶ RUNNING " + }; let status_color = if self.dap_is_paused { self.theme.ui.warning.as_f32() } else { @@ -146,8 +153,16 @@ impl Renderer { // Draw a sleek dark border/background for the debug control pill let pill_bg = blend_rgba(header_bg, self.theme.ui.border_color.as_f32(), 0.3, 1.0); chrome_quads.push( - RegionDrawInstance::new([pill_x - 4.0, pill_y - 2.0, pill_w + 8.0, geometry.line_height + 4.0], pill_bg) - .with_radius(4.0) + RegionDrawInstance::new( + [ + pill_x - 4.0, + pill_y - 2.0, + pill_w + 8.0, + geometry.line_height + 4.0, + ], + pill_bg, + ) + .with_radius(4.0), ); // Draw status @@ -298,25 +313,32 @@ impl Renderer { style, scroll, } => { - const PAD_X: f32 = 14.0; - const PAD_Y: f32 = 10.0; - const BORDER: f32 = 1.0; - const CODE_PAD_X: f32 = 12.0; - const CODE_PAD_Y: f32 = 8.0; - const CONTENT_INSET_X: f32 = 14.0; - const CONTENT_INSET_Y: f32 = 12.0; + #[allow(non_snake_case)] + let PAD_X: f32 = 14.0 * ui_s; + #[allow(non_snake_case)] + let PAD_Y: f32 = 10.0 * ui_s; + #[allow(non_snake_case)] + let BORDER: f32 = 1.0; + #[allow(non_snake_case)] + let CODE_PAD_X: f32 = 12.0 * ui_s; + #[allow(non_snake_case)] + let CODE_PAD_Y: f32 = 8.0 * ui_s; + #[allow(non_snake_case)] + let CONTENT_INSET_X: f32 = 14.0 * ui_s; + #[allow(non_snake_case)] + let CONTENT_INSET_Y: f32 = 12.0 * ui_s; let char_w = (geometry.font_size * 0.6).max(1.0); let is_doc_hover = matches!(style, FloatingBoxStyle::DocHover); let header_h = if is_doc_hover { - geometry.line_height + 14.0 + geometry.line_height + 14.0 * ui_s } else { 0.0 }; - let block_gap = if is_doc_hover { 10.0 } else { 0.0 }; + let block_gap = if is_doc_hover { 10.0 * ui_s } else { 0.0 }; // ── Hover card size caps ───────────────────────────────────── let max_popup_w = (center_bounds[2] * 0.58) - .max(260.0) + .max(260.0 * ui_s) .min(geometry.viewport_text_width); let max_popup_h = (center_bounds[3] * 0.58).max(geometry.line_height * 3.0); let wrap_cols = ((max_popup_w - PAD_X * 2.0) / char_w).floor() as usize; @@ -350,18 +372,19 @@ impl Renderer { .map(|(_, lines, _)| lines.len().max(1)) .sum(); let content_block_extra_h: f32 = if is_doc_hover { - rendered - .iter() - .filter(|(is_code, _, _)| *is_code) - .count() as f32 + rendered.iter().filter(|(is_code, _, _)| *is_code).count() as f32 * (CODE_PAD_Y * 2.0 + block_gap) } else { 0.0 }; - let content_inner_pad_y = if is_doc_hover { CONTENT_INSET_Y * 2.0 } else { 0.0 }; + let content_inner_pad_y = if is_doc_hover { + CONTENT_INSET_Y * 2.0 + } else { + 0.0 + }; let desired_popup_w = max_len as f32 * char_w + PAD_X * 2.0; - let popup_w = clamp_popup_width(desired_popup_w, 260.0, max_popup_w); + let popup_w = clamp_popup_width(desired_popup_w, 260.0 * ui_s, max_popup_w); debug_assert!( popup_w.is_finite() && popup_w >= 1.0, "floating overlay popup width must stay finite: desired={desired_popup_w}, max_popup_w={max_popup_w}" @@ -426,11 +449,15 @@ impl Renderer { ); if is_doc_hover { - let header_bg = blend_rgba(panel_bg, self.theme.ui.status_bar_bg.as_f32(), 0.5, 1.0); + let header_bg = + blend_rgba(panel_bg, self.theme.ui.status_bar_bg.as_f32(), 0.5, 1.0); let header_divider = blend_rgba(header_bg, border_color, 0.55, 1.0); chrome_quads.push( - RegionDrawInstance::new([popup_x, popup_y, popup_w, header_h], header_bg) - .with_radius(self.panel_corner_radius + 2.0), + RegionDrawInstance::new( + [popup_x, popup_y, popup_w, header_h], + header_bg, + ) + .with_radius(self.panel_corner_radius + 2.0), ); chrome_quads.push(RegionDrawInstance::new( [popup_x, popup_y + header_h - 1.0, popup_w, 1.0], @@ -476,35 +503,54 @@ impl Renderer { )); } - let content_left = popup_x + PAD_X + if is_doc_hover { CONTENT_INSET_X } else { 0.0 }; - let content_top = popup_y + PAD_Y + header_h + if is_doc_hover { CONTENT_INSET_Y } else { 0.0 }; - let content_bottom = popup_y + popup_h - PAD_Y - if is_doc_hover { CONTENT_INSET_Y } else { 0.0 }; - let needs_scrollbar = is_doc_hover && header_h + total_content_h + PAD_Y * 2.0 > popup_h; + let content_left = + popup_x + PAD_X + if is_doc_hover { CONTENT_INSET_X } else { 0.0 }; + let content_top = popup_y + + PAD_Y + + header_h + + if is_doc_hover { CONTENT_INSET_Y } else { 0.0 }; + let content_bottom = popup_y + popup_h + - PAD_Y + - if is_doc_hover { CONTENT_INSET_Y } else { 0.0 }; + let needs_scrollbar = + is_doc_hover && header_h + total_content_h + PAD_Y * 2.0 > popup_h; let scrollbar_w = if needs_scrollbar { 4.0 } else { 0.0 }; let line_w = (popup_w - PAD_X * 2.0 - - if is_doc_hover { CONTENT_INSET_X * 2.0 } else { 0.0 } + - if is_doc_hover { + CONTENT_INSET_X * 2.0 + } else { + 0.0 + } - scrollbar_w - 10.0) .max(1.0); let content_view_h = (content_bottom - content_top).max(geometry.line_height); - let max_scroll_lines = ((total_content_h - content_inner_pad_y - content_view_h).max(0.0) - / geometry.line_height) - .ceil() as usize; + let max_scroll_lines = + ((total_content_h - content_inner_pad_y - content_view_h).max(0.0) + / geometry.line_height) + .ceil() as usize; let effective_scroll_lines = scroll.offset_lines.min(max_scroll_lines); - let mut content_y = content_top - effective_scroll_lines as f32 * geometry.line_height; + let mut content_y = + content_top - effective_scroll_lines as f32 * geometry.line_height; if needs_scrollbar { let track_x = popup_x + popup_w - PAD_X - 4.0; let track_y = content_top; let track_h = content_view_h; - let scrollbar_track = blend_rgba(panel_bg, self.theme.ui.border_color.as_f32(), 0.28, 1.0); - let scrollbar_thumb = blend_rgba(scrollbar_track, self.theme.ui.accent.as_f32(), 0.62, 1.0); + let scrollbar_track = + blend_rgba(panel_bg, self.theme.ui.border_color.as_f32(), 0.28, 1.0); + let scrollbar_thumb = + blend_rgba(scrollbar_track, self.theme.ui.accent.as_f32(), 0.62, 1.0); chrome_quads.push( - RegionDrawInstance::new([track_x, track_y, scrollbar_w, track_h], scrollbar_track) - .with_radius(2.0), + RegionDrawInstance::new( + [track_x, track_y, scrollbar_w, track_h], + scrollbar_track, + ) + .with_radius(2.0), ); - let visible_ratio = (content_view_h / total_content_h.max(1.0)).clamp(0.08, 1.0); + let visible_ratio = + (content_view_h / total_content_h.max(1.0)).clamp(0.08, 1.0); let thumb_h = (track_h * visible_ratio).max(18.0).min(track_h); let scroll_ratio = if max_scroll_lines == 0 { 0.0 @@ -513,8 +559,11 @@ impl Renderer { }; let thumb_y = track_y + (track_h - thumb_h) * scroll_ratio; chrome_quads.push( - RegionDrawInstance::new([track_x, thumb_y, scrollbar_w, thumb_h], scrollbar_thumb) - .with_radius(2.0), + RegionDrawInstance::new( + [track_x, thumb_y, scrollbar_w, thumb_h], + scrollbar_thumb, + ) + .with_radius(2.0), ); } @@ -536,13 +585,29 @@ impl Renderer { && content_y < content_bottom && content_y + code_block_h > content_top { - let code_bg = blend_rgba(panel_bg, self.theme.ui.status_bar_bg.as_f32(), 0.35, 1.0); - let code_border = blend_rgba(code_bg, self.theme.ui.border_color.as_f32(), 0.7, 1.0); + let code_bg = blend_rgba( + panel_bg, + self.theme.ui.status_bar_bg.as_f32(), + 0.35, + 1.0, + ); + let code_border = blend_rgba( + code_bg, + self.theme.ui.border_color.as_f32(), + 0.7, + 1.0, + ); let clipped_code_y = content_y.max(content_top); - let clipped_code_h = (content_y + code_block_h).min(content_bottom) - clipped_code_y; + let clipped_code_h = + (content_y + code_block_h).min(content_bottom) - clipped_code_y; chrome_quads.push( RegionDrawInstance::new( - [content_left, clipped_code_y, line_w, clipped_code_h.max(0.0)], + [ + content_left, + clipped_code_y, + line_w, + clipped_code_h.max(0.0), + ], code_border, ) .with_radius(8.0), @@ -592,11 +657,7 @@ impl Renderer { self.editor_overlay_text_system.set_size( Some( (line_w - - if is_doc_hover { - CODE_PAD_X * 2.0 - } else { - 0.0 - }) + - if is_doc_hover { CODE_PAD_X * 2.0 } else { 0.0 }) .max(1.0), ), Some(geometry.line_height), @@ -636,7 +697,8 @@ impl Renderer { self.theme.ui.fg_dim.as_f32(), )); } - content_y += lines.len().max(1) as f32 * geometry.line_height + block_gap; + content_y += + lines.len().max(1) as f32 * geometry.line_height + block_gap; } } } @@ -652,8 +714,10 @@ impl Renderer { + geometry.line_height + 2.0; let line_h = geometry.line_height; - const PAD_X: f32 = 14.0; - const PAD_Y: f32 = 4.0; + #[allow(non_snake_case)] + let PAD_X: f32 = 14.0 * ui_s; + #[allow(non_snake_case)] + let PAD_Y: f32 = 4.0 * ui_s; const TEXT: &str = "⟳ Loading…"; let spinner_w = TEXT.chars().count() as f32 * char_w + PAD_X * 2.0; let spinner_h = line_h + PAD_Y * 2.0; @@ -695,26 +759,35 @@ impl Renderer { return; } - const PAD_X: f32 = 30.0; - const PAD_Y: f32 = 20.0; const MAX_VISIBLE_ROWS: usize = 10; - const BADGE_GAP: f32 = 25.0; - const SCROLLBAR_W: f32 = 7.5; - const FOOTER_H: f32 = 65.0; - const DOC_PANEL_W: f32 = 650.0; - const DOC_PAD: f32 = 35.0; - const MIN_LIST_W: f32 = 700.0; - const MAX_LIST_W_WITH_DOCS: f32 = 1200.0; + #[allow(non_snake_case)] + let PAD_X: f32 = 30.0 * ui_s; + #[allow(non_snake_case)] + let PAD_Y: f32 = 20.0 * ui_s; + #[allow(non_snake_case)] + let BADGE_GAP: f32 = 25.0 * ui_s; + #[allow(non_snake_case)] + let SCROLLBAR_W: f32 = 7.5 * ui_s; + #[allow(non_snake_case)] + let FOOTER_H: f32 = 65.0 * ui_s; + #[allow(non_snake_case)] + let DOC_PANEL_W: f32 = 650.0 * ui_s; + #[allow(non_snake_case)] + let DOC_PAD: f32 = 35.0 * ui_s; + #[allow(non_snake_case)] + let MIN_LIST_W: f32 = 700.0 * ui_s; + #[allow(non_snake_case)] + let MAX_LIST_W_WITH_DOCS: f32 = 1200.0 * ui_s; let char_w = (geometry.font_size * 0.6).max(1.0); // Badge size tracks row height so it never overflows the row // Row height slightly taller than natural line height - let popup_row_h = (geometry.line_height * 1.4).max(28.0); + let popup_row_h = (geometry.line_height * 1.4).max(28.0 * ui_s); // Text uses editor's actual font metrics — no size cap let popup_label_line_h = geometry.line_height; // Offset so the text block sits centered inside the taller row let text_v_center = (popup_row_h - popup_label_line_h) * 0.5; - let badge_size = (popup_row_h * 0.82).clamp(28.0, 44.0); + let badge_size = (popup_row_h * 0.82).clamp(28.0 * ui_s, 44.0 * ui_s); let badge_radius = badge_size * 0.22; let badge_col_w = PAD_X + badge_size + BADGE_GAP; @@ -785,7 +858,8 @@ impl Renderer { [geometry.origin_x, geometry.origin_y], app_state.folded_ranges(), ); - let anchor_x = (caret_layout.x - completion.typed_prefix.chars().count() as f32 * char_w) + let anchor_x = (caret_layout.x + - completion.typed_prefix.chars().count() as f32 * char_w) .max(geometry.viewport_text_left); let popup_right = anchor_x + popup_w; let anchor_x = if popup_right > viewport_right - 8.0 { @@ -816,8 +890,8 @@ impl Renderer { let doc_bg = blend_rgba(bg, fg, 0.02, 1.0); // Drop shadow: black at 45% alpha, offset +2x/+4y, oversized let shadow_color: [f32; 4] = [0.0, 0.0, 0.0, 0.45]; - let shadow_offset_x = 2.0; - let shadow_offset_y = 4.0; + let shadow_offset_x = 2.0 * ui_s; + let shadow_offset_y = 4.0 * ui_s; // --- Drop shadow --- chrome_quads.push(RegionDrawInstance::new( @@ -1267,13 +1341,22 @@ impl Renderer { let hint_line_h = hint_font_size * 1.4; // Count workspace symbol items - let workspace_count = completion.filtered_items.iter() - .filter(|item| matches!(item.source, crate::app::app_state::CompletionItemSource::WorkspaceSymbol)) + let workspace_count = completion + .filtered_items + .iter() + .filter(|item| { + matches!( + item.source, + crate::app::app_state::CompletionItemSource::WorkspaceSymbol + ) + }) .count(); let total_count = completion.filtered_items.len(); // Check if indexing is in progress - let is_indexing = completion.language_id.as_ref() + let is_indexing = completion + .language_id + .as_ref() .map(|lang_id| app_state.workspace_symbol_cache().is_indexing(lang_id)) .unwrap_or(false); @@ -1305,7 +1388,8 @@ impl Renderer { // Hint text on the right let hint_text = "↑↓ | ↩ accept | 󱊷 close"; let hint_w = estimate_monospace_width(hint_text, hint_font_size); - let hint_x = (popup_x + popup_w - hint_w - PAD_X).max(popup_x + PAD_X + status_w + 20.0); + let hint_x = + (popup_x + popup_w - hint_w - PAD_X).max(popup_x + PAD_X + status_w + 20.0); let hint_y = footer_y + (FOOTER_H - hint_line_h) * 0.5; self.editor_overlay_text_system .set_metrics(Metrics::new(hint_font_size, hint_line_h)); @@ -1337,7 +1421,10 @@ impl Renderer { self.editor_overlay_icon_pipeline.upload_instances( &self.device, &self.editor_overlay_icon_instances, - [self.surface_state.config.width, self.surface_state.config.height], + [ + self.surface_state.config.width, + self.surface_state.config.height, + ], ); self.editor_overlay_glyph_instances = glyphs; self.editor_overlay_text_pipeline.upload_instances( diff --git a/src/render/renderer/editor/selections.rs b/src/render/renderer/editor/selections.rs index 5ea248ee..695aa862 100644 --- a/src/render/renderer/editor/selections.rs +++ b/src/render/renderer/editor/selections.rs @@ -35,6 +35,7 @@ const GUTTER_BG_RIGHT_TRIM: f32 = 10.0; const GIT_GUTTER_MARKER_LEFT_INSET: f32 = -10.0; const GIT_GUTTER_MARKER_WIDTH: f32 = 6.0; const GIT_GUTTER_DELETED_MARKER_HEIGHT: f32 = 2.0; +const FOLD_ICON_LEFT_INSET: f32 = -18.0; fn leading_indent_info(app_state: &AppState, line_idx: usize, tab_width: usize) -> (usize, bool) { let text = app_state.line_string(line_idx); @@ -893,19 +894,37 @@ impl Renderer { } } + // ── Fold icon 󰡍 ─────────────────────────────────────────────────── + if has_folds && is_fold_marker { + let folded_line_count = + app_state.folded_line_count_at_marker(abs_line).unwrap_or(0); + if folded_line_count > 0 { + let icon_x = gutter_x + FOLD_ICON_LEFT_INSET; + let mut icon_color = gutter_text_color; + icon_color[3] = icon_color[3] * 0.6; + gutter_glyphs.extend(layout_panel_text( + "󰡍", + &mut self.gutter_text_system, + &mut self.atlas, + &self.queue, + icon_x, + line_top_y, + icon_color, + )); + } + } + let num_str = if has_folds && is_fold_marker { - // Tìm số dòng bị fold let folded_line_count = app_state.folded_line_count_at_marker(abs_line).unwrap_or(0); if folded_line_count > 0 { - // Show "…" for auto-folded long lines if app_state.is_auto_folded_long_line(abs_line) { format!("…") } else { - format!(" {}", folded_line_count) + format!("...{}", folded_line_count) } } else { - format!("") + format!("") } } else if self.relative_numbers { let dist = abs_line.abs_diff(cursor_line); @@ -934,11 +953,8 @@ impl Renderer { let dot_y = line_top_y + (run.line_height - dot_size) * 0.5; let dot_color = self.theme.ui.error.as_f32(); quads.push( - RegionDrawInstance::new( - [dot_x, dot_y, dot_size, dot_size], - dot_color, - ) - .with_radius(dot_size * 0.5), + RegionDrawInstance::new([dot_x, dot_y, dot_size, dot_size], dot_color) + .with_radius(dot_size * 0.5), ); } diff --git a/src/render/renderer/editor/settings.rs b/src/render/renderer/editor/settings.rs index 7dd25f1f..7315038c 100644 --- a/src/render/renderer/editor/settings.rs +++ b/src/render/renderer/editor/settings.rs @@ -37,9 +37,10 @@ impl SettingsSection { impl SettingItem { fn section(&self) -> SettingsSection { match self { - Self::ThemeSelector { .. } | Self::UiRounding { .. } | Self::EnableOutline { .. } => { - SettingsSection::Appearance - } + Self::ThemeSelector { .. } + | Self::UiRounding { .. } + | Self::EnableOutline { .. } + | Self::UiScale { .. } => SettingsSection::Appearance, Self::FontFamily { .. } | Self::FontSize { .. } | Self::LineHeight { .. } => { SettingsSection::Typography } @@ -89,6 +90,9 @@ impl SettingItem { Self::EnableOutline { .. } => { "Show borders on all panels. Off = only unfocused panels keep their accent border." } + Self::UiScale { .. } => { + "Force a fixed UI scale (0.5–3.0). Auto follows the display DPI. Use h/l for ±0.05 or Enter to type a value or 'auto'." + } } } @@ -119,6 +123,9 @@ impl SettingItem { "0".to_string() } } + Self::UiScale { current } => current + .map(|v| format!("{v:.2}")) + .unwrap_or_else(|| "\"auto\"".to_string()), } } @@ -151,6 +158,9 @@ impl SettingItem { "Off".to_string() } } + Self::UiScale { current } => current + .map(|v| format!("{v:.2}×")) + .unwrap_or_else(|| "Auto (display)".to_string()), } } } @@ -166,7 +176,11 @@ fn setting_value_ratio(item: &SettingItem) -> Option { SettingItem::LineHeight { current } => (*current - 10.0) / (64.0 - 10.0), SettingItem::IndentTabWidth { current } => (*current as f32 - 1.0) / (8.0 - 1.0), SettingItem::UiRounding { enabled, radius_px } => { - if *enabled { *radius_px / 24.0 } else { 0.0 } + if *enabled { + *radius_px / 24.0 + } else { + 0.0 + } } SettingItem::SidebarWidth { current } => (*current as f32 - 160.0) / (1280.0 - 160.0), SettingItem::RightSidebarWidth { current } => (*current as f32 - 180.0) / (1440.0 - 180.0), @@ -203,7 +217,8 @@ fn current_row_value(settings: &SettingsState, item: &SettingItem, is_selected: | (SettingsEditingKind::UiRounding, SettingItem::UiRounding { .. }) | (SettingsEditingKind::SidebarWidth, SettingItem::SidebarWidth { .. }) | (SettingsEditingKind::RightSidebarWidth, SettingItem::RightSidebarWidth { .. }) - | (SettingsEditingKind::BottomPanelHeight, SettingItem::BottomPanelHeight { .. }) => { + | (SettingsEditingKind::BottomPanelHeight, SettingItem::BottomPanelHeight { .. }) + | (SettingsEditingKind::UiScale, SettingItem::UiScale { .. }) => { format!("{}_", editing.draft) } _ => item.display_value(), @@ -269,6 +284,13 @@ fn settings_preview_lines(settings: &SettingsState) -> Vec { } } + lines.extend(["".to_string(), "[window]".to_string()]); + for item in &settings.items { + if let SettingItem::UiScale { .. } = item { + lines.push(format!("scale_factor_override = {}", item.preview_value())); + } + } + lines.extend(["".to_string(), "[dock.left]".to_string()]); for item in &settings.items { if let SettingItem::SidebarWidth { .. } = item { @@ -304,33 +326,44 @@ impl Renderer { return; } + // Uniform UI scale so hardcoded chrome px track the scaled text metrics. + let s = self.ui_scale.max(0.5); let font_size = self.theme.editor.font_size; - let line_height = self.theme.editor.line_height.max(font_size + 4.0); + let line_height = self.theme.editor.line_height.max(font_size + 4.0 * s); self.editor_overlay_text_system .set_metrics(Metrics::new(font_size, line_height)); self.editor_overlay_scissor = rect_to_scissor(center_bounds); - let pad_x = self.editor_padding_x.max(18.0); - let pad_y = self.editor_padding_y.max(18.0); + let pad_x = self.editor_padding_x.max(18.0 * s); + let pad_y = self.editor_padding_y.max(18.0 * s); let panel_x = center_bounds[0] + pad_x; let panel_y = center_bounds[1] + pad_y; let panel_w = (center_bounds[2] - pad_x * 2.0).max(1.0); let panel_h = (center_bounds[3] - pad_y * 2.0).max(1.0); - let gap = 20.0; - let left_w = (panel_w * 0.70).max(1.0); - let right_w = (panel_w - left_w - gap).max(280.0); + // Hide the config-preview pane when the panel is too narrow for two columns. + let show_preview = panel_w >= 760.0 * s; + let gap = if show_preview { 20.0 * s } else { 0.0 }; + let left_w = if show_preview { + (panel_w * 0.70).max(1.0) + } else { + panel_w + }; + let right_w = if show_preview { + (panel_w - left_w - gap).max(280.0 * s) + } else { + 0.0 + }; let left_x = panel_x; let right_x = left_x + left_w + gap; - let titlebar_h = line_height + 10.0; - let header_h = line_height * 2.0 + 46.0 + titlebar_h; - let footer_h = line_height + 12.0; + let titlebar_h = line_height + 10.0 * s; + let header_h = line_height * 2.0 + 46.0 * s + titlebar_h; + let footer_h = line_height + 12.0 * s; let content_top = panel_y + header_h; let content_bottom = panel_y + panel_h; let rows_bottom = (content_bottom - footer_h).max(content_top + line_height); - let panel_bg = self.theme.ui.panel_bg.as_f32(); let editor_bg = self.theme.editor.bg.as_f32(); let mut divider = self.theme.ui.fg_ghost.as_f32(); @@ -347,24 +380,31 @@ impl Renderer { let mut chrome = vec![ RegionDrawInstance::new([left_x, panel_y, left_w, panel_h], panel_bg), - RegionDrawInstance::new([right_x, panel_y, right_w, panel_h], editor_bg), RegionDrawInstance::new([left_x, panel_y, left_w, titlebar_h], titlebar_bg), - RegionDrawInstance::new([right_x, panel_y, right_w, titlebar_h], titlebar_bg), - RegionDrawInstance::new([right_x - gap * 0.5, panel_y, 1.0, panel_h], divider), RegionDrawInstance::new([left_x, panel_y + titlebar_h - 1.0, left_w, 1.0], divider), - RegionDrawInstance::new([right_x, panel_y + titlebar_h - 1.0, right_w, 1.0], divider), RegionDrawInstance::new([left_x, panel_y + header_h - 1.0, left_w, 1.0], divider), - RegionDrawInstance::new([right_x, panel_y + header_h - 1.0, right_w, 1.0], divider), RegionDrawInstance::new( [left_x, content_bottom - footer_h, left_w, footer_h], status_bg, ), RegionDrawInstance::new([left_x, content_bottom - footer_h, left_w, 1.0], divider), ]; + if show_preview { + chrome.extend([ + RegionDrawInstance::new([right_x, panel_y, right_w, panel_h], editor_bg), + RegionDrawInstance::new([right_x, panel_y, right_w, titlebar_h], titlebar_bg), + RegionDrawInstance::new([right_x - gap * 0.5, panel_y, 1.0, panel_h], divider), + RegionDrawInstance::new( + [right_x, panel_y + titlebar_h - 1.0, right_w, 1.0], + divider, + ), + RegionDrawInstance::new([right_x, panel_y + header_h - 1.0, right_w, 1.0], divider), + ]); + } let mut glyphs = Vec::new(); - let left_text_width = (left_w - 40.0).max(1.0); - let right_text_width = (right_w - 24.0).max(1.0); + let left_text_width = (left_w - 40.0 * s).max(1.0); + let right_text_width = (right_w - 24.0 * s).max(1.0); self.editor_overlay_text_system .set_size(Some(left_text_width), Some(line_height)); @@ -373,8 +413,8 @@ impl Renderer { &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - left_x + 12.0, - panel_y + 4.0, + left_x + 12.0 * s, + panel_y + 4.0 * s, fg_ghost, )); glyphs.extend(layout_panel_text( @@ -382,22 +422,24 @@ impl Renderer { &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - left_x + 54.0, - panel_y + 4.0, + left_x + 54.0 * s, + panel_y + 4.0 * s, fg, )); - self.editor_overlay_text_system - .set_size(Some(right_text_width), Some(line_height)); - glyphs.extend(layout_panel_text( - "settings.toml preview", - &mut self.editor_overlay_text_system, - &mut self.atlas, - &self.queue, - right_x + 10.0, - panel_y + 4.0, - fg_dim, - )); + if show_preview { + self.editor_overlay_text_system + .set_size(Some(right_text_width), Some(line_height)); + glyphs.extend(layout_panel_text( + "settings.toml preview", + &mut self.editor_overlay_text_system, + &mut self.atlas, + &self.queue, + right_x + 10.0 * s, + panel_y + 4.0 * s, + fg_dim, + )); + } self.editor_overlay_text_system .set_size(Some(left_text_width), Some(line_height)); @@ -406,31 +448,33 @@ impl Renderer { &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - left_x + 14.0, - panel_y + titlebar_h + 8.0, + left_x + 14.0 * s, + panel_y + titlebar_h + 8.0 * s, fg, )); - self.editor_overlay_text_system - .set_size(Some(right_text_width), Some(line_height)); - glyphs.extend(layout_panel_text( - "CONFIG PREVIEW", - &mut self.editor_overlay_text_system, - &mut self.atlas, - &self.queue, - right_x + 10.0, - panel_y + titlebar_h + 8.0, - fg_ghost, - )); - glyphs.extend(layout_panel_text( - "LIVE", - &mut self.editor_overlay_text_system, - &mut self.atlas, - &self.queue, - right_x + right_w - estimate_monospace_width("LIVE", font_size) - 12.0, - panel_y + titlebar_h + 8.0, - accent, - )); + if show_preview { + self.editor_overlay_text_system + .set_size(Some(right_text_width), Some(line_height)); + glyphs.extend(layout_panel_text( + "CONFIG PREVIEW", + &mut self.editor_overlay_text_system, + &mut self.atlas, + &self.queue, + right_x + 10.0 * s, + panel_y + titlebar_h + 8.0 * s, + fg_ghost, + )); + glyphs.extend(layout_panel_text( + "LIVE", + &mut self.editor_overlay_text_system, + &mut self.atlas, + &self.queue, + right_x + right_w - estimate_monospace_width("LIVE", font_size) - 12.0 * s, + panel_y + titlebar_h + 8.0 * s, + accent, + )); + } let selected_item = settings.selected_item(); let item_count_label = format!("{} SETTINGS", settings.items.len()); @@ -442,28 +486,30 @@ impl Renderer { &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - left_x + left_w - estimate_monospace_width(&item_count_label, font_size) - 14.0, - panel_y + titlebar_h + 8.0, + left_x + left_w - estimate_monospace_width(&item_count_label, font_size) - 14.0 * s, + panel_y + titlebar_h + 8.0 * s, fg_ghost, )); if let Some(item) = selected_item { - let shortcuts_y = panel_y + titlebar_h + 14.0 + line_height * 2.0; - let shortcut_font = (font_size * 0.72).max(10.0); - let shortcut_line_h = (line_height * 0.72).max(14.0); - let key_h = (line_height * 0.82).max(shortcut_line_h + 7.0).max(16.0); + let shortcuts_y = panel_y + titlebar_h + 14.0 * s + line_height * 2.0; + let shortcut_font = (font_size * 0.72).max(10.0 * s); + let shortcut_line_h = (line_height * 0.72).max(14.0 * s); + let key_h = (line_height * 0.82) + .max(shortcut_line_h + 7.0 * s) + .max(16.0 * s); self.editor_overlay_text_system .set_metrics(Metrics::new(shortcut_font, shortcut_line_h)); self.editor_overlay_text_system .set_size(None, Some(shortcut_line_h)); - let mut shortcut_x = left_x + 14.0; - let key_text_gap = 10.0; - let group_gap = 22.0; - let key_pad_x = 7.0; + let mut shortcut_x = left_x + 14.0 * s; + let key_text_gap = 10.0 * s; + let group_gap = 22.0 * s; + let key_pad_x = 7.0 * s; for (key, label) in setting_control_hint(item) { let key_w = estimate_monospace_width(key, shortcut_font) + key_pad_x * 2.0; let label_w = estimate_monospace_width(label, shortcut_font); - if shortcut_x + key_w + key_text_gap + label_w > left_x + left_w - 14.0 { + if shortcut_x + key_w + key_text_gap + label_w > left_x + left_w - 14.0 * s { break; } let mut key_outline = accent; @@ -471,15 +517,23 @@ impl Renderer { let mut key_fill = editor_bg; key_fill[3] = key_fill[3].max(0.92); chrome.push( - RegionDrawInstance::new([shortcut_x, shortcuts_y - 1.0, key_w, key_h], key_outline) - .with_radius(4.0), + RegionDrawInstance::new( + [shortcut_x, shortcuts_y - 1.0, key_w, key_h], + key_outline, + ) + .with_radius(4.0 * s), ); chrome.push( RegionDrawInstance::new( - [shortcut_x + 1.5, shortcuts_y + 0.5, key_w - 3.0, key_h - 3.0], + [ + shortcut_x + 1.5 * s, + shortcuts_y + 0.5 * s, + key_w - 3.0 * s, + key_h - 3.0 * s, + ], key_fill, ) - .with_radius(3.0), + .with_radius(3.0 * s), ); glyphs.extend(layout_panel_text_bold( key, @@ -507,11 +561,13 @@ impl Renderer { .set_size(Some(left_text_width), Some(line_height)); } - let key_col_w = 400.0; - let value_col_w = 300.0; - let desc_x_offset = 410.0; - let section_gap = 14.0; - let row_h = line_height * 2.0 + 20.0; + // Proportional columns: absolute widths (400/410/300) overlapped on narrow + // windows and collapsed to half-size on 2x displays. + let key_col_w = (left_w * 0.32).clamp(160.0 * s, 420.0 * s); + let value_col_w = (left_w * 0.26).clamp(140.0 * s, 320.0 * s); + let desc_x_offset = key_col_w + 18.0 * s; + let section_gap = 14.0 * s; + let row_h = line_height * 2.0 + 20.0 * s; // ── Virtual layout pre-pass ─────────────────────────────────────────── // Compute each item's virtual Y (relative to content_top + 8.0 at scroll=0) @@ -533,7 +589,7 @@ impl Renderer { vy += section_gap; } let y = vy; - vy += line_height + 4.0; + vy += line_height + 4.0 * s; last_sec = Some(sec); (Some(sec), y) } else { @@ -544,40 +600,40 @@ impl Renderer { sec_y, item_y: vy, }); - vy += row_h + 4.0; + vy += row_h + 4.0 * s; } total_virt_h = vy; } // Compute scroll_y: enough to keep selected item fully visible at the bottom. - let vis_h = (rows_bottom - content_top - 8.0).max(1.0); + let vis_h = (rows_bottom - content_top - 8.0 * s).max(1.0); let sel_virt_y = layouts .get(settings.selected_index) .map(|l| l.item_y) .unwrap_or(0.0); - let scroll_y = (sel_virt_y + row_h - vis_h + 8.0).max(0.0); - let base_y = content_top + 8.0 - scroll_y; + let scroll_y = (sel_virt_y + row_h - vis_h + 8.0 * s).max(0.0); + let base_y = content_top + 8.0 * s - scroll_y; // Scrollbar (shown only when content overflows) if total_virt_h > vis_h { - let track_x = left_x + left_w - 7.0; - let track_top = content_top + 4.0; - let track_h = (rows_bottom - footer_h - track_top - 4.0).max(8.0); - let thumb_h = (vis_h / total_virt_h * track_h).max(18.0).min(track_h); + let track_x = left_x + left_w - 7.0 * s; + let track_top = content_top + 4.0 * s; + let track_h = (rows_bottom - footer_h - track_top - 4.0 * s).max(8.0 * s); + let thumb_h = (vis_h / total_virt_h * track_h).max(18.0 * s).min(track_h); let thumb_y = track_top + (scroll_y / total_virt_h * track_h).min(track_h - thumb_h); chrome.push( RegionDrawInstance::new( - [track_x, track_top, 3.0, track_h], + [track_x, track_top, 3.0 * s, track_h], with_alpha(fg_ghost, 0.12), ) - .with_radius(1.5), + .with_radius(1.5 * s), ); chrome.push( RegionDrawInstance::new( - [track_x, thumb_y, 3.0, thumb_h], + [track_x, thumb_y, 3.0 * s, thumb_h], with_alpha(fg_ghost, 0.45), ) - .with_radius(1.5), + .with_radius(1.5 * s), ); } @@ -588,7 +644,7 @@ impl Renderer { // Render section header if this item starts a new section and header is visible. if let Some(sec) = layout.sec_header { let sec_abs_y = base_y + layout.sec_y; - if sec_abs_y + line_height > content_top && sec_abs_y < rows_bottom - 8.0 { + if sec_abs_y + line_height > content_top && sec_abs_y < rows_bottom - 8.0 * s { self.editor_overlay_text_system .set_size(Some(key_col_w), Some(line_height)); glyphs.extend(layout_panel_text( @@ -596,15 +652,15 @@ impl Renderer { &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - left_x + 14.0, + left_x + 14.0 * s, sec_abs_y, fg_ghost, )); chrome.push(RegionDrawInstance::new( [ - left_x + 14.0 + key_col_w, - sec_abs_y + 7.0, - (left_w - (key_col_w + 36.0)).max(1.0), + left_x + 14.0 * s + key_col_w, + sec_abs_y + 7.0 * s, + (left_w - (key_col_w + 36.0 * s)).max(1.0), 1.0, ], divider, @@ -613,7 +669,7 @@ impl Renderer { } // Skip items that are entirely outside the viewport. - if item_abs_y + row_h <= content_top || item_abs_y >= rows_bottom - 8.0 { + if item_abs_y + row_h <= content_top || item_abs_y >= rows_bottom - 8.0 * s { continue; } @@ -621,26 +677,37 @@ impl Renderer { if is_selected { chrome.push( RegionDrawInstance::new( - [left_x + 6.0, item_abs_y, (left_w - 18.0).max(1.0), row_h], + [ + left_x + 6.0 * s, + item_abs_y, + (left_w - 18.0 * s).max(1.0), + row_h, + ], selection_bg, ) - .with_radius(8.0), + .with_radius(8.0 * s), ); chrome.push( RegionDrawInstance::new( - [left_x + 6.0, item_abs_y + 4.0, 3.0, row_h - 8.0], + [ + left_x + 6.0 * s, + item_abs_y + 4.0 * s, + 3.0 * s, + row_h - 8.0 * s, + ], accent, ) - .with_radius(2.0), + .with_radius(2.0 * s), ); } - let key_x = left_x + 18.0; - let key_y = item_abs_y + 6.0; + let key_x = left_x + 18.0 * s; + let key_y = item_abs_y + 6.0 * s; let desc_x = left_x + desc_x_offset; - let desc_y = item_abs_y + line_height + 8.0; - let value_x = left_x + left_w - value_col_w - 18.0; - let desc_w = (value_x - desc_x - 18.0).max(220.0); + let desc_y = item_abs_y + line_height + 8.0 * s; + let value_x = left_x + left_w - value_col_w - 18.0 * s; + let desc_w = value_x - desc_x - 18.0 * s; + let show_desc = desc_w >= 120.0 * s; self.editor_overlay_text_system .set_size(Some(key_col_w), Some(line_height)); @@ -654,25 +721,27 @@ impl Renderer { if is_selected { fg } else { fg_dim }, )); - self.editor_overlay_text_system - .set_size(Some(desc_w), Some(line_height)); - glyphs.extend(layout_panel_text( - &clamp_monospace_text(item.description(), desc_w, font_size), - &mut self.editor_overlay_text_system, - &mut self.atlas, - &self.queue, - desc_x, - desc_y, - fg_ghost, - )); + if show_desc { + self.editor_overlay_text_system + .set_size(Some(desc_w), Some(line_height)); + glyphs.extend(layout_panel_text( + &clamp_monospace_text(item.description(), desc_w, font_size), + &mut self.editor_overlay_text_system, + &mut self.atlas, + &self.queue, + desc_x, + desc_y, + fg_ghost, + )); + } match item { SettingItem::EnableOutline { enabled } | SettingItem::IndentInsertSpaces { enabled } | SettingItem::InlineSuggestion { enabled } => { - let toggle_w = 40.0; - let toggle_h = 20.0; - let toggle_x = left_x + left_w - toggle_w - 22.0; + let toggle_w = 40.0 * s; + let toggle_h = 20.0 * s; + let toggle_x = left_x + left_w - toggle_w - 22.0 * s; let toggle_y = item_abs_y + (row_h - toggle_h) * 0.5; chrome.push( RegionDrawInstance::new( @@ -683,23 +752,23 @@ impl Renderer { with_alpha(fg_ghost, 0.28) }, ) - .with_radius(10.0), + .with_radius(10.0 * s), ); chrome.push( RegionDrawInstance::new( [ if *enabled { - toggle_x + toggle_w - 16.0 + toggle_x + toggle_w - 16.0 * s } else { - toggle_x + 2.0 + toggle_x + 2.0 * s }, - toggle_y + 2.0, - 16.0, - 16.0, + toggle_y + 2.0 * s, + 16.0 * s, + 16.0 * s, ], if *enabled { editor_bg } else { fg_dim }, ) - .with_radius(8.0), + .with_radius(8.0 * s), ); let badge_label = match item { SettingItem::IndentInsertSpaces { enabled } => { @@ -709,14 +778,20 @@ impl Renderer { "TAB" } } - SettingItem::InlineSuggestion { .. } => "OFF", + SettingItem::InlineSuggestion { enabled } => { + if *enabled { + "ON" + } else { + "OFF" + } + } _ => "", }; if !badge_label.is_empty() { let badge_x = - toggle_x - estimate_monospace_width(badge_label, font_size) - 10.0; + toggle_x - estimate_monospace_width(badge_label, font_size) - 10.0 * s; self.editor_overlay_text_system - .set_size(Some(80.0), Some(line_height)); + .set_size(Some(80.0 * s), Some(line_height)); glyphs.extend(layout_panel_text( badge_label, &mut self.editor_overlay_text_system, @@ -731,151 +806,170 @@ impl Renderer { _ => { chrome.push( RegionDrawInstance::new( - [value_x, item_abs_y + 6.0, value_col_w, line_height + 12.0], + [ + value_x, + item_abs_y + 6.0 * s, + value_col_w, + line_height + 12.0 * s, + ], if is_selected { accent_soft } else { with_alpha(panel_bg, 0.72) }, ) - .with_radius(5.0), + .with_radius(5.0 * s), ); chrome.push(RegionDrawInstance::new( - [value_x, item_abs_y + 6.0, value_col_w, 1.0], + [value_x, item_abs_y + 6.0 * s, value_col_w, 1.0], if is_selected { accent_border } else { divider }, )); self.editor_overlay_text_system - .set_size(Some((value_col_w - 16.0).max(1.0)), Some(line_height)); + .set_size(Some((value_col_w - 16.0 * s).max(1.0)), Some(line_height)); glyphs.extend(layout_panel_text( &clamp_monospace_text( ¤t_row_value(settings, item, is_selected), - (value_col_w - 16.0).max(1.0), + (value_col_w - 16.0 * s).max(1.0), font_size, ), &mut self.editor_overlay_text_system, &mut self.atlas, &self.queue, - value_x + 12.0, - item_abs_y + 12.0, + value_x + 12.0 * s, + item_abs_y + 12.0 * s, if is_selected { accent } else { fg_dim }, )); } } if let Some(ratio) = setting_value_ratio(item) { - let meter_w = 96.0; - let meter_h = 3.0; - let meter_x = value_x + value_col_w - meter_w - 8.0; - let meter_y = item_abs_y + row_h - 10.0; + let meter_w = 96.0 * s; + let meter_h = 3.0 * s; + let meter_x = value_x + value_col_w - meter_w - 8.0 * s; + let meter_y = item_abs_y + row_h - 10.0 * s; chrome.push( RegionDrawInstance::new( [meter_x, meter_y, meter_w, meter_h], with_alpha(fg_ghost, 0.18), ) - .with_radius(1.5), + .with_radius(1.5 * s), ); chrome.push( RegionDrawInstance::new( [meter_x, meter_y, meter_w * ratio, meter_h], - if is_selected { accent } else { with_alpha(accent, 0.48) }, + if is_selected { + accent + } else { + with_alpha(accent, 0.48) + }, ) - .with_radius(1.5), + .with_radius(1.5 * s), ); } chrome.push(RegionDrawInstance::new( [ - left_x + 14.0, + left_x + 14.0 * s, item_abs_y + row_h - 2.0, - (left_w - 28.0).max(1.0), + (left_w - 28.0 * s).max(1.0), 1.0, ], divider, )); } - let mut preview_start_y = content_top + 8.0; - if let Some(item) = selected_item { - let detail_top = content_top + 8.0; - let detail_h = line_height * 3.0 + 22.0; - preview_start_y = detail_top + detail_h + 12.0; - chrome.push( - RegionDrawInstance::new( - [right_x + 10.0, detail_top, right_w - 20.0, detail_h], - with_alpha(panel_bg, 0.55), - ) - .with_radius(8.0), - ); - chrome.push( - RegionDrawInstance::new( - [right_x + 10.0, detail_top, 3.0, detail_h], + if show_preview { + let mut preview_start_y = content_top + 8.0 * s; + if let Some(item) = selected_item { + let detail_top = content_top + 8.0 * s; + let detail_h = line_height * 3.0 + 22.0 * s; + preview_start_y = detail_top + detail_h + 12.0 * s; + chrome.push( + RegionDrawInstance::new( + [right_x + 10.0 * s, detail_top, right_w - 20.0 * s, detail_h], + with_alpha(panel_bg, 0.55), + ) + .with_radius(8.0 * s), + ); + chrome.push( + RegionDrawInstance::new( + [right_x + 10.0 * s, detail_top, 3.0 * s, detail_h], + accent, + ) + .with_radius(2.0 * s), + ); + self.editor_overlay_text_system + .set_size(Some(right_text_width - 20.0 * s), Some(line_height)); + glyphs.extend(layout_panel_text( + &clamp_monospace_text(item.title(), right_text_width - 20.0 * s, font_size), + &mut self.editor_overlay_text_system, + &mut self.atlas, + &self.queue, + right_x + 22.0 * s, + detail_top + 8.0 * s, + fg, + )); + glyphs.extend(layout_panel_text( + &clamp_monospace_text( + &item.display_value(), + right_text_width - 20.0 * s, + font_size, + ), + &mut self.editor_overlay_text_system, + &mut self.atlas, + &self.queue, + right_x + 22.0 * s, + detail_top + 8.0 * s + line_height, accent, - ) - .with_radius(2.0), - ); - self.editor_overlay_text_system - .set_size(Some(right_text_width - 20.0), Some(line_height)); - glyphs.extend(layout_panel_text( - &clamp_monospace_text(item.title(), right_text_width - 20.0, font_size), - &mut self.editor_overlay_text_system, - &mut self.atlas, - &self.queue, - right_x + 22.0, - detail_top + 8.0, - fg, - )); - glyphs.extend(layout_panel_text( - &clamp_monospace_text(&item.display_value(), right_text_width - 20.0, font_size), - &mut self.editor_overlay_text_system, - &mut self.atlas, - &self.queue, - right_x + 22.0, - detail_top + 8.0 + line_height, - accent, - )); - glyphs.extend(layout_panel_text( - &clamp_monospace_text(item.description(), right_text_width - 24.0, font_size), - &mut self.editor_overlay_text_system, - &mut self.atlas, - &self.queue, - right_x + 22.0, - detail_top + 8.0 + line_height * 2.0, - fg_ghost, - )); - } + )); + glyphs.extend(layout_panel_text( + &clamp_monospace_text( + item.description(), + right_text_width - 24.0 * s, + font_size, + ), + &mut self.editor_overlay_text_system, + &mut self.atlas, + &self.queue, + right_x + 22.0 * s, + detail_top + 8.0 * s + line_height * 2.0, + fg_ghost, + )); + } - let preview_lines = settings_preview_lines(settings); - let preview_h = (rows_bottom - preview_start_y - 8.0).max(line_height); - let preview_rows = (preview_h / line_height).floor() as usize; - self.editor_overlay_text_system - .set_size(Some(right_text_width), Some(line_height)); - for (idx, line) in preview_lines - .into_iter() - .take(preview_rows.max(1)) - .enumerate() - { - let y = preview_start_y + idx as f32 * line_height; - let color = if line.starts_with('#') { - fg_ghost - } else if line.starts_with('[') { - accent - } else { - fg_dim - }; - glyphs.extend(layout_panel_text( - &clamp_monospace_text(&line, right_text_width, font_size), - &mut self.editor_overlay_text_system, - &mut self.atlas, - &self.queue, - right_x + 10.0, - y, - color, - )); + let preview_lines = settings_preview_lines(settings); + let preview_h = (rows_bottom - preview_start_y - 8.0 * s).max(line_height); + let preview_rows = (preview_h / line_height).floor() as usize; + self.editor_overlay_text_system + .set_size(Some(right_text_width), Some(line_height)); + for (idx, line) in preview_lines + .into_iter() + .take(preview_rows.max(1)) + .enumerate() + { + let y = preview_start_y + idx as f32 * line_height; + let color = if line.starts_with('#') { + fg_ghost + } else if line.starts_with('[') { + accent + } else { + fg_dim + }; + glyphs.extend(layout_panel_text( + &clamp_monospace_text(&line, right_text_width, font_size), + &mut self.editor_overlay_text_system, + &mut self.atlas, + &self.queue, + right_x + 10.0 * s, + y, + color, + )); + } } self.editor_overlay_text_system .set_size(Some(left_text_width), Some(line_height)); - let footer_y = content_bottom - footer_h + 4.0; + let footer_y = content_bottom - footer_h + 4.0 * s; let left_status = if settings.editing.is_some() { [ ShortcutHintSegment::Keys(&["EDIT"]), @@ -901,7 +995,7 @@ impl Renderer { &mut self.atlas, &self.queue, &mut chrome, - left_x + 10.0, + left_x + 10.0 * s, footer_y, font_size, line_height, diff --git a/src/render/renderer/editor/viewport.rs b/src/render/renderer/editor/viewport.rs index 61382f1b..8ea6dfd4 100644 --- a/src/render/renderer/editor/viewport.rs +++ b/src/render/renderer/editor/viewport.rs @@ -27,8 +27,6 @@ use super::super::helpers::{ use super::{cursor_diagnostic, editor_viewport_geometry, run_x_for_byte, wrap_text_lines}; use crate::text::text_system::StyledTextSpan; - - /// Quick fingerprint để phát hiện thay đổi trong syntax / diagnostic spans. /// Không cần hoàn hảo — chỉ cần bắt được phần lớn thay đổi thực tế. fn inline_suggestion_virtual_gap(app_state: &AppState, line_height: f32) -> Option<(usize, f32)> { @@ -72,14 +70,20 @@ fn truncate_folded_lines( return (text.to_string(), spans.to_vec()); } - // Find auto-folded long lines (where start == end) + // Find auto-folded long lines and scope folds let auto_folded_lines: Vec = folded_ranges .iter() .filter(|&&(s, e)| s == e) .map(|&(s, _)| s) .collect(); - if auto_folded_lines.is_empty() { + let scope_fold_markers: Vec<(usize, usize)> = folded_ranges + .iter() + .filter(|&&(s, e)| s != e) + .copied() + .collect(); + + if auto_folded_lines.is_empty() && scope_fold_markers.is_empty() { return (text.to_string(), spans.to_vec()); } @@ -98,6 +102,16 @@ fn truncate_folded_lines( let old_line_start = line_byte_starts[line_idx]; let new_line_start = result.len(); + let is_scope_fold_marker = scope_fold_markers.iter().any(|&(s, _)| s == line_idx); + let scope_fold_count = if is_scope_fold_marker { + scope_fold_markers + .iter() + .find(|&&(s, _)| s == line_idx) + .map(|&(s, e)| e - s) + } else { + None + }; + if auto_folded_lines.contains(&line_idx) { // Truncate this line to 200 chars + "..." let chars: Vec = line.chars().collect(); @@ -124,6 +138,23 @@ fn truncate_folded_lines( } result.push_str(line); } + } else if let Some(count) = scope_fold_count { + // Scope fold marker: append ...N lines placeholder + for old_byte in 0..=line.len() { + byte_offset_map.push((old_line_start + old_byte, new_line_start + old_byte)); + } + result.push_str(line); + let placeholder = if count == 1 { + " ...1 line }".to_string() + } else { + format!(" ...{} lines }}", count) + }; + let placeholder_start = result.len(); + result.push_str(&placeholder); + // Map placeholder bytes to end of original line + for _ in 0..placeholder.len() { + byte_offset_map.push((old_line_start + line.len(), placeholder_start)); + } } else { // Not a folded line, copy as-is for old_byte in 0..=line.len() { @@ -325,8 +356,11 @@ impl Renderer { if needs_reshape { // Truncate auto-folded long lines to 100 chars + "..." before shaping let (display_text, adjusted_spans) = truncate_folded_lines(text, spans, app_state); - self.text_system - .set_text_with_spans(&display_text, default_color_rgba, &adjusted_spans); + self.text_system.set_text_with_spans( + &display_text, + default_color_rgba, + &adjusted_spans, + ); self.last_shaped_revision = current_revision; self.last_shaped_spans_fingerprint = spans_fp; self.last_shaped_viewport_width = width; @@ -438,7 +472,12 @@ impl Renderer { /// /// Must honor the same mode → shape mapping as `update_editor_content`, otherwise /// h/j/k/l in Normal mode would collapse the block caret back to a thin bar. - pub fn update_editor_caret(&mut self, app_state: &AppState, center_bounds: [f32; 4], breakpoint_lines: &[usize]) { + pub fn update_editor_caret( + &mut self, + app_state: &AppState, + center_bounds: [f32; 4], + breakpoint_lines: &[usize], + ) { self.caret_blink_visible = true; self.image_pipeline.clear(); self.image_scissor = None; diff --git a/src/render/renderer/helpers.rs b/src/render/renderer/helpers.rs index 198b8752..3921e1a2 100644 --- a/src/render/renderer/helpers.rs +++ b/src/render/renderer/helpers.rs @@ -343,7 +343,10 @@ mod tests { } pub(super) fn is_mode_block_cursor(mode: EditorMode) -> bool { - matches!(mode, EditorMode::Normal | EditorMode::Visual | EditorMode::VisualBlock) + matches!( + mode, + EditorMode::Normal | EditorMode::Visual | EditorMode::VisualBlock + ) } pub(super) fn should_draw_block_cursor(mode: EditorMode, cursor_shape: CursorShape) -> bool { @@ -384,5 +387,3 @@ pub(super) fn mode_pill_color(mode: EditorMode, theme: &ThemeConfig) -> [f32; 4] pub(super) fn theme_color_to_wgpu(color: ThemeColor) -> wgpu::Color { color.as_linear().to_wgpu() } - - diff --git a/src/render/renderer/lifecycle.rs b/src/render/renderer/lifecycle.rs index 3ee2b36c..b78cf19a 100644 --- a/src/render/renderer/lifecycle.rs +++ b/src/render/renderer/lifecycle.rs @@ -137,6 +137,7 @@ impl Renderer { let diagnostic_hover_text_system = make_text_system(editor_metrics, font_family.as_deref()); let ai_chat_text_system = make_text_system(ui_metrics, font_family.as_deref()); let toast_text_system = make_text_system(ui_metrics, font_family.as_deref()); + let whichkey_text_system = make_text_system(ui_metrics, font_family.as_deref()); let leap_label_text_system = make_text_system(leap_metrics, font_family.as_deref()); let atlas = GlyphAtlas::new(&device, ATLAS_SIZE, ATLAS_SIZE); @@ -171,6 +172,8 @@ impl Renderer { make_text_pipeline(&device, &atlas, surface_format, width, height); let toast_text_pipeline = make_text_pipeline(&device, &atlas, surface_format, width, height); + let whichkey_text_pipeline = + make_text_pipeline(&device, &atlas, surface_format, width, height); let leap_label_text_pipeline = make_text_pipeline(&device, &atlas, surface_format, width, height); let ai_chat_text_pipeline = @@ -288,6 +291,7 @@ impl Renderer { panel_padding: 10.0, panel_corner_radius: 10.0, round_ui: true, + ui_scale: 1.0, sidebar_base_padding: 10.0, sidebar_indent_per_depth: 15.0, topbar_padding_x: 14.0, @@ -322,6 +326,11 @@ impl Renderer { toast_glyph_instances: Vec::new(), toast_chrome_instances: Vec::new(), toast_scissor: None, + whichkey_text_system, + whichkey_text_pipeline, + whichkey_glyph_instances: Vec::new(), + whichkey_chrome_instances: Vec::new(), + whichkey_scissor: None, ai_chat_text_system, ai_chat_text_pipeline, ai_chat_header_image_pipeline, @@ -380,6 +389,7 @@ impl Renderer { theme.editor.line_height, )); self.toast_text_system.set_metrics(ui_metrics); + self.whichkey_text_system.set_metrics(ui_metrics); self.ai_chat_text_system.set_metrics(ui_metrics); let panel_metrics = Metrics::new(theme.ui.panel_font_size, theme.ui.panel_line_height); self.welcome_logo_text_system.set_metrics(panel_metrics); @@ -412,6 +422,7 @@ impl Renderer { self.system_dep_text_system.set_font_family(family); self.diagnostic_hover_text_system.set_font_family(family); self.toast_text_system.set_font_family(family); + self.whichkey_text_system.set_font_family(family); self.leap_label_text_system.set_font_family(family); self.ai_chat_text_system.set_font_family(family); @@ -423,7 +434,10 @@ impl Renderer { self.topbar_icon_pipeline.upload_instances( &self.device, &self.topbar_icon_instances, - [self.surface_state.config.width, self.surface_state.config.height], + [ + self.surface_state.config.width, + self.surface_state.config.height, + ], ); self.topbar_chrome_instances.clear(); self.topbar_text_batches.clear(); @@ -456,6 +470,10 @@ impl Renderer { self.last_palette_model = None; } + pub fn set_ui_scale(&mut self, scale: f32) { + self.ui_scale = scale.max(0.25); + } + pub fn apply_ui_config(&mut self, ui: &UiConfig) { self.editor_padding_x = ui.layout.inner_padding.max(ui.spacing.editor_padding); self.editor_padding_y = ui.layout.inner_padding.max(ui.spacing.editor_padding); @@ -489,6 +507,7 @@ impl Renderer { self.topbar_text_system.set_metrics(status_metrics); self.statusbar_text_system.set_metrics(status_metrics); self.toast_text_system.set_metrics(status_metrics); + self.whichkey_text_system.set_metrics(status_metrics); self.last_topbar_layout_key = None; self.last_statusbar_layout_key = None; self.last_palette_model = None; @@ -514,6 +533,7 @@ impl Renderer { &mut self.system_dep_text_pipeline, &mut self.diagnostic_hover_text_pipeline, &mut self.toast_text_pipeline, + &mut self.whichkey_text_pipeline, &mut self.leap_label_text_pipeline, &mut self.ai_chat_text_pipeline, ] { diff --git a/src/render/renderer/lifecycle/frame.rs b/src/render/renderer/lifecycle/frame.rs index d6ad9575..89815c8b 100644 --- a/src/render/renderer/lifecycle/frame.rs +++ b/src/render/renderer/lifecycle/frame.rs @@ -44,28 +44,16 @@ impl Renderer { let buf_term_cell_bg_count = self.buffer_terminal_cell_background_instances.len() as u32; let term_cursor_start = buf_term_cell_bg_start + buf_term_cell_bg_count; - let term_cursor_count = if self.caret_blink_visible { - self.terminal_cursor_instances.len() as u32 - } else { - 0 - }; + let term_cursor_count = self.terminal_cursor_instances.len() as u32; let buf_term_cursor_start = term_cursor_start + term_cursor_count; - let buf_term_cursor_count = if self.caret_blink_visible { - self.buffer_terminal_cursor_instances.len() as u32 - } else { - 0 - }; + let buf_term_cursor_count = self.buffer_terminal_cursor_instances.len() as u32; let right_term_cell_bg_start = buf_term_cursor_start + buf_term_cursor_count; let right_term_cell_bg_count = self.right_terminal_cell_background_instances.len() as u32; let right_term_cursor_start = right_term_cell_bg_start + right_term_cell_bg_count; - let right_term_cursor_count = if self.caret_blink_visible { - self.right_terminal_cursor_instances.len() as u32 - } else { - 0 - }; + let right_term_cursor_count = self.right_terminal_cursor_instances.len() as u32; let ai_chat_history_chrome_start = right_term_cursor_start + right_term_cursor_count; let ai_chat_history_chrome_count = self.ai_chat_history_chrome_instances.len() as u32; @@ -84,7 +72,10 @@ impl Renderer { let system_dep_start = lsp_guide_start + lsp_guide_count; let system_dep_count = self.system_dep_chrome_instances.len() as u32; - let toast_start = system_dep_start + system_dep_count; + let whichkey_start = system_dep_start + system_dep_count; + let whichkey_count = self.whichkey_chrome_instances.len() as u32; + + let toast_start = whichkey_start + whichkey_count; let toast_count = self.toast_chrome_instances.len() as u32; let diag_hover_start = toast_start + toast_count; @@ -99,19 +90,16 @@ impl Renderer { all_instances.extend_from_slice(&self.leap_label_bg_instances); all_instances.extend_from_slice(&self.terminal_cell_background_instances); all_instances.extend_from_slice(&self.buffer_terminal_cell_background_instances); - if self.caret_blink_visible { - all_instances.extend_from_slice(&self.terminal_cursor_instances); - all_instances.extend_from_slice(&self.buffer_terminal_cursor_instances); - } + all_instances.extend_from_slice(&self.terminal_cursor_instances); + all_instances.extend_from_slice(&self.buffer_terminal_cursor_instances); all_instances.extend_from_slice(&self.right_terminal_cell_background_instances); - if self.caret_blink_visible { - all_instances.extend_from_slice(&self.right_terminal_cursor_instances); - } + all_instances.extend_from_slice(&self.right_terminal_cursor_instances); all_instances.extend_from_slice(&self.ai_chat_history_chrome_instances); all_instances.extend_from_slice(&self.ai_chat_suggestion_chrome_instances); all_instances.extend_from_slice(&self.palette_chrome_instances); all_instances.extend_from_slice(&self.lsp_guide_chrome_instances); all_instances.extend_from_slice(&self.system_dep_chrome_instances); + all_instances.extend_from_slice(&self.whichkey_chrome_instances); all_instances.extend_from_slice(&self.toast_chrome_instances); all_instances.extend_from_slice(&self.diagnostic_hover_chrome_instances); @@ -772,6 +760,31 @@ impl Renderer { }, ); + if whichkey_count > 0 { + draw_text_region( + &mut pass, + self.whichkey_scissor, + viewport_width, + viewport_height, + |render_pass| { + self.region_pipeline.draw_range( + render_pass, + whichkey_start, + whichkey_count, + ); + }, + ); + } + draw_text_region( + &mut pass, + self.whichkey_scissor, + viewport_width, + viewport_height, + |render_pass| { + self.whichkey_text_pipeline.draw(render_pass); + }, + ); + if toast_count > 0 { draw_text_region( &mut pass, diff --git a/src/render/renderer/palette.rs b/src/render/renderer/palette.rs index 0143edc3..dbfaa422 100644 --- a/src/render/renderer/palette.rs +++ b/src/render/renderer/palette.rs @@ -11,14 +11,14 @@ use crate::{ app::command_palette::CommandPaletteRenderModel, render::{ glyph_instance::GlyphInstance, - icon_pipeline::{canonical_icon_id, IconDrawInstance}, + icon_pipeline::{IconDrawInstance, canonical_icon_id}, region_pipeline::RegionDrawInstance, renderer::Renderer, }, }; -use super::helpers::{estimate_monospace_width, layout_panel_text, layout_panel_text_bold}; use super::components::{estimate_help_keycaps_width, layout_help_keycaps}; +use super::helpers::{estimate_monospace_width, layout_panel_text, layout_panel_text_bold}; pub(super) const PALETTE_FRAME_RADIUS: f32 = 16.0; pub(super) const PALETTE_PANEL_RADIUS: f32 = 15.0; @@ -59,7 +59,10 @@ pub(super) fn render_palette_chrome( ) .with_radius(PALETTE_FRAME_RADIUS), ); - chrome.push(RegionDrawInstance::new(model.panel_bounds, model.panel_bg).with_radius(PALETTE_PANEL_RADIUS)); + chrome.push( + RegionDrawInstance::new(model.panel_bounds, model.panel_bg) + .with_radius(PALETTE_PANEL_RADIUS), + ); } pub(super) fn render_palette_badge( @@ -86,8 +89,7 @@ pub(super) fn render_palette_badge( let badge_text_color = blend_palette_badge_color(text_color, fill, 0.78); chrome.push( - RegionDrawInstance::new([x, y, badge_w, badge_h], badge_border) - .with_radius(badge_radius), + RegionDrawInstance::new([x, y, badge_w, badge_h], badge_border).with_radius(badge_radius), ); chrome.push( RegionDrawInstance::new( @@ -128,7 +130,10 @@ pub(super) fn push_palette_icon_or_badge( icons: &mut Vec, ) { if let Some(asset_icon) = canonical_icon_id(icon) { - if matches!(chrome_style, super::components::PrefixIconBadgeChrome::Outline) { + if matches!( + chrome_style, + super::components::PrefixIconBadgeChrome::Outline + ) { let [x, y, w, h] = bounds; let radius = h * 0.22; let border = (h * 0.075).clamp(2.0, 3.0); @@ -295,7 +300,10 @@ impl Renderer { self.palette_icon_pipeline.upload_instances( &self.device, &self.palette_icon_instances, - [self.surface_state.config.width, self.surface_state.config.height], + [ + self.surface_state.config.width, + self.surface_state.config.height, + ], ); } self.palette_text_pipeline.upload_instances( @@ -315,7 +323,10 @@ impl Renderer { self.palette_icon_pipeline.upload_instances( &self.device, &self.palette_icon_instances, - [self.surface_state.config.width, self.surface_state.config.height], + [ + self.surface_state.config.width, + self.surface_state.config.height, + ], ); self.palette_text_pipeline .upload_instances(&self.device, &self.queue, &[]); diff --git a/src/render/renderer/palette/file_picker.rs b/src/render/renderer/palette/file_picker.rs index 028a3da9..3035e424 100644 --- a/src/render/renderer/palette/file_picker.rs +++ b/src/render/renderer/palette/file_picker.rs @@ -9,17 +9,16 @@ use crate::{ }; use super::{ - palette_footer_content_height, palette_footer_height, render_palette_badge, - push_palette_icon_or_badge, render_palette_chrome, render_palette_footer, - render_palette_selection, PaletteFooterAction, PALETTE_FOOTER_TOP_PAD, - PALETTE_HEADER_BOTTOM_PAD, + PALETTE_FOOTER_TOP_PAD, PALETTE_HEADER_BOTTOM_PAD, PaletteFooterAction, + palette_footer_content_height, palette_footer_height, push_palette_icon_or_badge, + render_palette_badge, render_palette_chrome, render_palette_footer, render_palette_selection, }; use super::super::{ - components::{layout_prefix_icon_badge, PrefixIconBadge, PrefixIconBadgeChrome}, + components::{PrefixIconBadge, PrefixIconBadgeChrome, layout_prefix_icon_badge}, helpers::{ - clamp_monospace_text, estimate_monospace_width, gutter_width_for_editor, - layout_panel_text, rect_to_scissor, + clamp_monospace_text, estimate_monospace_width, gutter_width_for_editor, layout_panel_text, + rect_to_scissor, }, }; @@ -44,7 +43,8 @@ impl Renderer { } else { 4.0 }; - let is_file_picker = model.mode == crate::app::command_palette::CommandPaletteMode::FilePicker; + let is_file_picker = + model.mode == crate::app::command_palette::CommandPaletteMode::FilePicker; let row_h = model.row_height.max(1.0); let text_x = panel_x + model.panel_padding + 8.0; @@ -303,7 +303,10 @@ impl Renderer { self.palette_icon_pipeline.upload_instances( &self.device, &self.palette_icon_instances, - [self.surface_state.config.width, self.surface_state.config.height], + [ + self.surface_state.config.width, + self.surface_state.config.height, + ], ); self.palette_glyph_instances = glyphs; } @@ -342,8 +345,6 @@ fn shift_ranges_after_badge(label: &str, ranges: &[(usize, usize)]) -> Vec<(usiz .collect() } - - fn file_picker_tone_color( tone: crate::app::command_palette::CommandPaletteItemTone, model: &CommandPaletteRenderModel, diff --git a/src/render/renderer/palette/highlighted_label.rs b/src/render/renderer/palette/highlighted_label.rs index b629010a..37e321cc 100644 --- a/src/render/renderer/palette/highlighted_label.rs +++ b/src/render/renderer/palette/highlighted_label.rs @@ -8,8 +8,8 @@ use crate::{ }; use super::super::helpers::{ - clamp_monospace_text, estimate_monospace_width, gutter_width_for_editor, - layout_panel_text, layout_panel_text_bold, rect_to_scissor, + clamp_monospace_text, estimate_monospace_width, gutter_width_for_editor, layout_panel_text, + layout_panel_text_bold, rect_to_scissor, }; impl Renderer { diff --git a/src/render/renderer/palette/leap.rs b/src/render/renderer/palette/leap.rs index 02c3262c..8e0c4c3a 100644 --- a/src/render/renderer/palette/leap.rs +++ b/src/render/renderer/palette/leap.rs @@ -8,13 +8,11 @@ use crate::{ text::layout_sync::visual_y_for_logical_scroll_with_folds, }; -use super::super::components::{ - layout_prefix_icon_badge, PrefixIconBadge, PrefixIconBadgeChrome, -}; +use super::super::components::{PrefixIconBadge, PrefixIconBadgeChrome, layout_prefix_icon_badge}; use super::super::editor::editor_viewport_geometry; use super::super::helpers::{ - clamp_monospace_text, estimate_monospace_width, gutter_width_for_editor, - layout_panel_text, layout_panel_text_bold, rect_to_scissor, + clamp_monospace_text, estimate_monospace_width, gutter_width_for_editor, layout_panel_text, + layout_panel_text_bold, rect_to_scissor, }; impl Renderer { @@ -128,7 +126,11 @@ impl Renderer { color: label_color, panel_bg, bounds: [badge_x, badge_y, badge_w, badge_h], - icon_scale: if visible_label.chars().count() > 1 { 0.50 } else { 0.68 }, + icon_scale: if visible_label.chars().count() > 1 { + 0.50 + } else { + 0.68 + }, y_nudge_scale: 0.08, chrome: PrefixIconBadgeChrome::Outline, }; diff --git a/src/render/renderer/palette/live_grep.rs b/src/render/renderer/palette/live_grep.rs index 0325893c..d99e20f6 100644 --- a/src/render/renderer/palette/live_grep.rs +++ b/src/render/renderer/palette/live_grep.rs @@ -8,18 +8,18 @@ use crate::{ }, }; -use super::{ - palette_footer_content_height, palette_footer_height, push_palette_icon_or_badge, - render_palette_badge, render_palette_chrome, render_palette_footer, render_palette_selection, - PaletteFooterAction, PALETTE_FOOTER_TOP_PAD, PALETTE_HEADER_BOTTOM_PAD, -}; use super::super::{ components::PrefixIconBadgeChrome, helpers::{ - clamp_monospace_text, estimate_monospace_width, gutter_width_for_editor, - layout_panel_text, layout_panel_text_bold, rect_to_scissor, + clamp_monospace_text, estimate_monospace_width, gutter_width_for_editor, layout_panel_text, + layout_panel_text_bold, rect_to_scissor, }, }; +use super::{ + PALETTE_FOOTER_TOP_PAD, PALETTE_HEADER_BOTTOM_PAD, PaletteFooterAction, + palette_footer_content_height, palette_footer_height, push_palette_icon_or_badge, + render_palette_badge, render_palette_chrome, render_palette_footer, render_palette_selection, +}; impl Renderer { pub(super) fn render_live_grep_picker(&mut self, model: &CommandPaletteRenderModel) { @@ -253,7 +253,10 @@ impl Renderer { self.palette_icon_pipeline.upload_instances( &self.device, &self.palette_icon_instances, - [self.surface_state.config.width, self.surface_state.config.height], + [ + self.surface_state.config.width, + self.surface_state.config.height, + ], ); self.palette_glyph_instances = glyphs; } diff --git a/src/render/renderer/palette/minimal.rs b/src/render/renderer/palette/minimal.rs index 966fe0f5..75ddd15d 100644 --- a/src/render/renderer/palette/minimal.rs +++ b/src/render/renderer/palette/minimal.rs @@ -7,12 +7,12 @@ use crate::{ }, }; -use super::{render_palette_chrome, render_palette_selection}; use super::super::components::{estimate_help_keycaps_width, layout_help_keycaps}; use super::super::helpers::{ - clamp_monospace_text, estimate_monospace_width, gutter_width_for_editor, - layout_panel_text, layout_panel_text_bold, rect_to_scissor, + clamp_monospace_text, estimate_monospace_width, gutter_width_for_editor, layout_panel_text, + layout_panel_text_bold, rect_to_scissor, }; +use super::{render_palette_chrome, render_palette_selection}; impl Renderer { // ── Minimalist palette (Command / Symbol / VimCommand) ───────────────────── @@ -318,17 +318,23 @@ impl Renderer { model.hint_color, )); let aa_text = "Aa"; - let aa_text_w = crate::render::renderer::helpers::estimate_monospace_width(aa_text, font_size); - let option_w = if model.mode == crate::app::command_palette::CommandPaletteMode::InFileSearch { - aa_text_w + 22.0 - } else { - 0.0 - }; + let aa_text_w = + crate::render::renderer::helpers::estimate_monospace_width(aa_text, font_size); + let option_w = + if model.mode == crate::app::command_palette::CommandPaletteMode::InFileSearch { + aa_text_w + 22.0 + } else { + 0.0 + }; let query_w = (inner_width - prefix_w - option_w - 18.0).max(1.0); self.palette_text_system .set_size(Some(query_w), Some(model.line_height)); glyphs.extend(layout_panel_text( - &crate::render::renderer::helpers::clamp_monospace_text(&model.prompt_query, query_w, font_size), + &crate::render::renderer::helpers::clamp_monospace_text( + &model.prompt_query, + query_w, + font_size, + ), &mut self.palette_text_system, &mut self.atlas, &self.queue, @@ -355,7 +361,11 @@ impl Renderer { &self.queue, aa_box_x + ((option_w - aa_text_w) * 0.5).max(0.0), aa_box_y + ((aa_box_h - model.line_height) * 0.5).max(0.0), - if model.search_case_sensitive { model.text_color } else { model.hint_color }, + if model.search_case_sensitive { + model.text_color + } else { + model.hint_color + }, )); } row_top += prompt_h; diff --git a/src/render/renderer/palette/recent_projects.rs b/src/render/renderer/palette/recent_projects.rs index c043611b..ae2e54ea 100644 --- a/src/render/renderer/palette/recent_projects.rs +++ b/src/render/renderer/palette/recent_projects.rs @@ -8,17 +8,14 @@ use crate::{ }, }; -use super::{ - palette_footer_content_height, palette_footer_height, render_palette_badge, - push_palette_icon_or_badge, render_palette_chrome, render_palette_footer, - render_palette_selection, PaletteFooterAction, PALETTE_FOOTER_TOP_PAD, - PALETTE_HEADER_BOTTOM_PAD, -}; use super::super::{ - components::{layout_prefix_icon_badge, PrefixIconBadge, PrefixIconBadgeChrome}, - helpers::{ - clamp_monospace_text, estimate_monospace_width, layout_panel_text, rect_to_scissor, - }, + components::{PrefixIconBadge, PrefixIconBadgeChrome, layout_prefix_icon_badge}, + helpers::{clamp_monospace_text, estimate_monospace_width, layout_panel_text, rect_to_scissor}, +}; +use super::{ + PALETTE_FOOTER_TOP_PAD, PALETTE_HEADER_BOTTOM_PAD, PaletteFooterAction, + palette_footer_content_height, palette_footer_height, push_palette_icon_or_badge, + render_palette_badge, render_palette_chrome, render_palette_footer, render_palette_selection, }; impl Renderer { @@ -88,7 +85,10 @@ impl Renderer { let count_text = if is_theme_selector && !model.result_labels.is_empty() { format!( "{}/{}", - model.selected_index.saturating_add(1).min(model.result_labels.len()), + model + .selected_index + .saturating_add(1) + .min(model.result_labels.len()), model.total_results ) } else if is_theme_selector { @@ -155,7 +155,11 @@ impl Renderer { // ── Body rows ────────────────────────────────────────────────────────── let footer_h = palette_footer_height(line_h); - let row_h = if is_theme_selector { line_h + 18.0 } else { row_h }; + let row_h = if is_theme_selector { + line_h + 18.0 + } else { + row_h + }; let body_h = (panel_h - (row_top - panel_y) - footer_h - model.panel_padding).max(0.0); let max_visible = ((body_h / row_h).floor() as usize).max(1); if is_theme_selector && body_h > 1.0 { @@ -274,7 +278,10 @@ impl Renderer { ); } else { let full_path_buf = std::path::Path::new(full_path); - let inferred_icon_source = crate::app::persistence::AppPersistentState::infer_project_icon_source(full_path_buf); + let inferred_icon_source = + crate::app::persistence::AppPersistentState::infer_project_icon_source( + full_path_buf, + ); let icon_source = recent_meta .icon_source .as_deref() @@ -374,7 +381,11 @@ impl Renderer { [panel_x, footer_y, panel_w, 1.0], model.border_color, )); - let enter_label = if is_theme_selector { "apply theme" } else { "open" }; + let enter_label = if is_theme_selector { + "apply theme" + } else { + "open" + }; glyphs.extend(render_palette_footer( model, &mut self.palette_text_system, @@ -406,7 +417,10 @@ impl Renderer { self.palette_icon_pipeline.upload_instances( &self.device, &self.palette_icon_instances, - [self.surface_state.config.width, self.surface_state.config.height], + [ + self.surface_state.config.width, + self.surface_state.config.height, + ], ); self.palette_glyph_instances = glyphs; } @@ -456,12 +470,6 @@ fn relative_last_opened_label(last_opened_unix_secs: u64) -> String { } } - - - - - - fn theme_preview_colors(name: &str, model: &CommandPaletteRenderModel) -> [[f32; 4]; 6] { let mut hash = 0xcbf2_9ce4_8422_2325_u64; for byte in name.bytes() { diff --git a/src/render/renderer/ui.rs b/src/render/renderer/ui.rs index ee76e970..566ca5a7 100644 --- a/src/render/renderer/ui.rs +++ b/src/render/renderer/ui.rs @@ -8,3 +8,4 @@ mod terminal; mod topbar; pub(super) mod utils; mod welcome; +mod whichkey; diff --git a/src/render/renderer/ui/ai_chat.rs b/src/render/renderer/ui/ai_chat.rs index dc7c018c..79f7b6e8 100644 --- a/src/render/renderer/ui/ai_chat.rs +++ b/src/render/renderer/ui/ai_chat.rs @@ -1453,7 +1453,7 @@ impl Renderer { let mut all_glyphs: Vec = Vec::new(); let mut chrome_instances: Vec = Vec::new(); - + let code_bg = blend_rgb( self.theme.ui.panel_bg.as_f32(), self.theme.ui.selection_bg.as_f32(), @@ -1489,10 +1489,13 @@ impl Renderer { } let mut layouted_lines: Vec = Vec::new(); - + for (line_idx, preview_line) in lines.iter().enumerate() { let is_code = matches!(preview_line.block_type, MarkdownBlockType::CodeBlock); - let is_table = matches!(preview_line.block_type, MarkdownBlockType::TableHeader | MarkdownBlockType::TableRow); + let is_table = matches!( + preview_line.block_type, + MarkdownBlockType::TableHeader | MarkdownBlockType::TableRow + ); let wrapped_lines = if is_code { word_wrap_with_ranges(&preview_line.text, code_max_chars) } else if is_table { @@ -1508,14 +1511,16 @@ impl Renderer { .iter() .filter_map(|span| clip_styled_span_to_range(*span, &byte_range)) .collect(); - - let is_code_block_start = is_code - && (line_idx == 0 || !matches!(lines[line_idx - 1].block_type, MarkdownBlockType::CodeBlock)) + + let is_code_block_start = is_code + && (line_idx == 0 + || !matches!(lines[line_idx - 1].block_type, MarkdownBlockType::CodeBlock)) && w_idx == 0; - let is_code_block_end = is_code - && (line_idx + 1 == lines.len() || !matches!(lines[line_idx + 1].block_type, MarkdownBlockType::CodeBlock)) + let is_code_block_end = is_code + && (line_idx + 1 == lines.len() + || !matches!(lines[line_idx + 1].block_type, MarkdownBlockType::CodeBlock)) && w_idx + 1 == num_wrapped; - + layouted_lines.push(LayoutedLine { block_type: preview_line.block_type, text: wrapped_text, @@ -1538,17 +1543,17 @@ impl Renderer { let spacing_code_block_padding = 6.0; let mut current_y = 0.0; - + for i in 0..layouted_lines.len() { let is_code_block_start = layouted_lines[i].is_code_block_start; let is_code_block_end = layouted_lines[i].is_code_block_end; let is_code_block = layouted_lines[i].is_code_block; let block_type = layouted_lines[i].block_type; - + if i > 0 { let prev_block_type = layouted_lines[i - 1].block_type; let prev_is_code = layouted_lines[i - 1].is_code_block; - + if is_code_block_start { current_y += spacing_code_block_margin; } else if matches!(block_type, MarkdownBlockType::Heading(_)) { @@ -1571,14 +1576,14 @@ impl Renderer { } } } - + if is_code_block_start { current_y += spacing_code_block_padding; } - + layouted_lines[i].y_offset = current_y; current_y += line_h; - + if is_code_block_end { current_y += spacing_code_block_padding; } @@ -1592,7 +1597,7 @@ impl Renderer { // Step 3: Draw code block backgrounds (unified per block) and table header highlights let mut current_code_block_start: Option = None; let start_y = clip[1]; - + for i in 0..layouted_lines.len() { let line = &layouted_lines[i]; if line.is_code_block_start { @@ -1602,15 +1607,15 @@ impl Renderer { if let Some(start_idx) = current_code_block_start.take() { let y_start = layouted_lines[start_idx].y_offset - spacing_code_block_padding; let y_end = line.y_offset + line_h + spacing_code_block_padding; - + let screen_y_start = start_y + y_start - scroll_offset_y; let screen_y_end = start_y + y_end - scroll_offset_y; let height = screen_y_end - screen_y_start; - + let box_x = clip[0] + code_inset_x; let box_w = (clip[2] - code_inset_x * 2.0).max(1.0); let rect = [box_x, screen_y_start, box_w, height.max(1.0)]; - + chrome_instances.push( RegionDrawInstance::new(rect, code_border) .with_radius(self.panel_corner_radius.min(6.0)), @@ -1629,10 +1634,15 @@ impl Renderer { ); } } - + if matches!(line.block_type, MarkdownBlockType::TableHeader) { let y = start_y + line.y_offset - scroll_offset_y; - let rect = [clip[0] + code_inset_x, y, (clip[2] - code_inset_x * 2.0).max(1.0), line_h]; + let rect = [ + clip[0] + code_inset_x, + y, + (clip[2] - code_inset_x * 2.0).max(1.0), + line_h, + ]; chrome_instances.push( RegionDrawInstance::new(rect, table_header_bg) .with_radius(self.panel_corner_radius.min(4.0)), @@ -1642,10 +1652,10 @@ impl Renderer { // Step 4: Draw all text lines let visible_bottom = clip[1] + clip[3]; - + for line in &layouted_lines { let y = start_y + line.y_offset - scroll_offset_y; - + if y + line_h < clip[1] { continue; } @@ -1657,8 +1667,12 @@ impl Renderer { continue; } - let text_x = if line.is_code_block { code_text_x } else { clip[0] }; - + let text_x = if line.is_code_block { + code_text_x + } else { + clip[0] + }; + let default_color = match line.block_type { MarkdownBlockType::Heading(_) => fg, MarkdownBlockType::CodeBlock => fg_dim, diff --git a/src/render/renderer/ui/sidebar.rs b/src/render/renderer/ui/sidebar.rs index 252a804d..e9c0264e 100644 --- a/src/render/renderer/ui/sidebar.rs +++ b/src/render/renderer/ui/sidebar.rs @@ -1,5 +1,5 @@ use crate::render::{ - icon_pipeline::{canonical_icon_id, IconDrawInstance}, + icon_pipeline::{IconDrawInstance, canonical_icon_id}, region_pipeline::RegionDrawInstance, renderer::{Renderer, SidebarFilterState, SidebarRow}, }; @@ -47,7 +47,10 @@ impl Renderer { self.sidebar_icon_pipeline.upload_instances( &self.device, &self.sidebar_icon_instances, - [self.surface_state.config.width, self.surface_state.config.height], + [ + self.surface_state.config.width, + self.surface_state.config.height, + ], ); return Vec::new(); } @@ -270,7 +273,10 @@ impl Renderer { self.sidebar_icon_pipeline.upload_instances( &self.device, &self.sidebar_icon_instances, - [self.surface_state.config.width, self.surface_state.config.height], + [ + self.surface_state.config.width, + self.surface_state.config.height, + ], ); self.sidebar_glyph_instances = glyphs; self.sidebar_text_pipeline.upload_instances( @@ -291,7 +297,10 @@ impl Renderer { self.sidebar_icon_pipeline.upload_instances( &self.device, &self.sidebar_icon_instances, - [self.surface_state.config.width, self.surface_state.config.height], + [ + self.surface_state.config.width, + self.surface_state.config.height, + ], ); } } diff --git a/src/render/renderer/ui/statusbar.rs b/src/render/renderer/ui/statusbar.rs index 46bcb825..91923415 100644 --- a/src/render/renderer/ui/statusbar.rs +++ b/src/render/renderer/ui/statusbar.rs @@ -2,7 +2,7 @@ use crate::{ core::mode::EditorMode, render::{ region_pipeline::RegionDrawInstance, - renderer::{Renderer, StatusbarLayoutKey}, + renderer::{AiInlineStatus, LspStatusIndicator, Renderer, StatusbarLayoutKey}, }, }; @@ -39,6 +39,8 @@ impl Renderer { lsp_loading: bool, lsp_loading_frame: u8, lsp_progress: Option<&str>, + lsp_indicator: &LspStatusIndicator, + ai_status: Option, venv_name: Option<&str>, python_version: Option<&str>, node_version: Option<&str>, @@ -71,6 +73,8 @@ impl Renderer { lsp_loading, lsp_loading_frame, lsp_progress: lsp_progress.map(str::to_string), + lsp_indicator: lsp_indicator.clone(), + ai_status, bounds, venv_name: venv_name.map(str::to_string), python_version: python_version.map(str::to_string), @@ -191,11 +195,8 @@ impl Renderer { let badge_fg = [0.0, 0.0, 0.0, 1.0]; chrome.push( - RegionDrawInstance::new( - [left_x, pill_y, dbg_w, pill_height], - badge_bg, - ) - .with_radius(pill_radius), + RegionDrawInstance::new([left_x, pill_y, dbg_w, pill_height], badge_bg) + .with_radius(pill_radius), ); glyphs.extend(layout_panel_text_bold( @@ -283,6 +284,8 @@ impl Renderer { .map(|(c, t)| format!("{c}/{t}")) .unwrap_or_default(); + let show_lsp_dot = !matches!(lsp_indicator, LspStatusIndicator::NotApplicable); + // Right zone items in display order (left → right as rendered). let mut right_items: Vec<(String, [f32; 4])> = Vec::new(); // LSP progress label (leftmost in right zone, most prominent). @@ -306,6 +309,23 @@ impl Renderer { right_items.push((lang_str.to_string(), fg_ghost)); } + // ── LSP server chip (name colored by state, dot rendered far right) ─── + match lsp_indicator { + LspStatusIndicator::NotApplicable => {} + LspStatusIndicator::Inactive => { + right_items.push(("No LSP".to_string(), fg_ghost)); + } + LspStatusIndicator::Starting(name) => { + right_items.push((format!("{name}…"), amber)); + } + LspStatusIndicator::Running(name) => { + right_items.push((name.clone(), fg_ghost)); + } + LspStatusIndicator::Missing(name) => { + right_items.push((format!("{name} missing"), error_fg)); + } + } + // ── Runtime version badges — only shown when filetype matches ──────── let is_python = filetype == "Python"; let is_node = matches!( @@ -332,11 +352,14 @@ impl Renderer { } } - if let Some(device) = active_device { - let trimmed = device.trim(); - if !trimmed.is_empty() { - right_items.push((format!("📱 {trimmed}"), success_fg)); - } + // ── AI inline completion chip ───────────────────────────────────────── + if let Some(status) = ai_status { + let (label, color) = match status { + AiInlineStatus::Ready => ("AI", fg_ghost), + AiInlineStatus::Loading => ("AI…", amber), + AiInlineStatus::Error => ("AI!", error_fg), + }; + right_items.push((label.to_string(), color)); } let right_text_w: f32 = right_items @@ -345,7 +368,11 @@ impl Renderer { .sum::() + item_gap * right_items.len().saturating_sub(1) as f32; // LSP dot/spinner sits at the far right, separated by one item_gap. - let lsp_zone_w = lsp_dot_size + item_gap; + let lsp_zone_w = if show_lsp_dot || lsp_loading { + lsp_dot_size + item_gap + } else { + 0.0 + }; let total_right_w = right_text_w + lsp_zone_w; let right_start = (bounds[0] + bounds[2] - right_pad - total_right_w).max(left_zone_end + item_gap); @@ -510,13 +537,21 @@ impl Renderer { origin_y, amber, )); - } else { - let lsp_color = if lsp_progress.is_some() { - amber - } else if diagnostics_errors > 0 { - error_fg - } else { - success_fg + } else if show_lsp_dot { + let lsp_color = match lsp_indicator { + LspStatusIndicator::NotApplicable => fg_ghost, + LspStatusIndicator::Inactive => fg_ghost, + LspStatusIndicator::Starting(_) => amber, + LspStatusIndicator::Missing(_) => error_fg, + LspStatusIndicator::Running(_) => { + if lsp_progress.is_some() { + amber + } else if diagnostics_errors > 0 { + error_fg + } else { + success_fg + } + } }; chrome.push( RegionDrawInstance::new([lsp_x, lsp_y, lsp_dot_size, lsp_dot_size], lsp_color) diff --git a/src/render/renderer/ui/terminal.rs b/src/render/renderer/ui/terminal.rs index 216630b0..955cd546 100644 --- a/src/render/renderer/ui/terminal.rs +++ b/src/render/renderer/ui/terminal.rs @@ -183,13 +183,9 @@ impl Renderer { self.buffer_terminal_view_renderer.cell_height = line_h; self.buffer_terminal_view_renderer.font_size = font_size; - self.buffer_terminal_cell_background_instances = - self.buffer_terminal_view_renderer.build_background_instances( - grid, - default_fg, - default_bg, - width, - ); + self.buffer_terminal_cell_background_instances = self + .buffer_terminal_view_renderer + .build_background_instances(grid, default_fg, default_bg, width); self.buffer_terminal_glyph_instances = self.buffer_terminal_view_renderer.build_instances( grid, @@ -398,13 +394,9 @@ impl Renderer { 1.0, ) } - crate::config::ui_config::CursorShape::Block => ( - cell_x, - cell_y, - cell_w.max(1.0), - cell_h.max(1.0), - 0.45, - ), + crate::config::ui_config::CursorShape::Block => { + (cell_x, cell_y, cell_w.max(1.0), cell_h.max(1.0), 0.45) + } }; cursor_instances.push(RegionDrawInstance::new( [x, y, w, h], diff --git a/src/render/renderer/ui/topbar.rs b/src/render/renderer/ui/topbar.rs index 13a5fdc7..284c60e5 100644 --- a/src/render/renderer/ui/topbar.rs +++ b/src/render/renderer/ui/topbar.rs @@ -92,7 +92,10 @@ impl Renderer { self.topbar_icon_pipeline.upload_instances( &self.device, &self.topbar_icon_instances, - [self.surface_state.config.width, self.surface_state.config.height], + [ + self.surface_state.config.width, + self.surface_state.config.height, + ], ); self.topbar_chrome_instances.clear(); self.topbar_text_batches.clear(); @@ -174,7 +177,8 @@ impl Renderer { let folder_icon_w = folder_icon_size; // Compute maximum width available for the text itself - let allocated_fixed_w = left_pad + folder_icon_w + icon_text_gap + text_sep_gap + sep_w + sep_tab_gap; + let allocated_fixed_w = + left_pad + folder_icon_w + icon_text_gap + text_sep_gap + sep_w + sep_tab_gap; let max_text_w = (center_x - topbar_start_x - allocated_fixed_w).max(0.0); let clamped_project = clamp_project_name(project, max_text_w, font_size); @@ -222,7 +226,8 @@ impl Renderer { let scissor_w = draw_x - tab_x; let count = glyphs.len() as u32 - start; if count > 0 { - if let Some(scissor) = rect_to_scissor([tab_x, content_y, scissor_w, content_h]) { + if let Some(scissor) = rect_to_scissor([tab_x, content_y, scissor_w, content_h]) + { text_batches.push(TextScissorBatch { scissor, range: InstanceDrawRange { start, count }, @@ -406,7 +411,10 @@ impl Renderer { self.topbar_icon_pipeline.upload_instances( &self.device, &self.topbar_icon_instances, - [self.surface_state.config.width, self.surface_state.config.height], + [ + self.surface_state.config.width, + self.surface_state.config.height, + ], ); self.topbar_glyph_instances = glyphs; self.topbar_text_pipeline.upload_instances( diff --git a/src/render/renderer/ui/welcome.rs b/src/render/renderer/ui/welcome.rs index 833a6b15..2c4626a2 100644 --- a/src/render/renderer/ui/welcome.rs +++ b/src/render/renderer/ui/welcome.rs @@ -1,12 +1,17 @@ -use std::{fs, path::{Path, PathBuf}, sync::OnceLock}; +use std::{ + fs, + path::{Path, PathBuf}, + sync::OnceLock, +}; use cosmic_text::Metrics; use crate::{ render::{ glyph_instance::GlyphInstance, - icon_pipeline::{canonical_icon_id, IconDrawInstance}, - region_pipeline::RegionDrawInstance, renderer::Renderer, + icon_pipeline::{IconDrawInstance, canonical_icon_id}, + region_pipeline::RegionDrawInstance, + renderer::Renderer, }, terminal::grid::TerminalGrid, text::text_system::StyledTextSpan, @@ -14,9 +19,8 @@ use crate::{ use super::super::{ components::{ + HighlightChipStyle, PrefixIconBadge, PrefixIconBadgeChrome, ShortcutHintSegment, layout_prefix_icon_badge, layout_shortcut_hint, push_centered_highlight_chip, - HighlightChipStyle, PrefixIconBadge, PrefixIconBadgeChrome, - ShortcutHintSegment, }, helpers::{ estimate_monospace_width, layout_clamp, layout_panel_text, layout_panel_text_bold, @@ -72,8 +76,6 @@ fn parse_welcome_git_head(head: &str) -> Option { }) } - - fn bundled_logo() -> Option<&'static BundledLogo> { static LOGO: OnceLock> = OnceLock::new(); LOGO.get_or_init(|| { @@ -114,12 +116,10 @@ impl Renderer { let fg_dim = self.theme.ui.fg_dim.as_f32(); let fg_ghost = self.theme.ui.fg_ghost.as_f32(); let accent = self.theme.ui.accent.as_f32(); - let runtime_scale = (self.theme.editor.font_size / 14.0).max(0.5); - let welcome_scale = layout_clamp( - (self.welcome_card_max_width / 560.0) * runtime_scale, - 0.5, - 3.0, - ); + // welcome_card_max_width already carries the runtime scale (scale_ui_config), + // so dividing by the 560 design width yields the scale directly. Multiplying + // by font-derived runtime scale again squared the zoom across monitors. + let welcome_scale = layout_clamp(self.welcome_card_max_width / 560.0, 0.5, 3.0); let sx = |value: f32| value * welcome_scale; let text_w = |text: &str, size: f32| estimate_monospace_width(text, size); let centered_x = |center: f32, text: &str, size: f32| center - text_w(text, size) * 0.5; @@ -158,8 +158,8 @@ impl Renderer { // column widths instead so narrow windows/panes collapse predictably. let min_left_column_w = sx(500.0); let min_right_column_w = sx(560.0); - let two_column = bounds[2] >= min_left_column_w + min_right_column_w - && bounds[3] >= sx(520.0); + let two_column = + bounds[2] >= min_left_column_w + min_right_column_w && bounds[3] >= sx(520.0); let left_w = if two_column { (bounds[2] * 0.52).clamp(min_left_column_w, bounds[2] - min_right_column_w) } else { @@ -245,7 +245,18 @@ impl Renderer { 1.0, ]; chrome.push(RegionDrawInstance::new(bounds, border_color).with_radius(radius)); - chrome.push(RegionDrawInstance::new([x + border, y + border, (w - border * 2.0).max(1.0), (h - border * 2.0).max(1.0)], bg_color).with_radius((radius - border).max(2.0))); + chrome.push( + RegionDrawInstance::new( + [ + x + border, + y + border, + (w - border * 2.0).max(1.0), + (h - border * 2.0).max(1.0), + ], + bg_color, + ) + .with_radius((radius - border).max(2.0)), + ); } if let Some(asset_icon) = canonical_icon_id(icon) { let [x, y, w, h] = bounds; @@ -432,9 +443,33 @@ impl Renderer { let section_size = sx(10.5); let mut card_y = meta_y + sx(58.0); let action_cards = [ - ("START", "built_in:file", self.theme.ui.cyan.as_f32(), "New Instance", "Open another editor window", &["⌘", "⇧", "N"][..], false), - ("", "built_in:folder", self.theme.ui.info.as_f32(), "Open Folder", "Browse for a project folder", &["⌘", "O"][..], false), - ("", "built_in:conf", self.theme.ui.magenta.as_f32(), "Command Palette", "Search commands, files, symbols", &["⌘", "P"][..], false), + ( + "START", + "built_in:file", + self.theme.ui.cyan.as_f32(), + "New Instance", + "Open another editor window", + &["⌘", "⇧", "N"][..], + false, + ), + ( + "", + "built_in:folder", + self.theme.ui.info.as_f32(), + "Open Folder", + "Browse for a project folder", + &["⌘", "O"][..], + false, + ), + ( + "", + "built_in:conf", + self.theme.ui.magenta.as_f32(), + "Command Palette", + "Search commands, files, symbols", + &["⌘", "P"][..], + false, + ), ]; for (section, icon, color, title, sub, keys, active) in action_cards { if !section.is_empty() { @@ -455,8 +490,22 @@ impl Renderer { card_fill[3] = if active { 0.98 } else { 0.72 }; let mut card_border = if active { accent } else { border }; card_border[3] = if active { 0.72 } else { 0.60 }; - chrome.push(RegionDrawInstance::new([card_x, card_y, card_w, card_h], card_border).with_radius(sx(8.0))); - chrome.push(RegionDrawInstance::new([card_x + sx(1.0), card_y + sx(1.0), card_w - sx(2.0), card_h - sx(2.0)], card_fill).with_radius(sx(7.0))); + chrome.push( + RegionDrawInstance::new([card_x, card_y, card_w, card_h], card_border) + .with_radius(sx(8.0)), + ); + chrome.push( + RegionDrawInstance::new( + [ + card_x + sx(1.0), + card_y + sx(1.0), + card_w - sx(2.0), + card_h - sx(2.0), + ], + card_fill, + ) + .with_radius(sx(7.0)), + ); icon_badge( self, &mut glyphs, @@ -469,19 +518,57 @@ impl Renderer { 0.56, PrefixIconBadgeChrome::Outline, ); - line(self, &mut glyphs, title, card_x + sx(70.0), card_y + sx(14.0), card_title_size, sx(16.0), fg, true); - line(self, &mut glyphs, sub, card_x + sx(70.0), card_y + sx(33.0), card_sub_size, sx(14.0), fg_ghost, false); + line( + self, + &mut glyphs, + title, + card_x + sx(70.0), + card_y + sx(14.0), + card_title_size, + sx(16.0), + fg, + true, + ); + line( + self, + &mut glyphs, + sub, + card_x + sx(70.0), + card_y + sx(33.0), + card_sub_size, + sx(14.0), + fg_ghost, + false, + ); if card_w >= sx(350.0) { let key_size = sx(9.5); let key_x = card_x + card_w - sx(18.0) - shortcut_hint_width(keys, key_size); - key_hint(self, &mut glyphs, &mut chrome, keys, key_x, card_y + sx(17.0), key_size); + key_hint( + self, + &mut glyphs, + &mut chrome, + keys, + key_x, + card_y + sx(17.0), + key_size, + ); } card_y += card_h + card_gap; } if !two_column { let footer = "Press ⌘ ⇧ N for New Instance | Press ⌘ O for Open Folder"; - line(self, &mut glyphs, footer, centered_x(bounds[0] + bounds[2] * 0.5, footer, sx(10.0)), bounds[1] + bounds[3] - sx(30.0), sx(10.0), sx(13.0), fg_ghost, false); + line( + self, + &mut glyphs, + footer, + centered_x(bounds[0] + bounds[2] * 0.5, footer, sx(10.0)), + bounds[1] + bounds[3] - sx(30.0), + sx(10.0), + sx(13.0), + fg_ghost, + false, + ); self.welcome_logo_chrome_instances = chrome; self.welcome_icon_instances = icons; @@ -549,15 +636,33 @@ impl Renderer { let recent_panel_top = y - sx(8.0); let recent_rows = recent_projects.len().min(5).max(1) as f32; let recent_panel_h = sx(10.0) + recent_rows * sx(44.0); - chrome.push(RegionDrawInstance::new([rx, recent_panel_top, rw, recent_panel_h], recent_panel_border).with_radius(sx(8.0))); - chrome.push(RegionDrawInstance::new([rx + sx(1.0), recent_panel_top + sx(1.0), rw - sx(2.0), recent_panel_h - sx(2.0)], recent_panel_fill).with_radius(sx(7.0))); + chrome.push( + RegionDrawInstance::new( + [rx, recent_panel_top, rw, recent_panel_h], + recent_panel_border, + ) + .with_radius(sx(8.0)), + ); + chrome.push( + RegionDrawInstance::new( + [ + rx + sx(1.0), + recent_panel_top + sx(1.0), + rw - sx(2.0), + recent_panel_h - sx(2.0), + ], + recent_panel_fill, + ) + .with_radius(sx(7.0)), + ); for (index, project) in recent_projects.iter().take(5).enumerate() { let name = project .file_name() .and_then(|name| name.to_str()) .unwrap_or("unknown"); let path = project.display().to_string(); - let icon_source = crate::app::persistence::AppPersistentState::infer_project_icon_source(project); + let icon_source = + crate::app::persistence::AppPersistentState::infer_project_icon_source(project); let icon_name = Path::new(&icon_source) .file_name() .and_then(|name| name.to_str()) @@ -587,7 +692,11 @@ impl Renderer { let branch_max_w = sx(112.0).min((text_right - text_x) * 0.34).max(0.0); let branch_label = ellipsize(&raw_branch_label, path_size, branch_max_w); let branch_w = text_w(&branch_label, path_size); - let branch_gap = if branch_label.is_empty() { 0.0 } else { sx(14.0) }; + let branch_gap = if branch_label.is_empty() { + 0.0 + } else { + sx(14.0) + }; let branch_x = (text_right - branch_w).max(text_x); let path_max_w = (branch_x - text_x - branch_gap).max(0.0); let path_label = ellipsize(&path, path_size, path_max_w); @@ -674,13 +783,59 @@ impl Renderer { // false, // ); y += sx(34.0); - line(self, &mut glyphs, "MORE ACTIONS", rx, y, sx(12.0), sx(14.0), fg_ghost, true); + line( + self, + &mut glyphs, + "MORE ACTIONS", + rx, + y, + sx(12.0), + sx(14.0), + fg_ghost, + true, + ); y += sx(24.0); let more_cards = [ - ("built_in:folder_open", self.theme.ui.warning.as_f32(), "Recent Projects", "Jump back into previous work", &["Space", "P", "J"][..], false), - ("built_in:json", self.theme.ui.amber.as_f32(), "Search in Files", "Find text across project", &["Space", "F", "W"][..], false), - ("built_in:todo", self.theme.ui.magenta.as_f32(), "Explore Extensions", "Themes, languages, tools", &["Space", "X"][..], false), - ("built_in:conf", self.theme.ui.info.as_f32(), "Settings", "Configure editor behavior", &["⌘", ","][..], false), + ( + "built_in:folder_open", + self.theme.ui.warning.as_f32(), + "Recent Projects", + "Jump back into previous work", + &["Space", "P", "J"][..], + false, + ), + ( + "built_in:json", + self.theme.ui.amber.as_f32(), + "Search in Files", + "Find text across project", + &["Space", "F", "W"][..], + false, + ), + ( + "built_in:todo", + self.theme.ui.magenta.as_f32(), + "Explore Extensions", + "Themes, languages, tools", + &["Space", "X"][..], + false, + ), + ( + "built_in:conf", + self.theme.ui.info.as_f32(), + "Settings", + "Configure editor behavior", + &["⌘", ","][..], + false, + ), + ( + "built_in:readme", + self.theme.ui.success.as_f32(), + "Keyboard Cheat Sheet", + "Every keybinding at a glance", + &["Space", "?"][..], + false, + ), ]; let more_card_h = sx(54.0); let more_gap = sx(10.0); @@ -689,8 +844,21 @@ impl Renderer { card_fill[3] = if active { 0.90 } else { 0.52 }; let mut card_border = if active { accent } else { border }; card_border[3] = if active { 0.65 } else { 0.40 }; - chrome.push(RegionDrawInstance::new([rx, y, rw, more_card_h], card_border).with_radius(sx(8.0))); - chrome.push(RegionDrawInstance::new([rx + sx(1.0), y + sx(1.0), rw - sx(2.0), more_card_h - sx(2.0)], card_fill).with_radius(sx(7.0))); + chrome.push( + RegionDrawInstance::new([rx, y, rw, more_card_h], card_border).with_radius(sx(8.0)), + ); + chrome.push( + RegionDrawInstance::new( + [ + rx + sx(1.0), + y + sx(1.0), + rw - sx(2.0), + more_card_h - sx(2.0), + ], + card_fill, + ) + .with_radius(sx(7.0)), + ); icon_badge( self, &mut glyphs, @@ -703,17 +871,55 @@ impl Renderer { 0.54, PrefixIconBadgeChrome::Outline, ); - line(self, &mut glyphs, title, rx + sx(64.0), y + sx(12.0), sx(12.5), sx(15.0), fg, true); - line(self, &mut glyphs, sub, rx + sx(64.0), y + sx(31.0), sx(10.0), sx(13.0), fg_ghost, false); + line( + self, + &mut glyphs, + title, + rx + sx(64.0), + y + sx(12.0), + sx(12.5), + sx(15.0), + fg, + true, + ); + line( + self, + &mut glyphs, + sub, + rx + sx(64.0), + y + sx(31.0), + sx(10.0), + sx(13.0), + fg_ghost, + false, + ); if rw >= sx(330.0) { let key_size = sx(9.0); let key_x = rx + rw - sx(16.0) - shortcut_hint_width(keys, key_size); - key_hint(self, &mut glyphs, &mut chrome, keys, key_x, y + sx(16.0), key_size); + key_hint( + self, + &mut glyphs, + &mut chrome, + keys, + key_x, + y + sx(16.0), + key_size, + ); } y += more_card_h + more_gap; } - let footer = "Press Space P J for Recent Projects | Press ⌘ , for Settings"; - line(self, &mut glyphs, footer, centered_x(bounds[0] + bounds[2] * 0.5, footer, sx(10.0)), bounds[1] + bounds[3] - sx(34.0), sx(10.0), sx(13.0), fg_ghost, false); + let footer = "Press Space ? or F1 for all keybindings | Space P J for Recent Projects | ⌘ , for Settings"; + line( + self, + &mut glyphs, + footer, + centered_x(bounds[0] + bounds[2] * 0.5, footer, sx(10.0)), + bounds[1] + bounds[3] - sx(34.0), + sx(10.0), + sx(13.0), + fg_ghost, + false, + ); self.welcome_logo_chrome_instances = chrome; self.welcome_icon_instances = icons; diff --git a/src/render/renderer/ui/whichkey.rs b/src/render/renderer/ui/whichkey.rs new file mode 100644 index 00000000..d4932199 --- /dev/null +++ b/src/render/renderer/ui/whichkey.rs @@ -0,0 +1,195 @@ +//! Which-key overlay: while a chord is pending (e.g. Space …), show every +//! possible next key with a short description, anchored above the status bar. + +use crate::{ + app::input_map::WhichKeyEntry, + render::{ + glyph_instance::GlyphInstance, region_pipeline::RegionDrawInstance, renderer::Renderer, + }, +}; + +use super::super::{ + components::{estimate_help_keycaps_width, layout_help_keycaps}, + helpers::{estimate_monospace_width, layout_panel_text, rect_to_scissor}, +}; + +const MAX_ROWS: usize = 8; + +impl Renderer { + /// Rebuild the which-key panel. `statusbar_h` anchors the panel right above + /// the status bar; pass 0.0 when no status bar is visible. + pub fn update_whichkey_popup( + &mut self, + prefix_label: &str, + entries: &[WhichKeyEntry], + window_w: f32, + window_h: f32, + statusbar_h: f32, + ) { + if entries.is_empty() || window_w <= 1.0 || window_h <= 1.0 { + self.clear_whichkey_popup(); + return; + } + + let scale = self.ui_scale.max(0.5); + let margin_x = 14.0 * scale; + let pad_x = 16.0 * scale; + let pad_y = 10.0 * scale; + let font_size = self.theme.ui.sidebar_font_size.max(11.0); + let line_h = self.theme.ui.sidebar_line_height.max(font_size + 4.0); + let row_h = line_h + 10.0 * scale; + let key_label_gap = (font_size * 0.5).max(6.0 * scale); + let col_gap = (font_size * 1.6).max(18.0 * scale); + + let fg = self.theme.ui.fg.as_f32(); + let fg_dim = self.theme.ui.fg_dim.as_f32(); + let fg_ghost = self.theme.ui.fg_ghost.as_f32(); + let accent = self.theme.ui.accent.as_f32(); + let info = self.theme.ui.info.as_f32(); + let warning = self.theme.ui.warning.as_f32(); + let error = self.theme.ui.error.as_f32(); + let panel_bg = self.theme.ui.panel_bg.as_f32(); + let mut border_color = self.theme.ui.border_color.as_f32(); + border_color[3] = border_color[3].clamp(0.45, 0.75); + + let key_font_size = (font_size * 0.86).max(10.0); + let panel_w = (window_w - margin_x * 2.0).max(120.0); + let content_w = (panel_w - pad_x * 2.0).max(1.0); + + // Cell = keycap + gap + label; column width fits the widest cell. + let cell_w = entries + .iter() + .map(|entry| { + estimate_help_keycaps_width(&[entry.key.as_str()], key_font_size) + + key_label_gap + + estimate_monospace_width(&entry.label, font_size) + }) + .fold(80.0_f32, f32::max) + .min(content_w); + let cols = ((content_w + col_gap) / (cell_w + col_gap)) + .floor() + .max(1.0) as usize; + let visible = entries.len().min(cols * MAX_ROWS); + let rows = visible.div_ceil(cols); + let hidden = entries.len() - visible; + + let header_h = line_h + 6.0 * scale; + let panel_h = pad_y * 2.0 + header_h + rows as f32 * row_h; + let panel_x = margin_x; + let panel_y = (window_h - statusbar_h - panel_h - 8.0 * scale).max(0.0); + + let mut chrome = vec![ + RegionDrawInstance::new( + [panel_x - 1.0, panel_y - 1.0, panel_w + 2.0, panel_h + 2.0], + border_color, + ) + .with_radius(self.panel_corner_radius + 1.0), + RegionDrawInstance::new([panel_x, panel_y, panel_w, panel_h], panel_bg) + .with_radius(self.panel_corner_radius), + ]; + let mut glyphs: Vec = Vec::new(); + + // Header: pending prefix as keycaps + binding count hint. + let header_y = panel_y + pad_y; + let prefix_tokens: Vec<&str> = prefix_label.split_whitespace().collect(); + let mut header_x = panel_x + pad_x; + if !prefix_tokens.is_empty() { + glyphs.extend(layout_help_keycaps( + &prefix_tokens, + &mut self.whichkey_text_system, + &mut self.atlas, + &self.queue, + &mut chrome, + header_x, + header_y, + key_font_size, + header_h, + fg, + fg_dim, + accent, + info, + warning, + error, + panel_bg, + )); + header_x += + estimate_help_keycaps_width(&prefix_tokens, key_font_size) + key_label_gap * 2.0; + } + let hint = if hidden > 0 { + format!("{} bindings (+{hidden} hidden)", entries.len()) + } else { + format!("{} bindings", entries.len()) + }; + glyphs.extend(layout_panel_text( + &hint, + &mut self.whichkey_text_system, + &mut self.atlas, + &self.queue, + header_x, + header_y + ((header_h - line_h) * 0.5).max(0.0), + fg_ghost, + )); + + // Entry grid, column-major within each row. + let grid_y = header_y + header_h; + for (idx, entry) in entries.iter().take(visible).enumerate() { + let row = idx / cols; + let col = idx % cols; + let cell_x = panel_x + pad_x + col as f32 * (cell_w + col_gap); + let cell_y = grid_y + row as f32 * row_h; + + glyphs.extend(layout_help_keycaps( + &[entry.key.as_str()], + &mut self.whichkey_text_system, + &mut self.atlas, + &self.queue, + &mut chrome, + cell_x, + cell_y, + key_font_size, + row_h, + fg, + fg_dim, + accent, + info, + warning, + error, + panel_bg, + )); + let label_x = cell_x + + estimate_help_keycaps_width(&[entry.key.as_str()], key_font_size) + + key_label_gap; + let label_color = if entry.is_group { accent } else { fg_dim }; + glyphs.extend(layout_panel_text( + &entry.label, + &mut self.whichkey_text_system, + &mut self.atlas, + &self.queue, + label_x, + cell_y + ((row_h - line_h) * 0.5).max(0.0), + label_color, + )); + } + + self.whichkey_scissor = + rect_to_scissor([panel_x - 2.0, panel_y - 2.0, panel_w + 4.0, panel_h + 4.0]); + self.whichkey_chrome_instances = chrome; + self.whichkey_glyph_instances = glyphs; + self.whichkey_text_pipeline.upload_instances( + &self.device, + &self.queue, + &self.whichkey_glyph_instances, + ); + } + + pub fn clear_whichkey_popup(&mut self) { + if self.whichkey_scissor.is_none() && self.whichkey_chrome_instances.is_empty() { + return; + } + self.whichkey_scissor = None; + self.whichkey_chrome_instances.clear(); + self.whichkey_glyph_instances.clear(); + self.whichkey_text_pipeline + .upload_instances(&self.device, &self.queue, &[]); + } +} diff --git a/src/syntax/highlight/categories.rs b/src/syntax/highlight/categories.rs index 60c89c60..a857ca5b 100644 --- a/src/syntax/highlight/categories.rs +++ b/src/syntax/highlight/categories.rs @@ -1,22 +1,22 @@ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum HighlightCategory { Keyword, - KeywordControl, // if, for, while, match, return - KeywordStorage, // let, const, static, var + KeywordControl, // if, for, while, match, return + KeywordStorage, // let, const, static, var String, - StringTemplate, // Template string base (lower priority than String) - StringEscape, // \n, \t, \x00 inside strings + StringTemplate, // Template string base (lower priority than String) + StringEscape, // \n, \t, \x00 inside strings Comment, - CommentDoc, // /// doc comments + CommentDoc, // /// doc comments Type, - TypeBuiltin, // i32, String, bool (primitive types) + TypeBuiltin, // i32, String, bool (primitive types) Function, - FunctionBuiltin, // println!, len, push (built-in functions) + FunctionBuiltin, // println!, len, push (built-in functions) Number, Boolean, Identifier, Variable, - VariableBuiltin, // self, this, super + VariableBuiltin, // self, this, super Parameter, Field, Property, @@ -77,11 +77,17 @@ impl HighlightCategory { } pub fn is_bold(self) -> bool { - matches!(self, Self::Macro | Self::MarkupStrong | Self::KeywordControl) + matches!( + self, + Self::Macro | Self::MarkupStrong | Self::KeywordControl + ) } pub fn is_italic(self) -> bool { - matches!(self, Self::Comment | Self::CommentDoc | Self::MarkupItalic | Self::MarkupLink) + matches!( + self, + Self::Comment | Self::CommentDoc | Self::MarkupItalic | Self::MarkupLink + ) } pub(crate) fn priority(self) -> u8 { @@ -118,7 +124,7 @@ impl HighlightCategory { Self::VariableBuiltin => 45, Self::Variable => 42, Self::Identifier => 40, - Self::StringTemplate => 30, // Lower than all semantic tokens + Self::StringTemplate => 30, // Lower than all semantic tokens Self::Operator => 20, Self::Punctuation => 10, } @@ -162,29 +168,29 @@ impl Default for HighlightPalette { fn default() -> Self { Self { // Keywords - warm yellow/orange tones - keyword: [234, 205, 97, 255], // Warm yellow - keyword_control: [255, 120, 117, 255], // Coral red (if, for, while, return) - keyword_storage: [255, 180, 84, 255], // Orange (let, const, var) + keyword: [234, 205, 97, 255], // Warm yellow + keyword_control: [255, 120, 117, 255], // Coral red (if, for, while, return) + keyword_storage: [255, 180, 84, 255], // Orange (let, const, var) // Strings - green tones - string: [60, 236, 133, 255], // Bright green - string_escape: [255, 200, 87, 255], // Golden yellow for escapes + string: [60, 236, 133, 255], // Bright green + string_escape: [255, 200, 87, 255], // Golden yellow for escapes // Comments - muted blue/gray - comment: [74, 94, 132, 255], // Muted blue-gray - comment_doc: [95, 135, 175, 255], // Brighter blue for doc comments + comment: [74, 94, 132, 255], // Muted blue-gray + comment_doc: [95, 135, 175, 255], // Brighter blue for doc comments // Types - purple tones - ty: [183, 138, 255, 255], // Purple - type_builtin: [220, 160, 255, 255], // Lighter purple (i32, String) + ty: [183, 138, 255, 255], // Purple + type_builtin: [220, 160, 255, 255], // Lighter purple (i32, String) // Functions - cyan/blue tones function: [105, 195, 255, 255], // Cyan function_builtin: [130, 220, 255, 255], // Brighter cyan (println!, len) // Numbers and booleans - orange/red tones - number: [227, 85, 53, 255], // Red-orange - boolean: [255, 149, 92, 255], // Orange + number: [227, 85, 53, 255], // Red-orange + boolean: [255, 149, 92, 255], // Orange // Variables - neutral tones identifier: [208, 215, 228, 255], // Light gray @@ -192,27 +198,27 @@ impl Default for HighlightPalette { variable_builtin: [255, 203, 107, 255], // Golden (self, this, super) // Properties and fields - cyan tones - parameter: [34, 236, 219, 255], // Bright cyan - field: [105, 195, 255, 255], // Cyan - property: [208, 215, 228, 255], // Light gray + parameter: [34, 236, 219, 255], // Bright cyan + field: [105, 195, 255, 255], // Cyan + property: [208, 215, 228, 255], // Light gray // Constants - orange - constant: [255, 149, 92, 255], // Orange + constant: [255, 149, 92, 255], // Orange // Operators and punctuation - muted tones - operator: [175, 187, 210, 255], // Light blue-gray - punctuation: [129, 150, 181, 255], // Muted blue-gray + operator: [175, 187, 210, 255], // Light blue-gray + punctuation: [129, 150, 181, 255], // Muted blue-gray // Special - orange/yellow - escape: [255, 149, 92, 255], // Orange - macro_name: [105, 195, 255, 255], // Cyan - lifetime: [255, 149, 92, 255], // Orange + escape: [255, 149, 92, 255], // Orange + macro_name: [105, 195, 255, 255], // Cyan + lifetime: [255, 149, 92, 255], // Orange // Structural - purple - constructor: [183, 138, 255, 255], // Purple - attribute: [234, 205, 97, 255], // Yellow - namespace: [183, 138, 255, 255], // Purple - tag: [183, 138, 255, 255], // Purple + constructor: [183, 138, 255, 255], // Purple + attribute: [234, 205, 97, 255], // Yellow + namespace: [183, 138, 255, 255], // Purple + tag: [183, 138, 255, 255], // Purple } } } @@ -224,7 +230,7 @@ impl HighlightPalette { HighlightCategory::KeywordControl => self.keyword_control, HighlightCategory::KeywordStorage => self.keyword_storage, HighlightCategory::String => self.string, - HighlightCategory::StringTemplate => self.string, // Same color as String + HighlightCategory::StringTemplate => self.string, // Same color as String HighlightCategory::StringEscape => self.string_escape, HighlightCategory::Comment => self.comment, HighlightCategory::CommentDoc => self.comment_doc, diff --git a/src/syntax/highlight/engine.rs b/src/syntax/highlight/engine.rs index 5c72d2e2..04e6b040 100644 --- a/src/syntax/highlight/engine.rs +++ b/src/syntax/highlight/engine.rs @@ -213,9 +213,7 @@ pub(crate) fn generate_query_highlight_spans_for_node( // `@string` (priority 100) which masks nested interpolation // expressions (Variable priority 42, Function priority 85). // Override to StringTemplate (priority 30) so inner tokens win. - if category == HighlightCategory::String - && node.kind() == "template_string" - { + if category == HighlightCategory::String && node.kind() == "template_string" { category = HighlightCategory::StringTemplate; } @@ -256,35 +254,47 @@ pub(crate) fn capture_category(capture_name: &str) -> Option "string.special.key" => Some(HighlightCategory::Property), "markup.strong" | "syntax.markup.strong" => Some(HighlightCategory::MarkupStrong), "markup.italic" | "syntax.markup.italic" => Some(HighlightCategory::MarkupItalic), - "markup.raw.inline" | "syntax.markup.raw.inline" => Some(HighlightCategory::MarkupInlineCode), + "markup.raw.inline" | "syntax.markup.raw.inline" => { + Some(HighlightCategory::MarkupInlineCode) + } "markup.link.text" | "syntax.markup.link.text" => Some(HighlightCategory::MarkupLink), "syntax.keyword" => Some(HighlightCategory::Keyword), - "syntax.keyword.control" | "keyword.control" | "keyword.control.return" - | "keyword.control.conditional" | "keyword.control.repeat" | "keyword.control.import" => { - Some(HighlightCategory::KeywordControl) - } - "syntax.keyword.storage" | "keyword.storage" | "keyword.storage.type" - | "keyword.storage.modifier" => { - Some(HighlightCategory::KeywordStorage) - } + "syntax.keyword.control" + | "keyword.control" + | "keyword.control.return" + | "keyword.control.conditional" + | "keyword.control.repeat" + | "keyword.control.import" => Some(HighlightCategory::KeywordControl), + "syntax.keyword.storage" + | "keyword.storage" + | "keyword.storage.type" + | "keyword.storage.modifier" => Some(HighlightCategory::KeywordStorage), "syntax.string.template" => Some(HighlightCategory::StringTemplate), "syntax.string" => Some(HighlightCategory::String), "syntax.string.escape" | "string.escape" | "character.escape" | "escape.sequence" => { Some(HighlightCategory::StringEscape) } "syntax.comment" => Some(HighlightCategory::Comment), - "syntax.comment.doc" | "syntax.comment.documentation" | "comment.documentation" | "comment.doc" | "comment.block.documentation" => { - Some(HighlightCategory::CommentDoc) - } + "syntax.comment.doc" + | "syntax.comment.documentation" + | "comment.documentation" + | "comment.doc" + | "comment.block.documentation" => Some(HighlightCategory::CommentDoc), "syntax.type" => Some(HighlightCategory::Type), - "syntax.type.builtin" | "type.builtin" | "builtin.type" => Some(HighlightCategory::TypeBuiltin), + "syntax.type.builtin" | "type.builtin" | "builtin.type" => { + Some(HighlightCategory::TypeBuiltin) + } "syntax.function" => Some(HighlightCategory::Function), - "syntax.function.builtin" | "function.builtin" | "function.method.builtin" => Some(HighlightCategory::FunctionBuiltin), + "syntax.function.builtin" | "function.builtin" | "function.method.builtin" => { + Some(HighlightCategory::FunctionBuiltin) + } "syntax.number" => Some(HighlightCategory::Number), "syntax.boolean" => Some(HighlightCategory::Boolean), "syntax.identifier" => Some(HighlightCategory::Identifier), "syntax.variable" => Some(HighlightCategory::Variable), - "syntax.variable.builtin" | "variable.builtin" | "variable.language" => Some(HighlightCategory::VariableBuiltin), + "syntax.variable.builtin" | "variable.builtin" | "variable.language" => { + Some(HighlightCategory::VariableBuiltin) + } "syntax.parameter" => Some(HighlightCategory::Parameter), "syntax.field" => Some(HighlightCategory::Field), "syntax.property" => Some(HighlightCategory::Property), @@ -335,7 +345,9 @@ pub(crate) fn capture_category(capture_name: &str) -> Option _ if capture_name.starts_with("type") => Some(HighlightCategory::Type), _ if capture_name.starts_with("constructor") => Some(HighlightCategory::Constructor), _ if capture_name.starts_with("attribute") => Some(HighlightCategory::Attribute), - _ if capture_name.starts_with("function.builtin") => Some(HighlightCategory::FunctionBuiltin), + _ if capture_name.starts_with("function.builtin") => { + Some(HighlightCategory::FunctionBuiltin) + } _ if capture_name.starts_with("function") || capture_name.starts_with("method") => { Some(HighlightCategory::Function) } @@ -381,8 +393,6 @@ struct SpanRun { priority: u8, } - - pub(crate) fn normalize_spans( source: &str, spans: Vec, @@ -405,7 +415,8 @@ pub(crate) fn normalize_spans( } let mut runs: Vec = Vec::with_capacity(spans.len()); - let mut boundaries: Vec = Vec::with_capacity(spans.len().saturating_mul(2).saturating_add(2)); + let mut boundaries: Vec = + Vec::with_capacity(spans.len().saturating_mul(2).saturating_add(2)); boundaries.push(paint_start); boundaries.push(paint_end); diff --git a/src/syntax/highlight/mod.rs b/src/syntax/highlight/mod.rs index 3cec0ac8..b0f548a5 100644 --- a/src/syntax/highlight/mod.rs +++ b/src/syntax/highlight/mod.rs @@ -1,7 +1,7 @@ +use once_cell::sync::Lazy; use regex::Regex; use std::ops::Range; use tree_sitter::Node; -use once_cell::sync::Lazy; use crate::config::theme_config::ThemeConfig; use crate::syntax::{ @@ -13,10 +13,10 @@ use std::collections::HashMap; mod categories; mod engine; -mod queries; -mod spans; #[cfg(test)] mod normalize_tests; +mod queries; +mod spans; pub use categories::{HighlightCategory, HighlightPalette}; pub use queries::highlight_markdown_inline; @@ -55,8 +55,12 @@ pub fn generate_highlight_spans(tree_state: &SyntaxTreeState, source: &str) -> V source, None, ); - let injected = - generate_injection_highlights(tree_state.language_id(), tree_state.root_node(), source, None); + let injected = generate_injection_highlights( + tree_state.language_id(), + tree_state.root_node(), + source, + None, + ); if injected.is_empty() { base } else { @@ -159,13 +163,10 @@ pub fn generate_dotenv_highlight_spans(source: &str) -> Vec { spans } -static RE_PT_STRING: Lazy = Lazy::new(|| { - Regex::new(r#""(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'"#).unwrap() -}); +static RE_PT_STRING: Lazy = + Lazy::new(|| Regex::new(r#""(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'"#).unwrap()); -static RE_PT_HASH: Lazy = Lazy::new(|| { - Regex::new(r"\b[0-9a-fA-F]{8,}\b").unwrap() -}); +static RE_PT_HASH: Lazy = Lazy::new(|| Regex::new(r"\b[0-9a-fA-F]{8,}\b").unwrap()); static RE_PT_PATH: Lazy = Lazy::new(|| { Regex::new(r"(?i)\b[a-z0-9_.-]+(?:/[a-z0-9_.-]+)+\b|/(?:[a-z0-9_.-]+/)+[a-z0-9_.-]+\b|\b[a-z0-9_.-]+\.(?:js|ts|jsx|tsx|rs|go|py|c|cpp|h|html|css|json|yaml|yml|toml|md|txt|sh|bash|xml|proto|rs)\b").unwrap() @@ -175,33 +176,27 @@ static RE_PT_URL: Lazy = Lazy::new(|| { Regex::new(r"\bhttps?://[a-zA-Z0-9_.-]+(?::\d+)?(?:/[a-zA-Z0-9_.-]+)*\b").unwrap() }); -static RE_PT_COMMENT: Lazy = Lazy::new(|| { - Regex::new(r"(?m)(?:^\s*#|^\s*//|^\s*;|(?:\s+#|\s+//|\s+;)).*$").unwrap() -}); +static RE_PT_COMMENT: Lazy = + Lazy::new(|| Regex::new(r"(?m)(?:^\s*#|^\s*//|^\s*;|(?:\s+#|\s+//|\s+;)).*$").unwrap()); -static RE_PT_KEY_VALUE: Lazy = Lazy::new(|| { - Regex::new(r"(?m)^[a-zA-Z0-9_.-]+\s*[:=]").unwrap() -}); +static RE_PT_KEY_VALUE: Lazy = + Lazy::new(|| Regex::new(r"(?m)^[a-zA-Z0-9_.-]+\s*[:=]").unwrap()); static RE_PT_STATUS: Lazy = Lazy::new(|| { - Regex::new(r"\b(?:ERROR|WARN|WARNING|INFO|DEBUG|TRACE|FATAL|OK|SUCCESS|FAIL|FAILED|TODO|FIXME)\b").unwrap() + Regex::new( + r"\b(?:ERROR|WARN|WARNING|INFO|DEBUG|TRACE|FATAL|OK|SUCCESS|FAIL|FAILED|TODO|FIXME)\b", + ) + .unwrap() }); -static RE_PT_DATE: Lazy = Lazy::new(|| { - Regex::new(r"\b\d{4}-\d{2}-\d{2}\b").unwrap() -}); +static RE_PT_DATE: Lazy = Lazy::new(|| Regex::new(r"\b\d{4}-\d{2}-\d{2}\b").unwrap()); -static RE_PT_TIME: Lazy = Lazy::new(|| { - Regex::new(r"\b\d{2}:\d{2}:\d{2}(?:\.\d+)?\b").unwrap() -}); +static RE_PT_TIME: Lazy = + Lazy::new(|| Regex::new(r"\b\d{2}:\d{2}:\d{2}(?:\.\d+)?\b").unwrap()); -static RE_PT_NUMBER: Lazy = Lazy::new(|| { - Regex::new(r"\b\d+(?:\.\d+)?\b").unwrap() -}); +static RE_PT_NUMBER: Lazy = Lazy::new(|| Regex::new(r"\b\d+(?:\.\d+)?\b").unwrap()); -static RE_PT_BOOL: Lazy = Lazy::new(|| { - Regex::new(r"\b(?:true|false|null|nil)\b").unwrap() -}); +static RE_PT_BOOL: Lazy = Lazy::new(|| Regex::new(r"\b(?:true|false|null|nil)\b").unwrap()); pub fn generate_plaintext_highlight_spans(source: &str) -> Vec { let mut raw = Vec::new(); diff --git a/src/syntax/highlight/normalize_tests.rs b/src/syntax/highlight/normalize_tests.rs index 422adc70..e460315f 100644 --- a/src/syntax/highlight/normalize_tests.rs +++ b/src/syntax/highlight/normalize_tests.rs @@ -1,10 +1,6 @@ use std::ops::Range; -use super::{ - engine::normalize_spans, - HighlightCategory, - HighlightSpan, -}; +use super::{HighlightCategory, HighlightSpan, engine::normalize_spans}; fn span(range: Range, category: HighlightCategory) -> HighlightSpan { HighlightSpan { range, category } @@ -84,11 +80,7 @@ fn normalize_spans_clips_to_byte_window() { #[test] fn normalize_spans_sanitizes_utf8_boundaries() { let source = "aéb"; - let normalized = normalize_spans( - source, - vec![span(1..2, HighlightCategory::String)], - None, - ); + let normalized = normalize_spans(source, vec![span(1..2, HighlightCategory::String)], None); assert_eq!(normalized, vec![span(1..3, HighlightCategory::String)]); } diff --git a/src/terminal/ansi_parser.rs b/src/terminal/ansi_parser.rs index 96db4854..f7643174 100644 --- a/src/terminal/ansi_parser.rs +++ b/src/terminal/ansi_parser.rs @@ -58,9 +58,7 @@ impl AnsiColor { let (r, g, b) = xterm256_to_rgb(idx); srgb_rgba_to_linear_f32([r, g, b, 255]) } - AnsiColor::Rgb(r, g, b) => { - srgb_rgba_to_linear_f32([r, g, b, 255]) - } + AnsiColor::Rgb(r, g, b) => srgb_rgba_to_linear_f32([r, g, b, 255]), } } } @@ -415,7 +413,9 @@ fn parse_csi(buf: &[u8], final_char: char) -> Vec { } // Erase Display - 'J' => vec![AnsiEvent::EraseDisplay(parse_first_param(param_str, 0) as u8)], + 'J' => vec![AnsiEvent::EraseDisplay( + parse_first_param(param_str, 0) as u8 + )], // Erase Line 'K' => vec![AnsiEvent::EraseLine(parse_first_param(param_str, 0) as u8)], diff --git a/src/terminal/cell_shapes.rs b/src/terminal/cell_shapes.rs new file mode 100644 index 00000000..e3c819ca --- /dev/null +++ b/src/terminal/cell_shapes.rs @@ -0,0 +1,317 @@ +//! Procedural cell shapes — vẽ Block Elements (U+2580–U+259F) và Box Drawing +//! (U+2500–U+257F) bằng hình chữ nhật phủ kín cell thay vì glyph font. +//! +//! Lý do: glyph font chỉ phủ line-box tự nhiên của font (~font_size × 1.2), +//! trong khi cell terminal dùng `panel_line_height` (vd 22px cho font 14px). +//! Kết quả là border `┃` và logo khối `▀▄█` bị hở khe giữa các hàng/cột +//! ("đứt nét"). Terminal emulator thật (kitty, alacritty) đều tự vẽ các ký tự +//! này; ta làm tương tự bằng quad solid qua text pipeline. + +/// Một hình chữ nhật trong tọa độ cell-relative (pixel), kèm hệ số alpha +/// (dùng cho shade ░▒▓). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct CellRect { + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, + pub alpha: f32, +} + +impl CellRect { + fn new(x: f32, y: f32, w: f32, h: f32) -> Self { + Self { + x, + y, + w, + h, + alpha: 1.0, + } + } + + fn shaded(x: f32, y: f32, w: f32, h: f32, alpha: f32) -> Self { + Self { x, y, w, h, alpha } + } +} + +/// Bốn arm của box-drawing char, mỗi arm có weight. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Arm { + None, + Light, + Heavy, +} + +/// Trả danh sách rect phủ cell cho ký tự khối/viền, `None` nếu ký tự không +/// thuộc nhóm này (caller fallback về glyph font, vd dashed `┄`, diagonal `╱`). +pub fn solid_cell_rects(ch: char, cell_w: f32, cell_h: f32) -> Option> { + if let Some(rects) = block_element_rects(ch, cell_w, cell_h) { + return Some(rects); + } + box_drawing_rects(ch, cell_w, cell_h) +} + +fn block_element_rects(ch: char, w: f32, h: f32) -> Option> { + let r = |x: f32, y: f32, rw: f32, rh: f32| CellRect::new(x, y, rw, rh); + let rects = match ch { + '\u{2580}' => vec![r(0.0, 0.0, w, h / 2.0)], // ▀ upper half + // ▁▂▃▄▅▆▇█ — lower k/8 + '\u{2581}'..='\u{2588}' => { + let k = (ch as u32 - 0x2580) as f32; + let rh = h * k / 8.0; + vec![r(0.0, h - rh, w, rh)] + } + // ▉▊▋▌▍▎▏ — left (8-k)/8 với k = offset từ 0x2588 + '\u{2589}'..='\u{258F}' => { + let k = (ch as u32 - 0x2588) as f32; + let rw = w * (8.0 - k) / 8.0; + vec![r(0.0, 0.0, rw, h)] + } + '\u{2590}' => vec![r(w / 2.0, 0.0, w / 2.0, h)], // ▐ right half + '\u{2591}' => vec![CellRect::shaded(0.0, 0.0, w, h, 0.25)], // ░ + '\u{2592}' => vec![CellRect::shaded(0.0, 0.0, w, h, 0.5)], // ▒ + '\u{2593}' => vec![CellRect::shaded(0.0, 0.0, w, h, 0.75)], // ▓ + '\u{2594}' => vec![r(0.0, 0.0, w, h / 8.0)], // ▔ upper eighth + '\u{2595}' => vec![r(w * 7.0 / 8.0, 0.0, w / 8.0, h)], // ▕ right eighth + // Quadrants ▖▗▘▙▚▛▜▝▞▟ — encode (UL, UR, LL, LR) + '\u{2596}'..='\u{259F}' => { + let quads: (bool, bool, bool, bool) = match ch { + '\u{2596}' => (false, false, true, false), + '\u{2597}' => (false, false, false, true), + '\u{2598}' => (true, false, false, false), + '\u{2599}' => (true, false, true, true), + '\u{259A}' => (true, false, false, true), + '\u{259B}' => (true, true, true, false), + '\u{259C}' => (true, true, false, true), + '\u{259D}' => (false, true, false, false), + '\u{259E}' => (false, true, true, false), + '\u{259F}' => (false, true, true, true), + _ => return None, + }; + quadrant_rects(quads, w, h) + } + _ => return None, + }; + Some(rects) +} + +/// Gộp các quadrant liền kề theo hàng để tránh seam hairline giữa quad. +fn quadrant_rects((ul, ur, ll, lr): (bool, bool, bool, bool), w: f32, h: f32) -> Vec { + let hw = w / 2.0; + let hh = h / 2.0; + let mut rects = Vec::with_capacity(2); + // Hàng trên + match (ul, ur) { + (true, true) => rects.push(CellRect::new(0.0, 0.0, w, hh)), + (true, false) => rects.push(CellRect::new(0.0, 0.0, hw, hh)), + (false, true) => rects.push(CellRect::new(hw, 0.0, hw, hh)), + (false, false) => {} + } + // Hàng dưới + match (ll, lr) { + (true, true) => rects.push(CellRect::new(0.0, hh, w, hh)), + (true, false) => rects.push(CellRect::new(0.0, hh, hw, hh)), + (false, true) => rects.push(CellRect::new(hw, hh, hw, hh)), + (false, false) => {} + } + rects +} + +fn box_drawing_rects(ch: char, w: f32, h: f32) -> Option> { + use Arm::{Heavy, Light, None as N}; + // (up, down, left, right) + let arms: (Arm, Arm, Arm, Arm) = match ch { + '─' => (N, N, Light, Light), + '━' => (N, N, Heavy, Heavy), + '│' => (Light, Light, N, N), + '┃' => (Heavy, Heavy, N, N), + '┌' | '╭' => (N, Light, N, Light), + '┏' => (N, Heavy, N, Heavy), + '┐' | '╮' => (N, Light, Light, N), + '┓' => (N, Heavy, Heavy, N), + '└' | '╰' => (Light, N, N, Light), + '┗' => (Heavy, N, N, Heavy), + '┘' | '╯' => (Light, N, Light, N), + '┛' => (Heavy, N, Heavy, N), + '├' => (Light, Light, N, Light), + '┣' => (Heavy, Heavy, N, Heavy), + '┤' => (Light, Light, Light, N), + '┫' => (Heavy, Heavy, Heavy, N), + '┬' => (N, Light, Light, Light), + '┳' => (N, Heavy, Heavy, Heavy), + '┴' => (Light, N, Light, Light), + '┻' => (Heavy, N, Heavy, Heavy), + '┼' => (Light, Light, Light, Light), + '╋' => (Heavy, Heavy, Heavy, Heavy), + '╴' => (N, N, Light, N), + '╵' => (Light, N, N, N), + '╶' => (N, N, N, Light), + '╷' => (N, Light, N, N), + '╸' => (N, N, Heavy, N), + '╹' => (Heavy, N, N, N), + '╺' => (N, N, N, Heavy), + '╻' => (N, Heavy, N, N), + // Double lines — xấp xỉ bằng heavy single (đủ tốt cho TUI). + '═' => (N, N, Heavy, Heavy), + '║' => (Heavy, Heavy, N, N), + '╔' => (N, Heavy, N, Heavy), + '╗' => (N, Heavy, Heavy, N), + '╚' => (Heavy, N, N, Heavy), + '╝' => (Heavy, N, Heavy, N), + '╠' => (Heavy, Heavy, N, Heavy), + '╣' => (Heavy, Heavy, Heavy, N), + '╦' => (N, Heavy, Heavy, Heavy), + '╩' => (Heavy, N, Heavy, Heavy), + '╬' => (Heavy, Heavy, Heavy, Heavy), + _ => return None, + }; + Some(arm_rects(arms, w, h)) +} + +fn arm_thickness(arm: Arm, base: f32) -> f32 { + match arm { + Arm::None => 0.0, + Arm::Light => base, + Arm::Heavy => base * 2.0, + } +} + +/// Dựng các rect từ arm spec. Tâm cell là điểm giao; mỗi arm kéo từ tâm ra +/// mép cell. Phần giao được phủ bởi rect dọc/ngang chồng lên nhau (cùng màu +/// nên không thấy seam). +fn arm_rects((up, down, left, right): (Arm, Arm, Arm, Arm), w: f32, h: f32) -> Vec { + // Bề dày cơ sở: tỉ lệ theo cell, tối thiểu 1px (light ~1/10 cell width). + let base = (w / 8.0).max(1.0); + let cx = w / 2.0; + let cy = h / 2.0; + let mut rects = Vec::with_capacity(2); + + // Trục dọc (up/down). Bề dày lấy max của hai arm để khớp tại tâm. + let v_thick = arm_thickness(up, base).max(arm_thickness(down, base)); + if v_thick > 0.0 { + let x = cx - v_thick / 2.0; + let top = if up != Arm::None { + 0.0 + } else { + cy - v_thick / 2.0 + }; + let bottom = if down != Arm::None { + h + } else { + cy + v_thick / 2.0 + }; + rects.push(CellRect::new(x, top, v_thick, bottom - top)); + } + + // Trục ngang (left/right). + let h_thick = arm_thickness(left, base).max(arm_thickness(right, base)); + if h_thick > 0.0 { + let y = cy - h_thick / 2.0; + let lhs = if left != Arm::None { + 0.0 + } else { + cx - h_thick / 2.0 + }; + let rhs = if right != Arm::None { + w + } else { + cx + h_thick / 2.0 + }; + rects.push(CellRect::new(lhs, y, rhs - lhs, h_thick)); + } + + rects +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn full_block_fills_entire_cell() { + let rects = solid_cell_rects('█', 8.4, 22.0).expect("block"); + assert_eq!(rects.len(), 1); + let r = rects[0]; + assert_eq!((r.x, r.y, r.w, r.h), (0.0, 0.0, 8.4, 22.0)); + } + + #[test] + fn upper_half_block_covers_top_half() { + let rects = solid_cell_rects('▀', 8.0, 20.0).expect("upper half"); + assert_eq!(rects, vec![CellRect::new(0.0, 0.0, 8.0, 10.0)]); + } + + #[test] + fn lower_half_block_covers_bottom_half() { + let rects = solid_cell_rects('▄', 8.0, 20.0).expect("lower half"); + assert_eq!(rects, vec![CellRect::new(0.0, 10.0, 8.0, 10.0)]); + } + + #[test] + fn shades_carry_alpha() { + let rects = solid_cell_rects('▒', 8.0, 20.0).expect("shade"); + assert_eq!(rects[0].alpha, 0.5); + assert_eq!((rects[0].w, rects[0].h), (8.0, 20.0)); + } + + #[test] + fn vertical_line_spans_full_cell_height() { + // Đây chính là fix "đứt nét": │ và ┃ phải phủ kín chiều cao cell. + for ch in ['│', '┃', '║'] { + let rects = solid_cell_rects(ch, 8.4, 22.0).expect("vertical"); + let v = rects + .iter() + .find(|r| r.h == 22.0 && r.y == 0.0) + .unwrap_or_else(|| panic!("'{ch}' must span full cell height")); + assert!(v.w >= 1.0); + } + } + + #[test] + fn horizontal_line_spans_full_cell_width() { + for ch in ['─', '━', '═'] { + let rects = solid_cell_rects(ch, 8.4, 22.0).expect("horizontal"); + assert!( + rects.iter().any(|r| r.w == 8.4 && r.x == 0.0), + "'{ch}' must span full cell width" + ); + } + } + + #[test] + fn corner_arms_reach_cell_edges() { + // ┌ = down + right: trục dọc chạm đáy, trục ngang chạm mép phải. + let rects = solid_cell_rects('┌', 8.0, 20.0).expect("corner"); + assert!(rects.iter().any(|r| (r.y + r.h - 20.0).abs() < 0.01)); + assert!(rects.iter().any(|r| (r.x + r.w - 8.0).abs() < 0.01)); + } + + #[test] + fn half_up_line_stops_at_center() { + // ╹ (heavy up): từ mép trên xuống tâm. + let rects = solid_cell_rects('╹', 8.0, 20.0).expect("half up"); + assert_eq!(rects.len(), 1); + let r = rects[0]; + assert_eq!(r.y, 0.0); + assert!((r.y + r.h - (10.0 + r.w / 2.0)).abs() <= r.w); + } + + #[test] + fn quadrants_merge_rows() { + // ▙ = UL + LL + LR → hàng trên 1 rect trái, hàng dưới 1 rect full. + let rects = solid_cell_rects('▙', 8.0, 20.0).expect("quadrant"); + assert_eq!(rects.len(), 2); + assert!(rects.contains(&CellRect::new(0.0, 0.0, 4.0, 10.0))); + assert!(rects.contains(&CellRect::new(0.0, 10.0, 8.0, 10.0))); + } + + #[test] + fn non_block_chars_fall_through_to_font() { + for ch in ['a', 'ạ', '┄', '╱', '◆', ' '] { + assert!( + solid_cell_rects(ch, 8.0, 20.0).is_none(), + "'{ch}' should render via font glyph" + ); + } + } +} diff --git a/src/terminal/grid.rs b/src/terminal/grid.rs index 64887f9f..87d31dbd 100644 --- a/src/terminal/grid.rs +++ b/src/terminal/grid.rs @@ -14,6 +14,8 @@ //! // cell.ch == 'H', cell.style.fg == Index(2) (green) //! ``` +use unicode_normalization::UnicodeNormalization; + use crate::terminal::ansi_parser::{AnsiColor, AnsiEvent, AnsiParser}; // ─── Kiểu dữ liệu ──────────────────────────────────────────────────────────── @@ -45,6 +47,10 @@ pub struct TerminalCell { /// Per-cell foreground color override (linear RGBA). /// When `Some`, takes precedence over the ANSI-based `style.fg`. pub style_fg: Option<[f32; 4]>, + /// Set on the first cell (col 0) of a row that is a soft-wrap continuation + /// of the previous physical row (the line filled to the edge with no + /// newline). Lets highlighting treat wrapped rows as one logical line. + pub wrap_continued: bool, } impl Default for TerminalCell { @@ -53,6 +59,7 @@ impl Default for TerminalCell { ch: ' ', style: CellStyle::default(), style_fg: None, + wrap_continued: false, } } } @@ -374,8 +381,20 @@ impl TerminalGrid { /// In một ký tự tại vị trí cursor, advance cursor. fn print_char_at_cursor(&mut self, ch: char) -> usize { + // Combining marks (e.g. Vietnamese tone marks U+0300–U+036F) are zero-width: + // they must attach to the preceding cell's base character instead of consuming + // a new cell, otherwise the base letter renders without its mark ("cụ" → "cu"). + if unicode_normalization::char::is_combining_mark(ch) { + self.compose_combining_into_prev(ch); + return 0; + } + let mut scrolled = 0usize; - if self.cursor_col >= self.cols { + // A soft wrap happens when the cursor has run past the last column with + // no intervening newline. The first cell of the new row records this so + // highlighting can stitch wrapped rows back into one logical line. + let wrapped = self.cursor_col >= self.cols; + if wrapped { // Wrap: xuống dòng mới. self.cursor_col = 0; self.cursor_row += 1; @@ -391,11 +410,45 @@ impl TerminalGrid { ch, style: self.current_style, style_fg: None, + wrap_continued: wrapped, }; self.cursor_col += 1; scrolled } + /// Fold a combining mark into the base character of the previous cell using + /// NFC composition (e.g. 'ô' + U+0323 → 'ộ'). If no precomposed form exists, + /// or there is no base cell, the mark is dropped rather than misaligning the + /// grid. The cursor is not advanced — combining marks are zero-width. + fn compose_combining_into_prev(&mut self, mark: char) { + let (row, col) = if self.cursor_col > 0 { + (self.cursor_row, self.cursor_col - 1) + } else if self.cursor_row > 0 { + (self.cursor_row - 1, self.cols.saturating_sub(1)) + } else { + return; + }; + let idx = row * self.cols + col; + if let Some(cell) = self.cells.get_mut(idx) { + // NFC-normalize the base + mark together. This reorders the marks + // canonically first, so it composes even when the stream sends the + // marks out of canonical order (e.g. precomposed 'ô' followed by a + // combining dot-below — pairwise composition alone would miss this). + let mut grapheme = String::with_capacity(8); + grapheme.push(cell.ch); + grapheme.push(mark); + let mut nfc = grapheme.nfc(); + if let Some(first) = nfc.next() + && nfc.next().is_none() + { + // Only adopt the result when it collapses to a single precomposed + // scalar; otherwise keep the base and drop the mark rather than + // misaligning the fixed-width grid. + cell.ch = first; + } + } + } + /// Scroll lên 1 dòng: push dòng đầu vào scrollback, thêm dòng trống dưới. fn scroll_up(&mut self) { let pushed_row: Vec = self.cells[0..self.cols].to_vec(); @@ -899,6 +952,28 @@ impl TerminalGrid { /// **Step 2 — Data-type overrides:** scans each row for string, number, /// boolean/null and time patterns, overriding individual cell colors. pub fn apply_regex_highlights(&mut self) { + self.apply_regex_highlights_from(0); + } + + /// Incremental variant for PTY feeds: only re-run the regexes over rows + /// this feed could have touched — the live grid plus the `rows_scrolled` + /// rows the feed just pushed into scrollback. Rows deeper in scrollback can + /// never change again and keep the colors they got while still live. + /// + /// Without this, every output chunk re-highlighted the ENTIRE scrollback + /// (8 regexes × up to SCROLLBACK_LIMIT rows) on the UI thread, making a + /// busy terminal quadratic over the course of a session. + pub fn apply_regex_highlights_incremental(&mut self, rows_scrolled: usize) { + let mut from_row = self.scrollback.len().saturating_sub(rows_scrolled); + // Lùi về đầu logical line (soft-wrap) để token vắt ngang ranh giới vẫn + // được match trên text đầy đủ. + while from_row > 0 && self.row_is_wrap_continued(from_row) { + from_row -= 1; + } + self.apply_regex_highlights_from(from_row); + } + + fn apply_regex_highlights_from(&mut self, from_row: usize) { use crate::terminal::highlighter::{ RE_BOOL, RE_LOG_DEBUG, RE_LOG_ERROR, RE_LOG_WARN, RE_NULL, RE_NUMBER, RE_STRING, RE_TIME, @@ -906,115 +981,107 @@ impl TerminalGrid { let colors = self.highlight_colors; let cols = self.cols; - let rows = self.rows; - let sb_len = self.scrollback.len(); - let offset = self.scroll_offset; + let total = self.total_rows(); - for display_row in 0..rows { - // Map display_row to the actual cell storage (scrollback or live grid). - let source_from_bottom = (rows - 1 - display_row) + offset; + // Phase 1 (immutable borrow): walk the buffer one *logical* line at a + // time and collect cell color overrides as `(absolute_row, col, color)`. + // + // A logical line is a maximal run of physical rows where each row after + // the first is a soft-wrap continuation (`wrap_continued`). Running the + // regexes over the joined text — instead of per physical row — keeps log + // levels, strings, numbers and timestamps highlighted across the wrap + // instead of resetting at every visual line break. Char index `i` in the + // joined text maps back to row `start + i / cols`, col `i % cols`, since + // every physical row contributes exactly `cols` chars. + let mut updates: Vec<(usize, usize, [f32; 4])> = Vec::new(); + let mut start = from_row; + while start < total { + let mut end = start + 1; + while end < total && self.row_is_wrap_continued(end) { + end += 1; + } - // Step 1: Build row text for regex matching. - let row_text: String = if source_from_bottom < rows { - let live_row = rows - 1 - source_from_bottom; - let start = live_row * cols; - self.cells[start..start + cols] - .iter() - .map(|c| c.ch) - .collect() - } else { - let sb_idx = sb_len.saturating_sub(source_from_bottom - rows + 1); - if sb_idx < sb_len { - self.scrollback[sb_idx].iter().map(|c| c.ch).collect() - } else { - continue; + let mut logical = String::with_capacity((end - start) * cols); + for row in start..end { + match self.row_cells_absolute(row) { + Some(cells) => logical.extend(cells.iter().map(|c| c.ch)), + None => logical.extend(std::iter::repeat(' ').take(cols)), + } + } + + let push_range = |updates: &mut Vec<(usize, usize, [f32; 4])>, + s: usize, + e: usize, + color: [f32; 4]| { + for i in s..e { + updates.push((start + i / cols, i % cols, color)); } }; - // Step 1: Determine base line color from log-level prefix. - let line_color: Option<[f32; 4]> = if RE_LOG_ERROR.is_match(&row_text) { + // Step 1: base line color from the log-level prefix, every cell. + let line_color = if RE_LOG_ERROR.is_match(&logical) { Some(colors.error) - } else if RE_LOG_WARN.is_match(&row_text) { + } else if RE_LOG_WARN.is_match(&logical) { Some(colors.warn) - } else if RE_LOG_DEBUG.is_match(&row_text) { + } else if RE_LOG_DEBUG.is_match(&logical) { Some(colors.fg_dim) } else { None }; - - // Apply base line color to every cell in this row. if let Some(color) = line_color { - self.set_visible_row_style_fg(display_row, offset, cols, rows, sb_len, |_| { - Some(color) - }); + push_range(&mut updates, 0, (end - start) * cols, color); } - // Step 2: Data-type overrides (these win over the line color). - // Collect match ranges from all data-type regexes first, then apply. - let mut overrides: Vec<(usize, usize, [f32; 4])> = Vec::new(); - - for mat in RE_STRING.find_iter(&row_text) { - let (s, e) = byte_to_char_range(&row_text, mat.start(), mat.end()); - overrides.push((s, e, colors.syntax_string)); - } - for mat in RE_NUMBER.find_iter(&row_text) { - let (s, e) = byte_to_char_range(&row_text, mat.start(), mat.end()); - overrides.push((s, e, colors.syntax_number)); - } - for mat in RE_BOOL.find_iter(&row_text) { - let (s, e) = byte_to_char_range(&row_text, mat.start(), mat.end()); - overrides.push((s, e, colors.syntax_keyword)); - } - for mat in RE_NULL.find_iter(&row_text) { - let (s, e) = byte_to_char_range(&row_text, mat.start(), mat.end()); - overrides.push((s, e, colors.syntax_keyword)); - } - for mat in RE_TIME.find_iter(&row_text) { - let (s, e) = byte_to_char_range(&row_text, mat.start(), mat.end()); - overrides.push((s, e, colors.syntax_number)); + // Step 2: data-type overrides. Pushed lowest precedence first so + // that — since later writes win in phase 2 — strings end up on top, + // matching the original `find`-first ordering (string > number > + // bool > null > time, all above the base line color). + for (re, color) in [ + (&RE_TIME, colors.syntax_number), + (&RE_NULL, colors.syntax_keyword), + (&RE_BOOL, colors.syntax_keyword), + (&RE_NUMBER, colors.syntax_number), + (&RE_STRING, colors.syntax_string), + ] { + for mat in re.find_iter(&logical) { + let (s, e) = byte_to_char_range(&logical, mat.start(), mat.end()); + push_range(&mut updates, s, e, color); + } } - if !overrides.is_empty() { - self.set_visible_row_style_fg(display_row, offset, cols, rows, sb_len, |col| { - overrides - .iter() - .find(|(s, e, _)| col >= *s && col < *e) - .map(|(_, _, c)| *c) - }); - } + start = end; + } + + // Phase 2 (mutable borrow): apply the collected overrides in push order + // so higher-precedence matches (pushed later) win on overlapping cells. + for (row, col, color) in updates { + self.set_style_fg_absolute(row, col, color); } } - /// Set `style_fg` on cells in a visible display row. - /// - /// `color_fn` is called for each column; returning `Some(color)` sets the - /// override, `None` leaves the cell unchanged. - fn set_visible_row_style_fg( - &mut self, - display_row: usize, - offset: usize, - cols: usize, - rows: usize, - sb_len: usize, - color_fn: impl Fn(usize) -> Option<[f32; 4]>, - ) { - let source_from_bottom = (rows - 1 - display_row) + offset; - if source_from_bottom < rows { - let live_row = rows - 1 - source_from_bottom; - let start = live_row * cols; - for col in 0..cols { - if let Some(color) = color_fn(col) { - self.cells[start + col].style_fg = Some(color); - } + /// Whether the physical row at `absolute_row` is a soft-wrap continuation of + /// the row above it (flag stored on its first cell at write time). + fn row_is_wrap_continued(&self, absolute_row: usize) -> bool { + self.row_cells_absolute(absolute_row) + .and_then(|cells| cells.first()) + .is_some_and(|cell| cell.wrap_continued) + } + + /// Set `style_fg` on the cell at an absolute `(row, col)`, resolving whether + /// the row lives in scrollback or the live grid. Out-of-bounds is ignored. + fn set_style_fg_absolute(&mut self, row: usize, col: usize, color: [f32; 4]) { + if col >= self.cols { + return; + } + if row < self.scrollback.len() { + if let Some(cell) = self.scrollback[row].get_mut(col) { + cell.style_fg = Some(color); } - } else { - let sb_idx = sb_len.saturating_sub(source_from_bottom - rows + 1); - if sb_idx < sb_len { - for col in 0..cols { - if let Some(color) = color_fn(col) { - self.scrollback[sb_idx][col].style_fg = Some(color); - } - } + } else if let Some(live_row) = row.checked_sub(self.scrollback.len()) + && live_row < self.rows + { + if let Some(cell) = self.cells.get_mut(live_row * self.cols + col) { + cell.style_fg = Some(color); } } } @@ -1312,6 +1379,7 @@ static BLANK_CELL: TerminalCell = TerminalCell { bold: false, }, style_fg: None, + wrap_continued: false, }; /// Convert a byte-offset range from a regex match into a char-index range. @@ -1487,6 +1555,65 @@ mod tests { assert_eq!(grid.cursor_row, 1); } + #[test] + fn combining_marks_fold_into_base_cell() { + let mut grid = TerminalGrid::new(10, 2); + // "cụ" written decomposed: 'c', 'u', then combining dot-below (U+0323). + grid.feed_chunk("cu\u{0323}"); + assert_eq!(grid.cell_at(0, 0).ch, 'c'); + // 'u' + U+0323 composes to 'ụ' (U+1EE5) in the same cell. + assert_eq!(grid.cell_at(0, 1).ch, 'ụ'); + // The mark is zero-width: cursor only advanced for 'c' and 'u'. + assert_eq!(grid.cursor_col, 2); + } + + #[test] + fn stacked_combining_marks_compose_incrementally() { + let mut grid = TerminalGrid::new(10, 2); + // "ộ" decomposed: 'o' + circumflex (U+0302) + dot-below (U+0323). + grid.feed_chunk("o\u{0302}\u{0323}"); + assert_eq!(grid.cell_at(0, 0).ch, 'ộ'); + assert_eq!(grid.cursor_col, 1); + } + + #[test] + fn precomposed_vietnamese_passes_through() { + let mut grid = TerminalGrid::new(10, 2); + grid.feed_chunk("ộ"); + assert_eq!(grid.cell_at(0, 0).ch, 'ộ'); + assert_eq!(grid.cursor_col, 1); + } + + #[test] + fn vietnamese_phrase_keeps_precomposed_letters() { + let mut grid = TerminalGrid::new(40, 2); + grid.feed_chunk("Tôi không đổi tiếng Việt"); + let rendered: String = (0..24).map(|col| grid.cell_at(0, col).ch).collect(); + assert!(rendered.starts_with("Tôi không đổi tiếng Việt")); + } + + #[test] + fn vietnamese_utf8_split_across_chunks_is_preserved() { + let mut grid = TerminalGrid::new(10, 2); + let bytes = "đổi".as_bytes(); + grid.feed_bytes(&bytes[..1]); + grid.feed_bytes(&bytes[1..3]); + grid.feed_bytes(&bytes[3..]); + assert_eq!(grid.cell_at(0, 0).ch, 'đ'); + assert_eq!(grid.cell_at(0, 1).ch, 'ổ'); + assert_eq!(grid.cell_at(0, 2).ch, 'i'); + assert_eq!(grid.cursor_col, 3); + } + + #[test] + fn leading_combining_mark_is_dropped_not_misaligned() { + let mut grid = TerminalGrid::new(10, 2); + // A combining mark with no preceding base must not consume a cell. + grid.feed_chunk("\u{0323}a"); + assert_eq!(grid.cell_at(0, 0).ch, 'a'); + assert_eq!(grid.cursor_col, 1); + } + #[test] fn cr_resets_col() { let mut grid = TerminalGrid::new(10, 5); @@ -1711,4 +1838,84 @@ mod tests { // 'n' at col 30 starts "null". assert_eq!(grid.cell_at(0, 30).style_fg, Some(syn_kw)); } + + #[test] + fn apply_regex_highlights_string_spanning_soft_wrap_keeps_color_on_continuation() { + // A quoted string longer than the grid width soft-wraps onto a second + // physical row. The closing quote is on row 1, so neither row matches + // RE_STRING on its own — only the joined logical line does. + let mut grid = TerminalGrid::new(8, 4); + grid.feed_chunk("\"abcdefghij\""); + grid.apply_regex_highlights(); + + let syn_str = grid.highlight_colors.syntax_string; + // Row 1 is flagged as a wrap continuation of row 0. + assert!(grid.cell_at(1, 0).wrap_continued); + // Opening quote (row 0) and the wrapped tail (row 1) share string color. + assert_eq!(grid.cell_at(0, 0).style_fg, Some(syn_str)); + assert_eq!(grid.cell_at(1, 0).style_fg, Some(syn_str)); // 'h' + assert_eq!(grid.cell_at(1, 3).style_fg, Some(syn_str)); // closing quote + } + + #[test] + fn apply_regex_highlights_log_level_spans_soft_wrap() { + // An error line wraps; the continuation row must keep the error tint + // even though the "E/" prefix only appears on the first physical row. + let mut grid = TerminalGrid::new(8, 4); + grid.feed_chunk("E/fatal boom"); + grid.apply_regex_highlights(); + + let error = grid.highlight_colors.error; + assert!(grid.cell_at(1, 0).wrap_continued); + assert_eq!(grid.cell_at(0, 0).style_fg, Some(error)); + assert_eq!(grid.cell_at(1, 0).style_fg, Some(error)); + } + + #[test] + fn apply_regex_highlights_incremental_covers_rows_scrolled_into_scrollback() { + // 3 visible rows: feeding 5 log lines pushes 2 into scrollback within a + // single feed. The incremental pass must still color those 2 rows. + let mut grid = TerminalGrid::new(20, 3); + let scrolled = grid + .feed_chunk("E/boom one\r\nE/boom two\r\nE/boom three\r\nE/boom four\r\nE/boom five"); + grid.apply_regex_highlights_incremental(scrolled); + + let error = grid.highlight_colors.error; + let in_scrollback = grid.scrollback.len(); + assert!(in_scrollback >= 2, "expected rows pushed into scrollback"); + assert!( + scrolled >= in_scrollback, + "feed must report at least the rows it scrolled" + ); + for row in 0..in_scrollback { + assert_eq!( + grid.scrollback[row][0].style_fg, + Some(error), + "scrollback row {row} must be highlighted" + ); + } + // Live rows. + for row in 0..3 { + assert_eq!(grid.cell_at(row, 0).style_fg, Some(error)); + } + } + + #[test] + fn apply_regex_highlights_incremental_does_not_touch_old_scrollback() { + let mut grid = TerminalGrid::new(20, 2); + // First feed scrolls a warn line into scrollback and highlights it. + let scrolled = grid.feed_chunk("W/old warning\r\nplain\r\nplain2"); + grid.apply_regex_highlights_incremental(scrolled); + let warn = grid.highlight_colors.warn; + assert_eq!(grid.scrollback[0][0].style_fg, Some(warn)); + + // Wipe the recorded color to detect re-processing, then feed more + // output WITHOUT scrolling past that old row. + grid.scrollback[0][0].style_fg = None; + let scrolled = grid.feed_chunk("plain3\r\nplain4"); + grid.apply_regex_highlights_incremental(scrolled); + + // Old scrollback row was outside the incremental window — untouched. + assert_eq!(grid.scrollback[0][0].style_fg, None); + } } diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 514fdef4..87b3fcd2 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -1,4 +1,5 @@ pub mod ansi_parser; +pub mod cell_shapes; pub mod grid; pub mod highlighter; pub mod pty; diff --git a/src/terminal/terminal_renderer.rs b/src/terminal/terminal_renderer.rs index bdef39d9..50449c76 100644 --- a/src/terminal/terminal_renderer.rs +++ b/src/terminal/terminal_renderer.rs @@ -24,6 +24,7 @@ //! TUI panels do not depend on font glyph coverage. use crate::{ render::{glyph_instance::GlyphInstance, region_pipeline::RegionDrawInstance}, + terminal::cell_shapes::solid_cell_rects, terminal::grid::TerminalGrid, text::{atlas::GlyphAtlas, raster::rasterize_glyph_alpha, text_system::TextSystem}, }; @@ -172,6 +173,32 @@ impl TerminalViewRenderer { .to_rgba_f32_with_defaults(default_fg, default_bg, true) }); + // Block elements / box drawing: vẽ quad solid phủ kín cell thay vì + // glyph font (glyph chỉ phủ line-box của font, nhỏ hơn cell height + // panel_line_height → border/logo bị hở khe "đứt nét"). + if let Some(rects) = solid_cell_rects(cell.ch, self.cell_width, self.cell_height) { + if let Some(solid) = atlas.solid_entry() { + let (uv_min, uv_max) = atlas.uv_min_max(solid.region); + for rect in rects { + let x = screen_x + rect.x; + let w = rect.w.min((clip_right - x).max(0.0)); + if w <= 0.0 { + continue; + } + let mut color = fg_rgba; + color[3] *= rect.alpha; + instances.push(GlyphInstance::new( + [x, screen_y + rect.y], + [w, rect.h], + uv_min, + uv_max, + color, + )); + } + continue; + } + } + // Cấu hình text system để shape 1 ký tự. text_system.set_size(Some(self.cell_width), Some(self.cell_height)); let ch_str = cell.ch.to_string(); @@ -265,6 +292,9 @@ fn flush_background_run( #[cfg(test)] mod tests { use super::*; + use cosmic_text::Metrics; + + use crate::text::text_system::TextSystem; #[test] fn cell_rect_calculation() { @@ -302,4 +332,17 @@ mod tests { assert_eq!(instances.len(), 1); assert_eq!(instances[0].rect, [5.0, 7.0, 20.0, 20.0]); } + + #[test] + fn vietnamese_cells_shape_to_visible_glyphs() { + let mut text_system = + TextSystem::new(Metrics::new(14.0, 20.0), Some(14.0 * 0.6), Some(20.0)); + + for ch in ['đ', 'ổ', 'ệ'] { + text_system.set_size(Some(14.0 * 0.6), Some(20.0)); + text_system.set_text(&ch.to_string()); + let glyphs = text_system.collect_visible_glyphs(0.0, 0.0, [1.0, 1.0, 1.0, 1.0], None); + assert!(!glyphs.is_empty(), "expected visible glyph for {ch}"); + } + } } diff --git a/src/text/atlas.rs b/src/text/atlas.rs index cd1d7762..4f1ae04c 100644 --- a/src/text/atlas.rs +++ b/src/text/atlas.rs @@ -37,6 +37,9 @@ pub struct GlyphAtlas { /// `flush_pending()` 1 lần cuối batch để gộp `queue.write_texture` — /// tránh N submit GPU khi mở file mới (mỗi glyph riêng = 1 submit). pending: Vec, + /// Vùng texel đặc (alpha 255) — dùng vẽ hình chữ nhật solid (block/box + /// drawing chars) qua text pipeline. Khởi tạo lazy lần đầu cần. + solid: Option, } struct PendingGlyphUpload { @@ -84,6 +87,7 @@ impl GlyphAtlas { row_height: 0, entries: HashMap::new(), pending: Vec::new(), + solid: None, } } @@ -134,6 +138,33 @@ impl GlyphAtlas { Ok(entry) } + /// Entry trỏ tới một vùng texel đặc (alpha = 255). Dùng làm texture cho + /// các quad solid (block elements / box drawing) vẽ chung pass với text. + /// Vùng 4×4 được reserve nhưng entry chỉ trỏ vào lõi 2×2 để Nearest + /// sampling không bleed sang glyph kề cạnh. + pub fn solid_entry(&mut self) -> Option { + if let Some(entry) = self.solid { + return Some(entry); + } + let region = self.allocate_region(4, 4)?; + self.pending.push(PendingGlyphUpload { + region, + alpha: vec![255u8; 16], + }); + let entry = AtlasEntry { + region: AtlasRegion { + x: region.x + 1, + y: region.y + 1, + width: 2, + height: 2, + }, + placement_left: 0, + placement_top: 0, + }; + self.solid = Some(entry); + Some(entry) + } + /// Upload toàn bộ glyph mới reserve trong batch. Idempotent khi rỗng. /// Mỗi region được upload riêng (chỉ phần đã reserve, không upload toàn /// texture); nếu nội dung text không đổi thì `pending` rỗng → no-op. diff --git a/src/text/layout_sync.rs b/src/text/layout_sync.rs index 9c255784..f413dc3c 100644 --- a/src/text/layout_sync.rs +++ b/src/text/layout_sync.rs @@ -294,7 +294,11 @@ pub fn compute_caret_layout_with_folds( } let run_start = run.glyphs.first().map(|glyph| glyph.start).unwrap_or(0); - let run_end = run.glyphs.last().map(|glyph| glyph.end).unwrap_or(run_start); + let run_end = run + .glyphs + .last() + .map(|glyph| glyph.end) + .unwrap_or(run_start); if cursor_byte_in_line < run_start || cursor_byte_in_line > run_end { continue; } diff --git a/src/text/text_system.rs b/src/text/text_system.rs index 51d3dfe8..ab223764 100644 --- a/src/text/text_system.rs +++ b/src/text/text_system.rs @@ -7,8 +7,7 @@ use cosmic_text::{ const BUNDLED_GOOGLE_SANS_CODE_FONT: &[u8] = include_bytes!("../../config/fonts/GoogleSansCode.ttf"); -const BUNDLED_HACK_NERD_FONT: &[u8] = - include_bytes!("../../config/fonts/HackNerdFont-Regular.ttf"); +const BUNDLED_HACK_NERD_FONT: &[u8] = include_bytes!("../../config/fonts/HackNerdFont-Regular.ttf"); use crate::config::theme_config::srgb_rgba_to_linear_f32; diff --git a/src/workbench/debug_state.rs b/src/workbench/debug_state.rs index 4fbf3661..742a8c77 100644 --- a/src/workbench/debug_state.rs +++ b/src/workbench/debug_state.rs @@ -222,7 +222,10 @@ mod tests { #[test] fn toggle_breakpoint_on_execution_line() { let mut state = DebugSharedState::default(); - state.execution_location = Some(SourceLocation { line: 42, column: 0 }); + state.execution_location = Some(SourceLocation { + line: 42, + column: 0, + }); let path = std::path::PathBuf::from("main.dart"); assert!(state.toggle_breakpoint_on_execution_line(&path)); @@ -310,9 +313,7 @@ mod tests { let mut state = DebugSharedState::default(); state.add_watch_expression("x".to_string()); - state.update_watch_values(vec![ - ("unknown".to_string(), "999".to_string()), - ]); + state.update_watch_values(vec![("unknown".to_string(), "999".to_string())]); assert!(state.watch[0].value.is_empty()); } @@ -390,7 +391,10 @@ mod tests { fn breakpoint_defaults_are_correct() { let bp = Breakpoint { id: 42, - location: SourceLocation { line: 10, column: 5 }, + location: SourceLocation { + line: 10, + column: 5, + }, enabled: true, path: std::path::PathBuf::from("main.dart"), }; @@ -403,7 +407,10 @@ mod tests { fn stack_frame_stores_location_and_path() { let frame = StackFrame { function: "main".to_string(), - location: SourceLocation { line: 42, column: 2 }, + location: SourceLocation { + line: 42, + column: 2, + }, path: std::path::PathBuf::from("/lib/main.dart"), }; assert_eq!(frame.function, "main"); diff --git a/src/workbench/inspector_panel.rs b/src/workbench/inspector_panel.rs index 6cc2ae87..ac24d41d 100644 --- a/src/workbench/inspector_panel.rs +++ b/src/workbench/inspector_panel.rs @@ -273,10 +273,19 @@ impl InspectorPanelState { let mut current_y = sidebar_bounds.y; for section in &self.sections { - let section_h = if section.expanded { expanded_h } else { collapsed_h }; + let section_h = if section.expanded { + expanded_h + } else { + collapsed_h + }; surfaces.push(InspectorSectionSurface { kind: section.kind, - bounds: RegionBounds::new(sidebar_bounds.x, current_y, sidebar_bounds.width, section_h), + bounds: RegionBounds::new( + sidebar_bounds.x, + current_y, + sidebar_bounds.width, + section_h, + ), selected: selected_section.is_some_and(|kind| kind == section.kind), }); current_y += section_h + header_gap; @@ -432,7 +441,10 @@ mod tests { }, StackFrame { function: "build".to_string(), - location: SourceLocation { line: 41, column: 8 }, + location: SourceLocation { + line: 41, + column: 8, + }, path: std::path::PathBuf::from("widget.dart"), }, ]; @@ -597,7 +609,10 @@ mod tests { // Find the leaf node row let rows = state.visible_rows(); - let leaf_idx = rows.iter().position(|r| r.label.contains("x = 42")).unwrap(); + let leaf_idx = rows + .iter() + .position(|r| r.label.contains("x = 42")) + .unwrap(); state.selected_row = leaf_idx; // Toggle on leaf should return false (no children) diff --git a/src/workbench/overlay_manager.rs b/src/workbench/overlay_manager.rs index 3dc76409..ebef3b19 100644 --- a/src/workbench/overlay_manager.rs +++ b/src/workbench/overlay_manager.rs @@ -213,7 +213,10 @@ mod tests { debug_state.execution_location = Some(SourceLocation { line: 2, column: 4 }); debug_state.paused = true; debug_state.inline_values = vec![InlineValue { - location: SourceLocation { line: 2, column: 10 }, + location: SourceLocation { + line: 2, + column: 10, + }, text: "x = 42".to_string(), }]; debug_state.toggle_breakpoint_at_line(&std::path::PathBuf::from("main.dart"), 0); diff --git a/src/workbench/panel_state.rs b/src/workbench/panel_state.rs index a576492a..6431ba0c 100644 --- a/src/workbench/panel_state.rs +++ b/src/workbench/panel_state.rs @@ -188,7 +188,11 @@ pub struct WorkbenchPanelState { impl Default for WorkbenchPanelState { fn default() -> Self { Self { - left: PanelState::new(true, 240.0, vec![PanelTabId::Explorer, PanelTabId::Inspector]), + left: PanelState::new( + true, + 240.0, + vec![PanelTabId::Explorer, PanelTabId::Inspector], + ), right: PanelState::new( false, 650.0, @@ -203,10 +207,7 @@ impl Default for WorkbenchPanelState { bottom: PanelState::new( false, 420.0, - vec![ - PanelTabId::Terminal, - PanelTabId::DebugConsole, - ], + vec![PanelTabId::Terminal, PanelTabId::DebugConsole], ), overlay_visible: false, ai_chat: AiChatState::default(), diff --git a/src/workspace/model.rs b/src/workspace/model.rs index f3a2e2c5..f07fb6d5 100644 --- a/src/workspace/model.rs +++ b/src/workspace/model.rs @@ -171,6 +171,23 @@ impl WorkspaceModel { Ok(()) } + /// Everything an async `RescanWorkspace` worker request needs: the worker + /// walks the tree off the UI thread and the result is applied back via + /// [`Self::apply_rescanned_nodes`]. + pub fn rescan_request_params(&self) -> (PathBuf, WorkspaceIgnoreRules, WorkspaceScanOptions) { + ( + self.root_path.clone(), + self.ignore_rules.clone(), + self.scan_options(), + ) + } + + /// Swap in a fresh tree produced by an async rescan. + pub fn apply_rescanned_nodes(&mut self, nodes: Vec) { + self.nodes = nodes; + self.prune_explorer_state(); + } + pub fn show_hidden(&self) -> bool { self.show_hidden } diff --git a/src/workspace/scanner.rs b/src/workspace/scanner.rs index 121afba7..f35a8fda 100644 --- a/src/workspace/scanner.rs +++ b/src/workspace/scanner.rs @@ -52,7 +52,13 @@ impl WorkspaceScanner { .map_err(|err| format!("build gitignore matcher for {:?} failed: {err}", root_path))?; let mut nodes = Vec::new(); - self.push_node(&root_path, WorkspaceNodeType::Folder, false, false, &mut nodes)?; + self.push_node( + &root_path, + WorkspaceNodeType::Folder, + false, + false, + &mut nodes, + )?; self.scan_dir_recursive(&root_path, &root_path, &gitignore, &mut nodes)?; // Sort path để output ổn định cho test/debug. @@ -99,7 +105,8 @@ impl WorkspaceScanner { Err(err) => return Err(format!("file_type {:?} failed: {err}", path)), }; - let is_ignored = self.is_gitignored_path(&path, root_path, gitignore, file_type.is_dir()); + let is_ignored = + self.is_gitignored_path(&path, root_path, gitignore, file_type.is_dir()); if file_type.is_dir() { if self.ignore_rules.should_ignore_dir(&path) { @@ -110,7 +117,13 @@ impl WorkspaceScanner { continue; } - self.push_node(&path, WorkspaceNodeType::Folder, is_hidden, is_ignored, nodes)?; + self.push_node( + &path, + WorkspaceNodeType::Folder, + is_hidden, + is_ignored, + nodes, + )?; self.scan_dir_recursive(&path, root_path, gitignore, nodes)?; continue; } @@ -389,9 +402,11 @@ mod tests { .iter() .all(|node| !contains_path_suffix(&node.path, "/.env")) ); - assert!(nodes.iter().any(|node| { - contains_path_suffix(&node.path, "/ignored.txt") && node.is_ignored - })); + assert!( + nodes.iter().any(|node| { + contains_path_suffix(&node.path, "/ignored.txt") && node.is_ignored + }) + ); assert!( nodes .iter() diff --git a/tests/lsp_fvm_detection.rs b/tests/lsp_fvm_detection.rs index 55c47eac..ae6a35fc 100644 --- a/tests/lsp_fvm_detection.rs +++ b/tests/lsp_fvm_detection.rs @@ -56,7 +56,8 @@ mod lsp_fvm_detection_tests { &["debug_adapter".to_string()], Some(PathBuf::from("/Users/qc-bright/Project/mine_wallet")), event_tx, - ).expect("launch flutter debug adapter"); + ) + .expect("launch flutter debug adapter"); // Send initialize request let init_args = serde_json::json!({ @@ -67,9 +68,14 @@ mod lsp_fvm_detection_tests { "pathFormat": "path" }); - let init_resp = client.send_request("initialize", Some(init_args)).await + let init_resp = client + .send_request("initialize", Some(init_args)) + .await .expect("send initialize request"); assert!(init_resp.success, "initialize request should be successful"); - assert!(init_resp.body.is_some(), "initialize response should have capabilities"); + assert!( + init_resp.body.is_some(), + "initialize response should have capabilities" + ); } } diff --git a/tests/vietnamese_terminal_render.rs b/tests/vietnamese_terminal_render.rs new file mode 100644 index 00000000..8eba4dc8 --- /dev/null +++ b/tests/vietnamese_terminal_render.rs @@ -0,0 +1,211 @@ +//! Regression test for bug-047: Vietnamese diacritics lost when rendering +//! opencode output in the right-dock terminal ("bạn" → "ban"). +//! +//! Covers the full pipeline the dock uses: +//! PTY byte chunks → AnsiParser → TerminalGrid cells → TextSystem shaping +//! including chunk boundaries that split UTF-8 sequences and graphemes, +//! NFC vs NFD input, and SGR sequences interleaved between base + mark. + +use cosmic_text::Metrics; +use netherize_editor::terminal::grid::TerminalGrid; +use netherize_editor::terminal::terminal_renderer::TerminalViewRenderer; +use netherize_editor::text::atlas::GlyphAtlas; +use netherize_editor::text::raster::rasterize_glyph_alpha; +use netherize_editor::text::text_system::TextSystem; + +fn visible_row_text(grid: &TerminalGrid, row: usize) -> String { + let mut out = String::new(); + for (r, _c, cell) in grid.iter_visible_cells() { + if r == row { + out.push(cell.ch); + } + } + out.trim_end().to_string() +} + +#[test] +fn nfc_vietnamese_renders_intact() { + let mut grid = TerminalGrid::new(40, 5); + grid.feed_bytes("bạn cụ thể kiểu".as_bytes()); + assert_eq!(visible_row_text(&grid, 0), "bạn cụ thể kiểu"); +} + +#[test] +fn nfd_vietnamese_composes_into_cells() { + let mut grid = TerminalGrid::new(40, 5); + // NFD: "bạn" = b + a + U+0323 (dot below) + n + grid.feed_bytes("ba\u{0323}n".as_bytes()); + assert_eq!(visible_row_text(&grid, 0), "bạn"); +} + +#[test] +fn chunk_split_mid_utf8_sequence_still_decodes() { + let mut grid = TerminalGrid::new(40, 5); + let bytes = "bạn".as_bytes(); // ạ = 3 bytes at offset 1..4 + // Split inside the 'ạ' multi-byte sequence. + grid.feed_bytes(&bytes[..2]); + grid.feed_bytes(&bytes[2..]); + assert_eq!(visible_row_text(&grid, 0), "bạn"); +} + +#[test] +fn chunk_split_between_base_and_combining_mark() { + let mut grid = TerminalGrid::new(40, 5); + let nfd = "ba\u{0323}n"; + let bytes = nfd.as_bytes(); + // Split right after the base 'a' (offset 2), so the combining mark + // arrives in a separate PTY chunk. + grid.feed_bytes(&bytes[..2]); + grid.feed_bytes(&bytes[2..]); + assert_eq!(visible_row_text(&grid, 0), "bạn"); +} + +#[test] +fn sgr_between_base_and_combining_mark() { + let mut grid = TerminalGrid::new(40, 5); + // opencode styles per-segment; a color reset can land between the + // base char and its combining mark. + grid.feed_bytes("ba".as_bytes()); + grid.feed_bytes(b"\x1b[0m"); + grid.feed_bytes("\u{0323}n".as_bytes()); + assert_eq!(visible_row_text(&grid, 0), "bạn"); +} + +#[test] +fn partially_composed_circumflex_plus_dot_below() { + let mut grid = TerminalGrid::new(40, 5); + // 'ô' (precomposed) + combining dot below → 'ộ' + grid.feed_bytes("c\u{00F4}\u{0323}ng".as_bytes()); + assert_eq!(visible_row_text(&grid, 0), "cộng"); +} + +/// Shape every Vietnamese cell the way `TerminalViewRenderer::build_instances` +/// does — one char per cell-sized buffer — and assert a non-empty glyph raster +/// comes out for each. +#[test] +fn dock_shaping_produces_glyphs_for_vietnamese_cells() { + let font_size = 14.0_f32; + let line_h = 22.0_f32; + let cell_w = (font_size * 0.6).max(1.0); + + let mut ts = TextSystem::new(Metrics::new(font_size, line_h), None, None); + ts.set_font_family(Some("Google Sans Code")); + + for ch in "bạn cụ thể kiểu ộ ứ ề đ ữ".chars().filter(|c| *c != ' ') { + ts.set_size(Some(cell_w), Some(line_h)); + ts.set_text(&ch.to_string()); + let glyphs = ts.collect_visible_glyphs(0.0, 0.0, [1.0, 1.0, 1.0, 1.0], None); + assert!( + !glyphs.is_empty(), + "no glyph shaped for '{ch}' (U+{:04X})", + ch as u32 + ); + let mut any_pixels = false; + for vg in &glyphs { + if let Some(raster) = rasterize_glyph_alpha(&mut ts, vg.cache_key) { + if raster.alpha.iter().any(|a| *a > 0) { + any_pixels = true; + } + } + } + assert!( + any_pixels, + "glyph for '{ch}' (U+{:04X}) rasterized to empty bitmap", + ch as u32 + ); + } +} + +/// The right dock's text system gets `nerd_font_family` from `apply_theme` +/// (lifecycle.rs) so TUI icons render. Hack Nerd Font has no Vietnamese +/// Latin Extended Additional glyphs — shaping must still produce visible +/// glyphs via font fallback. +#[test] +fn nerd_font_family_still_shapes_vietnamese_via_fallback() { + let font_size = 14.0_f32; + let line_h = 22.0_f32; + let cell_w = (font_size * 0.6).max(1.0); + + let mut ts = TextSystem::new(Metrics::new(font_size, line_h), None, None); + ts.set_font_family(Some("Hack Nerd Font")); + + for ch in "ạứềộữ".chars() { + ts.set_size(Some(cell_w), Some(line_h)); + ts.set_text(&ch.to_string()); + let glyphs = ts.collect_visible_glyphs(0.0, 0.0, [1.0, 1.0, 1.0, 1.0], None); + assert!( + !glyphs.is_empty(), + "no glyph shaped for '{ch}' (U+{:04X}) with Hack Nerd Font", + ch as u32 + ); + let mut any_pixels = false; + for vg in &glyphs { + if let Some(raster) = rasterize_glyph_alpha(&mut ts, vg.cache_key) { + if raster.alpha.iter().any(|a| *a > 0) { + any_pixels = true; + } + } + } + assert!( + any_pixels, + "glyph for '{ch}' (U+{:04X}) rasterized empty with Hack Nerd Font", + ch as u32 + ); + } +} + +/// Full dock path with a real (headless) wgpu device and the real GlyphAtlas: +/// every non-space Vietnamese cell must produce at least one glyph instance. +#[test] +fn dock_build_instances_emits_glyph_per_vietnamese_cell() { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: wgpu::Backends::all(), + ..wgpu::InstanceDescriptor::new_without_display_handle() + }); + let adapter = match pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::LowPower, + compatible_surface: None, + force_fallback_adapter: false, + })) { + Ok(adapter) => adapter, + Err(err) => { + eprintln!("skipping: no wgpu adapter available headless: {err}"); + return; + } + }; + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + label: Some("vietnamese-test"), + required_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::default(), + experimental_features: Default::default(), + memory_hints: Default::default(), + trace: Default::default(), + })) + .expect("request_device"); + + let mut atlas = GlyphAtlas::new(&device, 2048, 2048); + let mut ts = TextSystem::new(Metrics::new(14.0, 22.0), None, None); + ts.set_font_family(Some("Google Sans Code")); + + let text = "Chào bạn, cụ thể kiểu cộng đồng ngôn ngữ"; + let mut grid = TerminalGrid::new(60, 5); + grid.feed_bytes(text.as_bytes()); + + let view = TerminalViewRenderer::new(8.4, 22.0, 0.0, 0.0, 14.0); + let instances = view.build_instances( + &grid, + &mut atlas, + &queue, + &mut ts, + [1.0, 1.0, 1.0, 1.0], + [0.0, 0.0, 0.0, 1.0], + 2000.0, + ); + + let non_space_cells = text.chars().filter(|c| *c != ' ').count(); + assert!( + instances.len() >= non_space_cells, + "expected at least {non_space_cells} glyph instances (one per non-space cell), got {}", + instances.len() + ); +} From 0059bf2fe52d0373263453c5f42094762d20950a Mon Sep 17 00:00:00 2001 From: Min Date: Sat, 13 Jun 2026 19:54:35 +0700 Subject: [PATCH 3/3] feat: record session stats in token ledger and hooks after stop --- .wolf/hooks/_session.json | 2 +- .wolf/memory.md | 1 + .wolf/token-ledger.json | 88 ++++++++++++++++++++++++++++++++++++--- 3 files changed, 84 insertions(+), 7 deletions(-) diff --git a/.wolf/hooks/_session.json b/.wolf/hooks/_session.json index 4aecc506..6b136d7b 100644 --- a/.wolf/hooks/_session.json +++ b/.wolf/hooks/_session.json @@ -79,5 +79,5 @@ "anatomy_misses": 0, "repeated_reads_warned": 1, "cerebrum_warnings": 0, - "stop_count": 0 + "stop_count": 1 } \ No newline at end of file diff --git a/.wolf/memory.md b/.wolf/memory.md index d19f448f..b8726d69 100644 --- a/.wolf/memory.md +++ b/.wolf/memory.md @@ -1243,3 +1243,4 @@ | 19:32 | Edited src/app/event_loop/application.rs | 9→6 lines | ~76 | | 19:32 | Edited src/app/event_loop/application.rs | 7→2 lines | ~31 | | 19:33 | Edited src/app/input/handler.rs | removed 34 lines | ~87 | +| 19:44 | Session end: 7 writes across 6 files (CLAUDE.md, AGENTS.md, ai.toml, resolve_merge.sh, application.rs) | 4 reads | ~51688 tok | diff --git a/.wolf/token-ledger.json b/.wolf/token-ledger.json index baa788c0..9c58df27 100644 --- a/.wolf/token-ledger.json +++ b/.wolf/token-ledger.json @@ -2,14 +2,14 @@ "version": 1, "created_at": "2026-05-16T05:48:05.030Z", "lifetime": { - "total_tokens_estimated": 12636927, - "total_reads": 1318, - "total_writes": 2120, + "total_tokens_estimated": 12688615, + "total_reads": 1322, + "total_writes": 2127, "total_sessions": 86, - "anatomy_hits": 1233, + "anatomy_hits": 1237, "anatomy_misses": 40, - "repeated_reads_blocked": 1581, - "estimated_savings_vs_bare_cli": 18167079 + "repeated_reads_blocked": 1582, + "estimated_savings_vs_bare_cli": 18197138 }, "sessions": [ { @@ -20991,6 +20991,82 @@ "repeated_reads_blocked": 10, "anatomy_lookups": 10 } + }, + { + "id": "session-2026-06-13-1918", + "started": "2026-06-13T12:18:25.553Z", + "ended": "2026-06-13T12:44:14.157Z", + "reads": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/AGENTS.md", + "tokens_estimated": 1490, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ai.toml", + "tokens_estimated": 76, + "was_repeated": false, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 29259, + "was_repeated": true, + "anatomy_had_description": false + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 20114, + "was_repeated": false, + "anatomy_had_description": false + } + ], + "writes": [ + { + "file": "/Users/qc-bright/Project/netherize_editor/CLAUDE.md", + "tokens_estimated": 58, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/AGENTS.md", + "tokens_estimated": 58, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/config/ai.toml", + "tokens_estimated": 45, + "action": "edit" + }, + { + "file": "/tmp/resolve_merge.sh", + "tokens_estimated": 380, + "action": "create" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 82, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/event_loop/application.rs", + "tokens_estimated": 33, + "action": "edit" + }, + { + "file": "/Users/qc-bright/Project/netherize_editor/src/app/input/handler.rs", + "tokens_estimated": 93, + "action": "edit" + } + ], + "totals": { + "input_tokens_estimated": 50939, + "output_tokens_estimated": 749, + "reads_count": 4, + "writes_count": 7, + "repeated_reads_blocked": 1, + "anatomy_lookups": 4 + } } ], "daemon_usage": [],