Compare commits

...

274 Commits

Author SHA1 Message Date
Shantur Rathore
d3706d2985 Improve message timeline tooltip positioning 2025-12-09 17:19:55 +00:00
Shantur Rathore
9769d7a46e Clarify pending tool call message 2025-12-09 17:14:21 +00:00
Shantur Rathore
783fb5c5b2 Defer initial message scroll until list renders 2025-12-09 17:09:30 +00:00
Shantur Rathore
82ff1916b7 Prevent cached session remeasurements and remove logs 2025-12-09 16:27:01 +00:00
Shantur Rathore
8204143810 Improve session cache eviction 2025-12-09 16:27:01 +00:00
Shantur Rathore
e54f80f20e Keep tabs hidden but in memory 2025-12-09 11:12:41 +00:00
Shantur Rathore
54a2917faa Merge pull request #40 from alexispurslane/tauri-native-menu
Implement complete native menu system with keyboard accelerators
2025-12-09 01:30:47 +00:00
Shantur Rathore
b72ead1bea bump to v0.3.0 2025-12-09 01:29:30 +00:00
Shantur Rathore
7996228327 lazy mount message blocks 2025-12-09 01:04:49 +00:00
Shantur Rathore
7aba3c1221 add timeline tool visibility toggle 2025-12-08 18:32:23 +00:00
Alexis Purslane
11dedd4446 Implement complete native menu system with keyboard accelerators
This commit establishes a comprehensive native menu system for the Tauri application, bringing it to parity with the Electron implementation and enabling full keyboard shortcut support.

## Key Features Added

**Native Menu System:**
- Complete menu bar with File, Edit, View, and Window menus
- Platform-specific adaptations (macOS app menu, Windows/Linux quit behavior)
- Full menu event handling system with proper window management

**Keyboard Accelerators:**
- CmdOrCtrl+N for New Instance creation
- Standard Edit menu shortcuts (Undo, Redo, Cut, Copy, Paste, Select All)
- View menu shortcuts (Reload, Force Reload, Toggle DevTools, Fullscreen)
- Built-in zoom hotkeys enabled via zoomHotkeysEnabled

**Enhanced Capabilities:**
- Added core:menu:default permission for menu management
- Added core:webview:allow-set-webview-zoom for zoom functionality
- Proper event communication between Tauri backend and frontend

**Frontend Integration:**
- Tauri event listener for menu:newInstance events
- Proper cleanup and error handling for event subscriptions
- Runtime environment detection for Tauri-specific behavior

## Technical Changes

**Backend (Rust):**
- Replaced empty menu stub with full SubmenuBuilder implementation
- Added comprehensive menu event handling with window operations
- Implemented MenuItem::with_id for accelerator support
- Added platform-specific menu construction logic

**Frontend (TypeScript):**
- Added Tauri event listener integration in main App component
- Proper lifecycle management with onMount/onCleanup
- Runtime environment detection for conditional Tauri behavior

**Configuration:**
- Enabled zoomHotkeysEnabled in tauri.conf.json for built-in zoom support
- Updated capabilities to include necessary menu and webview permissions

This implementation provides a native, platform-consistent user experience with full keyboard shortcut support, matching the functionality users expect from desktop applications.
2025-12-08 11:23:57 -05:00
Shantur Rathore
8fcf757c5c Share release workflows 2025-12-08 14:49:39 +00:00
Shantur Rathore
5cf3c001b5 adjust timeline hover tooltip position 2025-12-08 14:26:42 +00:00
Shantur Rathore
4ae54a1f7b use icons for timeline short labels 2025-12-08 13:46:38 +00:00
Shantur Rathore
81a9c28971 use tool prefixes in timeline 2025-12-08 13:40:42 +00:00
Shantur Rathore
235b9338a7 tweak: use initial for tool short label 2025-12-08 10:56:32 +00:00
Shantur Rathore
642d5e22e6 tweak: show tool name in timeline chip 2025-12-08 10:54:50 +00:00
Shantur Rathore
67ff00d83e feat: add quote mode options 2025-12-08 10:45:12 +00:00
Shantur Rathore
710938eef8 fix: hide assistant timeline entries without text 2025-12-08 10:34:00 +00:00
Shantur Rathore
dc702b1fb2 feat: quote message selections 2025-12-08 10:16:58 +00:00
Shantur Rathore
92d16084db fix: render full message preview for tool calls 2025-12-08 09:52:22 +00:00
Shantur Rathore
9b0e02f66f feat: add timeline hover message preview 2025-12-08 09:45:11 +00:00
Shantur Rathore
a2e5034c20 feat: sync timeline highlight with scroll 2025-12-07 21:59:57 +00:00
Shantur Rathore
e3489b22e6 feat: add timeline sidebar to message view 2025-12-07 21:40:52 +00:00
Shantur Rathore
cd8948770d feat: include bash timeout 2025-12-07 20:23:52 +00:00
Shantur Rathore
d4281f1d9c feat: surface read bounds 2025-12-07 20:15:39 +00:00
Shantur Rathore
49214c60ca Issue template 2025-12-07 18:12:11 +00:00
Shantur Rathore
0a530a257f Add information about codenomad dev version 2025-12-07 18:03:07 +00:00
Shantur Rathore
54f269e955 fix: unblock tauri build 2025-12-07 17:02:03 +00:00
Shantur Rathore
91ab2d5e2c Merge pull request #34 from tybradle/fix/crypto-uuid-fallback
Fix: Add crypto.randomUUID fallback for browser compatibility
2025-12-07 16:22:29 +00:00
Shantur Rathore
72773546f5 Merge remote-tracking branch 'origin/dev' into fork/tybradle-fix-crypto-uuid-fallback 2025-12-07 16:21:37 +00:00
Shantur Rathore
2f58e8a1a9 Show loading indicator before listing sessions 2025-12-07 01:29:36 +00:00
Shantur Rathore
d0cab51eca open external links in native shells 2025-12-07 01:18:26 +00:00
Shantur Rathore
6f04d23b09 limit release toast to folder view 2025-12-07 00:58:15 +00:00
Shantur Rathore
3e72b83393 add release monitor and ui toast 2025-12-07 00:55:10 +00:00
Shantur Rathore
87da8ee9f8 bump version 0.2.8 2025-12-06 23:22:18 +00:00
Shantur Rathore
ec5c5c8c0f add history rocker icons 2025-12-06 23:18:29 +00:00
Shantur Rathore
b9394fb467 add prompt history rocker 2025-12-06 23:13:01 +00:00
Shantur Rathore
de432106e5 add stop control to prompt input 2025-12-06 23:05:38 +00:00
Shantur Rathore
1fbf51b7ae align instance tab accessories to right 2025-12-06 22:38:43 +00:00
Shantur Rathore
864d665049 keep shortcuts in instance tab scroll 2025-12-06 22:37:31 +00:00
Shantur Rathore
c4a9c032a3 make instance tabs strip fully scrollable 2025-12-06 22:36:00 +00:00
Shantur Rathore
3373e23a41 sync hidden sidebar layout with mobile 2025-12-06 22:34:30 +00:00
Shantur Rathore
b0650a283e Revert "fix hidden sidebar toggle button"
This reverts commit f1ad1400a7.
2025-12-06 22:31:52 +00:00
Shantur Rathore
52149f5543 Revert "align sidebar toggle layout on collapse"
This reverts commit 2e5a904034.
2025-12-06 22:31:48 +00:00
Shantur Rathore
2e5a904034 align sidebar toggle layout on collapse 2025-12-06 22:30:37 +00:00
Shantur Rathore
f1ad1400a7 fix hidden sidebar toggle button 2025-12-06 22:29:03 +00:00
Shantur Rathore
bbd28404ff gate instance info overlay on desktop 2025-12-06 22:26:52 +00:00
Shantur Rathore
04f6e362b9 Centralize tool call scroll helpers 2025-12-06 22:22:44 +00:00
Shantur Rathore
0b9cce6f86 Add session delete button 2025-12-06 22:20:15 +00:00
Shantur Rathore
d68cb6b1b8 Add delete controls to resume sessions 2025-12-06 22:16:08 +00:00
Shantur Rathore
e345dc1262 Expose UI logger controls globally 2025-12-06 12:17:33 +00:00
Shantur Rathore
2b27790a81 add tool call auto scroll sentinels 2025-12-05 23:47:34 +00:00
Shantur Rathore
2514fa94b4 stabilize scroll event wiring 2025-12-05 22:05:27 +00:00
Shantur Rathore
522910ff64 refine message stream auto scroll 2025-12-05 21:57:10 +00:00
Shantur Rathore
971abe24d7 feat(ui): add runtime logger and replace console usage 2025-12-05 15:07:49 +00:00
Shantur Rathore
49143bd049 Upgrade sdk and use async prompt 2025-12-05 12:28:44 +00:00
Shantur Rathore
df52ed3035 improve server logging for sse and http 2025-12-05 10:53:57 +00:00
Shantur Rathore
617aac8fd8 Use monitor icon for remote connect 2025-12-04 10:58:33 +00:00
Shantur Rathore
6e82ecc97e Improve remote overlay responsiveness 2025-12-03 22:15:46 +00:00
Shantur Rathore
636a19fc50 Move remote connect button to tab bar edge 2025-12-03 22:12:16 +00:00
Shantur Rathore
97f78bb337 Make remote connect buttons icon-only 2025-12-03 22:10:53 +00:00
Shantur Rathore
0ca39d2fb0 Filter loopback addresses when remote 2025-12-03 22:05:26 +00:00
Shantur Rathore
aad1337111 Use remote-hand-over icon for connect buttons 2025-12-03 22:04:01 +00:00
Shantur Rathore
6d7bc813ed Adjust remote button placement 2025-12-03 22:02:50 +00:00
Shantur Rathore
1a0dd21540 Expose remote connect button on folder view 2025-12-03 22:02:17 +00:00
Shantur Rathore
7cf9c35375 Restrict meta addresses when local-only 2025-12-03 21:59:20 +00:00
Shantur Rathore
f1c32253af Adjust remote handover header copy 2025-12-03 21:57:54 +00:00
Shantur Rathore
4a8d13e2cd Update remote overlay copy 2025-12-03 21:57:27 +00:00
Shantur Rathore
b0fd63ead5 Limit remote overlay to single QR 2025-12-03 21:54:11 +00:00
Shantur Rathore
94cb741c7f Add remote access controls 2025-12-03 21:52:42 +00:00
Shantur Rathore
976430d61c Don't use vite.config.ts 2025-12-03 18:36:15 +00:00
Shantur Rathore
8a8555d591 Optimize task tool summary recompute on version changes 2025-12-03 18:13:56 +00:00
Shantur Rathore
57c1605242 Message addition performance improvements 2025-12-03 17:07:05 +00:00
Shantur Rathore
cfbd0bdffa Show view instance button only on small screen 2025-12-03 16:41:45 +00:00
Shantur Rathore
58efb8bc3e disable diff cache 2025-12-03 16:41:19 +00:00
Shantur Rathore
b35bfe63c0 Increase timeout for CLI startup 2025-12-03 16:37:48 +00:00
Shantur Rathore
d7b5f53d59 launch cli listeners on all interfaces 2025-12-03 00:16:02 +00:00
Shantur Rathore
168b782006 retry default port before auto ephemeral 2025-12-03 00:10:20 +00:00
Shantur Rathore
9e0fbd185d keep instance tab close buttons visible 2025-12-03 00:09:07 +00:00
Shantur Rathore
11be314f63 center mobile usage chips 2025-12-03 00:04:52 +00:00
Shantur Rathore
36ee301ef2 center session header metrics 2025-12-03 00:04:07 +00:00
Shantur Rathore
d6dd06b7d1 fix session sidebar width binding 2025-12-02 23:58:41 +00:00
Shantur Rathore
6a16dd8f98 align permission attachments with SSE stream 2025-12-02 23:53:34 +00:00
Shantur Rathore
78338f33c1 add responsive session sidebar 2025-12-02 23:52:45 +00:00
Shantur Rathore
8c72d279df add mobile overlay for instance info 2025-12-02 23:17:14 +00:00
Shantur Rathore
a9500276c8 add expand control for pasted text attachments 2025-12-02 22:59:36 +00:00
Shantur Rathore
f9ec757c64 refactor message stream layout 2025-12-02 19:23:05 +00:00
Tyler Bradley
f4c9385661 Fix: Add crypto.randomUUID fallback for browser compatibility
The crypto.randomUUID() API requires a secure context (HTTPS or specific
localhost conditions). When running CodeNomad Server on 0.0.0.0:9898 or
accessing via LAN/VPN, some browsers don't provide this API, causing
attachment creation to fail with TypeError.

Added generateUUID() helper that uses crypto.randomUUID() when available,
with a Math.random()-based UUID v4 fallback for compatibility.

Fixes file and agent attachment creation in the @ mention picker when
running in headless server mode.
2025-12-02 16:55:59 +00:00
Shantur Rathore
6ba50cadd2 modularize tool-call rendering and styles 2025-12-02 16:16:49 +00:00
Shantur Rathore
8d5169cb39 Memoize ToolCall task summary rendering 2025-12-02 13:45:35 +00:00
Shantur Rathore
fe8b4a9acd Drop tool-call scroll caching 2025-12-02 13:10:29 +00:00
Shantur Rathore
831e59cd77 Anchor scroll to message stream sentinel 2025-12-02 12:37:49 +00:00
Shantur Rathore
7fde8afcf0 Remove placeholder min-height fallback 2025-12-02 12:07:43 +00:00
Shantur Rathore
d07c2ec4a9 Stop gating virtualization on measurements 2025-12-02 12:02:05 +00:00
Shantur Rathore
4306147990 Precalc viewport window for virtualization 2025-12-02 11:49:42 +00:00
Shantur Rathore
c614da3e3c Batch virtual item visibility updates 2025-12-02 11:45:12 +00:00
Shantur Rathore
73b59d8266 Share intersection observers across virtual items 2025-12-02 11:33:22 +00:00
Shantur Rathore
a2d8ea0dfd Add local virtualization wrapper to message stream 2025-12-02 11:13:12 +00:00
Shantur Rathore
52ee196103 Add macos workaround 2025-12-02 10:23:21 +00:00
Shantur Rathore
1a1aee8f91 Group message action buttons 2025-12-01 23:42:01 +00:00
Shantur Rathore
5384ff8e80 Merge message parts props 2025-12-01 23:20:44 +00:00
Shantur Rathore
6d5836ce1f Always render step usage chips 2025-12-01 23:12:31 +00:00
Shantur Rathore
d3dc170e02 Lazy render tool-call bodies 2025-12-01 23:09:22 +00:00
Shantur Rathore
983c8cc4a3 Streamline tool-call header DOM 2025-12-01 22:48:22 +00:00
Shantur Rathore
757c587b17 Simplify message header markup 2025-12-01 22:41:30 +00:00
Shantur Rathore
5f9cf397b9 Reduce step-finish usage chip DOM 2025-12-01 22:36:03 +00:00
Shantur Rathore
78ab17d148 bump version to 0.2.7 2025-12-01 19:54:12 +00:00
Shantur Rathore
e91923ad99 Improve message stream auto-scroll during streaming 2025-12-01 19:46:16 +00:00
Shantur Rathore
fd23ea54b6 Simplify message cache pruning and diff/markdown initialization 2025-12-01 19:46:16 +00:00
Shantur Rathore
1e7969eaba Localize ToolCall expansion and diagnostics state 2025-12-01 19:46:16 +00:00
Shantur Rathore
77bfe41a8e Reduce message cloning and gate scroll work on load 2025-12-01 19:46:16 +00:00
Shantur Rathore
6d134e4dec Render stream with Index to limit churn on inserts/removes 2025-12-01 19:46:16 +00:00
Shantur Rathore
9423326193 Make MessageBlock rerender via keyed Show 2025-12-01 19:46:16 +00:00
Shantur Rathore
c5011e4ece Make message blocks reactive to session signals 2025-12-01 19:46:16 +00:00
Shantur Rathore
66c270151a Stream change token and scroll checks use ids only 2025-12-01 19:46:16 +00:00
Shantur Rathore
5ce41217e9 Make MessageBlock output reactive via Show 2025-12-01 19:46:16 +00:00
Shantur Rathore
1e4d949d35 Add message stream debug logging 2025-12-01 19:46:16 +00:00
Shantur Rathore
6bb9e8e414 Render message stream per message id 2025-12-01 19:46:16 +00:00
Shantur Rathore
1efc49b67b Prune reverted messages from session store 2025-12-01 19:46:16 +00:00
Shantur Rathore
f0ed98222a Skip reverted messages from display caches 2025-12-01 19:46:16 +00:00
Shantur Rathore
ddd8ce341a Add diff viewer fallback and extract message block 2025-12-01 19:46:16 +00:00
Shantur Rathore
b7721ba3e7 Merge pull request #13 from alexispurslane/blank-session-cleanup
Blank session cleanup
2025-12-01 19:45:09 +00:00
Alexis Purslane
0554018980 update message 2025-12-01 12:37:22 -05:00
Alexis Purslane
ca18942bfd confirmation dialogue 2025-12-01 11:43:04 -05:00
Alexis Purslane
c9c1f69b82 further improvements 2025-12-01 11:35:03 -05:00
Alexis Purslane
aa0c31fa1e improve PR 2025-11-29 21:44:15 -05:00
Alexis Purslane
96b88dbcdc update blank session cleanup code for now session store logic 2025-11-27 20:18:22 -05:00
Alexis Dumas
50676416ed blank session cleanup improvements
- make the blank session cleanup system optionally fetch full message histories for each session to better judge if it's blank
- make a command that does the deep clean, keep the clean that happens on new session creation shallow
2025-11-27 18:18:24 -05:00
Alexis Purslane
f633d75005 Blank session cleanup 2025-11-27 18:18:24 -05:00
Shantur Rathore
4085f6d6b9 Avoid npm ci pruning during prebuild 2025-11-27 20:40:21 +00:00
Shantur Rathore
ae288833e1 Install server deps before build in prebuild 2025-11-27 20:29:08 +00:00
Shantur Rathore
f16e244265 Handle rollup platform binaries in prebuild 2025-11-27 20:25:04 +00:00
Shantur Rathore
b6e43c899b Improve dialog text wrapping and sizing 2025-11-27 20:11:38 +00:00
Shantur Rathore
9fa436b0b8 Ensure rollup linux binary in tauri prebuild 2025-11-27 20:11:02 +00:00
Shantur Rathore
ccd65fbc74 publish-server after build in dev 2025-11-27 19:48:35 +00:00
Shantur Rathore
daa7e3a6d1 Only publish server after successful builds 2025-11-27 19:45:58 +00:00
Shantur Rathore
ff356ac5ea bump Version to 0.2.6 2025-11-27 19:42:55 +00:00
Shantur Rathore
d68b92ff38 Gate npm publish on successful builds 2025-11-27 19:41:02 +00:00
Shantur Rathore
940216d98b Ensure tauri prebuild installs UI workspace deps 2025-11-27 19:40:36 +00:00
Shantur Rathore
69cd3cf545 bumpVersion to 0.2.5 2025-11-27 19:34:30 +00:00
Shantur Rathore
042a45db0d Ensure autoscroll reacts to UI toggles 2025-11-27 19:20:55 +00:00
Shantur Rathore
cc45c16d73 Stabilize message stream autoscroll 2025-11-27 18:48:11 +00:00
Shantur Rathore
91fb351a63 Improve sidebar default width and message autoscroll 2025-11-27 18:24:45 +00:00
Shantur Rathore
d9b149a7cb Reintroduce scroll restore effect for message stream 2025-11-27 17:25:19 +00:00
Shantur Rathore
222a467a19 Improve message stream caching and scroll performance 2025-11-27 16:51:05 +00:00
Shantur Rathore
18513939f7 Tighten message spacing and restyle reasoning blocks 2025-11-27 13:53:52 +00:00
Shantur Rathore
c123714271 Add thinking expansion preference and step finish styling 2025-11-27 13:39:03 +00:00
Shantur Rathore
5c82a2d653 Align assistant metadata display with message content 2025-11-27 13:26:31 +00:00
Shantur Rathore
435881529e match thinking toggle button sizing 2025-11-27 13:10:56 +00:00
Shantur Rathore
700342670c refine thinking accordion layout 2025-11-27 13:05:52 +00:00
Shantur Rathore
2f40f5eedf refine step and stream spacing 2025-11-27 10:41:34 +00:00
Shantur Rathore
54905c5626 tighten message spacing 2025-11-27 10:30:30 +00:00
Shantur Rathore
1bf1a4761d soften assistant and thinking headers 2025-11-27 10:27:29 +00:00
Shantur Rathore
755695a35a refine thinking cards and message layout 2025-11-27 10:24:41 +00:00
Shantur Rathore
6a9a442948 Handle session cleanup and error message status 2025-11-26 16:20:02 +00:00
Shantur Rathore
3db9b0f673 tidy normalized store hydration 2025-11-26 15:59:24 +00:00
Shantur Rathore
4e0e5dcdca Restore tool navigation and balanced scroll controls 2025-11-26 15:28:48 +00:00
Shantur Rathore
fad2809299 Improve message stream caching and virtualization for large sessions 2025-11-26 13:30:20 +00:00
Shantur Rathore
c77bfc2ee7 Avoid deep reconcile in message hydrate 2025-11-26 11:08:54 +00:00
Shantur Rathore
f1fa28dd2c Optimize message hydrate to reduce traversal 2025-11-26 10:59:15 +00:00
Shantur Rathore
91ace25333 Batch hydrate normalized messages for session load 2025-11-26 10:57:39 +00:00
Shantur Rathore
b54db28fb1 avoid deep proxying message info 2025-11-26 10:29:14 +00:00
Shantur Rathore
f13feb3062 Revert "cap session order/history lengths"
This reverts commit 4622bdc7ea.
2025-11-26 10:24:58 +00:00
Shantur Rathore
4622bdc7ea cap session order/history lengths 2025-11-26 10:23:49 +00:00
Shantur Rathore
919127b6d9 fix session closing crash 2025-11-26 10:20:08 +00:00
Shantur Rathore
27cd4515cd finish migration to message-store 2025-11-26 10:13:05 +00:00
Shantur Rathore
93a5c16cab migrate session event/actions to v2 store 2025-11-26 09:57:21 +00:00
Shantur Rathore
16b76385e2 chore: add message store v2 baseline 2025-11-26 09:42:10 +00:00
Shantur Rathore
9313b2bd6c Add showUsageMetrics to Prefs schema 2025-11-25 16:06:14 +00:00
Shantur Rathore
d25cb09714 Align selector shortcuts and widen sidebar 2025-11-25 16:04:19 +00:00
Shantur Rathore
0d0d1271c3 Move assistant usage chips 2025-11-25 13:12:54 +00:00
Shantur Rathore
1fd3b2e75c Add toggle for usage metrics 2025-11-25 12:26:38 +00:00
Shantur Rathore
bf32fcf136 Refine session usage tracking 2025-11-25 12:03:33 +00:00
Shantur Rathore
48eb6b8982 bump version 0.2.4 2025-11-25 08:56:51 +00:00
Shantur Rathore
797fafe854 Normalize host when parsing CLI 2025-11-25 00:52:46 +00:00
Shantur Rathore
b342660ed0 Improve welcome mobile layout 2025-11-25 00:50:21 +00:00
Shantur Rathore
169d5ddeb9 Use npx tauri for workspace builds 2025-11-24 20:16:49 +00:00
Shantur Rathore
38642b60e9 add command palette button 2025-11-24 14:37:15 +00:00
Shantur Rathore
01effb8924 refine prompt overlay layout 2025-11-24 14:16:25 +00:00
Shantur Rathore
b434bfd3e9 Ensure tauri bundle includes server deps 2025-11-24 11:20:27 +00:00
Shantur Rathore
ed769911d6 bump to version v0.2.3 2025-11-23 19:37:41 +00:00
Shantur Rathore
dd6efee900 disable SSE body timeouts and ignore workspace-stopped disconnects 2025-11-23 19:34:14 +00:00
Shantur Rathore
48a16a6702 ignore expected workspace stops when showing disconnect modal 2025-11-23 19:17:53 +00:00
Shantur Rathore
841b9daa1f only show disconnect modal on final status 2025-11-23 19:13:22 +00:00
Shantur Rathore
1741e49568 aggregate instance SSE streams through server bus so UI uses single connection 2025-11-23 19:07:10 +00:00
Shantur Rathore
8577b3d1e6 show loading status only for errors 2025-11-23 14:42:09 +00:00
Shantur Rathore
011533b3c4 improve prompt submission history handling 2025-11-23 14:41:49 +00:00
Shantur Rathore
002efad9ad cap CLI proxy concurrency 2025-11-23 14:40:37 +00:00
Shantur Rathore
3ce5569b82 route CLI logs to host processes only 2025-11-23 13:38:50 +00:00
Shantur Rathore
d7c0c225b9 chore: align monorepo package versions with 0.2.2 2025-11-23 12:05:36 +00:00
Shantur Rathore
f4de0103a8 Resolve CLI binary metadata for UI 2025-11-23 11:59:12 +00:00
Shantur Rathore
0a9b7fafed Align Tauri dev flow with shared renderer 2025-11-23 10:37:45 +00:00
Shantur Rathore
073604c9f5 Force dark theme defaults across shells 2025-11-23 10:00:16 +00:00
Shantur Rathore
4062b43380 Enable native dialogs across shells 2025-11-23 00:36:43 +00:00
Shantur Rathore
00bd9f9c1c Allow proxy streams to stay open 2025-11-22 21:50:04 +00:00
Shantur Rathore
3edb0ac09e Add runtime environment detection 2025-11-22 21:46:53 +00:00
Shantur Rathore
e9f3c4ee52 Unify loader assets across shells 2025-11-22 21:20:29 +00:00
Shantur Rathore
92420d9e02 Move screenshots to correct folder 2025-11-21 21:59:58 +00:00
Shantur Rathore
3688be06ee Bump version to 0.2.1 2025-11-21 21:56:29 +00:00
Shantur Rathore
8b2be441fc Ensure Tauri is bundled 2025-11-21 21:19:38 +00:00
Shantur Rathore
b2493a3a53 Use reusable publish workflow with explicit versions 2025-11-21 21:01:22 +00:00
Shantur Rathore
4eb3dbf492 Route npm publish through reusable workflow 2025-11-21 20:54:59 +00:00
Shantur Rathore
adbe0399b2 Fix tauri conf 2025-11-21 20:53:40 +00:00
Shantur Rathore
e2461661f7 Test npm publish 2025-11-21 20:44:54 +00:00
Shantur Rathore
cc012094b4 Test npm publish 2025-11-21 20:42:11 +00:00
Shantur Rathore
968a6f3cab Test npm publish 2025-11-21 20:40:25 +00:00
Shantur Rathore
f0d8634a83 Test npm publish 2025-11-21 20:36:46 +00:00
Shantur Rathore
9f862d5afc Require all linux tauri bundles and upload raw artifacts 2025-11-21 20:17:41 +00:00
Shantur Rathore
08f3d75015 Package tauri linux multi-target and zip windows app folder 2025-11-21 20:15:23 +00:00
Shantur Rathore
d9adab3022 Bundle linux tauri as appimage 2025-11-21 20:10:54 +00:00
Shantur Rathore
9019f7622e Failure on missing tauri linux bundle 2025-11-21 19:30:46 +00:00
Shantur Rathore
631b5002e7 Use non-native alert and confirm dialogs 2025-11-21 19:28:53 +00:00
Shantur Rathore
b1987008c7 Add id token for npmjs 2025-11-21 19:20:46 +00:00
Shantur Rathore
3338109d51 Try to fix linux-arm 2025-11-21 18:58:07 +00:00
Shantur Rathore
c0616df704 Ensure tauri build bundles server 2025-11-21 18:57:19 +00:00
Shantur Rathore
ca4030e86e Fix electron loading screen and linux arm build 2025-11-21 18:50:19 +00:00
Shantur Rathore
0a2d57624c Enable trusted npm publish for server 2025-11-21 18:38:37 +00:00
Shantur Rathore
dbbed94381 Standardize artifact uploads and enable tauri arm64 cross-build 2025-11-21 18:31:45 +00:00
Shantur Rathore
a088b948b4 Fix tauri-linux 2025-11-21 18:08:24 +00:00
Shantur Rathore
87faa32c7c Use npm tauri on wiindows 2025-11-21 18:06:59 +00:00
Shantur Rathore
873be2d6c1 Fix tauri app package.json JSON 2025-11-21 17:59:31 +00:00
Shantur Rathore
5fafed31d0 Use npm Tauri CLI in CI 2025-11-21 17:56:40 +00:00
Shantur Rathore
a763831b83 Use npm @tauri-apps/cli 2.9.4 and npm tauri scripts 2025-11-21 17:55:00 +00:00
Shantur Rathore
076aa4ff9a Use available tauri-cli 2.9.4 in CI 2025-11-21 17:25:13 +00:00
Shantur Rathore
31cbb9cc53 Align Tauri to published 2.5.2 and CLI 2.5.4 2025-11-21 17:23:21 +00:00
Shantur Rathore
ee6db23b14 Pin Tauri to 2.9.3 available on crates.io 2025-11-21 17:19:33 +00:00
Shantur Rathore
9fe3dcdc6d Upgrade Tauri tooling to 2.9.4 2025-11-21 17:16:09 +00:00
Shantur Rathore
ea644a7d0f Switch mac build to zip-only 2025-11-21 16:59:02 +00:00
Shantur Rathore
2f7ddd57dd Remove universal mac build outputs 2025-11-21 16:58:04 +00:00
Shantur Rathore
f2f108c14e Correct macOS Tauri targets and restore jobs 2025-11-21 16:45:16 +00:00
Shantur Rathore
731885f54a Deduplicate Tauri Windows job and update runners 2025-11-21 16:27:11 +00:00
Shantur Rathore
3c11a1bfcb Fix Tauri CI runners and prebuild portability 2025-11-21 16:24:45 +00:00
Shantur Rathore
166dd3f719 Update README for Tauri 2025-11-21 16:13:00 +00:00
Shantur Rathore
123da12468 Use cargo-binstall for tauri-cli in CI 2025-11-21 16:02:01 +00:00
Shantur Rathore
2a7cdbae42 Add arm64 Tauri build jobs 2025-11-21 15:53:33 +00:00
Shantur Rathore
88952a140a Install tauri-cli in CI for all platforms 2025-11-21 15:49:01 +00:00
Shantur Rathore
b64ea411e6 Install rollup native for Tauri builds on macOS/Linux 2025-11-21 15:38:47 +00:00
Shantur Rathore
44b2bb1b68 Fix Windows Tauri build rollup dependency 2025-11-21 15:37:49 +00:00
Shantur Rathore
efabf83a26 Add Tauri build pipeline to releases 2025-11-21 15:31:11 +00:00
Shantur Rathore
ac540a18f2 Add Tauri app sources 2025-11-21 15:22:59 +00:00
Shantur Rathore
4bad384ca0 Switch server publish to npm trusted publisher (OIDC) 2025-11-21 15:20:45 +00:00
Shantur Rathore
0eb00901b9 Bundle server assets into Tauri app build 2025-11-21 15:19:28 +00:00
Shantur Rathore
459b950ab6 Install rollup native binary before server publish 2025-11-21 14:58:33 +00:00
Shantur Rathore
d7edd4cf4a Use non-timestamped dev tag/release names 2025-11-21 14:55:21 +00:00
Shantur Rathore
e0bd5ccc92 Updated browser screenshot 2025-11-21 14:48:28 +00:00
Shantur Rathore
5e82fc4e5d Add repository and homepage metadata for electron build 2025-11-21 14:40:06 +00:00
Shantur Rathore
1b2c775348 Avoid duplicate icon in electron build resources 2025-11-21 14:31:43 +00:00
Shantur Rathore
16e9cb21da Work around rollup native missing in CI builds 2025-11-21 14:21:03 +00:00
Shantur Rathore
cacfbc24cc Fix workspace version bumps in CI workflows 2025-11-21 13:52:45 +00:00
Shantur Rathore
2052c5566e Use root version for dev release workflow 2025-11-21 13:40:52 +00:00
Shantur Rathore
2486af2808 More screenshots 2025-11-21 13:30:59 +00:00
Shantur Rathore
881afbba0a UI Readme 2025-11-21 12:40:49 +00:00
Shantur Rathore
b6d48bfb69 Update Readmes 2025-11-21 12:37:24 +00:00
Shantur Rathore
d9596f7b4b Bump workspace and packages to 0.2.0 2025-11-21 10:51:31 +00:00
Shantur Rathore
6467bdfe7c Add reusable build workflows and dev prereleases 2025-11-21 09:48:25 +00:00
Shantur Rathore
4fdd299919 Version alias 2025-11-21 09:08:17 +00:00
Shantur Rathore
2de2d26043 Neural Nomads author 2025-11-21 07:46:31 +00:00
Shantur Rathore
70e6052dc8 Rename electron app package to @neuralnomads/codenomad-electron-app 2025-11-21 00:10:31 +00:00
Shantur Rathore
2ff51c1866 Use server naming for shared API/events 2025-11-21 00:04:01 +00:00
Shantur Rathore
d6fdef68d9 Rename CLI package to @neuralnomads/codenomad and bin codenomad 2025-11-20 23:51:44 +00:00
Shantur Rathore
30b075e4ba Improve CLI preload flow and SSE reconnects 2025-11-20 20:45:31 +00:00
Shantur Rathore
3f46d73a31 feat: add instance config provider and map storage ids 2025-11-20 14:46:13 +00:00
Shantur Rathore
038cf3c762 feat: preload cli browser view 2025-11-20 10:51:14 +00:00
Shantur Rathore
85c0632719 Remove padding from todo tool call list 2025-11-20 10:48:11 +00:00
Shantur Rathore
c4c2c92974 Simplify todo tool calls and tighten layout 2025-11-20 10:46:11 +00:00
Shantur Rathore
c5fd5694ee feat: make electron shell host CLI server 2025-11-20 10:41:07 +00:00
Shantur Rathore
bc5423ce14 Keep tool calls open while permissions pending and fix task session nav 2025-11-20 10:12:09 +00:00
Shantur Rathore
8fab34e356 Add attachment previews and data URLs for drops 2025-11-19 21:33:56 +00:00
Shantur Rathore
d3ee15dcd7 Add inline previews for prompt attachments 2025-11-19 18:49:50 +00:00
Shantur Rathore
45dca7a7f0 cache per-instance history via SSE 2025-11-19 17:48:07 +00:00
Shantur Rathore
885059b0aa refine filesystem dialog to load folders on demand 2025-11-19 17:13:35 +00:00
Shantur Rathore
629d098add add cached fuzzy file search and debounce unified picker 2025-11-19 16:43:28 +00:00
Shantur Rathore
7e95005d8c refine config provider and full replacement flow 2025-11-19 14:43:47 +00:00
205 changed files with 27520 additions and 5192 deletions

71
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,71 @@
name: Bug Report
description: Report a bug or regression in CodeNomad
labels:
- bug
title: "[Bug]: "
body:
- type: markdown
attributes:
value: |
Thanks for filing a bug report! Please review open issues before submitting a new one and provide as much detail as possible so we can reproduce the problem.
- type: dropdown
id: variant
attributes:
label: App Variant
description: Which build are you running when this issue appears?
multiple: false
options:
- Electron
- Tauri
- Server CLI
validations:
required: true
- type: input
id: os-version
attributes:
label: Operating System & Version
description: Include the OS family and version (e.g., macOS 15.0, Ubuntu 24.04, Windows 11 23H2).
placeholder: macOS 15.0
validations:
required: true
- type: input
id: summary
attributes:
label: Issue Summary
description: Briefly describe what is happening.
placeholder: A quick one sentence problem statement
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to Reproduce
description: List the steps needed to reproduce the problem.
placeholder: |
1. Go to ...
2. Click ...
3. Observe ...
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
description: Describe what you expected to happen instead.
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs & Screenshots
description: Attach relevant logs, stack traces, or screenshots if available.
placeholder: Paste logs here or drag-and-drop files onto the issue.
validations:
required: false
- type: textarea
id: extra
attributes:
label: Additional Context
description: Add any other context about the problem here.
validations:
required: false

519
.github/workflows/build-and-upload.yml vendored Normal file
View File

@@ -0,0 +1,519 @@
name: Build and Upload Binaries
on:
workflow_call:
inputs:
version:
description: "Version to apply to workspace packages"
required: true
type: string
tag:
description: "Git tag to upload assets to"
required: true
type: string
release_name:
description: "Release name (unused here, for context)"
required: true
type: string
permissions:
id-token: write
contents: write
env:
NODE_VERSION: 20
jobs:
build-macos:
runs-on: macos-15-intel
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.version }}
TAG: ${{ inputs.tag }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Set workspace versions
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies
run: npm ci --workspaces
- name: Ensure rollup native binary
run: npm install @rollup/rollup-darwin-x64 --no-save
- name: Build macOS binaries (Electron)
run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app
- name: Upload release assets
run: |
set -euo pipefail
shopt -s nullglob
for file in packages/electron-app/release/*.zip; do
[ -f "$file" ] || continue
echo "Uploading $file"
gh release upload "$TAG" "$file" --clobber
done
build-windows:
runs-on: windows-2025
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.version }}
TAG: ${{ inputs.tag }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Set workspace versions
run: npm version ${{ env.VERSION }} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
shell: bash
- name: Install dependencies
run: npm ci --workspaces
- name: Ensure rollup native binary
run: npm install @rollup/rollup-win32-x64-msvc --no-save
- name: Build Windows binaries (Electron)
run: npm run build:win --workspace @neuralnomads/codenomad-electron-app
- name: Upload release assets
shell: pwsh
run: |
Get-ChildItem -Path "packages/electron-app/release" -Filter *.zip -File | ForEach-Object {
Write-Host "Uploading $($_.FullName)"
gh release upload $env:TAG $_.FullName --clobber
}
build-linux:
runs-on: ubuntu-24.04
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.version }}
TAG: ${{ inputs.tag }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Set workspace versions
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies
run: npm ci --workspaces
- name: Ensure rollup native binary
run: npm install @rollup/rollup-linux-x64-gnu --no-save
- name: Build Linux binaries (Electron)
run: npm run build:linux --workspace @neuralnomads/codenomad-electron-app
- name: Upload release assets
run: |
set -euo pipefail
shopt -s nullglob
for file in packages/electron-app/release/*.zip; do
[ -f "$file" ] || continue
echo "Uploading $file"
gh release upload "$TAG" "$file" --clobber
done
build-tauri-macos:
runs-on: macos-15-intel
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.version }}
TAG: ${{ inputs.tag }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Setup Rust (Tauri)
uses: dtolnay/rust-toolchain@stable
- name: Set workspace versions
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies
run: npm ci --workspaces
- name: Ensure rollup native binary
run: npm install @rollup/rollup-darwin-x64 --no-save
- name: Build macOS bundle (Tauri)
run: npm run build --workspace @codenomad/tauri-app
- name: Package Tauri artifacts (macOS)
run: |
set -euo pipefail
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
ARTIFACT_DIR="packages/tauri-app/release-tauri"
rm -rf "$ARTIFACT_DIR"
mkdir -p "$ARTIFACT_DIR"
if [ -d "$BUNDLE_ROOT/macos/CodeNomad.app" ]; then
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-x64.zip"
fi
- name: Upload Tauri release assets (macOS)
run: |
set -euo pipefail
shopt -s nullglob
for file in packages/tauri-app/release-tauri/*.zip; do
[ -f "$file" ] || continue
echo "Uploading $file"
gh release upload "$TAG" "$file" --clobber
done
build-tauri-macos-arm64:
runs-on: macos-26
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.version }}
TAG: ${{ inputs.tag }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Setup Rust (Tauri)
uses: dtolnay/rust-toolchain@stable
- name: Set workspace versions
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies
run: npm ci --workspaces
- name: Ensure rollup native binary
run: npm install @rollup/rollup-darwin-arm64 --no-save
- name: Build macOS bundle (Tauri, arm64)
run: npm run build --workspace @codenomad/tauri-app
- name: Package Tauri artifacts (macOS arm64)
run: |
set -euo pipefail
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
ARTIFACT_DIR="packages/tauri-app/release-tauri"
rm -rf "$ARTIFACT_DIR"
mkdir -p "$ARTIFACT_DIR"
if [ -d "$BUNDLE_ROOT/macos/CodeNomad.app" ]; then
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-arm64.zip"
fi
- name: Upload Tauri release assets (macOS arm64)
run: |
set -euo pipefail
shopt -s nullglob
for file in packages/tauri-app/release-tauri/*.zip; do
[ -f "$file" ] || continue
echo "Uploading $file"
gh release upload "$TAG" "$file" --clobber
done
build-tauri-windows:
runs-on: windows-2025
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.version }}
TAG: ${{ inputs.tag }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Setup Rust (Tauri)
uses: dtolnay/rust-toolchain@stable
- name: Set workspace versions
run: npm version ${{ env.VERSION }} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
shell: bash
- name: Install dependencies
run: npm ci --workspaces
- name: Ensure rollup native binary
run: npm install @rollup/rollup-win32-x64-msvc --no-save
- name: Build Windows bundle (Tauri)
run: npm run build --workspace @codenomad/tauri-app
- name: Package Tauri artifacts (Windows)
shell: pwsh
run: |
$bundleRoot = "packages/tauri-app/target/release/bundle"
$artifactDir = "packages/tauri-app/release-tauri"
if (Test-Path $artifactDir) { Remove-Item $artifactDir -Recurse -Force }
New-Item -ItemType Directory -Path $artifactDir | Out-Null
$exe = Get-ChildItem -Path $bundleRoot -Recurse -File -Filter *.exe | Select-Object -First 1
if ($null -ne $exe) {
$dest = Join-Path $artifactDir ("CodeNomad-Tauri-$env:VERSION-windows-x64.zip")
Compress-Archive -Path $exe.Directory.FullName -DestinationPath $dest -Force
}
- name: Upload Tauri release assets (Windows)
shell: pwsh
run: |
if (Test-Path "packages/tauri-app/release-tauri") {
Get-ChildItem -Path "packages/tauri-app/release-tauri" -Filter *.zip -File | ForEach-Object {
Write-Host "Uploading $($_.FullName)"
gh release upload $env:TAG $_.FullName --clobber
}
}
build-tauri-linux:
runs-on: ubuntu-24.04
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.version }}
TAG: ${{ inputs.tag }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Setup Rust (Tauri)
uses: dtolnay/rust-toolchain@stable
- name: Install Linux build dependencies (Tauri)
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
pkg-config \
libgtk-3-dev \
libglib2.0-dev \
libwebkit2gtk-4.1-dev \
libsoup-3.0-dev \
libayatana-appindicator3-dev \
librsvg2-dev
- name: Set workspace versions
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies
run: npm ci --workspaces
- name: Ensure rollup native binary
run: npm install @rollup/rollup-linux-x64-gnu --no-save
- name: Build Linux bundle (Tauri)
run: npm run build --workspace @codenomad/tauri-app
- name: Package Tauri artifacts (Linux)
run: |
set -euo pipefail
SEARCH_ROOT="packages/tauri-app/target"
ARTIFACT_DIR="packages/tauri-app/release-tauri"
rm -rf "$ARTIFACT_DIR"
mkdir -p "$ARTIFACT_DIR"
shopt -s nullglob globstar
find_one() {
find "$SEARCH_ROOT" -type f -iname "$1" | head -n1
}
appimage=$(find_one "*.AppImage")
deb=$(find_one "*.deb")
rpm=$(find_one "*.rpm")
if [ -z "$appimage" ] || [ -z "$deb" ] || [ -z "$rpm" ]; then
echo "Missing bundle(s): appimage=${appimage:-none} deb=${deb:-none} rpm=${rpm:-none}" >&2
exit 1
fi
cp "$appimage" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.AppImage"
cp "$deb" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.deb"
cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm"
- name: Upload Tauri release assets (Linux)
run: |
set -euo pipefail
shopt -s nullglob
for file in packages/tauri-app/release-tauri/*; do
[ -f "$file" ] || continue
echo "Uploading $file"
gh release upload "$TAG" "$file" --clobber
done
build-tauri-linux-arm64:
if: ${{ false }}
runs-on: ubuntu-24.04
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.version }}
TAG: ${{ inputs.tag }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: linux/arm64
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Setup Rust (Tauri)
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-unknown-linux-gnu
- name: Install Linux build dependencies (Tauri)
run: |
sudo dpkg --add-architecture arm64
sudo tee /etc/apt/sources.list.d/arm64.list >/dev/null <<'EOF'
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports noble main restricted universe multiverse
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports noble-updates main restricted universe multiverse
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports noble-security main restricted universe multiverse
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports noble-backports main restricted universe multiverse
EOF
sudo apt-get update
sudo apt-get install -y \
build-essential \
pkg-config \
gcc-aarch64-linux-gnu \
g++-aarch64-linux-gnu \
libgtk-3-dev:arm64 \
libglib2.0-dev:arm64 \
libwebkit2gtk-4.1-dev:arm64 \
libsoup-3.0-dev:arm64 \
libayatana-appindicator3-dev:arm64 \
librsvg2-dev:arm64
- name: Set workspace versions
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies
run: npm ci --workspaces
- name: Ensure rollup native binary
run: npm install @rollup/rollup-linux-arm64-gnu --no-save
- name: Build Linux bundle (Tauri arm64)
env:
TAURI_BUILD_TARGET: aarch64-unknown-linux-gnu
PKG_CONFIG_PATH: /usr/lib/aarch64-linux-gnu/pkgconfig
CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc
CXX_aarch64_unknown_linux_gnu: aarch64-linux-gnu-g++
AR_aarch64_unknown_linux_gnu: aarch64-linux-gnu-ar
run: npm run build --workspace @codenomad/tauri-app
- name: Package Tauri artifacts (Linux arm64)
run: |
set -euo pipefail
SEARCH_ROOT="packages/tauri-app/target"
ARTIFACT_DIR="packages/tauri-app/release-tauri"
rm -rf "$ARTIFACT_DIR"
mkdir -p "$ARTIFACT_DIR"
shopt -s nullglob globstar
first_artifact=$(find "$SEARCH_ROOT" -type f \( -name "*.AppImage" -o -name "*.deb" -o -name "*.rpm" -o -name "*.tar.gz" \) | head -n1)
fallback_bin="$SEARCH_ROOT/release/codenomad-tauri"
if [ -n "$first_artifact" ]; then
zip -j "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.zip" "$first_artifact"
elif [ -f "$fallback_bin" ]; then
zip -j "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.zip" "$fallback_bin"
else
echo "No bundled artifact found under $SEARCH_ROOT and no binary at $fallback_bin" >&2
exit 1
fi
- name: Upload Tauri release assets (Linux arm64)
run: |
set -euo pipefail
shopt -s nullglob
for file in packages/tauri-app/release-tauri/*.zip; do
[ -f "$file" ] || continue
echo "Uploading $file"
gh release upload "$TAG" "$file" --clobber
done
build-linux-rpm:
runs-on: ubuntu-24.04
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.version }}
TAG: ${{ inputs.tag }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Install rpm packaging dependencies
run: |
sudo apt-get update
sudo apt-get install -y rpm ruby ruby-dev build-essential
sudo gem install --no-document fpm
- name: Set workspace versions
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install project dependencies
run: npm ci --workspaces
- name: Ensure rollup native binary
run: npm install @rollup/rollup-linux-x64-gnu --no-save
- name: Build Linux RPM binaries
run: npm run build:linux-rpm --workspace @neuralnomads/codenomad-electron-app
- name: Upload RPM release assets
run: |
set -euo pipefail
shopt -s nullglob
for file in packages/electron-app/release/*.rpm; do
[ -f "$file" ] || continue
echo "Uploading $file"
gh release upload "$TAG" "$file" --clobber
done

16
.github/workflows/dev-release.yml vendored Normal file
View File

@@ -0,0 +1,16 @@
name: Dev Release
on:
workflow_dispatch:
permissions:
id-token: write
contents: write
jobs:
dev-release:
uses: ./.github/workflows/reusable-release.yml
with:
version_suffix: -dev
dist_tag: dev
secrets: inherit

View File

@@ -0,0 +1,74 @@
name: Manual NPM Publish
on:
workflow_dispatch:
inputs:
version:
description: "Version to publish (e.g. 0.2.0-dev)"
required: false
type: string
dist_tag:
description: "npm dist-tag"
required: false
default: dev
type: string
workflow_call:
inputs:
version:
required: true
type: string
dist_tag:
required: false
type: string
default: dev
permissions:
contents: read
id-token: write
jobs:
publish:
runs-on: ubuntu-latest
env:
NODE_VERSION: 20
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
registry-url: https://registry.npmjs.org
- name: Ensure npm >=11.5.1
run: npm install -g npm@latest
- name: Install dependencies
run: npm ci --workspaces
- name: Ensure rollup native binary
run: npm install @rollup/rollup-linux-x64-gnu --no-save
- name: Build server package (includes UI bundling)
run: npm run build --workspace @neuralnomads/codenomad
- name: Set publish metadata
shell: bash
run: |
VERSION_INPUT="${{ inputs.version }}"
if [ -z "$VERSION_INPUT" ]; then
VERSION_INPUT=$(node -p "require('./package.json').version")
fi
echo "VERSION=$VERSION_INPUT" >> "$GITHUB_ENV"
echo "DIST_TAG=${{ inputs.dist_tag || 'dev' }}" >> "$GITHUB_ENV"
- name: Bump package version for publish
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Publish server package with provenance
env:
NPM_CONFIG_PROVENANCE: true
NPM_CONFIG_REGISTRY: https://registry.npmjs.org
run: |
npm publish --workspace @neuralnomads/codenomad --access public --tag ${DIST_TAG} --provenance

View File

@@ -6,211 +6,12 @@ on:
- main
permissions:
id-token: write
contents: write
env:
NODE_VERSION: 20
jobs:
prepare-release:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.get_version.outputs.version }}
tag: ${{ steps.ensure_tag.outputs.tag }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Read version
id: get_version
run: |
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Ensure git tag
id: ensure_tag
env:
VERSION: ${{ steps.get_version.outputs.version }}
run: |
TAG="v${VERSION}"
git fetch --tags
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "Tag $TAG already exists"
else
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag "$TAG"
git push origin "$TAG"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "Created tag $TAG"
fi
- name: Ensure GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.ensure_tag.outputs.tag }}
VERSION: ${{ steps.get_version.outputs.version }}
run: |
if gh release view "$TAG" >/dev/null 2>&1; then
echo "Release $TAG already exists"
else
gh release create "$TAG" --title "CodeNomad v${VERSION}" --generate-notes
fi
build-macos:
needs: prepare-release
runs-on: macos-13
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Install dependencies
run: npm ci --workspaces
- name: Build macOS binaries
run: npm run build:mac --workspace @codenomad/electron-app
- name: Upload release assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.prepare-release.outputs.tag }}
run: |
set -euo pipefail
shopt -s nullglob
for file in packages/electron-app/release/*; do
[ -f "$file" ] || continue
case "$file" in
*.dmg|*.zip)
gh release upload "$TAG" "$file" --clobber
;;
*)
echo "Skipping non-installer asset: $file"
;;
esac
done
build-windows:
needs: prepare-release
runs-on: windows-latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Install dependencies
run: npm ci --workspaces
- name: Build Windows binaries
run: npm run build:win --workspace @codenomad/electron-app
- name: Upload release assets
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.prepare-release.outputs.tag }}
run: |
Get-ChildItem -Path "packages/electron-app/release" -File | Where-Object {
$_.Name -match '\.(exe|zip)$'
} | ForEach-Object {
gh release upload $env:TAG $_.FullName --clobber
}
build-linux:
needs: prepare-release
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Install dependencies
run: npm ci --workspaces
- name: Build Linux binaries
run: npm run build:linux --workspace @codenomad/electron-app
- name: Upload release assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.prepare-release.outputs.tag }}
run: |
set -euo pipefail
shopt -s nullglob
for file in packages/electron-app/release/*; do
[ -f "$file" ] || continue
case "$file" in
*.AppImage|*.deb|*.tar.gz)
gh release upload "$TAG" "$file" --clobber
;;
*)
echo "Skipping non-installer asset: $file"
;;
esac
done
build-linux-rpm:
needs: prepare-release
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Install rpm packaging dependencies
run: |
sudo apt-get update
sudo apt-get install -y rpm ruby ruby-dev build-essential
sudo gem install --no-document fpm
- name: Install project dependencies
run: npm ci --workspaces
- name: Build Linux RPM binaries
run: npm run build:linux-rpm --workspace @codenomad/electron-app
- name: Upload RPM release assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.prepare-release.outputs.tag }}
run: |
set -euo pipefail
shopt -s nullglob
for file in packages/electron-app/release/*.rpm; do
[ -f "$file" ] || continue
gh release upload "$TAG" "$file" --clobber
done
release:
uses: ./.github/workflows/reusable-release.yml
with:
dist_tag: latest
secrets: inherit

80
.github/workflows/reusable-release.yml vendored Normal file
View File

@@ -0,0 +1,80 @@
name: Reusable Release
on:
workflow_call:
inputs:
version_suffix:
description: "Suffix appended to package.json version"
required: false
default: ""
type: string
dist_tag:
description: "npm dist-tag to publish under"
required: false
default: dev
type: string
permissions:
id-token: write
contents: write
env:
NODE_VERSION: 20
jobs:
prepare-release:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.versions.outputs.version }}
tag: ${{ steps.versions.outputs.tag }}
release_name: ${{ steps.versions.outputs.release_name }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Compute release versions
id: versions
env:
VERSION_SUFFIX: ${{ inputs.version_suffix }}
run: |
BASE_VERSION=$(node -p "require('./package.json').version")
VERSION="${BASE_VERSION}${VERSION_SUFFIX}"
TAG="v${VERSION}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "release_name=$TAG" >> "$GITHUB_OUTPUT"
- name: Create GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.versions.outputs.tag }}
run: |
if gh release view "$TAG" >/dev/null 2>&1; then
echo "Release $TAG already exists"
else
gh release create "$TAG" --title "$TAG" --generate-notes
fi
build-and-upload:
needs: prepare-release
uses: ./.github/workflows/build-and-upload.yml
with:
version: ${{ needs.prepare-release.outputs.version }}
tag: ${{ needs.prepare-release.outputs.tag }}
release_name: ${{ needs.prepare-release.outputs.release_name }}
secrets: inherit
publish-server:
needs:
- prepare-release
- build-and-upload
uses: ./.github/workflows/manual-npm-publish.yml
with:
version: ${{ needs.prepare-release.outputs.version }}
dist_tag: ${{ inputs.dist_tag }}
secrets: inherit

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@ release/
.vite/
.electron-vite/
out/
.dir-locals.el

View File

@@ -1,6 +1,5 @@
---
description: Develops Web UI components.
mode: all
model: zai-coding-plan/glm-4.6
---
You are a Web Frontend Developer Agent. Your primary focus is on developing SolidJS UI components, ensuring adherence to modern web best practices, excellent UI/UX, and efficient data integration.

View File

@@ -13,7 +13,7 @@ This guide explains how to build distributable binaries for CodeNomad.
All commands now run inside the workspace packages. From the repo root you can target the Electron app package directly:
```bash
npm run build --workspace @codenomad/electron-app
npm run build --workspace @neuralnomads/codenomad-electron-app
```
### Build for Current Platform (macOS default)
@@ -77,8 +77,8 @@ bun run build:all
The build script performs these steps:
1. **Compile TypeScript** → Electron app (main, preload, renderer)
2. **Bundle with Vite** → Optimized production build
1. **Build @neuralnomads/codenomad** → Produces the CLI `dist/` bundle (also rebuilds the UI assets it serves)
2. **Compile TypeScript + bundle with Vite** → Electron main, preload, and renderer output in `dist/`
3. **Package with electron-builder** → Platform-specific binaries
## Output

108
README.md
View File

@@ -1,58 +1,94 @@
# CodeNomad
## A fast, multi-instance desktop client for running OpenCode sessions the way long-haul builders actually work.
## What is CodeNomad?
## A fast, multi-instance workspace for running OpenCode sessions.
CodeNomad is built for people who live inside OpenCode for hours on end and need a cockpit, not a kiosk. When terminals get unwieldy and web clients feel laggy, CodeNomad delivers a desktop-native workspace that favors speed, clarity, and direct control. It runs on macOS, Windows, and Linux using Electron + SolidJS, with prebuilt binaries so you can get started immediately.
CodeNomad is built for people who live inside OpenCode for hours on end and need a cockpit, not a kiosk. It delivers a premium, low-latency workspace that favors speed, clarity, and direct control.
![Multi-instance workspace](docs/screenshots/newSession.png)
_Manage multiple OpenCode sessions side-by-side._
<details>
<summary>📸 More Screenshots</summary>
![Command palette overlay](docs/screenshots/command-palette.png)
_Global command palette for keyboard-first control._
![Image Previews](docs/screenshots/image-previews.png)
_Rich media previews for images and assets._
![Browser Support](docs/screenshots/browser-support.png)
_Browser support via CodeNomad Server._
</details>
## Getting Started
Choose the way that fits your workflow:
### 🖥️ Desktop App (Recommended)
The best experience. A native application (Electron-based) with global shortcuts, deeper system integration, and a dedicated window.
- **Download**: Grab the latest installer for macOS, Windows, or Linux from the [Releases Page](https://github.com/shantur/CodeNomad/releases).
- **Run**: Install and launch like any other app.
### 🦀 Tauri App (Experimental)
We are also working on a lightweight, high-performance version built with [Tauri](https://tauri.app). It is currently in active development.
- **Download**: Experimental builds are available on the [Releases Page](https://github.com/shantur/CodeNomad/releases).
- **Source**: Check out `packages/tauri-app` if you're interested in contributing.
### 💻 CodeNomad Server
Run CodeNomad as a local server and access it via your web browser. Perfect for remote development (SSH/VPN) or running as a service.
```bash
npx @neuralnomads/codenomad --launch
```
For dev version
```bash
npx @neuralnomads/codenomad@dev --launch
```
This command starts the server and opens the web client in your default browser.
## Highlights
- **Long-session native** scroll through massive transcripts without hitches and keep full context visible.
- **Multiple instances, one window** juggle several OpenCode instances side-by-side with per-instance tabs.
- **Deep task awareness** jump into sub/child sessions (Tasks tool) instantly, monitor their status, and answer directly without losing your flow.
- **Keyboard first** the full UI is optimized for shortcuts so you can stay mouse-free when you want to.
- **Command palette superpowers** summon a single, global palette to jump tabs, launch tools, tweak preferences, or fire shortcuts. Every action is categorized, fuzzy searchable, and previewed so you can chain moves together in seconds. It keeps your workflow predictable and fast whether you are juggling one session or ten.
- **Developer-friendly rendering** syntax highlighting, inline diffs, and thoughtful presentation keep the signal high.
- **Multi-Instance**: Juggle several OpenCode sessions side-by-side with tabs.
- **Long-Session Native**: Scroll through massive transcripts without hitches.
- **Command Palette**: A single global palette to jump tabs, launch tools, and control everything.
- **Deep Task Awareness**: Monitor background tasks and child sessions without losing flow.
## Requirements
- [OpenCode CLI](https://opencode.ai) installed and available in your `PATH`, or point CodeNomad to a local binary through Advanced Settings.
- **[OpenCode CLI](https://opencode.ai)**: Must be installed and available in your `PATH`.
- **Node.js 18+**: Required if running the CLI server or building from source.
## Repository Layout
## Troubleshooting
CodeNomad now ships as a small workspace with two packages:
### macOS says the app is damaged
If macOS reports that "CodeNomad.app is damaged and can't be opened," Gatekeeper flagged the download because the app is not yet notarized. You can clear the quarantine flag after moving CodeNomad into `/Applications`:
- `packages/ui` — SolidJS renderer, Tailwind styles, and standalone Vite configuration for building the UI bundle independently.
- `packages/electron-app` — Electron main/preload processes plus packaging scripts. It consumes the UI package during development/build via `electron-vite`.
```bash
xattr -l /Applications/CodeNomad.app
xattr -dr com.apple.quarantine /Applications/CodeNomad.app
```
Use `npm run dev --workspace @codenomad/electron-app` for the Electron shell and `npm run dev --workspace @codenomad/ui` for UI-only work. Working with the workspace requires Node.js 18+ with npm 7 or newer so the workspace protocol is available.
After removing the quarantine attribute, launch the app normally. On Intel Macs you may also need to approve CodeNomad from **System Settings → Privacy & Security** the first time you run it.
## Downloads
## Architecture & Development
Grab the latest build for macOS, Windows, and Linux from the [GitHub Releases page](https://github.com/shantur/CodeNomad/releases).
CodeNomad is a monorepo split into specialized packages. If you want to contribute or build from source, check out the individual package documentation:
## Quick Start
| Package | Description |
|---------|-------------|
| **[packages/electron-app](packages/electron-app/README.md)** | The native desktop application shell. Wraps the UI and Server. |
| **[packages/server](packages/server/README.md)** | The core logic and CLI. Manages workspaces, proxies OpenCode, and serves the API. |
| **[packages/ui](packages/ui/README.md)** | The SolidJS-based frontend. Fast, reactive, and beautiful. |
1. Install the OpenCode CLI and confirm it is reachable via your terminal.
2. Download the CodeNomad build for your platform and launch the app.
3. Connect to one or more OpenCode instances, set keyboard shortcuts in preferences, and start a session.
4. Use tabs to swap between instances, the task sidebar to dive into child sessions, and the prompt input to keep shipping.
## CLI Server Flags
The bundled CLI server (`@codenomad/cli`) controls which folders the UI can browse when you pick a workspace:
- `--workspace-root <path>` (default: current working directory) scopes browsing to a safe subtree. The UI can only see folders beneath this root.
- `--unrestricted-root` explicitly allows full-machine browsing for the current process. In this mode the UI starts from the host home directory, adds a "parent" option so you can reach `/` on macOS/Linux, and lists drives/UNC paths on Windows. The flag is runtime-only—restart the CLI without it to go back to restricted mode.
- `--ui-dev-server <url>` proxies UI asset requests to a running Vite dev server while the CLI continues to expose its REST APIs and workspace proxies from the same port. Point this at `http://localhost:3000` when developing the renderer to keep hot reloads without sacrificing the single entry point.
Use unrestricted mode only when you trust the host; the CLI will skip directories it cannot read and never persists the opt-in.
### Single Port Proxying
Every OpenCode instance now tunnels through the CLI port. Each workspace descriptor publishes a stable `proxyPath` (e.g., `/workspaces/<id>/instance`), and the CLI exposes `GET/POST/...` + SSE at `http(s)://<cli-host>:<cli-port>${proxyPath}`. That means the UI, Electron shell, and browser clients only need firewall access to the CLI; instance ports stay private on `127.0.0.1`. In development, the `--ui-dev-server` flag still routes UI traffic through the CLI proxy so all instance calls share the same origin.
### Quick Build
To build the Desktop App from source:
1. Clone the repo.
2. Run `npm install` (requires pnpm or npm 7+ for workspaces).
3. Run `npm run build --workspace @neuralnomads/codenomad-electron-app`.

View File

@@ -29,13 +29,13 @@ CodeNomad is a cross-platform desktop application built with Electron that provi
│ │ │ State Management (SolidJS Stores) │ │ │
│ │ │ - instances[] │ │ │
│ │ │ - sessions[] per instance │ │ │
│ │ │ - messages[] per session │ │ │
│ │ │ - normalized message store per session │ │ │
│ │ └────────────────────────────────────────────┘ │ │
│ │ ┌────────────────────────────────────────────┐ │ │
│ │ │ UI Components │ │ │
│ │ │ - InstanceTabs │ │ │
│ │ │ - SessionTabs │ │ │
│ │ │ - MessageStream │ │ │
│ │ │ - MessageSection │ │ │
│ │ │ - PromptInput │ │ │
│ │ └────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────┘ │

82
dev-docs/solidjs-llms.txt Normal file
View File

@@ -0,0 +1,82 @@
# SolidJS Documentation
> Solid is a modern JavaScript framework for building user interfaces with fine-grained reactivity. It compiles JSX to real DOM elements and updates only what changes, delivering exceptional performance without a virtual DOM. Solid provides reactive primitives like signals, effects, and stores for predictable state management.
SolidJS is a declarative JavaScript framework that prioritizes performance and developer experience. Unlike frameworks that re-run components on every update, Solid components run once during initialization and set up a reactive system that precisely updates the DOM when dependencies change.
Key principles:
- Fine-grained reactivity: Updates only the specific DOM nodes that depend on changed data
- Compile-time optimization: JSX transforms into efficient DOM operations
- Unidirectional data flow: Props are read-only, promoting predictable state management
- Component lifecycle: Components run once, with reactive primitives handling updates
**Use your web fetch tool on any of the following links to understand the relevant concept**.
## Quick Start
- [Overview](https://docs.solidjs.com/): Framework introduction and key advantages
- [Quick Start](https://docs.solidjs.com/quick-start): Installation and project setup with create-solid
- [Interactive Tutorial](https://www.solidjs.com/tutorial/introduction_basics): Learn Solid basics through guided examples
- [Playground](https://playground.solidjs.com/): Experiment with Solid directly in your browser
## Core Concepts
- [Intro to Reactivity](https://docs.solidjs.com/concepts/intro-to-reactivity): Signals, subscribers, and reactive principles
- [Understanding JSX](https://docs.solidjs.com/concepts/understanding-jsx): How Solid uses JSX and key differences from HTML
- [Components Basics](https://docs.solidjs.com/concepts/components/basics): Component trees, lifecycles, and composition patterns
- [Signals](https://docs.solidjs.com/concepts/signals): Core reactive primitive for state management with getters/setters
- [Effects](https://docs.solidjs.com/concepts/effects): Side effects, dependency tracking, and lifecycle functions
- [Stores](https://docs.solidjs.com/concepts/stores): Complex state management with proxy-based reactivity
- [Context](https://docs.solidjs.com/concepts/context): Cross-component state sharing without prop drilling
## Component APIs
- [Props](https://docs.solidjs.com/concepts/components/props): Passing data and handlers to child components
- [Event Handlers](https://docs.solidjs.com/concepts/components/event-handlers): Managing user interactions
- [Class and Style](https://docs.solidjs.com/concepts/components/class-style): Dynamic styling approaches
- [Refs](https://docs.solidjs.com/concepts/refs): Accessing DOM elements directly
## Control Flow
- [Conditional Rendering](https://docs.solidjs.com/concepts/control-flow/conditional-rendering): Show, Switch, and Match components
- [List Rendering](https://docs.solidjs.com/concepts/control-flow/list-rendering): For, Index, and keyed iteration
- [Dynamic](https://docs.solidjs.com/concepts/control-flow/dynamic): Dynamic component switching
- [Portal](https://docs.solidjs.com/concepts/control-flow/portal): Rendering outside component hierarchy
- [Error Boundary](https://docs.solidjs.com/concepts/control-flow/error-boundary): Graceful error handling
## Derived Values
- [Derived Signals](https://docs.solidjs.com/concepts/derived-values/derived-signals): Computed values from signals
- [Memos](https://docs.solidjs.com/concepts/derived-values/memos): Cached computed values for performance
## State Management
- [Basic State Management](https://docs.solidjs.com/guides/state-management): One-way data flow and lifting state
- [Complex State Management](https://docs.solidjs.com/guides/complex-state-management): Stores for scalable applications
- [Fetching Data](https://docs.solidjs.com/guides/fetching-data): Async data with createResource
## Routing
- [Routing & Navigation](https://docs.solidjs.com/guides/routing-and-navigation): @solidjs/router setup and usage
- [Dynamic Routes](https://docs.solidjs.com/guides/routing-and-navigation#dynamic-routes): Route parameters and validation
- [Nested Routes](https://docs.solidjs.com/guides/routing-and-navigation#nested-routes): Hierarchical route structures
- [Preload Functions](https://docs.solidjs.com/guides/routing-and-navigation#preload-functions): Parallel data fetching
## Advanced Topics
- [Fine-Grained Reactivity](https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity): Deep dive into reactive system
- [TypeScript](https://docs.solidjs.com/configuration/typescript): Type safety and configuration
## Ecosystem
- [Solid Router](https://docs.solidjs.com/solid-router/): File-system routing and data APIs
- [SolidStart](https://docs.solidjs.com/solid-start/): Full-stack meta-framework
- [Solid Meta](https://docs.solidjs.com/solid-meta/): Document head management
- [Templates](https://github.com/solidjs/templates): Starter templates for different setups
## Optional
- [Ecosystem Libraries](https://www.solidjs.com/ecosystem): Community packages and tools
- [API Reference](https://docs.solidjs.com/reference/): Complete API documentation
- [Testing](https://docs.solidjs.com/guides/testing): Testing strategies and utilities
- [Deployment](https://docs.solidjs.com/guides/deploying-your-app): Build and deployment options

View File

@@ -49,7 +49,7 @@ packages/opencode-client/
│ ├── components/
│ │ ├── instance-tabs.tsx # Level 1 tabs
│ │ ├── session-tabs.tsx # Level 2 tabs
│ │ ├── message-stream.tsx # Messages display
│ │ ├── message-stream-v2.tsx # Messages display (normalized store)
│ │ ├── message-item.tsx # Single message
│ │ ├── tool-call.tsx # Tool execution display
│ │ ├── prompt-input.tsx # Input with attachments
@@ -153,16 +153,24 @@ interface Session {
providerId: string
modelId: string
}
messages: Message[]
status: SessionStatus
createdAt: number
updatedAt: number
version: string
time: { created: number; updated: number }
revert?: {
messageID?: string
partID?: string
snapshot?: string
diff?: string
}
}
// Message content lives in the normalized message-v2 store
// keyed by instanceId/sessionId/messageId
type SessionStatus =
| "idle" // No activity
| "streaming" // Assistant responding
| "error" // Error occurred
```
### UI Store

Binary file not shown.

After

Width:  |  Height:  |  Size: 845 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

549
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "codenomad-workspace",
"version": "0.1.2",
"version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codenomad-workspace",
"version": "0.1.2",
"version": "0.3.0",
"dependencies": {
"7zip-bin": "^5.2.0",
"google-auth-library": "^10.5.0"
@@ -313,12 +313,8 @@
"node": ">=6.9.0"
}
},
"node_modules/@codenomad/cli": {
"resolved": "packages/cli",
"link": true
},
"node_modules/@codenomad/electron-app": {
"resolved": "packages/electron-app",
"node_modules/@codenomad/tauri-app": {
"resolved": "packages/tauri-app",
"link": true
},
"node_modules/@codenomad/ui": {
@@ -1233,6 +1229,14 @@
"node": ">= 10.0.0"
}
},
"node_modules/@neuralnomads/codenomad": {
"resolved": "packages/server",
"link": true
},
"node_modules/@neuralnomads/codenomad-electron-app": {
"resolved": "packages/electron-app",
"link": true
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1272,9 +1276,9 @@
}
},
"node_modules/@opencode-ai/sdk": {
"version": "1.0.68",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.0.68.tgz",
"integrity": "sha512-QdpLZw2L/nHdPFGCz8z4du2RvlALgZTFgNeKUM+kJuZTtOWC5t425ELGg5xKIpynD0kj83Euvfn6l6uHs99g3w=="
"version": "1.0.133",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.0.133.tgz",
"integrity": "sha512-kM+VJJ09SU51aruQ78DSy+6CjNc4wMytvGBrZ1IIJ8etUIdGA59wrnIOSxBVs4u/Gb9pjjgsF8sWp59UdLWP9w=="
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
@@ -1549,6 +1553,223 @@
"node": ">=10"
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.9.4.tgz",
"integrity": "sha512-pvylWC9QckrOS9ATWXIXcgu7g2hKK5xTL5ZQyZU/U0n9l88SEFGcWgLQNa8WZmd+wWIOWhkxOFcOl3i6ubDNNw==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
"tauri": "tauri.js"
},
"engines": {
"node": ">= 10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.9.4",
"@tauri-apps/cli-darwin-x64": "2.9.4",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.9.4",
"@tauri-apps/cli-linux-arm64-gnu": "2.9.4",
"@tauri-apps/cli-linux-arm64-musl": "2.9.4",
"@tauri-apps/cli-linux-riscv64-gnu": "2.9.4",
"@tauri-apps/cli-linux-x64-gnu": "2.9.4",
"@tauri-apps/cli-linux-x64-musl": "2.9.4",
"@tauri-apps/cli-win32-arm64-msvc": "2.9.4",
"@tauri-apps/cli-win32-ia32-msvc": "2.9.4",
"@tauri-apps/cli-win32-x64-msvc": "2.9.4"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.9.4.tgz",
"integrity": "sha512-9rHkMVtbMhe0AliVbrGpzMahOBg3rwV46JYRELxR9SN6iu1dvPOaMaiC4cP6M/aD1424ziXnnMdYU06RAH8oIw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.9.4.tgz",
"integrity": "sha512-VT9ymNuT06f5TLjCZW2hfSxbVtZDhORk7CDUDYiq5TiSYQdxkl8MVBy0CCFFcOk4QAkUmqmVUA9r3YZ/N/vPRQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.9.4.tgz",
"integrity": "sha512-tTWkEPig+2z3Rk0zqZYfjUYcgD+aSm72wdrIhdYobxbQZOBw0zfn50YtWv+av7bm0SHvv75f0l7JuwgZM1HFow==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.9.4.tgz",
"integrity": "sha512-ql6vJ611qoqRYHxkKPnb2vHa27U+YRKRmIpLMMBeZnfFtZ938eao7402AQCH1mO2+/8ioUhbpy9R/ZcLTXVmkg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.9.4.tgz",
"integrity": "sha512-vg7yNn7ICTi6hRrcA/6ff2UpZQP7un3xe3SEld5QM0prgridbKAiXGaCKr3BnUBx/rGXegQlD/wiLcWdiiraSw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.9.4.tgz",
"integrity": "sha512-l8L+3VxNk6yv5T/Z/gv5ysngmIpsai40B9p6NQQyqYqxImqYX37pqREoEBl1YwG7szGnDibpWhidPrWKR59OJA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.9.4.tgz",
"integrity": "sha512-PepPhCXc/xVvE3foykNho46OmCyx47E/aG676vKTVp+mqin5d+IBqDL6wDKiGNT5OTTxKEyNlCQ81Xs2BQhhqA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.9.4.tgz",
"integrity": "sha512-zcd1QVffh5tZs1u1SCKUV/V7RRynebgYUNWHuV0FsIF1MjnULUChEXhAhug7usCDq4GZReMJOoXa6rukEozWIw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.9.4.tgz",
"integrity": "sha512-/7ZhnP6PY04bEob23q8MH/EoDISdmR1wuNm0k9d5HV7TDMd2GGCDa8dPXA4vJuglJKXIfXqxFmZ4L+J+MO42+w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.9.4.tgz",
"integrity": "sha512-1LmAfaC4Cq+3O1Ir1ksdhczhdtFSTIV51tbAGtbV/mr348O+M52A/xwCCXQank0OcdBxy5BctqkMtuZnQvA8uQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.9.4.tgz",
"integrity": "sha512-EdYd4c9wGvtPB95kqtEyY+bUR+k4kRw3IA30mAQ1jPH6z57AftT8q84qwv0RDp6kkEqOBKxeInKfqi4BESYuqg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tootallnate/once": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
@@ -2677,6 +2898,15 @@
"node": ">= 0.4"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/camelcase-css": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
@@ -3172,6 +3402,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@@ -3315,6 +3554,12 @@
"node": ">=0.3.1"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dir-compare": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-3.3.0.tgz",
@@ -4219,6 +4464,19 @@
"node": ">=14"
}
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
@@ -4439,7 +4697,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
@@ -5000,15 +5257,6 @@
],
"license": "BSD-3-Clause"
},
"node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -5414,6 +5662,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
@@ -6042,6 +6302,42 @@
"node": ">=8"
}
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -6061,6 +6357,15 @@
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
@@ -6489,6 +6794,98 @@
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -6656,7 +7053,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -6671,6 +7067,12 @@
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -7026,6 +7428,12 @@
"seroval": "^1.0"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
@@ -8224,6 +8632,12 @@
"node": ">= 8"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
@@ -8399,44 +8813,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"packages/cli": {
"name": "@codenomad/cli",
"version": "0.1.0",
"dependencies": {
"@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0",
"@fastify/static": "^7.0.4",
"commander": "^12.1.0",
"fastify": "^4.28.1",
"pino": "^9.4.0",
"undici": "^6.19.8",
"zod": "^3.23.8"
},
"bin": {
"codenomad-cli": "dist/bin.js"
},
"devDependencies": {
"cross-env": "^7.0.3",
"ts-node": "^10.9.2",
"tsx": "^4.20.6",
"typescript": "^5.6.3"
}
},
"packages/cli/node_modules/commander": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"packages/electron-app": {
"name": "@codenomad/electron-app",
"version": "0.1.2",
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.3.0",
"dependencies": {
"@codenomad/ui": "file:../ui",
"ignore": "7.0.5"
"@neuralnomads/codenomad": "file:../server"
},
"devDependencies": {
"7zip-bin": "^5.2.0",
@@ -8446,6 +8828,7 @@
"electron-vite": "4.0.1",
"png2icons": "^2.0.1",
"pngjs": "^7.0.0",
"tsx": "^4.20.6",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vite-plugin-solid": "^2.10.0"
@@ -8458,17 +8841,65 @@
"dev": true,
"license": "MIT"
},
"packages/server": {
"name": "@neuralnomads/codenomad",
"version": "0.3.0",
"dependencies": {
"@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0",
"@fastify/static": "^7.0.4",
"commander": "^12.1.0",
"fastify": "^4.28.1",
"fuzzysort": "^2.0.4",
"pino": "^9.4.0",
"undici": "^6.19.8",
"zod": "^3.23.8"
},
"bin": {
"codenomad": "dist/bin.js"
},
"devDependencies": {
"cross-env": "^7.0.3",
"ts-node": "^10.9.2",
"tsx": "^4.20.6",
"typescript": "^5.6.3"
}
},
"packages/server/node_modules/commander": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/fuzzysort": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-2.0.4.tgz",
"integrity": "sha512-Api1mJL+Ad7W7vnDZnWq5pGaXJjyencT+iKGia2PlHUcSsSzWwIQ3S1isiMpwpavjYtGd2FzhUIhnnhOULZgDw==",
"license": "MIT"
},
"packages/tauri-app": {
"name": "@codenomad/tauri-app",
"version": "0.3.0",
"devDependencies": {
"@tauri-apps/cli": "^2.9.4"
}
},
"packages/ui": {
"name": "@codenomad/ui",
"version": "0.1.2",
"version": "0.3.0",
"dependencies": {
"@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11",
"@opencode-ai/sdk": "1.0.68",
"@opencode-ai/sdk": "^1.0.133",
"@solidjs/router": "^0.13.0",
"debug": "^4.4.3",
"github-markdown-css": "^5.8.1",
"lucide-solid": "^0.300.0",
"marked": "^12.0.0",
"qrcode": "^1.5.3",
"shiki": "^3.13.0",
"solid-js": "^1.8.0",
"solid-toast": "^0.5.0"

View File

@@ -1,6 +1,6 @@
{
"name": "codenomad-workspace",
"version": "0.1.2",
"version": "0.3.0",
"private": true,
"description": "CodeNomad monorepo workspace",
"workspaces": {
@@ -9,14 +9,16 @@
]
},
"scripts": {
"dev": "npm run dev --workspace @codenomad/electron-app",
"dev:electron": "npm run dev --workspace @codenomad/electron-app",
"dev:ui": "npm run dev --workspace @codenomad/ui",
"build": "npm run build --workspace @codenomad/electron-app",
"dev": "npm run dev --workspace @neuralnomads/codenomad-electron-app",
"dev:electron": "npm run dev --workspace @neuralnomads/codenomad-electron-app",
"dev:tauri": "npm run dev --workspace @codenomad/tauri-app",
"build": "npm run build --workspace @neuralnomads/codenomad-electron-app",
"build:tauri": "npm run build --workspace @codenomad/tauri-app",
"build:ui": "npm run build --workspace @codenomad/ui",
"build:mac-x64": "npm run build:mac-x64 --workspace @codenomad/electron-app",
"build:binaries": "npm run build:binaries --workspace @codenomad/electron-app",
"typecheck": "npm run typecheck --workspace @codenomad/ui && npm run typecheck --workspace @codenomad/electron-app"
"build:mac-x64": "npm run build:mac-x64 --workspace @neuralnomads/codenomad-electron-app",
"build:binaries": "npm run build:binaries --workspace @neuralnomads/codenomad-electron-app",
"typecheck": "npm run typecheck --workspace @codenomad/ui && npm run typecheck --workspace @neuralnomads/codenomad-electron-app",
"bumpVersion": "npm version --workspaces --include-workspace-root --no-git-tag-version"
},
"dependencies": {
"7zip-bin": "^5.2.0",

View File

@@ -1,10 +0,0 @@
import { FastifyInstance } from "fastify"
import { ServerMeta } from "../../api-types"
interface RouteDeps {
serverMeta: ServerMeta
}
export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/meta", async () => deps.serverMeta)
}

View File

@@ -0,0 +1,40 @@
# CodeNomad App
This package contains the native desktop application shell for CodeNomad, built with [Electron](https://www.electronjs.org/).
## Overview
The Electron app wraps the CodeNomad UI and Server into a standalone executable. It provides deeper system integration, such as:
- Native window management
- Global keyboard shortcuts
- Application menu integration
## Development
To run the Electron app in development mode:
```bash
npm run dev
```
This will start the renderer (UI) and the main process with hot reloading.
## Building
To build the application for your current platform:
```bash
npm run build
```
To build for specific platforms (requires appropriate build tools):
- **macOS**: `npm run build:mac`
- **Windows**: `npm run build:win`
- **Linux**: `npm run build:linux`
## Structure
- `electron/main`: Main process code (window creation, IPC).
- `electron/preload`: Preload scripts for secure bridge between main and renderer.
- `electron/resources`: Static assets like icons.

View File

@@ -6,6 +6,7 @@ const uiRoot = resolve(__dirname, "../ui")
const uiSrc = resolve(uiRoot, "src")
const uiRendererRoot = resolve(uiRoot, "src/renderer")
const uiRendererEntry = resolve(uiRendererRoot, "index.html")
const uiRendererLoadingEntry = resolve(uiRendererRoot, "loading.html")
export default defineConfig({
main: {
@@ -25,7 +26,7 @@ export default defineConfig({
build: {
outDir: "dist/preload",
lib: {
entry: resolve(__dirname, "electron/preload/index.ts"),
entry: resolve(__dirname, "electron/preload/index.cjs"),
formats: ["cjs"],
fileName: () => "index.js",
},
@@ -52,9 +53,19 @@ export default defineConfig({
port: 3000,
},
build: {
minify: false,
cssMinify: false,
sourcemap: true,
outDir: resolve(__dirname, "dist/renderer"),
rollupOptions: {
input: uiRendererEntry,
input: {
main: uiRendererEntry,
loading: uiRendererLoadingEntry,
},
output: {
compact: false,
minifyInternalExports: false,
},
},
},
},

View File

@@ -1,243 +1,65 @@
import { ipcMain, BrowserWindow, dialog } from "electron"
import { processManager } from "./process-manager"
import { randomBytes } from "crypto"
import * as fs from "fs"
import * as path from "path"
import { spawn } from "child_process"
import ignore from "ignore"
import { BrowserWindow, dialog, ipcMain, type OpenDialogOptions } from "electron"
import type { CliProcessManager, CliStatus } from "./process-manager"
interface Instance {
id: string
folder: string
port: number
pid: number
status: "starting" | "ready" | "error" | "stopped"
error?: string
interface DialogOpenRequest {
mode: "directory" | "file"
title?: string
defaultPath?: string
filters?: Array<{ name?: string; extensions: string[] }>
}
const instances = new Map<string, Instance>()
function generateId(): string {
return randomBytes(16).toString("hex")
interface DialogOpenResult {
canceled: boolean
paths: string[]
}
function runBinaryVersion(binaryPath: string, timeoutMs = 5000): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn(binaryPath, ["-v"], {
stdio: ["ignore", "pipe", "pipe"],
})
export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessManager) {
cliManager.on("status", (status: CliStatus) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send("cli:status", status)
}
})
let stdout = ""
let stderr = ""
cliManager.on("ready", (status: CliStatus) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send("cli:ready", status)
}
})
const timeout = setTimeout(() => {
child.kill("SIGTERM")
reject(new Error("Version check timed out"))
}, timeoutMs)
cliManager.on("error", (error: Error) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send("cli:error", { message: error.message })
}
})
child.stdout?.on("data", (data) => {
stdout += data.toString()
})
ipcMain.handle("cli:getStatus", async () => cliManager.getStatus())
child.stderr?.on("data", (data) => {
stderr += data.toString()
})
ipcMain.handle("cli:restart", async () => {
const devMode = process.env.NODE_ENV === "development"
await cliManager.stop()
return cliManager.start({ dev: devMode })
})
child.on("error", (error) => {
clearTimeout(timeout)
reject(error)
})
ipcMain.handle("dialog:open", async (_, request: DialogOpenRequest): Promise<DialogOpenResult> => {
const properties: OpenDialogOptions["properties"] =
request.mode === "directory" ? ["openDirectory", "createDirectory"] : ["openFile"]
child.on("close", (code) => {
clearTimeout(timeout)
if (code === 0) {
resolve(stdout.trim())
} else {
reject(new Error(stderr.trim() || `Binary exited with code ${code}`))
}
})
})
}
export function setupInstanceIPC(mainWindow: BrowserWindow) {
processManager.setMainWindow(mainWindow)
ipcMain.handle("dialog:selectFolder", async () => {
const result = await dialog.showOpenDialog(mainWindow!, {
title: "Select Project Folder",
properties: ["openDirectory"],
})
if (result.canceled || !result.filePaths.length) {
return null
}
return result.filePaths[0]
})
ipcMain.handle(
"instance:create",
async (event, id: string, folder: string, binaryPath?: string, environmentVariables?: Record<string, string>) => {
const instance: Instance = {
id,
folder,
port: 0,
pid: 0,
status: "starting",
}
instances.set(id, instance)
try {
const {
pid,
port,
binaryPath: actualBinaryPath,
} = await processManager.spawn(folder, id, binaryPath, environmentVariables)
instance.port = port
instance.pid = pid
instance.status = "ready"
mainWindow.webContents.send("instance:started", { id, port, pid, binaryPath: actualBinaryPath })
const meta = processManager.getAllProcesses().get(pid)
if (meta) {
meta.childProcess.on("exit", (code, signal) => {
instance.status = "stopped"
mainWindow.webContents.send("instance:stopped", { id })
})
}
return { id, port, pid, binaryPath: actualBinaryPath }
} catch (error) {
instance.status = "error"
instance.error = error instanceof Error ? error.message : String(error)
mainWindow.webContents.send("instance:error", {
id,
error: instance.error,
})
throw error
}
},
)
ipcMain.handle("instance:stop", async (event, pid: number) => {
await processManager.kill(pid)
for (const [id, instance] of instances.entries()) {
if (instance.pid === pid) {
instance.status = "stopped"
break
}
}
})
ipcMain.handle("instance:status", async (event, pid: number) => {
return processManager.getStatus(pid)
})
ipcMain.handle("instance:list", async () => {
return Array.from(instances.values())
})
ipcMain.handle("fs:scanDirectory", async (event, workspaceFolder: string) => {
const ig = ignore()
ig.add([".git", "node_modules"])
const gitignorePath = path.join(workspaceFolder, ".gitignore")
if (fs.existsSync(gitignorePath)) {
const content = fs.readFileSync(gitignorePath, "utf-8")
ig.add(content)
}
function scanDir(dirPath: string, baseDir: string): string[] {
const results: string[] = []
try {
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name)
const relativePath = path.relative(baseDir, fullPath)
if (ig.ignores(relativePath)) {
continue
}
if (entry.isDirectory()) {
const dirWithSlash = relativePath + "/"
if (!ig.ignores(dirWithSlash)) {
results.push(dirWithSlash)
const subFiles = scanDir(fullPath, baseDir)
results.push(...subFiles)
}
} else {
results.push(relativePath)
}
}
} catch (error) {
console.warn(`Error scanning ${dirPath}:`, error)
}
return results
}
return scanDir(workspaceFolder, workspaceFolder)
})
// OpenCode binary operations
ipcMain.handle("dialog:selectOpenCodeBinary", async () => {
const result = await dialog.showOpenDialog(mainWindow!, {
title: "Select OpenCode Binary",
filters: [
{ name: "Executable Files", extensions: ["exe", "cmd", "bat", "sh", "command", "app", ""] },
{ name: "All Files", extensions: ["*"] },
],
properties: ["openFile"],
})
if (result.canceled || !result.filePaths.length) {
return null
}
return result.filePaths[0]
})
ipcMain.handle("opencode:validateBinary", async (event, binaryPath: string) => {
try {
// Special handling for system PATH binary
const isSystemPath = binaryPath === "opencode"
if (!isSystemPath) {
// Check if file exists and is executable for custom paths
if (!fs.existsSync(binaryPath)) {
return { valid: false, error: "File does not exist" }
}
const stats = fs.statSync(binaryPath)
if (!stats.isFile()) {
return { valid: false, error: "Path is not a file" }
}
}
// Try to get version once via -v flag
try {
const version = await runBinaryVersion(binaryPath)
return { valid: true, version }
} catch (error) {
return {
valid: false,
error: error instanceof Error ? error.message : String(error),
}
}
} catch (error) {
return {
valid: false,
error: error instanceof Error ? error.message : String(error),
}
}
const filters = request.filters?.map((filter) => ({
name: filter.name ?? "Files",
extensions: filter.extensions,
}))
const windowTarget = mainWindow.isDestroyed() ? undefined : mainWindow
const dialogOptions: OpenDialogOptions = {
title: request.title,
defaultPath: request.defaultPath,
properties,
filters,
}
const result = windowTarget
? await dialog.showOpenDialog(windowTarget, dialogOptions)
: await dialog.showOpenDialog(dialogOptions)
return { canceled: result.canceled, paths: result.filePaths }
})
}

View File

@@ -1,30 +1,190 @@
import { app, BrowserWindow, dialog, ipcMain, nativeImage, nativeTheme, session } from "electron"
import { join } from "path"
import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron"
import { existsSync } from "fs"
import { dirname, join } from "path"
import { fileURLToPath } from "url"
import { createApplicationMenu } from "./menu"
import { setupInstanceIPC } from "./ipc"
import { setupStorageIPC } from "./storage"
import { setupCliIPC } from "./ipc"
import { CliProcessManager } from "./process-manager"
const mainFilename = fileURLToPath(import.meta.url)
const mainDirname = dirname(mainFilename)
const isMac = process.platform === "darwin"
const cliManager = new CliProcessManager()
let mainWindow: BrowserWindow | null = null
let currentCliUrl: string | null = null
let pendingCliUrl: string | null = null
let showingLoadingScreen = false
let preloadingView: BrowserView | null = null
if (isMac) {
app.commandLine.appendSwitch("disable-spell-checking")
}
// Setup IPC handlers before creating windows
setupStorageIPC()
let mainWindow: BrowserWindow | null = null
function getIconPath() {
if (app.isPackaged) {
return join(process.resourcesPath, "icon.png")
}
return join(app.getAppPath(), "electron/resources/icon.png")
return join(mainDirname, "../resources/icon.png")
}
type LoadingTarget =
| { type: "url"; source: string }
| { type: "file"; source: string }
function resolveDevLoadingUrl(): string | null {
if (app.isPackaged) {
return null
}
const devBase = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL
if (!devBase) {
return null
}
try {
const normalized = devBase.endsWith("/") ? devBase : `${devBase}/`
return new URL("loading.html", normalized).toString()
} catch (error) {
console.warn("[cli] failed to construct dev loading URL", devBase, error)
return null
}
}
function resolveLoadingTarget(): LoadingTarget {
const devUrl = resolveDevLoadingUrl()
if (devUrl) {
return { type: "url", source: devUrl }
}
const filePath = resolveLoadingFilePath()
return { type: "file", source: filePath }
}
function resolveLoadingFilePath() {
const candidates = [
join(app.getAppPath(), "dist/renderer/loading.html"),
join(process.resourcesPath, "dist/renderer/loading.html"),
join(mainDirname, "../dist/renderer/loading.html"),
]
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate
}
}
return join(app.getAppPath(), "dist/renderer/loading.html")
}
function loadLoadingScreen(window: BrowserWindow) {
const target = resolveLoadingTarget()
const loader =
target.type === "url"
? window.loadURL(target.source)
: window.loadFile(target.source)
loader.catch((error) => {
console.error("[cli] failed to load loading screen:", error)
})
}
function getAllowedRendererOrigins(): string[] {
const origins = new Set<string>()
const rendererCandidates = [currentCliUrl, process.env.VITE_DEV_SERVER_URL, process.env.ELECTRON_RENDERER_URL]
for (const candidate of rendererCandidates) {
if (!candidate) {
continue
}
try {
origins.add(new URL(candidate).origin)
} catch (error) {
console.warn("[cli] failed to parse origin for", candidate, error)
}
}
return Array.from(origins)
}
function shouldOpenExternally(url: string): boolean {
try {
const parsed = new URL(url)
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return true
}
const allowedOrigins = getAllowedRendererOrigins()
return !allowedOrigins.includes(parsed.origin)
} catch {
return false
}
}
function setupNavigationGuards(window: BrowserWindow) {
const handleExternal = (url: string) => {
shell.openExternal(url).catch((error) => console.error("[cli] failed to open external URL", url, error))
}
window.webContents.setWindowOpenHandler(({ url }) => {
if (shouldOpenExternally(url)) {
handleExternal(url)
return { action: "deny" }
}
return { action: "allow" }
})
window.webContents.on("will-navigate", (event, url) => {
if (shouldOpenExternally(url)) {
event.preventDefault()
handleExternal(url)
}
})
}
let cachedPreloadPath: string | null = null
function getPreloadPath() {
if (cachedPreloadPath && existsSync(cachedPreloadPath)) {
return cachedPreloadPath
}
const candidates = [
join(process.resourcesPath, "preload/index.js"),
join(mainDirname, "../preload/index.js"),
join(mainDirname, "../preload/index.cjs"),
join(mainDirname, "../../preload/index.cjs"),
join(mainDirname, "../../electron/preload/index.cjs"),
join(app.getAppPath(), "preload/index.cjs"),
join(app.getAppPath(), "electron/preload/index.cjs"),
]
for (const candidate of candidates) {
if (existsSync(candidate)) {
cachedPreloadPath = candidate
return candidate
}
}
return join(mainDirname, "../preload/index.js")
}
function destroyPreloadingView(target?: BrowserView | null) {
const view = target ?? preloadingView
if (!view) {
return
}
try {
const contents = view.webContents as any
contents?.destroy?.()
} catch (error) {
console.warn("[cli] failed to destroy preloading view", error)
}
if (!target || view === preloadingView) {
preloadingView = null
}
}
function createWindow() {
const prefersDark = true //nativeTheme.shouldUseDarkColors
const prefersDark = true
const backgroundColor = prefersDark ? "#1a1a1a" : "#ffffff"
const iconPath = getIconPath()
@@ -36,33 +196,146 @@ function createWindow() {
backgroundColor,
icon: iconPath,
webPreferences: {
preload: join(__dirname, "../preload/index.js"),
preload: getPreloadPath(),
contextIsolation: true,
nodeIntegration: false,
spellcheck: !isMac,
},
})
setupNavigationGuards(mainWindow)
if (isMac) {
// Disable macOS spell server to avoid input lag
mainWindow.webContents.session.setSpellCheckerEnabled(false)
}
showingLoadingScreen = true
currentCliUrl = null
loadLoadingScreen(mainWindow)
if (process.env.NODE_ENV === "development") {
mainWindow.loadURL("http://localhost:3000")
mainWindow.webContents.openDevTools()
} else {
mainWindow.loadFile(join(__dirname, "../renderer/index.html"))
mainWindow.webContents.openDevTools({ mode: "detach" })
}
createApplicationMenu(mainWindow)
setupInstanceIPC(mainWindow)
setupCliIPC(mainWindow, cliManager)
mainWindow.on("closed", () => {
destroyPreloadingView()
mainWindow = null
currentCliUrl = null
pendingCliUrl = null
showingLoadingScreen = false
})
if (pendingCliUrl) {
const url = pendingCliUrl
pendingCliUrl = null
startCliPreload(url)
}
}
function showLoadingScreen(force = false) {
if (!mainWindow || mainWindow.isDestroyed()) {
return
}
if (showingLoadingScreen && !force) {
return
}
destroyPreloadingView()
showingLoadingScreen = true
currentCliUrl = null
pendingCliUrl = null
loadLoadingScreen(mainWindow)
}
function startCliPreload(url: string) {
if (!mainWindow || mainWindow.isDestroyed()) {
pendingCliUrl = url
return
}
if (currentCliUrl === url && !showingLoadingScreen) {
return
}
pendingCliUrl = url
destroyPreloadingView()
if (!showingLoadingScreen) {
showLoadingScreen(true)
}
const view = new BrowserView({
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
spellcheck: !isMac,
},
})
preloadingView = view
view.webContents.once("did-finish-load", () => {
if (preloadingView !== view) {
destroyPreloadingView(view)
return
}
finalizeCliSwap(url)
})
view.webContents.loadURL(url).catch((error) => {
console.error("[cli] failed to preload CLI view:", error)
if (preloadingView === view) {
destroyPreloadingView(view)
}
})
}
function finalizeCliSwap(url: string) {
destroyPreloadingView()
if (!mainWindow || mainWindow.isDestroyed()) {
pendingCliUrl = url
return
}
showingLoadingScreen = false
currentCliUrl = url
pendingCliUrl = null
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
}
async function startCli() {
try {
const devMode = process.env.NODE_ENV === "development"
console.info("[cli] start requested (dev mode:", devMode, ")")
await cliManager.start({ dev: devMode })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error("[cli] start failed:", message)
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("cli:error", { message })
}
}
}
cliManager.on("ready", (status) => {
if (!status.url) {
return
}
startCliPreload(status.url)
})
cliManager.on("status", (status) => {
if (status.state !== "ready") {
showLoadingScreen()
}
})
if (isMac) {
app.on("web-contents-created", (_, contents) => {
contents.session.setSpellCheckerEnabled(false)
@@ -70,6 +343,8 @@ if (isMac) {
}
app.whenReady().then(() => {
startCli()
if (isMac) {
session.defaultSession.setSpellCheckerEnabled(false)
app.on("browser-window-created", (_, window) => {
@@ -84,8 +359,6 @@ app.whenReady().then(() => {
}
}
console.log("[spellcheck] default session enabled:", session.defaultSession.isSpellCheckerEnabled())
createWindow()
app.on("activate", () => {
@@ -95,6 +368,12 @@ app.whenReady().then(() => {
})
})
app.on("before-quit", async (event) => {
event.preventDefault()
await cliManager.stop().catch(() => {})
app.exit(0)
})
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit()

View File

@@ -1,218 +1,186 @@
import { spawn, execSync, ChildProcess } from "child_process"
import { app, BrowserWindow } from "electron"
import { existsSync, statSync } from "fs"
import { buildUserShellCommand, getUserShellEnv, runUserShellCommandSync, supportsUserShell } from "./user-shell"
import { spawn, type ChildProcess } from "child_process"
import { app } from "electron"
import { createRequire } from "module"
import { EventEmitter } from "events"
import { existsSync, readFileSync } from "fs"
import os from "os"
import path from "path"
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
export interface ProcessInfo {
pid: number
port: number
binaryPath: string
const nodeRequire = createRequire(import.meta.url)
type CliState = "starting" | "ready" | "error" | "stopped"
type ListeningMode = "local" | "all"
export interface CliStatus {
state: CliState
pid?: number
port?: number
url?: string
error?: string
}
interface ProcessMeta {
pid: number
port: number
folder: string
startTime: number
childProcess: ChildProcess
logs: string[]
instanceId: string
export interface CliLogEntry {
stream: "stdout" | "stderr"
message: string
}
class ProcessManager {
private processes = new Map<number, ProcessMeta>()
private mainWindow: BrowserWindow | null = null
interface StartOptions {
dev: boolean
}
setMainWindow(window: BrowserWindow) {
this.mainWindow = window
interface CliEntryResolution {
entry: string
runner: "node" | "tsx"
runnerPath?: string
}
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
function resolveConfigPath(configPath?: string): string {
const target = configPath && configPath.trim().length > 0 ? configPath : DEFAULT_CONFIG_PATH
if (target.startsWith("~/")) {
return path.join(os.homedir(), target.slice(2))
}
return path.resolve(target)
}
private parseLogLevel(message: string): "info" | "error" | "warn" | "debug" {
const upperMessage = message.toUpperCase()
if (upperMessage.includes("[ERROR]") || upperMessage.includes("ERROR:")) return "error"
if (upperMessage.includes("[WARN]") || upperMessage.includes("WARN:")) return "warn"
if (upperMessage.includes("[DEBUG]") || upperMessage.includes("DEBUG:")) return "debug"
if (upperMessage.includes("[INFO]") || upperMessage.includes("INFO:")) return "info"
return "info"
function resolveHostForMode(mode: ListeningMode): string {
return mode === "local" ? "127.0.0.1" : "0.0.0.0"
}
function readListeningModeFromConfig(): ListeningMode {
try {
const configPath = resolveConfigPath(process.env.CLI_CONFIG)
if (!existsSync(configPath)) return "local"
const content = readFileSync(configPath, "utf-8")
const parsed = JSON.parse(content)
const mode = parsed?.preferences?.listeningMode
if (mode === "local" || mode === "all") {
return mode
}
} catch (error) {
console.warn("[cli] failed to read listening mode from config", error)
}
return "local"
}
private sendLog(instanceId: string, level: "info" | "error" | "warn" | "debug", message: string) {
if (this.mainWindow && message.trim()) {
const parsedLevel = this.parseLogLevel(message)
this.mainWindow.webContents.send("instance:log", {
id: instanceId,
entry: {
timestamp: Date.now(),
level: parsedLevel,
message: message.trim(),
},
})
}
}
export declare interface CliProcessManager {
on(event: "status", listener: (status: CliStatus) => void): this
on(event: "ready", listener: (status: CliStatus) => void): this
on(event: "log", listener: (entry: CliLogEntry) => void): this
on(event: "exit", listener: (status: CliStatus) => void): this
on(event: "error", listener: (error: Error) => void): this
}
async spawn(
folder: string,
instanceId: string,
binaryPath?: string,
environmentVariables?: Record<string, string>,
): Promise<ProcessInfo> {
this.validateFolder(folder)
const useUserShell = supportsUserShell()
const logAttempt = (message: string) => {
console.info(`[ProcessManager] ${message}`)
this.sendLog(instanceId, "debug", message)
export class CliProcessManager extends EventEmitter {
private child?: ChildProcess
private status: CliStatus = { state: "stopped" }
private stdoutBuffer = ""
private stderrBuffer = ""
async start(options: StartOptions): Promise<CliStatus> {
if (this.child) {
await this.stop()
}
const env = useUserShell ? getUserShellEnv() : { ...process.env }
if (environmentVariables) {
Object.assign(env, environmentVariables)
this.sendLog(
instanceId,
"info",
`Using ${Object.keys(environmentVariables).length} custom environment variables:`,
)
this.stdoutBuffer = ""
this.stderrBuffer = ""
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
// Log each environment variable
for (const [key, value] of Object.entries(environmentVariables)) {
this.sendLog(instanceId, "info", ` ${key}=${value}`)
}
}
const cliEntry = this.resolveCliEntry(options)
const listeningMode = this.resolveListeningMode()
const host = resolveHostForMode(listeningMode)
const args = this.buildCliArgs(options, host)
let targetBinary: string
if (!binaryPath || binaryPath === "opencode") {
targetBinary = useUserShell ? "opencode" : this.validateOpenCodeBinary(logAttempt)
} else {
targetBinary = this.validateCustomBinary(binaryPath, logAttempt)
}
const spawnCommand = useUserShell
? this.buildShellServeCommand(targetBinary)
: { command: targetBinary, args: this.buildServeArgs() }
const launchDetail = `${spawnCommand.command} ${spawnCommand.args.join(" ")}`.trim()
this.sendLog(instanceId, "debug", `Launching process with: ${launchDetail}`)
this.sendLog(
instanceId,
"info",
`Starting OpenCode server for ${folder} using ${targetBinary}...`,
console.info(
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
)
return new Promise((resolve, reject) => {
const child = spawn(spawnCommand.command, spawnCommand.args, {
cwd: folder,
stdio: ["ignore", "pipe", "pipe"],
env,
shell: false,
})
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
env.ELECTRON_RUN_AS_NODE = "1"
const spawnDetails = supportsUserShell()
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
: this.buildDirectSpawn(cliEntry, args)
const child = spawn(spawnDetails.command, spawnDetails.args, {
cwd: process.cwd(),
stdio: ["ignore", "pipe", "pipe"],
env,
shell: false,
})
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
if (!child.pid) {
console.error("[cli] spawn failed: no pid")
}
this.child = child
this.updateStatus({ pid: child.pid ?? undefined })
child.stdout?.on("data", (data: Buffer) => {
this.handleStream(data.toString(), "stdout")
})
child.stderr?.on("data", (data: Buffer) => {
this.handleStream(data.toString(), "stderr")
})
child.on("error", (error) => {
console.error("[cli] failed to start CLI:", error)
this.updateStatus({ state: "error", error: error.message })
this.emit("error", error)
})
child.on("exit", (code, signal) => {
const failed = this.status.state !== "ready"
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined
console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`)
this.updateStatus({ state: failed ? "error" : "stopped", error })
if (failed && error) {
this.emit("error", new Error(error))
}
this.emit("exit", this.status)
this.child = undefined
})
return new Promise<CliStatus>((resolve, reject) => {
const timeout = setTimeout(() => {
child.kill("SIGKILL")
this.sendLog(instanceId, "error", "Server startup timeout (10s exceeded)")
reject(new Error("Server startup timeout (10s exceeded)"))
}, 10000)
this.handleTimeout()
reject(new Error("CLI startup timeout"))
}, 60000)
let stdoutBuffer = ""
let stderrBuffer = ""
let portFound = false
child.stdout?.on("data", (data: Buffer) => {
const text = data.toString()
stdoutBuffer += text
const lines = stdoutBuffer.split("\n")
stdoutBuffer = lines.pop() || ""
for (const line of lines) {
if (!line.trim()) continue
this.sendLog(instanceId, "info", line)
const portMatch = line.match(/opencode server listening on http:\/\/[^:]+:(\d+)/)
if (portMatch && !portFound) {
portFound = true
const port = parseInt(portMatch[1], 10)
clearTimeout(timeout)
const meta: ProcessMeta = {
pid: child.pid!,
port,
folder,
startTime: Date.now(),
childProcess: child,
logs: [line],
instanceId,
}
this.processes.set(child.pid!, meta)
resolve({ pid: child.pid!, port, binaryPath: targetBinary })
}
const meta = this.processes.get(child.pid!)
if (meta) {
meta.logs.push(line)
}
}
})
child.stderr?.on("data", (data: Buffer) => {
const text = data.toString()
stderrBuffer += text
const lines = stderrBuffer.split("\n")
stderrBuffer = lines.pop() || ""
for (const line of lines) {
if (!line.trim()) continue
this.sendLog(instanceId, "error", line)
const meta = this.processes.get(child.pid!)
if (meta) {
meta.logs.push(line)
}
}
})
child.on("error", (error) => {
this.once("ready", (status) => {
clearTimeout(timeout)
if (error.message.includes("ENOENT")) {
reject(new Error("opencode binary not found in PATH"))
} else {
reject(error)
}
resolve(status)
})
child.on("exit", (code, signal) => {
this.once("error", (error) => {
clearTimeout(timeout)
this.processes.delete(child.pid!)
if (!portFound) {
const errorMsg = stderrBuffer || `Process exited with code ${code}`
reject(new Error(errorMsg))
}
reject(error)
})
})
}
async kill(pid: number): Promise<void> {
const meta = this.processes.get(pid)
if (!meta) {
// Treat unknown processes as already stopped so tabs close cleanly
async stop(): Promise<void> {
const child = this.child
if (!child) {
this.updateStatus({ state: "stopped" })
return
}
return new Promise((resolve, reject) => {
const child = meta.childProcess
return new Promise((resolve) => {
const killTimeout = setTimeout(() => {
child.kill("SIGKILL")
}, 2000)
}, 4000)
child.on("exit", () => {
clearTimeout(killTimeout)
this.processes.delete(pid)
this.child = undefined
console.info("[cli] CLI process exited")
this.updateStatus({ state: "stopped" })
resolve()
})
@@ -220,134 +188,177 @@ class ProcessManager {
})
}
getStatus(pid: number): "running" | "stopped" | "unknown" {
if (!this.processes.has(pid)) {
return "unknown"
}
getStatus(): CliStatus {
return { ...this.status }
}
try {
process.kill(pid, 0)
return "running"
} catch {
return "stopped"
private resolveListeningMode(): ListeningMode {
return readListeningModeFromConfig()
}
private handleTimeout() {
if (this.child) {
this.child.kill("SIGKILL")
this.child = undefined
}
this.updateStatus({ state: "error", error: "CLI did not start in time" })
this.emit("error", new Error("CLI did not start in time"))
}
private handleStream(chunk: string, stream: "stdout" | "stderr") {
if (stream === "stdout") {
this.stdoutBuffer += chunk
this.processBuffer("stdout")
} else {
this.stderrBuffer += chunk
this.processBuffer("stderr")
}
}
getAllProcesses(): Map<number, ProcessMeta> {
return new Map(this.processes)
}
private processBuffer(stream: "stdout" | "stderr") {
const buffer = stream === "stdout" ? this.stdoutBuffer : this.stderrBuffer
const lines = buffer.split("\n")
const trailing = lines.pop() ?? ""
async cleanup(): Promise<void> {
const killPromises = Array.from(this.processes.keys()).map((pid) => this.kill(pid).catch(() => {}))
await Promise.all(killPromises)
}
private validateFolder(folder: string): void {
if (!existsSync(folder)) {
throw new Error(`Folder does not exist: ${folder}`)
if (stream === "stdout") {
this.stdoutBuffer = trailing
} else {
this.stderrBuffer = trailing
}
const stats = statSync(folder)
if (!stats.isDirectory()) {
throw new Error(`Path is not a directory: ${folder}`)
}
}
for (const line of lines) {
if (!line.trim()) continue
console.info(`[cli][${stream}] ${line}`)
this.emit("log", { stream, message: line })
private validateOpenCodeBinary(logAttempt?: (message: string) => void): string {
const log = logAttempt ?? ((message: string) => console.info(`[ProcessManager] ${message}`))
if (process.platform === "win32") {
log("Checking PATH via 'where opencode'")
return this.resolveBinaryViaLocator("where opencode", log)
}
const shellCheck = buildUserShellCommand("command -v opencode")
const shellPreview = [shellCheck.command, ...shellCheck.args].join(" ")
log(`Checking PATH via shell: ${shellPreview}`)
try {
const resolved = runUserShellCommandSync("command -v opencode")
const path = this.pickFirstPath(resolved)
if (path) {
log(`Shell located opencode at ${path}`)
return path
}
throw new Error("Empty result from shell lookup")
} catch (shellError) {
const message = shellError instanceof Error ? shellError.message : String(shellError)
log(`Shell lookup failed: ${message}`)
try {
log("Fallback to 'which opencode'")
return this.resolveBinaryViaLocator("which opencode", log)
} catch (locatorError) {
const locatorMessage = locatorError instanceof Error ? locatorError.message : String(locatorError)
log(`Locator fallback failed: ${locatorMessage}`)
throw new Error(
"opencode binary not found in PATH. Please install OpenCode CLI first: npm install -g @opencode/cli",
)
const port = this.extractPort(line)
if (port && this.status.state === "starting") {
const url = `http://127.0.0.1:${port}`
console.info(`[cli] ready on ${url}`)
this.updateStatus({ state: "ready", port, url })
this.emit("ready", this.status)
}
}
}
private validateCustomBinary(binaryPath: string, log?: (message: string) => void): string {
log?.(`Validating custom binary at ${binaryPath}`)
if (!existsSync(binaryPath)) {
throw new Error(`OpenCode binary not found: ${binaryPath}`)
private extractPort(line: string): number | null {
const readyMatch = line.match(/CodeNomad Server is ready at http:\/\/[^:]+:(\d+)/i)
if (readyMatch) {
return parseInt(readyMatch[1], 10)
}
const stats = statSync(binaryPath)
if (!stats.isFile()) {
throw new Error(`Path is not a file: ${binaryPath}`)
}
// Check if executable (on Unix systems)
if (process.platform !== "win32") {
if (line.toLowerCase().includes("http server listening")) {
const httpMatch = line.match(/:(\d{2,5})(?!.*:\d)/)
if (httpMatch) {
return parseInt(httpMatch[1], 10)
}
try {
execSync(`test -x "${binaryPath}"`, { stdio: "pipe" })
const parsed = JSON.parse(line)
if (typeof parsed.port === "number") {
return parsed.port
}
} catch {
throw new Error(`Binary is not executable: ${binaryPath}`)
// not JSON, ignore
}
}
return binaryPath
return null
}
private resolveBinaryViaLocator(command: string, log?: (message: string) => void): string {
log?.(`Running locator command: ${command}`)
const output = execSync(command, { stdio: "pipe", encoding: "utf-8" })
log?.(`Locator output: ${output.trim() || "<empty>"}`)
const path = this.pickFirstPath(output)
if (!path) {
throw new Error("opencode binary not found in PATH")
private updateStatus(patch: Partial<CliStatus>) {
this.status = { ...this.status, ...patch }
this.emit("status", this.status)
}
private buildCliArgs(options: StartOptions, host: string): string[] {
const args = ["serve", "--host", host, "--port", "0"]
if (options.dev) {
args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug")
}
return path
return args
}
private pickFirstPath(output: string): string | null {
const line = output
.split("\n")
.map((entry) => entry.trim())
.find((entry) => entry.length > 0)
return line ?? null
private buildCommand(cliEntry: CliEntryResolution, args: string[]): string {
const parts = [JSON.stringify(process.execPath)]
if (cliEntry.runner === "tsx" && cliEntry.runnerPath) {
parts.push(JSON.stringify(cliEntry.runnerPath))
}
parts.push(JSON.stringify(cliEntry.entry))
args.forEach((arg) => parts.push(JSON.stringify(arg)))
return parts.join(" ")
}
private buildServeArgs(): string[] {
return ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"]
private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
if (cliEntry.runner === "tsx") {
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
}
return { command: process.execPath, args: [cliEntry.entry, ...args] }
}
private buildShellServeCommand(binaryPath: string): { command: string; args: string[] } {
const args = this.buildServeArgs()
.map((arg) => JSON.stringify(arg))
.join(" ")
return buildUserShellCommand(`exec ${JSON.stringify(binaryPath)} ${args}`)
private resolveCliEntry(options: StartOptions): CliEntryResolution {
if (options.dev) {
const tsxPath = this.resolveTsx()
if (!tsxPath) {
throw new Error("tsx is required to run the CLI in development mode. Please install dependencies.")
}
const devEntry = this.resolveDevEntry()
return { entry: devEntry, runner: "tsx", runnerPath: tsxPath }
}
const distEntry = this.resolveProdEntry()
return { entry: distEntry, runner: "node" }
}
private resolveTsx(): string | null {
const candidates: Array<string | (() => string)> = [
() => nodeRequire.resolve("tsx/cli"),
() => nodeRequire.resolve("tsx/dist/cli.mjs"),
() => nodeRequire.resolve("tsx/dist/cli.cjs"),
path.resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.mjs"),
path.resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.cjs"),
path.resolve(process.cwd(), "..", "node_modules", "tsx", "dist", "cli.mjs"),
path.resolve(process.cwd(), "..", "node_modules", "tsx", "dist", "cli.cjs"),
path.resolve(process.cwd(), "..", "..", "node_modules", "tsx", "dist", "cli.mjs"),
path.resolve(process.cwd(), "..", "..", "node_modules", "tsx", "dist", "cli.cjs"),
path.resolve(app.getAppPath(), "..", "node_modules", "tsx", "dist", "cli.mjs"),
path.resolve(app.getAppPath(), "..", "node_modules", "tsx", "dist", "cli.cjs"),
]
for (const candidate of candidates) {
try {
const resolved = typeof candidate === "function" ? candidate() : candidate
if (resolved && existsSync(resolved)) {
return resolved
}
} catch {
continue
}
}
return null
}
private resolveDevEntry(): string {
const entry = path.resolve(process.cwd(), "..", "server", "src", "index.ts")
if (!existsSync(entry)) {
throw new Error(`Dev CLI entry not found at ${entry}. Run npm run dev:electron from the repository root after installing dependencies.`)
}
return entry
}
private resolveProdEntry(): string {
try {
const entry = nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js")
if (existsSync(entry)) {
return entry
}
} catch {
// fall through to error below
}
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
}
}
export const processManager = new ProcessManager()
app.on("before-quit", async (event) => {
event.preventDefault()
await processManager.cleanup()
app.exit(0)
})

View File

@@ -59,7 +59,7 @@ export function setupStorageIPC() {
return await readConfigWithCache()
} catch (error) {
// Return empty config if file doesn't exist
return JSON.stringify({ preferences: { showThinkingBlocks: false }, recentFolders: [] }, null, 2)
return JSON.stringify({ preferences: { showThinkingBlocks: false, thinkingBlocksExpansion: "expanded" }, recentFolders: [] }, null, 2)
}
})

View File

@@ -0,0 +1,17 @@
const { contextBridge, ipcRenderer } = require("electron")
const electronAPI = {
onCliStatus: (callback) => {
ipcRenderer.on("cli:status", (_, data) => callback(data))
return () => ipcRenderer.removeAllListeners("cli:status")
},
onCliError: (callback) => {
ipcRenderer.on("cli:error", (_, data) => callback(data))
return () => ipcRenderer.removeAllListeners("cli:error")
},
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
restartCli: () => ipcRenderer.invoke("cli:restart"),
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
}
contextBridge.exposeInMainWorld("electronAPI", electronAPI)

View File

@@ -1,49 +0,0 @@
import { contextBridge, ipcRenderer } from "electron"
import type { ElectronAPI } from "../../../ui/src/types/electron-api"
const electronAPI: ElectronAPI = {
selectFolder: () => ipcRenderer.invoke("dialog:selectFolder"),
createInstance: (id: string, folder: string, binaryPath?: string, environmentVariables?: Record<string, string>) =>
ipcRenderer.invoke("instance:create", id, folder, binaryPath, environmentVariables),
stopInstance: (pid: number) => ipcRenderer.invoke("instance:stop", pid),
onInstanceStarted: (callback) => {
ipcRenderer.on("instance:started", (_, data) => callback(data))
},
onInstanceError: (callback) => {
ipcRenderer.on("instance:error", (_, data) => callback(data))
},
onInstanceStopped: (callback) => {
ipcRenderer.on("instance:stopped", (_, data) => callback(data))
},
onInstanceLog: (callback) => {
ipcRenderer.on("instance:log", (_, data) => callback(data))
},
onNewInstance: (callback) => {
ipcRenderer.on("menu:newInstance", () => callback())
},
scanDirectory: (workspaceFolder: string) => ipcRenderer.invoke("fs:scanDirectory", workspaceFolder),
// OpenCode binary operations
selectOpenCodeBinary: () => ipcRenderer.invoke("dialog:selectOpenCodeBinary"),
validateOpenCodeBinary: (path: string) => ipcRenderer.invoke("opencode:validateBinary", path),
// Storage operations
getConfigPath: () => ipcRenderer.invoke("storage:getConfigPath"),
getInstancesDir: () => ipcRenderer.invoke("storage:getInstancesDir"),
readConfigFile: () => ipcRenderer.invoke("storage:readConfigFile"),
writeConfigFile: (content: string) => ipcRenderer.invoke("storage:writeConfigFile", content),
readInstanceFile: (filename: string) => ipcRenderer.invoke("storage:readInstanceFile", filename),
writeInstanceFile: (filename: string, content: string) =>
ipcRenderer.invoke("storage:writeInstanceFile", filename, content),
deleteInstanceFile: (filename: string) => ipcRenderer.invoke("storage:deleteInstanceFile", filename),
onConfigChanged: (callback: () => void) => {
ipcRenderer.on("storage:configChanged", () => callback())
return () => ipcRenderer.removeAllListeners("storage:configChanged")
},
}
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
declare global {
interface Window {
electronAPI: ElectronAPI
}
}

View File

@@ -1,16 +1,21 @@
{
"name": "@codenomad/electron-app",
"version": "0.1.2",
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.3.0",
"description": "CodeNomad - AI coding assistant",
"author": {
"name": "Shantur Rathore",
"email": "codenomad@shantur.com"
"name": "Neural Nomads",
"email": "codenomad@neuralnomads.ai"
},
"type": "module",
"main": "dist/main/main.js",
"repository": {
"type": "git",
"url": "https://github.com/NeuralNomadsAI/CodeNomad.git"
},
"homepage": "https://github.com/NeuralNomadsAI/CodeNomad",
"scripts": {
"dev": "electron-vite dev",
"dev:electron": "NODE_ENV=development electron .",
"dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
"build": "electron-vite build",
"typecheck": "tsc --noEmit -p tsconfig.json",
"preview": "electron-vite preview",
@@ -29,8 +34,8 @@
"package:linux": "electron-builder --linux"
},
"dependencies": {
"@codenomad/ui": "file:../ui",
"ignore": "7.0.5"
"@neuralnomads/codenomad": "file:../server",
"@codenomad/ui": "file:../ui"
},
"devDependencies": {
"7zip-bin": "^5.2.0",
@@ -40,6 +45,7 @@
"electron-vite": "4.0.1",
"png2icons": "^2.0.1",
"pngjs": "^7.0.0",
"tsx": "^4.20.6",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vite-plugin-solid": "^2.10.0"
@@ -55,39 +61,55 @@
"dist/**/*",
"package.json"
],
"extraResources": [
{
"from": "electron/resources",
"to": "",
"filter": [
"!icon.icns",
"!icon.ico"
]
}
],
"mac": {
"category": "public.app-category.developer-tools",
"target": [
{
"target": "dmg",
"arch": ["x64", "arm64", "universal"]
},
{
"target": "zip",
"arch": ["x64", "arm64", "universal"]
"arch": [
"x64",
"arm64"
]
}
],
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
"icon": "electron/resources/icon.icns"
},
"dmg": {
"contents": [
{ "x": 130, "y": 220 },
{ "x": 410, "y": 220, "type": "link", "path": "/Applications" }
{
"x": 130,
"y": 220
},
{
"x": 410,
"y": 220,
"type": "link",
"path": "/Applications"
}
]
},
"win": {
"target": [
{
"target": "nsis",
"arch": ["x64", "arm64"]
},
{
"target": "zip",
"arch": ["x64", "arm64"]
"arch": [
"x64",
"arm64"
]
}
],
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
"icon": "electron/resources/icon.ico"
},
"nsis": {
@@ -99,23 +121,14 @@
"linux": {
"target": [
{
"target": "AppImage",
"arch": ["x64", "arm64"]
},
{
"target": "deb",
"arch": ["x64", "arm64"]
},
{
"target": "rpm",
"arch": ["x64", "arm64"]
},
{
"target": "tar.gz",
"arch": ["x64", "arm64"]
"target": "zip",
"arch": [
"x64",
"arm64"
]
}
],
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
"category": "Development",
"icon": "electron/resources/icon.png"
}

View File

@@ -7,15 +7,17 @@ import { fileURLToPath } from "url"
const __dirname = fileURLToPath(new URL(".", import.meta.url))
const appDir = join(__dirname, "..")
const workspaceRoot = join(appDir, "..", "..")
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"
const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx"
const nodeModulesPath = join(appDir, "node_modules")
const workspaceNodeModulesPath = join(workspaceRoot, "node_modules")
const platforms = {
mac: {
args: ["--mac", "--x64", "--arm64", "--universal"],
description: "macOS (Intel, Apple Silicon, Universal)",
args: ["--mac", "--x64", "--arm64"],
description: "macOS (Intel & Apple Silicon)",
},
"mac-x64": {
args: ["--mac", "--x64"],
@@ -93,10 +95,16 @@ async function build(platform) {
console.log(`\n🔨 Building for: ${config.description}\n`)
try {
console.log("📦 Step 1/2: Building Electron app...\n")
console.log("📦 Step 1/3: Building CLI dependency...\n")
await run(npmCmd, ["run", "build", "--workspace", "@neuralnomads/codenomad"], {
cwd: workspaceRoot,
env: { NODE_PATH: workspaceNodeModulesPath },
})
console.log("\n📦 Step 2/3: Building Electron app...\n")
await run(npmCmd, ["run", "build"])
console.log("\n📦 Step 2/2: Packaging binaries...\n")
console.log("\n📦 Step 3/3: Packaging binaries...\n")
const distPath = join(appDir, "dist")
if (!existsSync(distPath)) {
throw new Error("dist/ directory not found. Build failed.")

58
packages/server/README.md Normal file
View File

@@ -0,0 +1,58 @@
# CodeNomad Server
**CodeNomad Server** is the high-performance engine behind the CodeNomad cockpit. It transforms your machine into a robust development host, managing the lifecycle of multiple OpenCode instances and providing the low-latency data streams that long-haul builders demand. It bridges your local filesystem with the UI, ensuring that whether you are on localhost or a remote tunnel, you have the speed, clarity, and control of a native workspace.
## Features & Capabilities
### 🌍 Deployment Freedom
- **Remote Access**: Host CodeNomad on a powerful workstation and access it from your lightweight laptop.
- **Code Anywhere**: Tunnel in via VPN or SSH to code securely from coffee shops or while traveling.
- **Multi-Device**: The responsive web client works on tablets and iPads, turning any screen into a dev terminal.
- **Always-On**: Run as a background service so your sessions are always ready when you connect.
### ⚡️ Workspace Power
- **Multi-Instance**: Juggle multiple OpenCode sessions side-by-side with per-instance tabs.
- **Long-Context Native**: Scroll through massive transcripts without hitches.
- **Deep Task Awareness**: Monitor background tasks and child sessions without losing your flow.
- **Command Palette**: A single, global palette to jump tabs, launch tools, and fire shortcuts.
## Prerequisites
- **OpenCode**: `opencode` must be installed and configured on your system.
- Node.js 18+ and npm (for running or building from source).
- A workspace folder on disk you want to serve.
- Optional: a Chromium-based browser if you want `--launch` to open the UI automatically.
## Usage
### Run via npx (Recommended)
You can run CodeNomad directly without installing it:
```sh
npx @neuralnomads/codenomad --launch
```
### Install Globally
Or install it globally to use the `codenomad` command:
```sh
npm install -g @neuralnomads/codenomad
codenomad --launch
```
### Common Flags
You can configure the server using flags or environment variables:
| Flag | Env Variable | Description |
|------|--------------|-------------|
| `--port <number>` | `CLI_PORT` | HTTP port (default 9898) |
| `--host <addr>` | `CLI_HOST` | Interface to bind (default 127.0.0.1) |
| `--workspace-root <path>` | `CLI_WORKSPACE_ROOT` | Default root for new workspaces |
| `--unrestricted-root` | `CLI_UNRESTRICTED_ROOT` | Allow full-filesystem browsing |
| `--config <path>` | `CLI_CONFIG` | Config file location |
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
| `--log-level <level>` | `CLI_LOG_LEVEL` | Logging level (trace, debug, info, warn, error) |
### Data Storage
- **Config**: `~/.config/codenomad/config.json`
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)

View File

@@ -1,12 +1,12 @@
{
"name": "@codenomad/cli",
"version": "0.1.0",
"name": "@neuralnomads/codenomad",
"version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@codenomad/cli",
"version": "0.1.0",
"name": "@neuralnomads/codenomad",
"version": "0.3.0",
"dependencies": {
"@fastify/cors": "^8.5.0",
"commander": "^12.1.0",

View File

@@ -1,11 +1,19 @@
{
"name": "@codenomad/cli",
"version": "0.1.0",
"description": "CodeNomad CLI server for HTTP/SSE control plane",
"name": "@neuralnomads/codenomad",
"version": "0.3.0",
"description": "CodeNomad Server",
"author": {
"name": "Neural Nomads",
"email": "codenomad@neuralnomads.ai"
},
"repository": {
"type": "git",
"url": "https://github.com/NeuralNomadsAI/CodeNomad.git"
},
"type": "module",
"main": "dist/index.js",
"bin": {
"codenomad-cli": "dist/bin.js"
"codenomad": "dist/bin.js"
},
"scripts": {
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json",
@@ -20,6 +28,7 @@
"@fastify/static": "^7.0.4",
"commander": "^12.1.0",
"fastify": "^4.28.1",
"fuzzysort": "^2.0.4",
"pino": "^9.4.0",
"undici": "^6.19.8",
"zod": "^3.23.8"

View File

@@ -1,4 +1,5 @@
import type {
AgentModelSelection,
AgentModelSelections,
ConfigFile,
ModelPreference,
@@ -103,8 +104,19 @@ export interface WorkspaceFileResponse {
contents: string
}
export type WorkspaceFileSearchResponse = FileSystemEntry[]
export interface InstanceData {
messageHistory: string[]
agentModelSelections: AgentModelSelection
}
export type InstanceStreamStatus = "connecting" | "connected" | "error" | "disconnected"
export interface InstanceStreamEvent {
type: string
properties?: Record<string, unknown>
[key: string]: unknown
}
export interface BinaryRecord {
@@ -112,6 +124,7 @@ export interface BinaryRecord {
path: string
label: string
version?: string
/** Indicates that this binary will be picked when workspaces omit an explicit choice. */
isDefault: boolean
lastValidatedAt?: string
@@ -151,6 +164,10 @@ export type WorkspaceEventType =
| "workspace.log"
| "config.appChanged"
| "config.binariesChanged"
| "instance.dataChanged"
| "instance.event"
| "instance.eventStatus"
| "app.releaseAvailable"
export type WorkspaceEventPayload =
| { type: "workspace.created"; workspace: WorkspaceDescriptor }
@@ -160,16 +177,46 @@ export type WorkspaceEventPayload =
| { type: "workspace.log"; entry: WorkspaceLogEntry }
| { type: "config.appChanged"; config: AppConfig }
| { type: "config.binariesChanged"; binaries: BinaryRecord[] }
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
| { type: "app.releaseAvailable"; release: LatestReleaseInfo }
export interface NetworkAddress {
ip: string
family: "ipv4" | "ipv6"
scope: "external" | "internal" | "loopback"
url: string
}
export interface LatestReleaseInfo {
version: string
tag: string
url: string
channel: "stable" | "dev"
publishedAt?: string
notes?: string
}
export interface ServerMeta {
/** Base URL clients should target for REST calls (useful for Electron embedding). */
httpBaseUrl: string
/** SSE endpoint advertised to clients (`/api/events` by default). */
eventsUrl: string
/** Host the server is bound to (e.g., 127.0.0.1 or 0.0.0.0). */
host: string
/** Listening mode derived from host binding. */
listeningMode: "local" | "all"
/** Actual port in use after binding. */
port: number
/** Display label for the host (e.g., hostname or friendly name). */
hostLabel: string
/** Absolute path of the filesystem root exposed to clients. */
workspaceRoot: string
/** Reachable addresses for this server, external first. */
addresses: NetworkAddress[]
/** Optional metadata about the most recent public release. */
latestRelease?: LatestReleaseInfo
}
export type {

View File

@@ -6,7 +6,7 @@ import {
} from "../api-types"
import { ConfigStore } from "./store"
import { EventBus } from "../events/bus"
import type { ConfigFileUpdate } from "./schema"
import type { ConfigFile } from "./schema"
import { Logger } from "../logger"
export class BinaryRegistry {
@@ -39,17 +39,15 @@ export class BinaryRegistry {
}
const config = this.configStore.get()
const deduped = config.opencodeBinaries.filter((binary) => binary.path !== request.path)
const update: ConfigFileUpdate = {
opencodeBinaries: [entry, ...deduped],
}
const nextConfig = this.cloneConfig(config)
const deduped = nextConfig.opencodeBinaries.filter((binary) => binary.path !== request.path)
nextConfig.opencodeBinaries = [entry, ...deduped]
if (request.makeDefault) {
update.preferences = { lastUsedBinary: request.path }
nextConfig.preferences.lastUsedBinary = request.path
}
this.configStore.update(update)
this.configStore.replace(nextConfig)
const record = this.getById(request.path)
this.emitChange()
return record
@@ -58,19 +56,16 @@ export class BinaryRegistry {
update(id: string, updates: BinaryUpdateRequest): BinaryRecord {
this.logger.debug({ id }, "Updating OpenCode binary")
const config = this.configStore.get()
const updatedEntries = config.opencodeBinaries.map((binary) =>
const nextConfig = this.cloneConfig(config)
nextConfig.opencodeBinaries = nextConfig.opencodeBinaries.map((binary) =>
binary.path === id ? { ...binary, label: updates.label ?? binary.label } : binary,
)
const update: ConfigFileUpdate = {
opencodeBinaries: updatedEntries,
}
if (updates.makeDefault) {
update.preferences = { lastUsedBinary: id }
nextConfig.preferences.lastUsedBinary = id
}
this.configStore.update(update)
this.configStore.replace(nextConfig)
const record = this.getById(id)
this.emitChange()
return record
@@ -79,14 +74,15 @@ export class BinaryRegistry {
remove(id: string) {
this.logger.debug({ id }, "Removing OpenCode binary")
const config = this.configStore.get()
const remaining = config.opencodeBinaries.filter((binary) => binary.path !== id)
const update: ConfigFileUpdate = { opencodeBinaries: remaining }
const nextConfig = this.cloneConfig(config)
const remaining = nextConfig.opencodeBinaries.filter((binary) => binary.path !== id)
nextConfig.opencodeBinaries = remaining
if (config.preferences.lastUsedBinary === id) {
update.preferences = { lastUsedBinary: remaining[0]?.path }
if (nextConfig.preferences.lastUsedBinary === id) {
nextConfig.preferences.lastUsedBinary = remaining[0]?.path
}
this.configStore.update(update)
this.configStore.replace(nextConfig)
this.emitChange()
}
@@ -100,7 +96,12 @@ export class BinaryRegistry {
})
}
private cloneConfig(config: ConfigFile): ConfigFile {
return JSON.parse(JSON.stringify(config)) as ConfigFile
}
private mapRecords(): BinaryRecord[] {
const config = this.configStore.get()
const configuredBinaries = config.opencodeBinaries.map<BinaryRecord>((binary) => ({
id: binary.path,

View File

@@ -10,24 +10,17 @@ const AgentModelSelectionsSchema = z.record(z.string(), AgentModelSelectionSchem
const PreferencesSchema = z.object({
showThinkingBlocks: z.boolean().default(false),
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
showTimelineTools: z.boolean().default(true),
lastUsedBinary: z.string().optional(),
environmentVariables: z.record(z.string()).default({}),
modelRecents: z.array(ModelPreferenceSchema).default([]),
agentModelSelections: AgentModelSelectionsSchema.default({}),
diffViewMode: z.enum(["split", "unified"]).default("split"),
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
})
const PreferencesUpdateSchema = z.object({
showThinkingBlocks: z.boolean().optional(),
lastUsedBinary: z.string().optional(),
environmentVariables: z.record(z.string()).optional(),
modelRecents: z.array(ModelPreferenceSchema).optional(),
agentModelSelections: AgentModelSelectionsSchema.optional(),
diffViewMode: z.enum(["split", "unified"]).optional(),
toolOutputExpansion: z.enum(["expanded", "collapsed"]).optional(),
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).optional(),
showUsageMetrics: z.boolean().default(true),
autoCleanupBlankSessions: z.boolean().default(true),
listeningMode: z.enum(["local", "all"]).default("local"),
})
const RecentFolderSchema = z.object({
@@ -49,13 +42,6 @@ const ConfigFileSchema = z.object({
theme: z.enum(["light", "dark", "system"]).optional(),
})
const ConfigFileUpdateSchema = z.object({
preferences: PreferencesUpdateSchema.optional(),
recentFolders: z.array(RecentFolderSchema).optional(),
opencodeBinaries: z.array(OpenCodeBinarySchema).optional(),
theme: z.enum(["light", "dark", "system"]).optional(),
})
const DEFAULT_CONFIG = ConfigFileSchema.parse({})
export {
@@ -66,7 +52,6 @@ export {
RecentFolderSchema,
OpenCodeBinarySchema,
ConfigFileSchema,
ConfigFileUpdateSchema,
DEFAULT_CONFIG,
}
@@ -77,4 +62,3 @@ export type Preferences = z.infer<typeof PreferencesSchema>
export type RecentFolder = z.infer<typeof RecentFolderSchema>
export type OpenCodeBinary = z.infer<typeof OpenCodeBinarySchema>
export type ConfigFile = z.infer<typeof ConfigFileSchema>
export type ConfigFileUpdate = z.infer<typeof ConfigFileUpdateSchema>

View File

@@ -2,14 +2,7 @@ import fs from "fs"
import path from "path"
import { EventBus } from "../events/bus"
import { Logger } from "../logger"
import {
AgentModelSelections,
ConfigFile,
ConfigFileUpdate,
ConfigFileSchema,
ConfigFileUpdateSchema,
DEFAULT_CONFIG,
} from "./schema"
import { ConfigFile, ConfigFileSchema, DEFAULT_CONFIG } from "./schema"
export class ConfigStore {
private cache: ConfigFile = DEFAULT_CONFIG
@@ -50,54 +43,19 @@ export class ConfigStore {
return this.load()
}
update(partial: ConfigFile | ConfigFileUpdate) {
const safePartial =
"recentFolders" in partial && "opencodeBinaries" in partial
? ConfigFileSchema.parse(partial)
: ConfigFileUpdateSchema.parse(partial ?? {})
const merged = this.mergeConfig(this.load(), safePartial)
this.cache = ConfigFileSchema.parse(merged)
replace(config: ConfigFile) {
const validated = ConfigFileSchema.parse(config)
this.commit(validated)
}
private commit(next: ConfigFile) {
this.cache = next
this.loaded = true
this.persist()
const published = Boolean(this.eventBus)
this.eventBus?.publish({ type: "config.appChanged", config: this.cache })
this.logger.debug("Config updated")
}
private mergeConfig(current: ConfigFile, partial: ConfigFile | ConfigFileUpdate): ConfigFile {
const mergedPreferences = {
...current.preferences,
...partial.preferences,
environmentVariables: {
...current.preferences.environmentVariables,
...(partial.preferences?.environmentVariables ?? {}),
},
agentModelSelections: this.mergeAgentSelections(
current.preferences.agentModelSelections,
partial.preferences?.agentModelSelections,
),
}
return {
...current,
...partial,
preferences: mergedPreferences,
recentFolders: partial.recentFolders ?? current.recentFolders,
opencodeBinaries: partial.opencodeBinaries ?? current.opencodeBinaries,
}
}
private mergeAgentSelections(base: AgentModelSelections, update?: AgentModelSelections) {
if (!update) {
return base
}
const result: AgentModelSelections = { ...base }
for (const [instanceId, agentMap] of Object.entries(update)) {
result[instanceId] = {
...(base[instanceId] ?? {}),
...agentMap,
}
}
return result
this.logger.debug({ broadcast: published }, "Config SSE event emitted")
this.logger.trace({ config: this.cache }, "Config payload")
}
private persist() {

View File

@@ -8,7 +8,12 @@ export class EventBus extends EventEmitter {
}
publish(event: WorkspaceEventPayload): boolean {
this.logger?.debug({ event }, "Publishing workspace event")
if (event.type !== "instance.event" && event.type !== "instance.eventStatus") {
this.logger?.debug({ type: event.type }, "Publishing workspace event")
if (this.logger?.isLevelEnabled("trace")) {
this.logger.trace({ event }, "Workspace event payload")
}
}
return super.emit(event.type, event)
}
@@ -21,6 +26,10 @@ export class EventBus extends EventEmitter {
this.on("workspace.log", handler)
this.on("config.appChanged", handler)
this.on("config.binariesChanged", handler)
this.on("instance.dataChanged", handler)
this.on("instance.event", handler)
this.on("instance.eventStatus", handler)
this.on("app.releaseAvailable", handler)
return () => {
this.off("workspace.created", handler)
this.off("workspace.started", handler)
@@ -29,6 +38,10 @@ export class EventBus extends EventEmitter {
this.off("workspace.log", handler)
this.off("config.appChanged", handler)
this.off("config.binariesChanged", handler)
this.off("instance.dataChanged", handler)
this.off("instance.event", handler)
this.off("instance.eventStatus", handler)
this.off("app.releaseAvailable", handler)
}
}
}

View File

@@ -0,0 +1,61 @@
import assert from "node:assert/strict"
import { beforeEach, describe, it } from "node:test"
import type { FileSystemEntry } from "../../api-types"
import {
clearWorkspaceSearchCache,
getWorkspaceCandidates,
refreshWorkspaceCandidates,
WORKSPACE_CANDIDATE_CACHE_TTL_MS,
} from "../search-cache"
describe("workspace search cache", () => {
beforeEach(() => {
clearWorkspaceSearchCache()
})
it("expires cached candidates after the TTL", () => {
const workspacePath = "/tmp/workspace"
const startTime = 1_000
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], startTime)
const beforeExpiry = getWorkspaceCandidates(
workspacePath,
startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS - 1,
)
assert.ok(beforeExpiry)
assert.equal(beforeExpiry.length, 1)
assert.equal(beforeExpiry[0].name, "file-a")
const afterExpiry = getWorkspaceCandidates(
workspacePath,
startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS + 1,
)
assert.equal(afterExpiry, undefined)
})
it("replaces cached entries when manually refreshed", () => {
const workspacePath = "/tmp/workspace"
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], 5_000)
const initial = getWorkspaceCandidates(workspacePath)
assert.ok(initial)
assert.equal(initial[0].name, "file-a")
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-b")], 6_000)
const refreshed = getWorkspaceCandidates(workspacePath)
assert.ok(refreshed)
assert.equal(refreshed[0].name, "file-b")
})
})
function createEntry(name: string): FileSystemEntry {
return {
name,
path: name,
absolutePath: `/tmp/${name}`,
type: "file",
size: 1,
modifiedAt: new Date().toISOString(),
}
}

View File

@@ -0,0 +1,66 @@
import path from "path"
import type { FileSystemEntry } from "../api-types"
export const WORKSPACE_CANDIDATE_CACHE_TTL_MS = 30_000
interface WorkspaceCandidateCacheEntry {
expiresAt: number
candidates: FileSystemEntry[]
}
const workspaceCandidateCache = new Map<string, WorkspaceCandidateCacheEntry>()
export function getWorkspaceCandidates(rootDir: string, now = Date.now()): FileSystemEntry[] | undefined {
const key = normalizeKey(rootDir)
const cached = workspaceCandidateCache.get(key)
if (!cached) {
return undefined
}
if (cached.expiresAt <= now) {
workspaceCandidateCache.delete(key)
return undefined
}
return cloneEntries(cached.candidates)
}
export function refreshWorkspaceCandidates(
rootDir: string,
builder: () => FileSystemEntry[],
now = Date.now(),
): FileSystemEntry[] {
const key = normalizeKey(rootDir)
const freshCandidates = builder()
if (!freshCandidates || freshCandidates.length === 0) {
workspaceCandidateCache.delete(key)
return []
}
const storedCandidates = cloneEntries(freshCandidates)
workspaceCandidateCache.set(key, {
expiresAt: now + WORKSPACE_CANDIDATE_CACHE_TTL_MS,
candidates: storedCandidates,
})
return cloneEntries(storedCandidates)
}
export function clearWorkspaceSearchCache(rootDir?: string) {
if (typeof rootDir === "undefined") {
workspaceCandidateCache.clear()
return
}
const key = normalizeKey(rootDir)
workspaceCandidateCache.delete(key)
}
function cloneEntries(entries: FileSystemEntry[]): FileSystemEntry[] {
return entries.map((entry) => ({ ...entry }))
}
function normalizeKey(rootDir: string) {
return path.resolve(rootDir)
}

View File

@@ -0,0 +1,184 @@
import fs from "fs"
import path from "path"
import fuzzysort from "fuzzysort"
import type { FileSystemEntry } from "../api-types"
import { clearWorkspaceSearchCache, getWorkspaceCandidates, refreshWorkspaceCandidates } from "./search-cache"
const DEFAULT_LIMIT = 100
const MAX_LIMIT = 200
const MAX_CANDIDATES = 8000
const IGNORED_DIRECTORIES = new Set(
[".git", ".hg", ".svn", "node_modules", "dist", "build", ".next", ".nuxt", ".turbo", ".cache", "coverage"].map(
(name) => name.toLowerCase(),
),
)
export type WorkspaceFileSearchType = "all" | "file" | "directory"
export interface WorkspaceFileSearchOptions {
limit?: number
type?: WorkspaceFileSearchType
refresh?: boolean
}
interface CandidateEntry {
entry: FileSystemEntry
key: string
}
export function searchWorkspaceFiles(
rootDir: string,
query: string,
options: WorkspaceFileSearchOptions = {},
): FileSystemEntry[] {
const trimmedQuery = query.trim()
if (!trimmedQuery) {
throw new Error("Search query is required")
}
const normalizedRoot = path.resolve(rootDir)
const limit = normalizeLimit(options.limit)
const typeFilter: WorkspaceFileSearchType = options.type ?? "all"
const refreshRequested = options.refresh === true
let entries: FileSystemEntry[] | undefined
try {
if (!refreshRequested) {
entries = getWorkspaceCandidates(normalizedRoot)
}
if (!entries) {
entries = refreshWorkspaceCandidates(normalizedRoot, () => collectCandidates(normalizedRoot))
}
} catch (error) {
clearWorkspaceSearchCache(normalizedRoot)
throw error
}
if (!entries || entries.length === 0) {
clearWorkspaceSearchCache(normalizedRoot)
return []
}
const candidates = buildCandidateEntries(entries, typeFilter)
if (candidates.length === 0) {
return []
}
const matches = fuzzysort.go<CandidateEntry>(trimmedQuery, candidates, {
key: "key",
limit,
})
if (!matches || matches.length === 0) {
return []
}
return matches.map((match) => match.obj.entry)
}
function collectCandidates(rootDir: string): FileSystemEntry[] {
const queue: string[] = [""]
const entries: FileSystemEntry[] = []
while (queue.length > 0 && entries.length < MAX_CANDIDATES) {
const relativeDir = queue.pop() || ""
const absoluteDir = relativeDir ? path.join(rootDir, relativeDir) : rootDir
let dirents: fs.Dirent[]
try {
dirents = fs.readdirSync(absoluteDir, { withFileTypes: true })
} catch {
continue
}
for (const dirent of dirents) {
const entryName = dirent.name
const lowerName = entryName.toLowerCase()
const relativePath = relativeDir ? `${relativeDir}/${entryName}` : entryName
const absolutePath = path.join(absoluteDir, entryName)
if (dirent.isDirectory() && IGNORED_DIRECTORIES.has(lowerName)) {
continue
}
let stats: fs.Stats
try {
stats = fs.statSync(absolutePath)
} catch {
continue
}
const isDirectory = stats.isDirectory()
if (isDirectory && !IGNORED_DIRECTORIES.has(lowerName)) {
if (entries.length < MAX_CANDIDATES) {
queue.push(relativePath)
}
}
const entryType: FileSystemEntry["type"] = isDirectory ? "directory" : "file"
const normalizedPath = normalizeRelativeEntryPath(relativePath)
const entry: FileSystemEntry = {
name: entryName,
path: normalizedPath,
absolutePath: path.resolve(rootDir, normalizedPath === "." ? "" : normalizedPath),
type: entryType,
size: entryType === "file" ? stats.size : undefined,
modifiedAt: stats.mtime.toISOString(),
}
entries.push(entry)
if (entries.length >= MAX_CANDIDATES) {
break
}
}
}
return entries
}
function buildCandidateEntries(entries: FileSystemEntry[], filter: WorkspaceFileSearchType): CandidateEntry[] {
const filtered: CandidateEntry[] = []
for (const entry of entries) {
if (!shouldInclude(entry.type, filter)) {
continue
}
filtered.push({ entry, key: buildSearchKey(entry) })
}
return filtered
}
function normalizeLimit(limit?: number) {
if (!limit || Number.isNaN(limit)) {
return DEFAULT_LIMIT
}
const clamped = Math.min(Math.max(limit, 1), MAX_LIMIT)
return clamped
}
function shouldInclude(entryType: FileSystemEntry["type"], filter: WorkspaceFileSearchType) {
return filter === "all" || entryType === filter
}
function normalizeRelativeEntryPath(relativePath: string): string {
if (!relativePath) {
return "."
}
let normalized = relativePath.replace(/\\+/g, "/")
if (normalized.startsWith("./")) {
normalized = normalized.replace(/^\.\/+/, "")
}
if (normalized.startsWith("/")) {
normalized = normalized.replace(/^\/+/g, "")
}
return normalized || "."
}
function buildSearchKey(entry: FileSystemEntry) {
return entry.path.toLowerCase()
}

View File

@@ -14,9 +14,13 @@ import { FileSystemBrowser } from "./filesystem/browser"
import { EventBus } from "./events/bus"
import { ServerMeta } from "./api-types"
import { InstanceStore } from "./storage/instance-store"
import { InstanceEventBridge } from "./workspaces/instance-events"
import { createLogger } from "./logger"
import { launchInBrowser } from "./launcher"
import { startReleaseMonitor } from "./releases/release-monitor"
const require = createRequire(import.meta.url)
const packageJson = require("../package.json") as { version: string }
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
@@ -32,6 +36,7 @@ interface CliOptions {
logDestination?: string
uiStaticDir: string
uiDevServer?: string
launch: boolean
}
const DEFAULT_PORT = 9898
@@ -40,7 +45,7 @@ const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
function parseCliOptions(argv: string[]): CliOptions {
const program = new Command()
.name("codenomad-cli")
.name("codenomad")
.description("CodeNomad CLI server")
.version(packageJson.version, "-v, --version", "Show the CLI version")
.addOption(new Option("--host <host>", "Host interface to bind").env("CLI_HOST").default(DEFAULT_HOST))
@@ -57,6 +62,7 @@ function parseCliOptions(argv: string[]): CliOptions {
new Option("--ui-dir <path>", "Directory containing the built UI bundle").env("CLI_UI_DIR").default(DEFAULT_UI_STATIC_DIR),
)
.addOption(new Option("--ui-dev-server <url>", "Proxy UI requests to a running dev server").env("CLI_UI_DEV_SERVER"))
.addOption(new Option("--launch", "Launch the UI in a browser after start").env("CLI_LAUNCH").default(false))
program.parse(argv, { from: "user" })
const parsed = program.opts<{
@@ -70,13 +76,16 @@ function parseCliOptions(argv: string[]): CliOptions {
logDestination?: string
uiDir: string
uiDevServer?: string
launch?: boolean
}>()
const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
const normalizedHost = resolveHost(parsed.host)
return {
port: parsed.port,
host: parsed.host,
host: normalizedHost,
rootDir: resolvedRoot,
configPath: parsed.config,
unrestrictedRoot: Boolean(parsed.unrestrictedRoot),
@@ -84,17 +93,25 @@ function parseCliOptions(argv: string[]): CliOptions {
logDestination: parsed.logDestination,
uiStaticDir: parsed.uiDir,
uiDevServer: parsed.uiDevServer,
launch: Boolean(parsed.launch),
}
}
function parsePort(input: string): number {
const value = Number(input)
if (!Number.isInteger(value) || value < 1 || value > 65535) {
throw new InvalidArgumentError("Port must be an integer between 1 and 65535")
if (!Number.isInteger(value) || value < 0 || value > 65535) {
throw new InvalidArgumentError("Port must be an integer between 0 and 65535")
}
return value
}
function resolveHost(input: string | undefined): string {
if (input && input.trim() === "0.0.0.0") {
return "0.0.0.0"
}
return DEFAULT_HOST
}
async function main() {
const options = parseCliOptions(process.argv.slice(2))
const logger = createLogger({ level: options.logLevel, destination: options.logDestination, component: "app" })
@@ -116,14 +133,36 @@ async function main() {
})
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
const instanceStore = new InstanceStore()
const instanceEventBridge = new InstanceEventBridge({
workspaceManager,
eventBus,
logger: logger.child({ component: "instance-events" }),
})
const serverMeta: ServerMeta = {
httpBaseUrl: `http://${options.host}:${options.port}`,
eventsUrl: `/api/events`,
host: options.host,
listeningMode: options.host === "0.0.0.0" ? "all" : "local",
port: options.port,
hostLabel: options.host,
workspaceRoot: options.rootDir,
addresses: [],
}
const releaseMonitor = startReleaseMonitor({
currentVersion: packageJson.version,
logger: logger.child({ component: "release-monitor" }),
onUpdate: (release) => {
if (release) {
serverMeta.latestRelease = release
eventBus.publish({ type: "app.releaseAvailable", release })
} else {
delete serverMeta.latestRelease
}
},
})
const server = createHttpServer({
host: options.host,
port: options.port,
@@ -139,11 +178,13 @@ async function main() {
logger,
})
const startInfo = await server.start()
logger.info({ port: startInfo.port, host: options.host }, "HTTP server listening")
console.log(`CodeNomad Server is ready at ${startInfo.url}`)
await server.start()
logger.info({ port: options.port, host: options.host }, "HTTP server listening")
const displayHost = options.host === "127.0.0.1" || options.host === "0.0.0.0" ? "localhost" : options.host
console.log(`CodeNomad Server is ready at http://${displayHost}:${options.port}`)
if (options.launch) {
await launchInBrowser(startInfo.url, logger.child({ component: "launcher" }))
}
let shuttingDown = false
@@ -162,12 +203,15 @@ async function main() {
}
try {
instanceEventBridge.shutdown()
await workspaceManager.shutdown()
logger.info("Workspace manager shutdown complete")
} catch (error) {
logger.error({ err: error }, "Workspace manager shutdown failed")
}
releaseMonitor.stop()
logger.info("Exiting process")
process.exit(0)
}

View File

@@ -0,0 +1,177 @@
import { spawn } from "child_process"
import os from "os"
import path from "path"
import type { Logger } from "./logger"
interface BrowserCandidate {
name: string
command: string
args: (url: string) => string[]
}
const APP_ARGS = (url: string) => [`--app=${url}`, "--new-window"]
export async function launchInBrowser(url: string, logger: Logger): Promise<boolean> {
const { platform, candidates, manualExamples } = buildPlatformCandidates(url)
console.log(`Attempting to launch browser (${platform}) using:`)
candidates.forEach((candidate) => console.log(` - ${candidate.name}: ${candidate.command}`))
for (const candidate of candidates) {
const success = await tryLaunch(candidate, url, logger)
if (success) {
return true
}
}
console.error(
"No supported browser found to launch. Run without --launch and use one of the commands below or install a compatible browser.",
)
if (manualExamples.length > 0) {
console.error("Manual launch commands:")
manualExamples.forEach((line) => console.error(` ${line}`))
}
return false
}
async function tryLaunch(candidate: BrowserCandidate, url: string, logger: Logger): Promise<boolean> {
return new Promise((resolve) => {
let resolved = false
try {
const args = candidate.args(url)
const child = spawn(candidate.command, args, { stdio: "ignore", detached: true })
child.once("error", (error) => {
if (resolved) return
resolved = true
logger.debug({ err: error, candidate: candidate.name, command: candidate.command, args }, "Browser launch failed")
resolve(false)
})
child.once("spawn", () => {
if (resolved) return
resolved = true
logger.info(
{
browser: candidate.name,
command: candidate.command,
args,
fullCommand: [candidate.command, ...args].join(" "),
},
"Launched browser in app mode",
)
child.unref()
resolve(true)
})
} catch (error) {
if (resolved) return
resolved = true
logger.debug({ err: error, candidate: candidate.name, command: candidate.command }, "Browser spawn threw")
resolve(false)
}
})
}
function buildPlatformCandidates(url: string) {
switch (os.platform()) {
case "darwin":
return {
platform: "macOS",
candidates: buildMacCandidates(),
manualExamples: buildMacManualExamples(url),
}
case "win32":
return {
platform: "Windows",
candidates: buildWindowsCandidates(),
manualExamples: buildWindowsManualExamples(url),
}
default:
return {
platform: "Linux",
candidates: buildLinuxCandidates(),
manualExamples: buildLinuxManualExamples(url),
}
}
}
function buildMacCandidates(): BrowserCandidate[] {
const apps = [
{ name: "Google Chrome", path: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" },
{ name: "Google Chrome Canary", path: "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary" },
{ name: "Microsoft Edge", path: "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" },
{ name: "Brave Browser", path: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" },
{ name: "Chromium", path: "/Applications/Chromium.app/Contents/MacOS/Chromium" },
{ name: "Vivaldi", path: "/Applications/Vivaldi.app/Contents/MacOS/Vivaldi" },
{ name: "Arc", path: "/Applications/Arc.app/Contents/MacOS/Arc" },
]
return apps.map((entry) => ({ name: entry.name, command: entry.path, args: APP_ARGS }))
}
function buildWindowsCandidates(): BrowserCandidate[] {
const programFiles = process.env["ProgramFiles"]
const programFilesX86 = process.env["ProgramFiles(x86)"]
const localAppData = process.env["LocalAppData"]
const paths = [
[programFiles, "Google/Chrome/Application/chrome.exe", "Google Chrome"],
[programFilesX86, "Google/Chrome/Application/chrome.exe", "Google Chrome (x86)"],
[localAppData, "Google/Chrome/Application/chrome.exe", "Google Chrome (User)"],
[programFiles, "Microsoft/Edge/Application/msedge.exe", "Microsoft Edge"],
[programFilesX86, "Microsoft/Edge/Application/msedge.exe", "Microsoft Edge (x86)"],
[localAppData, "Microsoft/Edge/Application/msedge.exe", "Microsoft Edge (User)"],
[programFiles, "BraveSoftware/Brave-Browser/Application/brave.exe", "Brave"],
[localAppData, "BraveSoftware/Brave-Browser/Application/brave.exe", "Brave (User)"],
[programFiles, "Chromium/Application/chromium.exe", "Chromium"],
] as const
return paths
.filter(([root]) => Boolean(root))
.map(([root, rel, name]) => ({
name,
command: path.join(root as string, rel),
args: APP_ARGS,
}))
}
function buildLinuxCandidates(): BrowserCandidate[] {
const names = [
"google-chrome",
"google-chrome-stable",
"chromium",
"chromium-browser",
"brave-browser",
"microsoft-edge",
"microsoft-edge-stable",
"vivaldi",
]
return names.map((name) => ({ name, command: name, args: APP_ARGS }))
}
function buildMacManualExamples(url: string) {
return [
`"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --app="${url}" --new-window`,
`"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" --app="${url}" --new-window`,
`"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" --app="${url}" --new-window`,
]
}
function buildWindowsManualExamples(url: string) {
return [
`"%ProgramFiles%\\Google\\Chrome\\Application\\chrome.exe" --app="${url}" --new-window`,
`"%ProgramFiles%\\Microsoft\\Edge\\Application\\msedge.exe" --app="${url}" --new-window`,
`"%ProgramFiles%\\BraveSoftware\\Brave-Browser\\Application\\brave.exe" --app="${url}" --new-window`,
]
}
function buildLinuxManualExamples(url: string) {
return [
`google-chrome --app="${url}" --new-window`,
`chromium --app="${url}" --new-window`,
`brave-browser --app="${url}" --new-window`,
`microsoft-edge --app="${url}" --new-window`,
]
}

View File

@@ -0,0 +1,141 @@
import { fetch } from "undici"
import type { LatestReleaseInfo } from "../api-types"
import type { Logger } from "../logger"
const RELEASES_API_URL = "https://api.github.com/repos/NeuralNomadsAI/CodeNomad/releases/latest"
interface ReleaseMonitorOptions {
currentVersion: string
logger: Logger
onUpdate: (release: LatestReleaseInfo | null) => void
}
interface GithubReleaseResponse {
tag_name?: string
name?: string
html_url?: string
body?: string
published_at?: string
created_at?: string
prerelease?: boolean
}
interface NormalizedVersion {
major: number
minor: number
patch: number
prerelease: string | null
}
export interface ReleaseMonitor {
stop(): void
}
export function startReleaseMonitor(options: ReleaseMonitorOptions): ReleaseMonitor {
let stopped = false
const refreshRelease = async () => {
if (stopped) return
try {
const release = await fetchLatestRelease(options)
options.onUpdate(release)
} catch (error) {
options.logger.warn({ err: error }, "Failed to refresh release information")
}
}
void refreshRelease()
return {
stop() {
stopped = true
},
}
}
async function fetchLatestRelease(options: ReleaseMonitorOptions): Promise<LatestReleaseInfo | null> {
const response = await fetch(RELEASES_API_URL, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "CodeNomad-CLI",
},
})
if (!response.ok) {
throw new Error(`Release API responded with ${response.status}`)
}
const json = (await response.json()) as GithubReleaseResponse
const tagFromServer = json.tag_name || json.name
if (!tagFromServer) {
return null
}
const normalizedVersion = stripTagPrefix(tagFromServer)
if (!normalizedVersion) {
return null
}
const current = parseVersion(options.currentVersion)
const remote = parseVersion(normalizedVersion)
if (compareVersions(remote, current) <= 0) {
return null
}
return {
version: normalizedVersion,
tag: tagFromServer,
url: json.html_url ?? `https://github.com/NeuralNomadsAI/CodeNomad/releases/tag/${encodeURIComponent(tagFromServer)}`,
channel: json.prerelease || normalizedVersion.includes("-") ? "dev" : "stable",
publishedAt: json.published_at ?? json.created_at,
notes: json.body,
}
}
function stripTagPrefix(tag: string | undefined): string | null {
if (!tag) return null
const trimmed = tag.trim()
if (!trimmed) return null
return trimmed.replace(/^v/i, "")
}
function parseVersion(value: string): NormalizedVersion {
const normalized = stripTagPrefix(value) ?? "0.0.0"
const [core, prerelease = null] = normalized.split("-", 2)
const [major = 0, minor = 0, patch = 0] = core.split(".").map((segment) => {
const parsed = Number.parseInt(segment, 10)
return Number.isFinite(parsed) ? parsed : 0
})
return {
major,
minor,
patch,
prerelease,
}
}
function compareVersions(a: NormalizedVersion, b: NormalizedVersion): number {
if (a.major !== b.major) {
return a.major > b.major ? 1 : -1
}
if (a.minor !== b.minor) {
return a.minor > b.minor ? 1 : -1
}
if (a.patch !== b.patch) {
return a.patch > b.patch ? 1 : -1
}
const aPre = a.prerelease && a.prerelease.length > 0 ? a.prerelease : null
const bPre = b.prerelease && b.prerelease.length > 0 ? b.prerelease : null
if (aPre === bPre) {
return 0
}
if (!aPre) {
return 1
}
if (!bPre) {
return -1
}
return aPre.localeCompare(bPre)
}

View File

@@ -1,7 +1,7 @@
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify"
import cors from "@fastify/cors"
import fastifyStatic from "@fastify/static"
import replyFrom, { type FastifyReplyFromOptions } from "@fastify/reply-from"
import replyFrom from "@fastify/reply-from"
import fs from "fs"
import path from "path"
import { fetch } from "undici"
@@ -36,10 +36,19 @@ interface HttpServerDeps {
logger: Logger
}
interface HttpServerStartResult {
port: number
url: string
displayHost: string
}
const DEFAULT_HTTP_PORT = 9898
export function createHttpServer(deps: HttpServerDeps) {
const app = Fastify({ logger: false })
const proxyLogger = deps.logger.child({ component: "proxy" })
const apiLogger = deps.logger.child({ component: "http" })
const sseLogger = deps.logger.child({ component: "sse" })
const sseClients = new Set<() => void>()
const registerSseClient = (cleanup: () => void) => {
@@ -53,6 +62,29 @@ export function createHttpServer(deps: HttpServerDeps) {
sseClients.clear()
}
app.addHook("onRequest", (request, _reply, done) => {
;(request as FastifyRequest & { __logMeta?: { start: bigint } }).__logMeta = {
start: process.hrtime.bigint(),
}
done()
})
app.addHook("onResponse", (request, reply, done) => {
const meta = (request as FastifyRequest & { __logMeta?: { start: bigint } }).__logMeta
const durationMs = meta ? Number((process.hrtime.bigint() - meta.start) / BigInt(1_000_000)) : undefined
const base = {
method: request.method,
url: request.url,
status: reply.statusCode,
durationMs,
}
apiLogger.debug(base, "HTTP request completed")
if (apiLogger.isLevelEnabled("trace")) {
apiLogger.trace({ ...base, params: request.params, query: request.query, body: request.body }, "HTTP request payload")
}
done()
})
app.register(cors, {
origin: true,
credentials: true,
@@ -60,16 +92,27 @@ export function createHttpServer(deps: HttpServerDeps) {
app.register(replyFrom, {
contentTypesToEncode: [],
undici: {
connections: 16,
pipelining: 1,
bodyTimeout: 0,
headersTimeout: 0,
},
})
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient })
registerStorageRoutes(app, { instanceStore: deps.instanceStore })
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
registerStorageRoutes(app, {
instanceStore: deps.instanceStore,
eventBus: deps.eventBus,
workspaceManager: deps.workspaceManager,
})
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
if (deps.uiDevServerUrl) {
setupDevProxy(app, deps.uiDevServerUrl)
} else {
@@ -78,7 +121,61 @@ export function createHttpServer(deps: HttpServerDeps) {
return {
instance: app,
start: () => app.listen({ port: deps.port, host: deps.host }),
start: async (): Promise<HttpServerStartResult> => {
const attemptListen = async (requestedPort: number) => {
const addressInfo = await app.listen({ port: requestedPort, host: deps.host })
return { addressInfo, requestedPort }
}
const autoPortRequested = deps.port === 0
const primaryPort = autoPortRequested ? DEFAULT_HTTP_PORT : deps.port
const shouldRetryWithEphemeral = (error: unknown) => {
if (!autoPortRequested) return false
const err = error as NodeJS.ErrnoException | undefined
return Boolean(err && err.code === "EADDRINUSE")
}
let listenResult
try {
listenResult = await attemptListen(primaryPort)
} catch (error) {
if (!shouldRetryWithEphemeral(error)) {
throw error
}
deps.logger.warn({ err: error, port: primaryPort }, "Preferred port unavailable, retrying on ephemeral port")
listenResult = await attemptListen(0)
}
let actualPort = listenResult.requestedPort
if (typeof listenResult.addressInfo === "string") {
try {
const parsed = new URL(listenResult.addressInfo)
actualPort = Number(parsed.port) || listenResult.requestedPort
} catch {
actualPort = listenResult.requestedPort
}
} else {
const address = app.server.address()
if (typeof address === "object" && address) {
actualPort = address.port
}
}
const displayHost = deps.host === "0.0.0.0" ? "127.0.0.1" : deps.host === "127.0.0.1" ? "localhost" : deps.host
const serverUrl = `http://${displayHost}:${actualPort}`
deps.serverMeta.httpBaseUrl = serverUrl
deps.serverMeta.host = deps.host
deps.serverMeta.port = actualPort
deps.serverMeta.listeningMode = deps.host === "0.0.0.0" ? "all" : "local"
deps.logger.info({ port: actualPort, host: deps.host }, "HTTP server listening")
console.log(`CodeNomad Server is ready at ${serverUrl}`)
return { port: actualPort, url: serverUrl, displayHost }
},
stop: () => {
closeSseClients()
return app.close()
@@ -153,6 +250,11 @@ async function proxyWorkspaceRequest(args: {
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
logger.debug({ workspaceId, method: request.method, targetUrl }, "Proxying request to instance")
if (logger.isLevelEnabled("trace")) {
logger.trace({ workspaceId, targetUrl, body: request.body }, "Instance proxy payload")
}
return reply.from(targetUrl, {
onError: (proxyReply, { error }) => {
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")

View File

@@ -2,7 +2,7 @@ import { FastifyInstance } from "fastify"
import { z } from "zod"
import { ConfigStore } from "../../config/store"
import { BinaryRegistry } from "../../config/binaries"
import { ConfigFileSchema, ConfigFileUpdateSchema } from "../../config/schema"
import { ConfigFileSchema } from "../../config/schema"
interface RouteDeps {
configStore: ConfigStore
@@ -29,13 +29,7 @@ export function registerConfigRoutes(app: FastifyInstance, deps: RouteDeps) {
app.put("/api/config/app", async (request) => {
const body = ConfigFileSchema.parse(request.body ?? {})
deps.configStore.update(body)
return deps.configStore.get()
})
app.patch("/api/config/app", async (request) => {
const body = ConfigFileUpdateSchema.parse(request.body ?? {})
deps.configStore.update(body)
deps.configStore.replace(body)
return deps.configStore.get()
})

View File

@@ -1,14 +1,21 @@
import { FastifyInstance } from "fastify"
import { EventBus } from "../../events/bus"
import { WorkspaceEventPayload } from "../../api-types"
import { Logger } from "../../logger"
interface RouteDeps {
eventBus: EventBus
registerClient: (cleanup: () => void) => () => void
logger: Logger
}
let nextClientId = 0
export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/events", (request, reply) => {
const clientId = ++nextClientId
deps.logger.debug({ clientId }, "SSE client connected")
const origin = request.headers.origin ?? "*"
reply.raw.setHeader("Access-Control-Allow-Origin", origin)
reply.raw.setHeader("Access-Control-Allow-Credentials", "true")
@@ -19,6 +26,10 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
reply.hijack()
const send = (event: WorkspaceEventPayload) => {
deps.logger.debug({ clientId, type: event.type }, "SSE event dispatched")
if (deps.logger.isLevelEnabled("trace")) {
deps.logger.trace({ clientId, event }, "SSE event payload")
}
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`)
}
@@ -34,6 +45,7 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
clearInterval(heartbeat)
unsubscribe()
reply.raw.end?.()
deps.logger.debug({ clientId }, "SSE client disconnected")
}
const unregister = deps.registerClient(close)

View File

@@ -0,0 +1,104 @@
import { FastifyInstance } from "fastify"
import os from "os"
import { NetworkAddress, ServerMeta } from "../../api-types"
interface RouteDeps {
serverMeta: ServerMeta
}
export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/meta", async () => buildMetaResponse(deps.serverMeta))
}
function buildMetaResponse(meta: ServerMeta): ServerMeta {
const port = resolvePort(meta)
const addresses = port > 0 ? resolveAddresses(port, meta.host) : []
return {
...meta,
port,
listeningMode: meta.host === "0.0.0.0" ? "all" : "local",
addresses,
}
}
function resolvePort(meta: ServerMeta): number {
if (Number.isInteger(meta.port) && meta.port > 0) {
return meta.port
}
try {
const parsed = new URL(meta.httpBaseUrl)
const port = Number(parsed.port)
return Number.isInteger(port) && port > 0 ? port : 0
} catch {
return 0
}
}
function resolveAddresses(port: number, host: string): NetworkAddress[] {
const interfaces = os.networkInterfaces()
const seen = new Set<string>()
const results: NetworkAddress[] = []
const addAddress = (ip: string, scope: NetworkAddress["scope"]) => {
if (!ip || ip === "0.0.0.0") return
const key = `ipv4-${ip}`
if (seen.has(key)) return
seen.add(key)
results.push({ ip, family: "ipv4", scope, url: `http://${ip}:${port}` })
}
const normalizeFamily = (value: string | number) => {
if (typeof value === "string") {
const lowered = value.toLowerCase()
if (lowered === "ipv4") {
return "ipv4" as const
}
}
if (value === 4) return "ipv4" as const
return null
}
if (host === "0.0.0.0") {
// Enumerate system interfaces (IPv4 only)
for (const entries of Object.values(interfaces)) {
if (!entries) continue
for (const entry of entries) {
const family = normalizeFamily(entry.family)
if (!family) continue
if (!entry.address || entry.address === "0.0.0.0") continue
const scope: NetworkAddress["scope"] = entry.internal ? "loopback" : "external"
addAddress(entry.address, scope)
}
}
}
// Always include loopback address
addAddress("127.0.0.1", "loopback")
// Include explicitly configured host if it was IPv4
if (isIPv4Address(host) && host !== "0.0.0.0") {
const isLoopback = host.startsWith("127.")
addAddress(host, isLoopback ? "loopback" : "external")
}
const scopeWeight: Record<NetworkAddress["scope"], number> = { external: 0, internal: 1, loopback: 2 }
return results.sort((a, b) => {
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
if (scopeDelta !== 0) return scopeDelta
return a.ip.localeCompare(b.ip)
})
}
function isIPv4Address(value: string | undefined): value is string {
if (!value) return false
const parts = value.split(".")
if (parts.length !== 4) return false
return parts.every((part) => {
if (part.length === 0 || part.length > 3) return false
if (!/^[0-9]+$/.test(part)) return false
const num = Number(part)
return Number.isInteger(num) && num >= 0 && num <= 255
})
}

View File

@@ -1,19 +1,37 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import { InstanceStore } from "../../storage/instance-store"
import { EventBus } from "../../events/bus"
import { ModelPreferenceSchema } from "../../config/schema"
import type { InstanceData } from "../../api-types"
import { WorkspaceManager } from "../../workspaces/manager"
interface RouteDeps {
instanceStore: InstanceStore
eventBus: EventBus
workspaceManager: WorkspaceManager
}
const InstanceDataSchema = z.object({
messageHistory: z.array(z.string()).default([]),
agentModelSelections: z.record(z.string(), ModelPreferenceSchema).default({}),
})
const EMPTY_INSTANCE_DATA: InstanceData = {
messageHistory: [],
agentModelSelections: {},
}
export function registerStorageRoutes(app: FastifyInstance, deps: RouteDeps) {
const resolveStorageKey = (instanceId: string): string => {
const workspace = deps.workspaceManager.get(instanceId)
return workspace?.path ?? instanceId
}
app.get<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => {
try {
const data = await deps.instanceStore.read(request.params.id)
const storageId = resolveStorageKey(request.params.id)
const data = await deps.instanceStore.read(storageId)
return data
} catch (error) {
reply.code(500)
@@ -24,7 +42,9 @@ export function registerStorageRoutes(app: FastifyInstance, deps: RouteDeps) {
app.put<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => {
try {
const body = InstanceDataSchema.parse(request.body ?? {})
await deps.instanceStore.write(request.params.id, body)
const storageId = resolveStorageKey(request.params.id)
await deps.instanceStore.write(storageId, body)
deps.eventBus.publish({ type: "instance.dataChanged", instanceId: request.params.id, data: body })
reply.code(204)
} catch (error) {
reply.code(400)
@@ -34,7 +54,9 @@ export function registerStorageRoutes(app: FastifyInstance, deps: RouteDeps) {
app.delete<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => {
try {
await deps.instanceStore.delete(request.params.id)
const storageId = resolveStorageKey(request.params.id)
await deps.instanceStore.delete(storageId)
deps.eventBus.publish({ type: "instance.dataChanged", instanceId: request.params.id, data: EMPTY_INSTANCE_DATA })
reply.code(204)
} catch (error) {
reply.code(500)

View File

@@ -19,6 +19,16 @@ const WorkspaceFileContentQuerySchema = z.object({
path: z.string(),
})
const WorkspaceFileSearchQuerySchema = z.object({
q: z.string().trim().min(1, "Query is required"),
limit: z.coerce.number().int().positive().max(200).optional(),
type: z.enum(["all", "file", "directory"]).optional(),
refresh: z
.string()
.optional()
.transform((value) => (value === undefined ? undefined : value === "true")),
})
export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/workspaces", async () => {
return deps.workspaceManager.list()
@@ -57,6 +67,22 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
}
})
app.get<{
Params: { id: string }
Querystring: { q?: string; limit?: string; type?: "all" | "file" | "directory"; refresh?: string }
}>("/api/workspaces/:id/files/search", async (request, reply) => {
try {
const query = WorkspaceFileSearchQuerySchema.parse(request.query ?? {})
return deps.workspaceManager.searchFiles(request.params.id, query.q, {
limit: query.limit,
type: query.type,
refresh: query.refresh,
})
} catch (error) {
return handleWorkspaceError(error, reply)
}
})
app.get<{
Params: { id: string }
Querystring: { path?: string }
@@ -70,6 +96,7 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
})
}
function handleWorkspaceError(error: unknown, reply: FastifyReply) {
if (error instanceof Error && error.message === "Workspace not found") {
reply.code(404)

View File

@@ -6,6 +6,7 @@ import type { InstanceData } from "../api-types"
const DEFAULT_INSTANCE_DATA: InstanceData = {
messageHistory: [],
agentModelSelections: {},
}
export class InstanceStore {

View File

@@ -0,0 +1,195 @@
import { Agent, fetch } from "undici"
import { Agent as UndiciAgent } from "undici"
import { EventBus } from "../events/bus"
import { Logger } from "../logger"
import { WorkspaceManager } from "./manager"
import { InstanceStreamEvent, InstanceStreamStatus } from "../api-types"
const INSTANCE_HOST = "127.0.0.1"
const STREAM_AGENT = new UndiciAgent({ bodyTimeout: 0, headersTimeout: 0 })
const RECONNECT_DELAY_MS = 1000
interface InstanceEventBridgeOptions {
workspaceManager: WorkspaceManager
eventBus: EventBus
logger: Logger
}
interface ActiveStream {
controller: AbortController
task: Promise<void>
}
export class InstanceEventBridge {
private readonly streams = new Map<string, ActiveStream>()
constructor(private readonly options: InstanceEventBridgeOptions) {
const bus = this.options.eventBus
bus.on("workspace.started", (event) => this.startStream(event.workspace.id))
bus.on("workspace.stopped", (event) => this.stopStream(event.workspaceId, "workspace stopped"))
bus.on("workspace.error", (event) => this.stopStream(event.workspace.id, "workspace error"))
}
shutdown() {
for (const [id, active] of this.streams) {
active.controller.abort()
this.publishStatus(id, "disconnected")
}
this.streams.clear()
}
private startStream(workspaceId: string) {
if (this.streams.has(workspaceId)) {
return
}
const controller = new AbortController()
const task = this.runStream(workspaceId, controller.signal)
.catch((error) => {
if (!controller.signal.aborted) {
this.options.logger.warn({ workspaceId, err: error }, "Instance event stream failed")
this.publishStatus(workspaceId, "error", error instanceof Error ? error.message : String(error))
}
})
.finally(() => {
const active = this.streams.get(workspaceId)
if (active?.controller === controller) {
this.streams.delete(workspaceId)
}
})
this.streams.set(workspaceId, { controller, task })
}
private stopStream(workspaceId: string, reason?: string) {
const active = this.streams.get(workspaceId)
if (!active) {
return
}
active.controller.abort()
this.streams.delete(workspaceId)
this.publishStatus(workspaceId, "disconnected", reason)
}
private async runStream(workspaceId: string, signal: AbortSignal) {
while (!signal.aborted) {
const port = this.options.workspaceManager.getInstancePort(workspaceId)
if (!port) {
await this.delay(RECONNECT_DELAY_MS, signal)
continue
}
this.publishStatus(workspaceId, "connecting")
try {
await this.consumeStream(workspaceId, port, signal)
} catch (error) {
if (signal.aborted) {
break
}
this.options.logger.warn({ workspaceId, err: error }, "Instance event stream disconnected")
this.publishStatus(workspaceId, "error", error instanceof Error ? error.message : String(error))
await this.delay(RECONNECT_DELAY_MS, signal)
}
}
}
private async consumeStream(workspaceId: string, port: number, signal: AbortSignal) {
const url = `http://${INSTANCE_HOST}:${port}/event`
const response = await fetch(url, {
headers: { Accept: "text/event-stream" },
signal,
dispatcher: STREAM_AGENT,
})
if (!response.ok || !response.body) {
throw new Error(`Instance event stream unavailable (${response.status})`)
}
this.publishStatus(workspaceId, "connected")
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ""
while (!signal.aborted) {
const { done, value } = await reader.read()
if (done || !value) {
break
}
buffer += decoder.decode(value, { stream: true })
buffer = this.flushEvents(buffer, workspaceId)
}
}
private flushEvents(buffer: string, workspaceId: string) {
let separatorIndex = buffer.indexOf("\n\n")
while (separatorIndex >= 0) {
const chunk = buffer.slice(0, separatorIndex)
buffer = buffer.slice(separatorIndex + 2)
this.processChunk(chunk, workspaceId)
separatorIndex = buffer.indexOf("\n\n")
}
return buffer
}
private processChunk(chunk: string, workspaceId: string) {
const lines = chunk.split(/\r?\n/)
const dataLines: string[] = []
for (const line of lines) {
if (line.startsWith(":")) {
continue
}
if (line.startsWith("data:")) {
dataLines.push(line.slice(5).trimStart())
}
}
if (dataLines.length === 0) {
return
}
const payload = dataLines.join("\n").trim()
if (!payload) {
return
}
try {
const event = JSON.parse(payload) as InstanceStreamEvent
this.options.logger.debug({ workspaceId, eventType: event.type }, "Instance SSE event received")
if (this.options.logger.isLevelEnabled("trace")) {
this.options.logger.trace({ workspaceId, event }, "Instance SSE event payload")
}
this.options.eventBus.publish({ type: "instance.event", instanceId: workspaceId, event })
} catch (error) {
this.options.logger.warn({ workspaceId, chunk: payload, err: error }, "Failed to parse instance SSE payload")
}
}
private publishStatus(instanceId: string, status: InstanceStreamStatus, reason?: string) {
this.options.logger.debug({ instanceId, status, reason }, "Instance SSE status updated")
this.options.eventBus.publish({ type: "instance.eventStatus", instanceId, status, reason })
}
private delay(duration: number, signal: AbortSignal) {
if (duration <= 0) {
return Promise.resolve()
}
return new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
signal.removeEventListener("abort", onAbort)
resolve()
}, duration)
const onAbort = () => {
clearTimeout(timeout)
resolve()
}
signal.addEventListener("abort", onAbort, { once: true })
})
}
}

View File

@@ -1,8 +1,11 @@
import path from "path"
import { spawnSync } from "child_process"
import { EventBus } from "../events/bus"
import { ConfigStore } from "../config/store"
import { BinaryRegistry } from "../config/binaries"
import { FileSystemBrowser } from "../filesystem/browser"
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
import { WorkspaceRuntime } from "./runtime"
import { Logger } from "../logger"
@@ -43,6 +46,11 @@ export class WorkspaceManager {
return browser.list(relativePath)
}
searchFiles(workspaceId: string, query: string, options?: WorkspaceFileSearchOptions): FileSystemEntry[] {
const workspace = this.requireWorkspace(workspaceId)
return searchWorkspaceFiles(workspace.path, query, options)
}
readFile(workspaceId: string, relativePath: string): WorkspaceFileResponse {
const workspace = this.requireWorkspace(workspaceId)
const browser = new FileSystemBrowser({ rootDir: workspace.path })
@@ -55,28 +63,38 @@ export class WorkspaceManager {
}
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
const id = `${Date.now().toString(36)}`
const binary = this.options.binaryRegistry.resolveDefault()
const resolvedBinaryPath = this.resolveBinaryPath(binary.path)
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
clearWorkspaceSearchCache(workspacePath)
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: binary.path }, "Creating workspace")
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace")
const proxyPath = `/workspaces/${id}/instance`
const descriptor: WorkspaceRecord = {
id,
path: workspacePath,
name,
status: "starting",
proxyPath,
binaryId: binary.id,
binaryId: resolvedBinaryPath,
binaryLabel: binary.label,
binaryVersion: binary.version,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
if (!descriptor.binaryVersion) {
descriptor.binaryVersion = this.detectBinaryVersion(resolvedBinaryPath)
}
this.workspaces.set(id, descriptor)
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
const environment = this.options.configStore.get().preferences.environmentVariables ?? {}
@@ -85,7 +103,7 @@ export class WorkspaceManager {
const { pid, port } = await this.runtime.launch({
workspaceId: id,
folder: workspacePath,
binaryPath: binary.path,
binaryPath: resolvedBinaryPath,
environment,
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
})
@@ -120,6 +138,7 @@ export class WorkspaceManager {
}
this.workspaces.delete(id)
clearWorkspaceSearchCache(workspace.path)
if (!wasRunning) {
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: id })
}
@@ -150,6 +169,70 @@ export class WorkspaceManager {
return workspace
}
private resolveBinaryPath(identifier: string): string {
if (!identifier) {
return identifier
}
const looksLikePath = identifier.includes("/") || identifier.includes("\\") || identifier.startsWith(".")
if (path.isAbsolute(identifier) || looksLikePath) {
return identifier
}
const locator = process.platform === "win32" ? "where" : "which"
try {
const result = spawnSync(locator, [identifier], { encoding: "utf8" })
if (result.status === 0 && result.stdout) {
const resolved = result.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0)
if (resolved) {
this.options.logger.debug({ identifier, resolved }, "Resolved binary path from system PATH")
return resolved
}
} else if (result.error) {
this.options.logger.warn({ identifier, err: result.error }, "Failed to resolve binary path via locator command")
}
} catch (error) {
this.options.logger.warn({ identifier, err: error }, "Failed to resolve binary path from system PATH")
}
return identifier
}
private detectBinaryVersion(resolvedPath: string): string | undefined {
if (!resolvedPath) {
return undefined
}
try {
const result = spawnSync(resolvedPath, ["--version"], { encoding: "utf8" })
if (result.status === 0 && result.stdout) {
const line = result.stdout.split(/\r?\n/).find((entry) => entry.trim().length > 0)
if (line) {
const normalized = line.trim()
const versionMatch = normalized.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
if (versionMatch) {
const version = versionMatch[1]
this.options.logger.debug({ binary: resolvedPath, version }, "Detected binary version")
return version
}
this.options.logger.debug({ binary: resolvedPath, reported: normalized }, "Binary reported version string")
return normalized
}
} else if (result.error) {
this.options.logger.warn({ binary: resolvedPath, err: result.error }, "Failed to read binary version")
}
} catch (error) {
this.options.logger.warn({ binary: resolvedPath, err: error }, "Failed to detect binary version")
}
return undefined
}
private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) {
const workspace = this.workspaces.get(workspaceId)
if (!workspace) return

View File

@@ -37,7 +37,10 @@ export class WorkspaceRuntime {
const env = { ...process.env, ...(options.environment ?? {}) }
return new Promise((resolve, reject) => {
this.logger.info({ workspaceId: options.workspaceId, folder: options.folder }, "Launching OpenCode process")
this.logger.info(
{ workspaceId: options.workspaceId, folder: options.folder, binary: options.binaryPath },
"Launching OpenCode process",
)
const child = spawn(options.binaryPath, args, {
cwd: options.folder,
env,

7
packages/tauri-app/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
src-tauri/target
src-tauri/Cargo.lock
src-tauri/resources/
target
node_modules
dist
.DS_Store

5589
packages/tauri-app/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
[workspace]
members = ["src-tauri"]
resolver = "2"

View File

@@ -0,0 +1,17 @@
{
"name": "@codenomad/tauri-app",
"version": "0.3.0",
"private": true,
"scripts": {
"dev": "npx --yes @tauri-apps/cli@^2.9.4 dev",
"dev:ui": "npm run dev --workspace @codenomad/ui",
"dev:prep": "node ./scripts/dev-prep.js",
"dev:bootstrap": "npm run dev:prep && npm run dev:ui",
"prebuild": "node ./scripts/prebuild.js",
"bundle:server": "npm run prebuild",
"build": "npx --yes @tauri-apps/cli@^2.9.4 build"
},
"devDependencies": {
"@tauri-apps/cli": "^2.9.4"
}
}

View File

@@ -0,0 +1,46 @@
#!/usr/bin/env node
const fs = require("fs")
const path = require("path")
const { execSync } = require("child_process")
const root = path.resolve(__dirname, "..")
const workspaceRoot = path.resolve(root, "..", "..")
const uiRoot = path.resolve(root, "..", "ui")
const uiDist = path.resolve(uiRoot, "src", "renderer", "dist")
const uiLoadingDest = path.resolve(root, "src-tauri", "resources", "ui-loading")
function ensureUiBuild() {
const loadingHtml = path.join(uiDist, "loading.html")
if (fs.existsSync(loadingHtml)) {
return
}
console.log("[dev-prep] UI loader build missing; running workspace build…")
execSync("npm --workspace @codenomad/ui run build", {
cwd: workspaceRoot,
stdio: "inherit",
})
if (!fs.existsSync(loadingHtml)) {
throw new Error("[dev-prep] failed to produce loading.html after UI build")
}
}
function copyUiLoadingAssets() {
const loadingSource = path.join(uiDist, "loading.html")
const assetsSource = path.join(uiDist, "assets")
fs.rmSync(uiLoadingDest, { recursive: true, force: true })
fs.mkdirSync(uiLoadingDest, { recursive: true })
fs.copyFileSync(loadingSource, path.join(uiLoadingDest, "loading.html"))
if (fs.existsSync(assetsSource)) {
fs.cpSync(assetsSource, path.join(uiLoadingDest, "assets"), { recursive: true })
}
console.log(`[dev-prep] copied loader bundle from ${uiDist}`)
}
ensureUiBuild()
copyUiLoadingAssets()

View File

@@ -0,0 +1,195 @@
#!/usr/bin/env node
const fs = require("fs")
const path = require("path")
const { execSync } = require("child_process")
const root = path.resolve(__dirname, "..")
const workspaceRoot = path.resolve(root, "..", "..")
const serverRoot = path.resolve(root, "..", "server")
const uiRoot = path.resolve(root, "..", "ui")
const uiDist = path.resolve(uiRoot, "src", "renderer", "dist")
const serverDest = path.resolve(root, "src-tauri", "resources", "server")
const uiLoadingDest = path.resolve(root, "src-tauri", "resources", "ui-loading")
const sources = ["dist", "public", "node_modules", "package.json"]
const serverInstallCommand =
"npm install --omit=dev --ignore-scripts --workspaces=false --package-lock=false --install-strategy=shallow --fund=false --audit=false"
const serverDevInstallCommand =
"npm install --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
const uiDevInstallCommand =
"npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
const envWithRootBin = {
...process.env,
PATH: `${path.join(workspaceRoot, "node_modules/.bin")}:${process.env.PATH}`,
}
const braceExpansionPath = path.join(
serverRoot,
"node_modules",
"@fastify",
"static",
"node_modules",
"brace-expansion",
"package.json",
)
const viteBinPath = path.join(uiRoot, "node_modules", ".bin", "vite")
function ensureServerBuild() {
const distPath = path.join(serverRoot, "dist")
const publicPath = path.join(serverRoot, "public")
if (fs.existsSync(distPath) && fs.existsSync(publicPath)) {
return
}
console.log("[prebuild] server build missing; running workspace build...")
execSync("npm --workspace @neuralnomads/codenomad run build", {
cwd: workspaceRoot,
stdio: "inherit",
env: {
...process.env,
PATH: `${path.join(workspaceRoot, "node_modules/.bin")}:${process.env.PATH}`,
},
})
if (!fs.existsSync(distPath) || !fs.existsSync(publicPath)) {
throw new Error("[prebuild] server artifacts still missing after build")
}
}
function ensureUiBuild() {
const loadingHtml = path.join(uiDist, "loading.html")
if (fs.existsSync(loadingHtml)) {
return
}
console.log("[prebuild] ui build missing; running workspace build...")
execSync("npm --workspace @codenomad/ui run build", {
cwd: workspaceRoot,
stdio: "inherit",
})
if (!fs.existsSync(loadingHtml)) {
throw new Error("[prebuild] ui loading assets missing after build")
}
}
function ensureServerDevDependencies() {
if (fs.existsSync(braceExpansionPath)) {
return
}
console.log("[prebuild] ensuring server build dependencies (with dev)...")
execSync(serverDevInstallCommand, {
cwd: workspaceRoot,
stdio: "inherit",
env: envWithRootBin,
})
}
function ensureServerDependencies() {
if (fs.existsSync(braceExpansionPath)) {
return
}
console.log("[prebuild] ensuring server production dependencies...")
execSync(serverInstallCommand, {
cwd: serverRoot,
stdio: "inherit",
})
}
function ensureUiDevDependencies() {
if (fs.existsSync(viteBinPath)) {
return
}
console.log("[prebuild] ensuring ui build dependencies...")
execSync(uiDevInstallCommand, {
cwd: workspaceRoot,
stdio: "inherit",
env: envWithRootBin,
})
}
function ensureRollupPlatformBinary() {
const platformKey = `${process.platform}-${process.arch}`
const platformPackages = {
"linux-x64": "@rollup/rollup-linux-x64-gnu",
"linux-arm64": "@rollup/rollup-linux-arm64-gnu",
"darwin-arm64": "@rollup/rollup-darwin-arm64",
"darwin-x64": "@rollup/rollup-darwin-x64",
"win32-x64": "@rollup/rollup-win32-x64-msvc",
}
const pkgName = platformPackages[platformKey]
if (!pkgName) {
return
}
const platformPackagePath = path.join(workspaceRoot, "node_modules", "@rollup", pkgName.split("/").pop())
if (fs.existsSync(platformPackagePath)) {
return
}
let rollupVersion = ""
try {
rollupVersion = require(path.join(workspaceRoot, "node_modules", "rollup", "package.json")).version
} catch (error) {
// leave version empty; fallback install will use latest compatible
}
const packageSpec = rollupVersion ? `${pkgName}@${rollupVersion}` : pkgName
console.log("[prebuild] installing rollup platform binary (optional dep workaround)...")
execSync(`npm install ${packageSpec} --no-save --ignore-scripts --fund=false --audit=false`, {
cwd: workspaceRoot,
stdio: "inherit",
})
}
function copyServerArtifacts() {
fs.rmSync(serverDest, { recursive: true, force: true })
fs.mkdirSync(serverDest, { recursive: true })
for (const name of sources) {
const from = path.join(serverRoot, name)
const to = path.join(serverDest, name)
if (!fs.existsSync(from)) {
console.warn(`[prebuild] skipped missing ${from}`)
continue
}
fs.cpSync(from, to, { recursive: true, dereference: true })
console.log(`[prebuild] copied ${from} -> ${to}`)
}
}
function copyUiLoadingAssets() {
const loadingSource = path.join(uiDist, "loading.html")
const assetsSource = path.join(uiDist, "assets")
if (!fs.existsSync(loadingSource)) {
throw new Error("[prebuild] cannot find built loading.html")
}
fs.rmSync(uiLoadingDest, { recursive: true, force: true })
fs.mkdirSync(uiLoadingDest, { recursive: true })
fs.copyFileSync(loadingSource, path.join(uiLoadingDest, "loading.html"))
if (fs.existsSync(assetsSource)) {
fs.cpSync(assetsSource, path.join(uiLoadingDest, "assets"), { recursive: true })
}
console.log(`[prebuild] prepared UI loading assets from ${uiDist}`)
}
ensureServerDevDependencies()
ensureUiDevDependencies()
ensureRollupPlatformBinary()
ensureServerDependencies()
ensureServerBuild()
ensureUiBuild()
copyServerArtifacts()
copyUiLoadingAssets()

View File

@@ -0,0 +1,23 @@
[package]
name = "codenomad-tauri"
version = "0.1.0"
edition = "2021"
[build-dependencies]
tauri-build = { version = "2.5.2", features = [] }
[dependencies]
tauri = { version = "2.5.2", features = [ "devtools"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
regex = "1"
once_cell = "1"
parking_lot = "0.12"
thiserror = "1"
anyhow = "1"
which = "4"
libc = "0.2"
tauri-plugin-dialog = "2"
dirs = "5"
tauri-plugin-opener = "2"
url = "2"

View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -0,0 +1,16 @@
{
"$schema": "https://schema.tauri.app/capabilities.json",
"identifier": "main-window-native-dialogs",
"description": "Grant the main window access to required core features and native dialog commands.",
"remote": {
"urls": ["http://127.0.0.1:*", "http://localhost:*"]
},
"windows": ["main"],
"permissions": [
"core:default",
"core:menu:default",
"dialog:allow-open",
"opener:allow-default-urls",
"core:webview:allow-set-webview-zoom"
]
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","core:webview:allow-set-webview-zoom"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -0,0 +1,700 @@
use dirs::home_dir;
use parking_lot::Mutex;
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::VecDeque;
use std::env;
use std::ffi::OsStr;
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter, Manager, Url};
fn log_line(message: &str) {
println!("[tauri-cli] {message}");
}
fn workspace_root() -> Option<PathBuf> {
std::env::current_dir().ok().and_then(|mut dir| {
for _ in 0..3 {
if let Some(parent) = dir.parent() {
dir = parent.to_path_buf();
}
}
Some(dir)
})
}
fn navigate_main(app: &AppHandle, url: &str) {
if let Some(win) = app.webview_windows().get("main") {
log_line(&format!("navigating main to {url}"));
if let Ok(parsed) = Url::parse(url) {
let _ = win.navigate(parsed);
} else {
log_line("failed to parse URL for navigation");
}
} else {
log_line("main window not found for navigation");
}
}
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
#[derive(Debug, Deserialize)]
struct PreferencesConfig {
#[serde(rename = "listeningMode")]
listening_mode: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AppConfig {
preferences: Option<PreferencesConfig>,
}
fn resolve_config_path() -> PathBuf {
let raw = env::var("CLI_CONFIG")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string());
expand_home(&raw)
}
fn expand_home(path: &str) -> PathBuf {
if path.starts_with("~/") {
if let Some(home) = home_dir().or_else(|| env::var("HOME").ok().map(PathBuf::from)) {
return home.join(path.trim_start_matches("~/"));
}
}
PathBuf::from(path)
}
fn resolve_listening_mode() -> String {
let path = resolve_config_path();
if let Ok(content) = fs::read_to_string(path) {
if let Ok(config) = serde_json::from_str::<AppConfig>(&content) {
if let Some(mode) = config
.preferences
.as_ref()
.and_then(|prefs| prefs.listening_mode.as_ref())
{
if mode == "local" {
return "local".to_string();
}
if mode == "all" {
return "all".to_string();
}
}
}
}
"local".to_string()
}
fn resolve_listening_host() -> String {
let mode = resolve_listening_mode();
if mode == "local" {
"127.0.0.1".to_string()
} else {
"0.0.0.0".to_string()
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum CliState {
Starting,
Ready,
Error,
Stopped,
}
#[derive(Debug, Clone, Serialize)]
pub struct CliStatus {
pub state: CliState,
pub pid: Option<u32>,
pub port: Option<u16>,
pub url: Option<String>,
pub error: Option<String>,
}
impl Default for CliStatus {
fn default() -> Self {
Self {
state: CliState::Stopped,
pid: None,
port: None,
url: None,
error: None,
}
}
}
#[derive(Debug, Clone)]
pub struct CliProcessManager {
status: Arc<Mutex<CliStatus>>,
child: Arc<Mutex<Option<Child>>>,
ready: Arc<AtomicBool>,
}
impl CliProcessManager {
pub fn new() -> Self {
Self {
status: Arc::new(Mutex::new(CliStatus::default())),
child: Arc::new(Mutex::new(None)),
ready: Arc::new(AtomicBool::new(false)),
}
}
pub fn start(&self, app: AppHandle, dev: bool) -> anyhow::Result<()> {
log_line(&format!("start requested (dev={dev})"));
self.stop()?;
self.ready.store(false, Ordering::SeqCst);
{
let mut status = self.status.lock();
status.state = CliState::Starting;
status.port = None;
status.url = None;
status.error = None;
status.pid = None;
}
Self::emit_status(&app, &self.status.lock());
let status_arc = self.status.clone();
let child_arc = self.child.clone();
let ready_flag = self.ready.clone();
thread::spawn(move || {
if let Err(err) = Self::spawn_cli(app.clone(), status_arc.clone(), child_arc, ready_flag, dev) {
log_line(&format!("cli spawn failed: {err}"));
let mut locked = status_arc.lock();
locked.state = CliState::Error;
locked.error = Some(err.to_string());
let snapshot = locked.clone();
drop(locked);
let _ = app.emit("cli:error", json!({"message": err.to_string()}));
let _ = app.emit("cli:status", snapshot);
}
});
Ok(())
}
pub fn stop(&self) -> anyhow::Result<()> {
let mut child_opt = self.child.lock();
if let Some(mut child) = child_opt.take() {
#[cfg(unix)]
unsafe {
libc::kill(child.id() as i32, libc::SIGTERM);
}
#[cfg(windows)]
{
let _ = child.kill();
}
let start = Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
if start.elapsed() > Duration::from_secs(4) {
#[cfg(unix)]
unsafe {
libc::kill(child.id() as i32, libc::SIGKILL);
}
#[cfg(windows)]
{
let _ = child.kill();
}
break;
}
thread::sleep(Duration::from_millis(50));
}
Err(_) => break,
}
}
}
let mut status = self.status.lock();
status.state = CliState::Stopped;
status.pid = None;
status.port = None;
status.url = None;
status.error = None;
Ok(())
}
pub fn status(&self) -> CliStatus {
self.status.lock().clone()
}
fn spawn_cli(
app: AppHandle,
status: Arc<Mutex<CliStatus>>,
child_holder: Arc<Mutex<Option<Child>>>,
ready: Arc<AtomicBool>,
dev: bool,
) -> anyhow::Result<()> {
log_line("resolving CLI entry");
let resolution = CliEntry::resolve(&app, dev)?;
let host = resolve_listening_host();
log_line(&format!(
"resolved CLI entry runner={:?} entry={} host={}",
resolution.runner, resolution.entry, host
));
let args = resolution.build_args(dev, &host);
log_line(&format!("CLI args: {:?}", args));
if dev {
log_line("development mode: will prefer tsx + source if present");
}
let cwd = workspace_root();
if let Some(ref c) = cwd {
log_line(&format!("using cwd={}", c.display()));
}
let command_info = if supports_user_shell() {
log_line("spawning via user shell");
ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?)
} else {
log_line("spawning directly with node");
ShellCommandType::Direct(DirectCommand {
program: resolution.node_binary.clone(),
args: resolution.runner_args(&args),
})
};
if !supports_user_shell() {
if which::which(&resolution.node_binary).is_err() {
return Err(anyhow::anyhow!("Node binary not found. Make sure Node.js is installed."));
}
}
let child = match &command_info {
ShellCommandType::UserShell(cmd) => {
log_line(&format!("spawn command: {} {:?}", cmd.shell, cmd.args));
let mut c = Command::new(&cmd.shell);
c.args(&cmd.args)
.env("ELECTRON_RUN_AS_NODE", "1")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(ref cwd) = cwd {
c.current_dir(cwd);
}
c.spawn()?
}
ShellCommandType::Direct(cmd) => {
log_line(&format!("spawn command: {} {:?}", cmd.program, cmd.args));
let mut c = Command::new(&cmd.program);
c.args(&cmd.args)
.env("ELECTRON_RUN_AS_NODE", "1")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(ref cwd) = cwd {
c.current_dir(cwd);
}
c.spawn()?
}
};
let pid = child.id();
log_line(&format!("spawned pid={pid}"));
{
let mut locked = status.lock();
locked.pid = Some(pid);
}
Self::emit_status(&app, &status.lock());
{
let mut holder = child_holder.lock();
*holder = Some(child);
}
let child_clone = child_holder.clone();
let status_clone = status.clone();
let app_clone = app.clone();
let ready_clone = ready.clone();
thread::spawn(move || {
let stdout = child_clone
.lock()
.as_mut()
.and_then(|c| c.stdout.take())
.map(BufReader::new);
let stderr = child_clone
.lock()
.as_mut()
.and_then(|c| c.stderr.take())
.map(BufReader::new);
if let Some(reader) = stdout {
Self::process_stream(reader, "stdout", &app_clone, &status_clone, &ready_clone);
}
if let Some(reader) = stderr {
Self::process_stream(reader, "stderr", &app_clone, &status_clone, &ready_clone);
}
});
let app_clone = app.clone();
let status_clone = status.clone();
let ready_clone = ready.clone();
let child_holder_clone = child_holder.clone();
thread::spawn(move || {
let timeout = Duration::from_secs(60);
thread::sleep(timeout);
if ready_clone.load(Ordering::SeqCst) {
return;
}
let mut locked = status_clone.lock();
locked.state = CliState::Error;
locked.error = Some("CLI did not start in time".to_string());
log_line("timeout waiting for CLI readiness");
if let Some(child) = child_holder_clone.lock().as_mut() {
let _ = child.kill();
}
let _ = app_clone.emit("cli:error", json!({"message": "CLI did not start in time"}));
Self::emit_status(&app_clone, &locked);
});
let status_clone = status.clone();
let app_clone = app.clone();
thread::spawn(move || {
let code = {
let mut guard = child_holder.lock();
if let Some(child) = guard.as_mut() {
child.wait().ok()
} else {
None
}
};
let mut locked = status_clone.lock();
let failed = locked.state != CliState::Ready;
let err_msg = if failed {
Some(match code {
Some(status) => format!("CLI exited early: {status}"),
None => "CLI exited early".to_string(),
})
} else {
None
};
if failed {
locked.state = CliState::Error;
if locked.error.is_none() {
locked.error = err_msg.clone();
}
log_line(&format!("cli process exited before ready: {:?}", locked.error));
let _ = app_clone.emit("cli:error", json!({"message": locked.error.clone().unwrap_or_default()}));
} else {
locked.state = CliState::Stopped;
log_line("cli process stopped cleanly");
}
Self::emit_status(&app_clone, &locked);
});
Ok(())
}
fn process_stream<R: BufRead>(
mut reader: R,
stream: &str,
app: &AppHandle,
status: &Arc<Mutex<CliStatus>>,
ready: &Arc<AtomicBool>,
) {
let mut buffer = String::new();
let port_regex = Regex::new(r"CodeNomad Server is ready at http://[^:]+:(\d+)").ok();
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
loop {
buffer.clear();
match reader.read_line(&mut buffer) {
Ok(0) => break,
Ok(_) => {
let line = buffer.trim_end();
if !line.is_empty() {
log_line(&format!("[cli][{}] {}", stream, line));
if ready.load(Ordering::SeqCst) {
continue;
}
if let Some(port) = port_regex
.as_ref()
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
.and_then(|m| m.as_str().parse::<u16>().ok())
{
Self::mark_ready(app, status, ready, port);
continue;
}
if line.to_lowercase().contains("http server listening") {
if let Some(port) = http_regex
.as_ref()
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
.and_then(|m| m.as_str().parse::<u16>().ok())
{
Self::mark_ready(app, status, ready, port);
continue;
}
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
if let Some(port) = value.get("port").and_then(|p| p.as_u64()) {
Self::mark_ready(app, status, ready, port as u16);
continue;
}
}
}
}
}
Err(_) => break,
}
}
}
fn mark_ready(app: &AppHandle, status: &Arc<Mutex<CliStatus>>, ready: &Arc<AtomicBool>, port: u16) {
ready.store(true, Ordering::SeqCst);
let mut locked = status.lock();
let url = format!("http://127.0.0.1:{port}");
locked.port = Some(port);
locked.url = Some(url.clone());
locked.state = CliState::Ready;
locked.error = None;
log_line(&format!("cli ready on {url}"));
navigate_main(app, &url);
let _ = app.emit("cli:ready", locked.clone());
Self::emit_status(app, &locked);
}
fn emit_status(app: &AppHandle, status: &CliStatus) {
let _ = app.emit("cli:status", status.clone());
}
}
fn supports_user_shell() -> bool {
cfg!(unix)
}
#[derive(Debug)]
struct ShellCommand {
shell: String,
args: Vec<String>,
}
#[derive(Debug)]
struct DirectCommand {
program: String,
args: Vec<String>,
}
#[derive(Debug)]
enum ShellCommandType {
UserShell(ShellCommand),
Direct(DirectCommand),
}
#[derive(Debug)]
struct CliEntry {
entry: String,
runner: Runner,
runner_path: Option<String>,
node_binary: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Runner {
Node,
Tsx,
}
impl CliEntry {
fn resolve(app: &AppHandle, dev: bool) -> anyhow::Result<Self> {
let node_binary = std::env::var("NODE_BINARY").unwrap_or_else(|_| "node".to_string());
if dev {
if let Some(tsx_path) = resolve_tsx(app) {
if let Some(entry) = resolve_dev_entry(app) {
return Ok(Self {
entry,
runner: Runner::Tsx,
runner_path: Some(tsx_path),
node_binary,
});
}
}
}
if let Some(entry) = resolve_dist_entry(app) {
return Ok(Self {
entry,
runner: Runner::Node,
runner_path: None,
node_binary,
});
}
Err(anyhow::anyhow!(
"Unable to locate CodeNomad CLI build (dist/bin.js). Please build @neuralnomads/codenomad."
))
}
fn build_args(&self, dev: bool, host: &str) -> Vec<String> {
let mut args = vec![
"serve".to_string(),
"--host".to_string(),
host.to_string(),
"--port".to_string(),
"0".to_string(),
];
if dev {
args.push("--ui-dev-server".to_string());
args.push("http://localhost:3000".to_string());
args.push("--log-level".to_string());
args.push("debug".to_string());
}
args
}
fn runner_args(&self, cli_args: &[String]) -> Vec<String> {
let mut args = VecDeque::new();
if self.runner == Runner::Tsx {
if let Some(path) = &self.runner_path {
args.push_back(path.clone());
}
}
args.push_back(self.entry.clone());
for arg in cli_args {
args.push_back(arg.clone());
}
args.into_iter().collect()
}
}
fn resolve_tsx(_app: &AppHandle) -> Option<String> {
let candidates = vec![
std::env::current_dir()
.ok()
.map(|p| p.join("node_modules/tsx/dist/cli.js")),
std::env::current_exe()
.ok()
.and_then(|ex| ex.parent().map(|p| p.join("../node_modules/tsx/dist/cli.js"))),
];
first_existing(candidates)
}
fn resolve_dev_entry(_app: &AppHandle) -> Option<String> {
let candidates = vec![
std::env::current_dir()
.ok()
.map(|p| p.join("packages/server/src/index.ts")),
std::env::current_dir()
.ok()
.map(|p| p.join("../server/src/index.ts")),
];
first_existing(candidates)
}
fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
let base = workspace_root();
let mut candidates: Vec<Option<PathBuf>> = vec![
base.as_ref().map(|p| p.join("packages/server/dist/bin.js")),
base.as_ref().map(|p| p.join("packages/server/dist/index.js")),
base.as_ref().map(|p| p.join("server/dist/bin.js")),
base.as_ref().map(|p| p.join("server/dist/index.js")),
];
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
let resources = dir.join("../Resources");
candidates.push(Some(resources.join("server/dist/bin.js")));
candidates.push(Some(resources.join("server/dist/index.js")));
candidates.push(Some(resources.join("server/dist/server/bin.js")));
candidates.push(Some(resources.join("server/dist/server/index.js")));
candidates.push(Some(resources.join("resources/server/dist/bin.js")));
candidates.push(Some(resources.join("resources/server/dist/index.js")));
candidates.push(Some(resources.join("resources/server/dist/server/bin.js")));
candidates.push(Some(resources.join("resources/server/dist/server/index.js")));
}
}
first_existing(candidates)
}
fn build_shell_command_string(entry: &CliEntry, cli_args: &[String]) -> anyhow::Result<ShellCommand> {
let shell = default_shell();
let mut quoted: Vec<String> = Vec::new();
quoted.push(shell_escape(&entry.node_binary));
for arg in entry.runner_args(cli_args) {
quoted.push(shell_escape(&arg));
}
let command = format!("ELECTRON_RUN_AS_NODE=1 exec {}", quoted.join(" "));
let args = build_shell_args(&shell, &command);
log_line(&format!("user shell command: {} {:?}", shell, args));
Ok(ShellCommand { shell, args })
}
fn default_shell() -> String {
if let Ok(shell) = std::env::var("SHELL") {
if !shell.trim().is_empty() {
return shell;
}
}
if cfg!(target_os = "macos") {
"/bin/zsh".to_string()
} else {
"/bin/bash".to_string()
}
}
fn shell_escape(input: &str) -> String {
if input.is_empty() {
"''".to_string()
} else if !input
.chars()
.any(|c| matches!(c, ' ' | '"' | '\'' | '$' | '`' | '!' ))
{
input.to_string()
} else {
let escaped = input.replace('\'', "'\\''");
format!("'{}'", escaped)
}
}
fn build_shell_args(shell: &str, command: &str) -> Vec<String> {
let shell_name = std::path::Path::new(shell)
.file_name()
.and_then(OsStr::to_str)
.unwrap_or("")
.to_lowercase();
if shell_name.contains("zsh") {
vec!["-l".into(), "-i".into(), "-c".into(), command.into()]
} else {
vec!["-l".into(), "-c".into(), command.into()]
}
}
fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> {
paths
.into_iter()
.flatten()
.find(|p| p.exists())
.map(|p| normalize_path(p))
}
fn normalize_path(path: PathBuf) -> String {
if let Ok(clean) = path.canonicalize() {
clean.to_string_lossy().to_string()
} else {
path.to_string_lossy().to_string()
}
}

View File

@@ -0,0 +1,267 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod cli_manager;
use cli_manager::{CliProcessManager, CliStatus};
use serde_json::json;
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
use tauri::webview::Webview;
use tauri::{AppHandle, Emitter, Manager, Runtime, Wry};
use tauri_plugin_opener::OpenerExt;
use url::Url;
#[derive(Clone)]
pub struct AppState {
pub manager: CliProcessManager,
}
#[tauri::command]
fn cli_get_status(state: tauri::State<AppState>) -> CliStatus {
state.manager.status()
}
#[tauri::command]
fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatus, String> {
let dev_mode = is_dev_mode();
state.manager.stop().map_err(|e| e.to_string())?;
state
.manager
.start(app, dev_mode)
.map_err(|e| e.to_string())?;
Ok(state.manager.status())
}
fn is_dev_mode() -> bool {
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
}
fn should_allow_internal(url: &Url) -> bool {
match url.scheme() {
"tauri" | "asset" | "file" => true,
"http" | "https" => matches!(url.host_str(), Some("127.0.0.1" | "localhost")),
_ => false,
}
}
fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
if should_allow_internal(url) {
return true;
}
if let Err(err) = webview
.app_handle()
.opener()
.open_url(url.as_str(), None::<&str>)
{
eprintln!("[tauri] failed to open external link {}: {}", url, err);
}
false
}
fn main() {
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
.on_navigation(|webview, url| intercept_navigation(webview, url))
.build();
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.plugin(navigation_guard)
.manage(AppState {
manager: CliProcessManager::new(),
})
.setup(|app| {
build_menu(&app.handle())?;
let dev_mode = is_dev_mode();
let app_handle = app.handle().clone();
let manager = app.state::<AppState>().manager.clone();
std::thread::spawn(move || {
if let Err(err) = manager.start(app_handle.clone(), dev_mode) {
let _ = app_handle.emit("cli:error", json!({"message": err.to_string()}));
}
});
Ok(())
})
.invoke_handler(tauri::generate_handler![cli_get_status, cli_restart])
.on_menu_event(|app_handle, event| {
match event.id().0.as_str() {
// File menu
"new_instance" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.emit("menu:newInstance", ());
}
}
"close" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.close();
}
}
"quit" => {
app_handle.exit(0);
}
// View menu
"reload" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.eval("window.location.reload()");
}
}
"force_reload" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.eval("window.location.reload(true)");
}
}
"toggle_devtools" => {
if let Some(window) = app_handle.get_webview_window("main") {
window.open_devtools();
}
}
"toggle_fullscreen" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.set_fullscreen(!window.is_fullscreen().unwrap_or(false));
}
}
// Window menu
"minimize" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.minimize();
}
}
"zoom" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.maximize();
}
}
// App menu (macOS)
"about" => {
// TODO: Implement about dialog
println!("About menu item clicked");
}
"hide" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.hide();
}
}
"hide_others" => {
// TODO: Hide other app windows
println!("Hide Others menu item clicked");
}
"show_all" => {
// TODO: Show all app windows
println!("Show All menu item clicked");
}
_ => {
println!("Unhandled menu event: {}", event.id().0);
}
}
})
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| match event {
tauri::RunEvent::ExitRequested { .. } => {
let app = app_handle.clone();
std::thread::spawn(move || {
if let Some(state) = app.try_state::<AppState>() {
let _ = state.manager.stop();
}
app.exit(0);
});
}
tauri::RunEvent::WindowEvent {
event: tauri::WindowEvent::Destroyed,
..
} => {
if app_handle.webview_windows().len() <= 1 {
let app = app_handle.clone();
std::thread::spawn(move || {
if let Some(state) = app.try_state::<AppState>() {
let _ = state.manager.stop();
}
app.exit(0);
});
}
}
_ => {}
});
}
fn build_menu(app: &AppHandle) -> tauri::Result<()> {
let is_mac = cfg!(target_os = "macos");
// Create submenus
let mut submenus = Vec::new();
// App menu (macOS only)
if is_mac {
let app_menu = SubmenuBuilder::new(app, "CodeNomad")
.text("about", "About CodeNomad")
.separator()
.text("hide", "Hide CodeNomad")
.text("hide_others", "Hide Others")
.text("show_all", "Show All")
.separator()
.text("quit", "Quit CodeNomad")
.build()?;
submenus.push(app_menu);
}
// File menu - create New Instance with accelerator
let new_instance_item = MenuItem::with_id(
app,
"new_instance",
"New Instance",
true,
Some("CmdOrCtrl+N")
)?;
let file_menu = SubmenuBuilder::new(app, "File")
.item(&new_instance_item)
.separator()
.text(if is_mac { "close" } else { "quit" }, if is_mac { "Close" } else { "Quit" })
.build()?;
submenus.push(file_menu);
// Edit menu with predefined items for standard functionality
let edit_menu = SubmenuBuilder::new(app, "Edit")
.undo()
.redo()
.separator()
.cut()
.copy()
.paste()
.separator()
.select_all()
.build()?;
submenus.push(edit_menu);
// View menu
let view_menu = SubmenuBuilder::new(app, "View")
.text("reload", "Reload")
.text("force_reload", "Force Reload")
.text("toggle_devtools", "Toggle Developer Tools")
.separator()
.separator()
.text("toggle_fullscreen", "Toggle Full Screen")
.build()?;
submenus.push(view_menu);
// Window menu
let window_menu = SubmenuBuilder::new(app, "Window")
.text("minimize", "Minimize")
.text("zoom", "Zoom")
.build()?;
submenus.push(window_menu);
// Build the main menu with all submenus
let submenu_refs: Vec<&dyn tauri::menu::IsMenuItem<_>> = submenus.iter().map(|s| s as &dyn tauri::menu::IsMenuItem<_>).collect();
let menu = MenuBuilder::new(app).items(&submenu_refs).build()?;
app.set_menu(menu)?;
Ok(())
}

View File

@@ -0,0 +1,50 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CodeNomad",
"version": "0.1.0",
"identifier": "ai.opencode.client",
"build": {
"beforeDevCommand": "npm run dev:bootstrap",
"beforeBuildCommand": "npm run bundle:server",
"frontendDist": "resources/ui-loading"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"label": "main",
"title": "CodeNomad",
"url": "loading.html",
"width": 1400,
"height": 900,
"minWidth": 800,
"minHeight": 600,
"center": true,
"resizable": true,
"fullscreen": false,
"decorations": true,
"theme": "Dark",
"backgroundColor": "#1a1a1a",
"zoomHotkeysEnabled": true
}
],
"security": {
"assetProtocol": {
"scope": ["**"]
},
"capabilities": ["main-window-native-dialogs"]
}
},
"bundle": {
"active": true,
"resources": [
"resources/server",
"resources/ui-loading"
],
"icon": ["icon.icns", "icon.ico", "icon.png"],
"targets": ["app", "appimage", "deb", "rpm", "nsis"]
}
}

54
packages/ui/README.md Normal file
View File

@@ -0,0 +1,54 @@
# CodeNomad UI
This package contains the frontend user interface for CodeNomad, built with [SolidJS](https://www.solidjs.com/) and [Tailwind CSS](https://tailwindcss.com/).
## Overview
The UI is designed to be a high-performance, low-latency cockpit for managing OpenCode sessions. It connects to the CodeNomad server (either running locally via CLI or embedded in the Electron app).
## Features
- **SolidJS**: Fine-grained reactivity for high performance.
- **Tailwind CSS**: Utility-first styling for rapid development.
- **Vite**: Fast build tool and dev server.
## Development
To run the UI in standalone mode (connected to a running server):
```bash
npm run dev
```
This starts the Vite dev server at `http://localhost:3000`.
## Building
To build the production assets:
```
npm run build
```
The output will be generated in the `dist` directory, which is then consumed by the Server or Electron app.
## Debug Logging
The UI now routes all logging through a lightweight wrapper around [`debug`](https://github.com/debug-js/debug). The logger exposes four namespaces that can be toggled at runtime:
- `sse` Server-sent event transport and handlers
- `api` HTTP/API calls and workspace lifecycle
- `session` Session/model state, prompt handling, tool calls
- `actions` User-driven interactions in UI components
You can enable or disable namespaces from DevTools (in dev or production builds) via the global `window.codenomadLogger` helpers:
```js
window.codenomadLogger?.listLoggerNamespaces() // => [{ name: "sse", enabled: false }, ...]
window.codenomadLogger?.enableLogger("sse") // turn on SSE logs
window.codenomadLogger?.disableLogger("sse") // turn them off again
window.codenomadLogger?.enableAllLoggers() // optional helper
```
Enabled namespaces are persisted in `localStorage` under `opencode:logger:namespaces`, so your preference survives reloads.

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/ui",
"version": "0.1.2",
"version": "0.3.0",
"private": true,
"type": "module",
"scripts": {
@@ -12,11 +12,13 @@
"dependencies": {
"@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11",
"@opencode-ai/sdk": "1.0.68",
"@opencode-ai/sdk": "^1.0.133",
"@solidjs/router": "^0.13.0",
"debug": "^4.4.3",
"github-markdown-css": "^5.8.1",
"lucide-solid": "^0.300.0",
"marked": "^12.0.0",
"qrcode": "^1.5.3",
"shiki": "^3.13.0",
"solid-js": "^1.8.0",
"solid-toast": "^0.5.0"

View File

@@ -1,14 +1,20 @@
import { Component, Show, createMemo, createEffect, createSignal } from "solid-js"
import { Component, For, Show, createMemo, createEffect, createSignal, onMount, onCleanup } from "solid-js"
import { Dialog } from "@kobalte/core/dialog"
import { Toaster } from "solid-toast"
import AlertDialog from "./components/alert-dialog"
import FolderSelectionView from "./components/folder-selection-view"
import { showConfirmDialog } from "./stores/alerts"
import InstanceTabs from "./components/instance-tabs"
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
import InstanceShell from "./components/instance/instance-shell"
import { RemoteAccessOverlay } from "./components/remote-access-overlay"
import { initMarkdown } from "./lib/markdown"
import { useTheme } from "./lib/theme"
import { useCommands } from "./lib/hooks/use-commands"
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
import { getLogger } from "./lib/logger"
import { initReleaseNotifications } from "./stores/releases"
import { runtimeEnv } from "./lib/runtime-env"
import {
hasInstances,
isSelectingFolder,
@@ -39,22 +45,33 @@ import {
updateSessionModel,
} from "./stores/sessions"
const log = getLogger("actions")
const App: Component = () => {
const { isDark } = useTheme()
const {
preferences,
addRecentFolder,
recordWorkspaceLaunch,
toggleShowThinkingBlocks,
toggleShowTimelineTools,
toggleAutoCleanupBlankSessions,
toggleUsageMetrics,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
setThinkingBlocksExpansion,
} = useConfig()
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
const [launchErrorBinary, setLaunchErrorBinary] = createSignal<string | null>(null)
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
createEffect(() => {
void initMarkdown(isDark()).catch(console.error)
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
})
createEffect(() => {
initReleaseNotifications()
})
const activeInstance = createMemo(() => getActiveInstance())
@@ -92,20 +109,23 @@ const App: Component = () => {
setIsSelectingFolder(true)
const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode"
try {
addRecentFolder(folderPath)
recordWorkspaceLaunch(folderPath, selectedBinary)
clearLaunchError()
const instanceId = await createInstance(folderPath, selectedBinary)
setHasInstances(true)
setShowFolderSelection(false)
setIsAdvancedSettingsOpen(false)
console.log("Created instance:", instanceId, "Port:", instances().get(instanceId)?.port)
log.info("Created instance", {
instanceId,
port: instances().get(instanceId)?.port,
})
} catch (error) {
clearLaunchError()
if (isMissingBinaryError(error)) {
setLaunchErrorBinary(selectedBinary)
}
console.error("Failed to create instance:", error)
log.error("Failed to create instance", error)
} finally {
setIsSelectingFolder(false)
}
@@ -130,16 +150,26 @@ const App: Component = () => {
try {
await acknowledgeDisconnectedInstance()
} catch (error) {
console.error("Failed to finalize disconnected instance:", error)
log.error("Failed to finalize disconnected instance", error)
}
}
async function handleCloseInstance(instanceId: string) {
if (confirm("Stop OpenCode instance? This will stop the server.")) {
await stopInstance(instanceId)
if (instances().size === 0) {
setHasInstances(false)
}
const confirmed = await showConfirmDialog(
"Stop OpenCode instance? This will stop the server.",
{
title: "Stop instance",
variant: "warning",
confirmLabel: "Stop",
cancelLabel: "Keep running",
},
)
if (!confirmed) return
await stopInstance(instanceId)
if (instances().size === 0) {
setHasInstances(false)
}
}
@@ -148,7 +178,7 @@ const App: Component = () => {
const session = await createSession(instanceId)
setActiveParentSession(instanceId, session.id)
} catch (error) {
console.error("Failed to create session:", error)
log.error("Failed to create session", error)
}
}
@@ -172,7 +202,7 @@ const App: Component = () => {
try {
await fetchSessions(instanceId)
} catch (error) {
console.error("Failed to refresh sessions after closing:", error)
log.error("Failed to refresh sessions after closing", error)
}
}
@@ -192,10 +222,14 @@ const App: Component = () => {
const { commands: paletteCommands, executeCommand } = useCommands({
preferences,
toggleAutoCleanupBlankSessions,
toggleShowThinkingBlocks,
toggleShowTimelineTools,
toggleUsageMetrics,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
setThinkingBlocksExpansion,
handleNewInstanceRequest,
handleCloseInstance,
handleNewSession,
@@ -216,6 +250,28 @@ const App: Component = () => {
getActiveSessionIdForInstance: activeSessionIdForInstance,
})
// Listen for Tauri menu events
onMount(() => {
if (runtimeEnv.host === "tauri") {
const tauriBridge = (window as { __TAURI__?: { event?: { listen: (event: string, handler: (event: { payload: unknown }) => void) => Promise<() => void> } } }).__TAURI__
if (tauriBridge?.event) {
let unlistenMenu: (() => void) | null = null
tauriBridge.event.listen("menu:newInstance", () => {
handleNewInstanceRequest()
}).then((unlisten) => {
unlistenMenu = unlisten
}).catch((error) => {
log.error("Failed to listen for menu:newInstance event", error)
})
onCleanup(() => {
unlistenMenu?.()
})
}
}
})
return (
<>
<InstanceDisconnectedModal
@@ -232,7 +288,7 @@ const App: Component = () => {
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6">
<div>
<Dialog.Title class="text-xl font-semibold text-primary">Unable to launch OpenCode</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-2">
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
Install the OpenCode CLI and make sure it is available in your PATH, or pick a custom binary from
Advanced Settings.
</Dialog.Description>
@@ -266,22 +322,29 @@ const App: Component = () => {
onSelect={setActiveInstanceId}
onClose={handleCloseInstance}
onNew={handleNewInstanceRequest}
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
/>
<For each={Array.from(instances().values())}>
{(instance) => {
const isActiveInstance = () => activeInstanceId() === instance.id
return (
<div class="flex-1 min-h-0" style={{ display: isActiveInstance() ? "flex" : "none" }}>
<InstanceShell
instance={instance}
escapeInDebounce={escapeInDebounce()}
paletteCommands={paletteCommands}
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
onNewSession={() => handleNewSession(instance.id)}
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
onExecuteCommand={executeCommand}
/>
</div>
)
}}
</For>
<Show when={activeInstance()} keyed>
{(instance) => (
<InstanceShell
instance={instance}
escapeInDebounce={escapeInDebounce()}
paletteCommands={paletteCommands}
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
onNewSession={() => handleNewSession(instance.id)}
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
onExecuteCommand={executeCommand}
/>
)}
</Show>
</>
}
>
@@ -291,6 +354,7 @@ const App: Component = () => {
advancedSettingsOpen={isAdvancedSettingsOpen()}
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
/>
</Show>
@@ -320,6 +384,10 @@ const App: Component = () => {
</div>
</div>
</Show>
<RemoteAccessOverlay open={remoteAccessOpen()} onClose={() => setRemoteAccessOpen(false)} />
<AlertDialog />
<Toaster
position="top-right"
@@ -334,4 +402,5 @@ const App: Component = () => {
)
}
export default App

View File

@@ -3,7 +3,9 @@ import { For, Show, createEffect, createMemo } from "solid-js"
import { agents, fetchAgents, sessions } from "../stores/sessions"
import { ChevronDown } from "lucide-solid"
import type { Agent } from "../types/session"
import Kbd from "./kbd"
import { getLogger } from "../lib/logger"
const log = getLogger("session")
interface AgentSelectorProps {
instanceId: string
@@ -50,10 +52,11 @@ export default function AgentSelector(props: AgentSelectorProps) {
createEffect(() => {
if (instanceAgents().length === 0) {
fetchAgents(props.instanceId).catch(console.error)
fetchAgents(props.instanceId).catch((error) => log.error("Failed to fetch agents", error))
}
})
const handleChange = async (value: Agent | null) => {
if (value && value.name !== props.currentAgent) {
await props.onAgentChange(value.name)
@@ -116,9 +119,6 @@ export default function AgentSelector(props: AgentSelectorProps) {
</Select.Content>
</Select.Portal>
</Select>
<span class="hint sidebar-selector-hint">
<Kbd shortcut="cmd+shift+a" />
</span>
</div>
)
}

View File

@@ -0,0 +1,132 @@
import { Dialog } from "@kobalte/core/dialog"
import { Component, Show, createEffect } from "solid-js"
import { alertDialogState, dismissAlertDialog } from "../stores/alerts"
import type { AlertVariant, AlertDialogState } from "../stores/alerts"
const variantAccent: Record<AlertVariant, { badgeBg: string; badgeBorder: string; badgeText: string; symbol: string; fallbackTitle: string }> = {
info: {
badgeBg: "var(--badge-neutral-bg)",
badgeBorder: "var(--border-base)",
badgeText: "var(--accent-primary)",
symbol: "i",
fallbackTitle: "Heads up",
},
warning: {
badgeBg: "rgba(255, 152, 0, 0.14)",
badgeBorder: "var(--status-warning)",
badgeText: "var(--status-warning)",
symbol: "!",
fallbackTitle: "Please review",
},
error: {
badgeBg: "var(--danger-soft-bg)",
badgeBorder: "var(--status-error)",
badgeText: "var(--status-error)",
symbol: "!",
fallbackTitle: "Something went wrong",
},
}
function dismiss(confirmed: boolean, payload?: AlertDialogState | null) {
const current = payload ?? alertDialogState()
if (current?.type === "confirm") {
if (confirmed) {
current.onConfirm?.()
} else {
current.onCancel?.()
}
current.resolve?.(confirmed)
} else if (confirmed) {
current?.onConfirm?.()
}
dismissAlertDialog()
}
const AlertDialog: Component = () => {
let primaryButtonRef: HTMLButtonElement | undefined
createEffect(() => {
if (alertDialogState()) {
queueMicrotask(() => {
primaryButtonRef?.focus()
})
}
})
return (
<Show when={alertDialogState()} keyed>
{(payload) => {
const variant = payload.variant ?? "info"
const accent = variantAccent[variant]
const title = payload.title || accent.fallbackTitle
const isConfirm = payload.type === "confirm"
const confirmLabel = payload.confirmLabel || (isConfirm ? "Confirm" : "OK")
const cancelLabel = payload.cancelLabel || "Cancel"
return (
<Dialog
open
modal
onOpenChange={(open) => {
if (!open) {
dismiss(false, payload)
}
}}
>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-sm p-6 border border-base shadow-2xl" tabIndex={-1}>
<div class="flex items-start gap-3">
<div
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
style={{
"background-color": accent.badgeBg,
"border-color": accent.badgeBorder,
color: accent.badgeText,
}}
aria-hidden
>
{accent.symbol}
</div>
<div class="flex-1 min-w-0">
<Dialog.Title class="text-lg font-semibold text-primary">{title}</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-1 whitespace-pre-line break-words">
{payload.message}
{payload.detail && <p class="mt-2 text-secondary">{payload.detail}</p>}
</Dialog.Description>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
{isConfirm && (
<button
type="button"
class="button-secondary"
onClick={() => dismiss(false, payload)}
>
{cancelLabel}
</button>
)}
<button
type="button"
class="button-primary"
ref={(el) => {
primaryButtonRef = el
}}
onClick={() => dismiss(true, payload)}
>
{confirmLabel}
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
)
}}
</Show>
)
}
export default AlertDialog

View File

@@ -1,10 +1,18 @@
import { createMemo, Show, onMount, createEffect } from "solid-js"
import { createMemo, Show, createEffect, onCleanup } from "solid-js"
import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
import { disableCache } from "@git-diff-view/core"
import type { DiffHighlighterLang } from "@git-diff-view/core"
import { ErrorBoundary } from "solid-js"
import { getLanguageFromPath } from "../lib/markdown"
import { normalizeDiffText } from "../lib/diff-utils"
import { setToolRenderCache } from "../lib/tool-render-cache"
import { setCacheEntry } from "../lib/global-cache"
import type { CacheEntryParams } from "../lib/global-cache"
import type { DiffViewMode } from "../stores/preferences"
import { getLogger } from "../lib/logger"
const log = getLogger("session")
disableCache()
interface ToolCallDiffViewerProps {
diffText: string
@@ -13,7 +21,7 @@ interface ToolCallDiffViewerProps {
mode: DiffViewMode
onRendered?: () => void
cachedHtml?: string
cacheKey?: string
cacheEntryParams?: CacheEntryParams
}
type DiffData = {
@@ -22,16 +30,23 @@ type DiffData = {
hunks: string[]
}
type CaptureContext = {
theme: ToolCallDiffViewerProps["theme"]
mode: DiffViewMode
diffText: string
cacheEntryParams?: CacheEntryParams
}
export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
const diffData = createMemo<DiffData | null>(() => {
const normalized = normalizeDiffText(props.diffText)
if (!normalized) {
return null
}
const language = getLanguageFromPath(props.filePath) || "text"
const fileName = props.filePath || "diff"
return {
oldFile: {
fileName,
@@ -44,34 +59,48 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
hunks: [normalized],
}
})
let diffContainerRef: HTMLDivElement | undefined
const captureAndCacheHtml = () => {
if (diffContainerRef && props.cacheKey && !props.cachedHtml) {
// Extract the rendered HTML from DiffView container
const renderedHtml = diffContainerRef.innerHTML
if (renderedHtml) {
setToolRenderCache(props.cacheKey, {
let lastCapturedKey: string | undefined
const contextKey = createMemo(() => {
const data = diffData()
if (!data) return ""
return `${props.theme}|${props.mode}|${props.diffText}`
})
createEffect(() => {
const cachedHtml = props.cachedHtml
if (cachedHtml) {
// When we are given cached HTML, we rely on the caller's cache
// and simply notify once rendered.
props.onRendered?.()
return
}
const key = contextKey()
if (!key) return
if (!diffContainerRef) return
if (lastCapturedKey === key) return
requestAnimationFrame(() => {
if (!diffContainerRef) return
const markup = diffContainerRef.innerHTML
if (!markup) return
lastCapturedKey = key
if (props.cacheEntryParams) {
setCacheEntry(props.cacheEntryParams, {
text: props.diffText,
html: renderedHtml,
html: markup,
theme: props.theme,
mode: props.mode,
})
}
}
props.onRendered?.()
}
// Also capture HTML when diff data changes
createEffect(() => {
const data = diffData()
if (data && !props.cachedHtml) {
// Delay to allow DiffView to re-render with new data
setTimeout(captureAndCacheHtml, 100)
}
props.onRendered?.()
})
})
return (
<div class="tool-call-diff-viewer">
<Show
@@ -83,14 +112,19 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
fallback={<pre class="tool-call-diff-fallback">{props.diffText}</pre>}
>
{(data) => (
<DiffView
data={data()}
diffViewMode={props.mode === "split" ? DiffModeEnum.Split : DiffModeEnum.Unified}
diffViewTheme={props.theme}
diffViewHighlight
diffViewWrap={false}
diffViewFontSize={13}
/>
<ErrorBoundary fallback={(error) => {
log.warn("Failed to render diff view", error)
return <pre class="tool-call-diff-fallback">{props.diffText}</pre>
}}>
<DiffView
data={data()}
diffViewMode={props.mode === "split" ? DiffModeEnum.Split : DiffModeEnum.Unified}
diffViewTheme={props.theme}
diffViewHighlight
diffViewWrap={false}
diffViewFontSize={13}
/>
</ErrorBoundary>
)}
</Show>
</div>

View File

@@ -1,8 +1,8 @@
import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js"
import { ArrowUpLeft, Folder as FolderIcon, Loader2, X } from "lucide-solid"
import type { FileSystemEntry, FileSystemListingMetadata } from "../../../cli/src/api-types"
import { WINDOWS_DRIVES_ROOT } from "../../../cli/src/api-types"
import { cliApi } from "../lib/api-client"
import type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types"
import { WINDOWS_DRIVES_ROOT } from "../../../server/src/api-types"
import { serverApi } from "../lib/api-client"
function normalizePathKey(input?: string | null) {
if (!input || input === "." || input === "./") {
@@ -144,7 +144,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
})
}
const response = await cliApi.listFileSystem(targetPath, { includeFiles: false })
const response = await serverApi.listFileSystem(targetPath, { includeFiles: false })
const canonicalKey = normalizePathKey(response.metadata.currentPath)
const directories = response.entries
.filter((entry) => entry.type === "directory")

View File

@@ -1,222 +0,0 @@
import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js"
import type { OpencodeClient } from "@opencode-ai/sdk/client"
import { cliApi } from "../lib/api-client"
interface FileItem {
path: string
added?: number
removed?: number
isGitFile: boolean
}
interface FilePickerProps {
open: boolean
onSelect: (path: string) => void
onNavigate: (direction: "up" | "down") => void
onClose: () => void
instanceClient: OpencodeClient
searchQuery: string
textareaRef?: HTMLTextAreaElement
workspaceId: string
}
const FilePicker: Component<FilePickerProps> = (props) => {
const [files, setFiles] = createSignal<FileItem[]>([])
const [selectedIndex, setSelectedIndex] = createSignal(0)
const [loading, setLoading] = createSignal(false)
const [allFiles, setAllFiles] = createSignal<FileItem[]>([])
const [isInitialized, setIsInitialized] = createSignal(false)
let containerRef: HTMLDivElement | undefined
let scrollContainerRef: HTMLDivElement | undefined
async function fetchFiles(searchQuery: string) {
console.log(`[FilePicker] Fetching files for query: "${searchQuery}"`)
setLoading(true)
try {
if (allFiles().length === 0) {
console.log(`[FilePicker] Scanning workspace: ${props.workspaceId}`)
const entries = await cliApi.listWorkspaceFiles(props.workspaceId)
const scannedFiles: FileItem[] = entries.map<FileItem>((entry) => ({
path: entry.path,
isGitFile: false,
}))
setAllFiles(scannedFiles)
console.log(`[FilePicker] Found ${scannedFiles.length} files`)
}
const filteredFiles = searchQuery.trim()
? allFiles().filter((f) => f.path.toLowerCase().includes(searchQuery.toLowerCase()))
: allFiles()
console.log(`[FilePicker] Showing ${filteredFiles.length} files`)
setFiles(filteredFiles)
setSelectedIndex(0)
setTimeout(() => {
if (scrollContainerRef) {
scrollContainerRef.scrollTop = 0
}
}, 0)
} catch (error) {
console.error(`[FilePicker] Failed to fetch files:`, error)
setFiles([])
} finally {
setLoading(false)
}
}
let lastQuery = ""
createEffect(() => {
console.log(
`[FilePicker] Effect triggered - open: ${props.open}, query: "${props.searchQuery}", isInitialized: ${isInitialized()}`,
)
if (props.open && !isInitialized()) {
setIsInitialized(true)
console.log("[FilePicker] First open - fetching files")
fetchFiles(props.searchQuery)
lastQuery = props.searchQuery
return
}
if (props.open && props.searchQuery !== lastQuery) {
console.log(`[FilePicker] Query changed from "${lastQuery}" to "${props.searchQuery}"`)
lastQuery = props.searchQuery
fetchFiles(props.searchQuery)
}
})
function scrollToSelected() {
setTimeout(() => {
const selectedElement = containerRef?.querySelector('[data-file-selected="true"]')
if (selectedElement) {
selectedElement.scrollIntoView({ block: "nearest", behavior: "smooth" })
}
}, 0)
}
function handleSelect(path: string) {
props.onSelect(path)
}
function handleNavigateUp() {
setSelectedIndex((prev) => {
const next = Math.max(prev - 1, 0)
scrollToSelected()
return next
})
}
function handleNavigateDown() {
setSelectedIndex((prev) => {
const next = Math.min(prev + 1, files().length - 1)
scrollToSelected()
return next
})
}
createEffect(() => {
if (!props.open) return
const listener = (e: KeyboardEvent) => {
if (!props.open) return
const fileList = files()
if (e.key === "Escape") {
e.preventDefault()
e.stopPropagation()
props.onClose()
return
}
if (fileList.length === 0) return
if (e.key === "ArrowDown") {
e.preventDefault()
e.stopPropagation()
handleNavigateDown()
props.onNavigate("down")
} else if (e.key === "ArrowUp") {
e.preventDefault()
e.stopPropagation()
handleNavigateUp()
props.onNavigate("up")
} else if (e.key === "Enter") {
e.preventDefault()
e.stopPropagation()
if (fileList[selectedIndex()]) {
handleSelect(fileList[selectedIndex()].path)
}
}
}
document.addEventListener("keydown", listener, true)
onCleanup(() => document.removeEventListener("keydown", listener, true))
})
return (
<Show when={props.open}>
<div
ref={containerRef}
class="dropdown-surface bottom-full left-0 mb-2 max-w-2xl rounded-lg"
style={{ "z-index": 100 }}
>
<div ref={scrollContainerRef} class="dropdown-content max-h-96">
<Show
when={!loading() && isInitialized()}
fallback={
<div class="dropdown-loading">
<div class="spinner inline-block h-4 w-4 mr-2"></div>
<span>Loading files...</span>
</div>
}
>
<Show
when={files().length > 0}
fallback={<div class="dropdown-empty">No matching files</div>}
>
<For each={files()}>
{(file, index) => (
<div
data-file-selected={index() === selectedIndex()}
class={`dropdown-item border-b px-4 py-2 font-mono text-sm ${
index() === selectedIndex() ? "dropdown-item-highlight" : ""
}`}
style="border-color: var(--border-muted)"
onClick={() => handleSelect(file.path)}
onMouseEnter={() => setSelectedIndex(index())}
>
<div class="flex items-center justify-between">
<span>{file.path}</span>
<Show when={file.isGitFile && (file.added || file.removed)}>
<div class="flex gap-2">
<Show when={file.added}>
<span class="dropdown-diff-added">+{file.added}</span>
</Show>
<Show when={file.removed}>
<span class="dropdown-diff-removed">-{file.removed}</span>
</Show>
</div>
</Show>
</div>
</div>
)}
</For>
</Show>
</Show>
</div>
<div class="dropdown-footer p-2">
<div class="flex items-center justify-between px-2">
<span> Navigate Enter Select Esc Close</span>
</div>
</div>
</div>
</Show>
)
}
export default FilePicker

View File

@@ -1,197 +1,26 @@
import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup, onMount } from "solid-js"
import { Folder as FolderIcon, File as FileIcon, Loader2, Search, X } from "lucide-solid"
import type { FileSystemEntry } from "../../../cli/src/api-types"
import { cliApi } from "../lib/api-client"
import { getServerMeta } from "../lib/server-meta"
import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js"
import { Folder as FolderIcon, File as FileIcon, Loader2, Search, X, ArrowUpLeft } from "lucide-solid"
import type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types"
import { serverApi } from "../lib/api-client"
import { getLogger } from "../lib/logger"
const log = getLogger("actions")
const MAX_RESULTS = 200
type CacheListener = (entries: FileSystemEntry[]) => void
interface FileSystemCacheState {
entriesMap: Map<string, FileSystemEntry>
entriesList: FileSystemEntry[]
loadedDirectories: Set<string>
loadingPromises: Map<string, Promise<void>>
pendingDirectories: string[]
listeners: Set<CacheListener>
queueActive: boolean
}
const fileSystemCache: FileSystemCacheState = {
entriesMap: new Map(),
entriesList: [],
loadedDirectories: new Set(),
loadingPromises: new Map(),
pendingDirectories: [],
listeners: new Set(),
queueActive: false,
}
let cacheWorkspaceRoot: string | null = null
function normalizeEntryPath(path: string): string {
if (!path || path === ".") {
function normalizeEntryPath(path: string | undefined): string {
if (!path || path === "." || path === "./") {
return "."
}
const cleaned = path.replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/\/+/g, "/")
return cleaned || "."
}
function updateCache(entries: FileSystemEntry[]): boolean {
let changed = false
for (const entry of entries) {
const normalizedPath = normalizeEntryPath(entry.path)
const normalizedEntry = normalizedPath === entry.path ? entry : { ...entry, path: normalizedPath }
const existing = fileSystemCache.entriesMap.get(normalizedPath)
if (
!existing ||
existing.name !== normalizedEntry.name ||
existing.type !== normalizedEntry.type ||
existing.size !== normalizedEntry.size ||
existing.modifiedAt !== normalizedEntry.modifiedAt
) {
fileSystemCache.entriesMap.set(normalizedPath, normalizedEntry)
changed = true
}
let cleaned = path.replace(/\\/g, "/")
if (cleaned.startsWith("./")) {
cleaned = cleaned.replace(/^\.\/+/, "")
}
if (changed) {
fileSystemCache.entriesList = Array.from(fileSystemCache.entriesMap.values()).sort((a, b) =>
a.path.localeCompare(b.path),
)
if (cleaned.startsWith("/")) {
cleaned = cleaned.replace(/^\/+/, "")
}
return changed
}
function notifyCacheListeners() {
for (const listener of fileSystemCache.listeners) {
listener(fileSystemCache.entriesList)
}
}
function subscribeToCache(listener: CacheListener) {
fileSystemCache.listeners.add(listener)
listener(fileSystemCache.entriesList)
return () => fileSystemCache.listeners.delete(listener)
}
function resetFileSystemCache() {
fileSystemCache.entriesMap.clear()
fileSystemCache.entriesList = []
fileSystemCache.loadedDirectories.clear()
fileSystemCache.loadingPromises.clear()
fileSystemCache.pendingDirectories = []
fileSystemCache.queueActive = false
notifyCacheListeners()
}
function enqueueDirectory(path: string, priority = false) {
const normalized = normalizeEntryPath(path)
if (normalized === "." || fileSystemCache.loadedDirectories.has(normalized) || fileSystemCache.loadingPromises.has(normalized)) {
return
}
const existingIndex = fileSystemCache.pendingDirectories.indexOf(normalized)
if (existingIndex !== -1) {
if (priority) {
fileSystemCache.pendingDirectories.splice(existingIndex, 1)
fileSystemCache.pendingDirectories.unshift(normalized)
}
return
}
if (priority) {
fileSystemCache.pendingDirectories.unshift(normalized)
} else {
fileSystemCache.pendingDirectories.push(normalized)
}
}
async function loadDirectory(path: string): Promise<void> {
const normalized = normalizeEntryPath(path)
if (fileSystemCache.loadedDirectories.has(normalized)) {
return
}
const existing = fileSystemCache.loadingPromises.get(normalized)
if (existing) {
await existing
return
}
const promise = cliApi
.listFileSystem(normalized === "." ? "." : normalized)
.then(({ entries }) => {
const changed = updateCache(entries)
fileSystemCache.loadedDirectories.add(normalized)
for (const entry of entries) {
if (entry.type === "directory") {
enqueueDirectory(entry.path)
}
}
if (changed) {
notifyCacheListeners()
}
})
.finally(() => {
fileSystemCache.loadingPromises.delete(normalized)
})
fileSystemCache.loadingPromises.set(normalized, promise)
await promise
}
async function processDirectoryQueue() {
if (fileSystemCache.queueActive) {
return
}
fileSystemCache.queueActive = true
try {
while (fileSystemCache.pendingDirectories.length > 0) {
const next = fileSystemCache.pendingDirectories.shift()
if (!next) continue
try {
await loadDirectory(next)
} catch (error) {
console.warn("Failed to load directory", next, error)
}
}
} finally {
fileSystemCache.queueActive = false
}
}
function startBackgroundLoading() {
void processDirectoryQueue()
}
function prioritizeDirectoriesForQuery(query: string) {
const normalized = query.replace(/\\/g, "/").trim()
if (!normalized) {
return
}
const segments = normalized.split("/").filter(Boolean)
let prefix = ""
for (const segment of segments) {
prefix = prefix ? `${prefix}/${segment}` : segment
enqueueDirectory(prefix, true)
}
startBackgroundLoading()
}
async function ensureWorkspaceFilesystemLoaded(workspaceRoot: string) {
if (cacheWorkspaceRoot && cacheWorkspaceRoot !== workspaceRoot) {
cacheWorkspaceRoot = workspaceRoot
resetFileSystemCache()
} else if (!cacheWorkspaceRoot) {
cacheWorkspaceRoot = workspaceRoot
}
await loadDirectory(".")
startBackgroundLoading()
cleaned = cleaned.replace(/\/+/g, "/")
return cleaned === "" ? "." : cleaned
}
function resolveAbsolutePath(root: string, relativePath: string): string {
@@ -207,11 +36,6 @@ function resolveAbsolutePath(root: string, relativePath: string): string {
return `${trimmedRoot}${normalized}`
}
function formatRootLabel(root: string): string {
if (!root) return "Workspace Root"
const parts = root.split(/[/\\]/).filter(Boolean)
return parts[parts.length - 1] || root || "Workspace Root"
}
interface FileSystemBrowserDialogProps {
open: boolean
@@ -222,73 +46,174 @@ interface FileSystemBrowserDialogProps {
onClose: () => void
}
type FolderRow = { type: "up"; path: string } | { type: "entry"; entry: FileSystemEntry }
const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props) => {
const [entries, setEntries] = createSignal<FileSystemEntry[]>([])
const [rootPath, setRootPath] = createSignal("")
const [loading, setLoading] = createSignal(false)
const [entries, setEntries] = createSignal<FileSystemEntry[]>([])
const [currentMetadata, setCurrentMetadata] = createSignal<FileSystemListingMetadata | null>(null)
const [loadingPath, setLoadingPath] = createSignal<string | null>(null)
const [error, setError] = createSignal<string | null>(null)
const [searchQuery, setSearchQuery] = createSignal("")
const [selectedIndex, setSelectedIndex] = createSignal(0)
let searchInputRef: HTMLInputElement | undefined
onMount(() => {
const unsubscribe = subscribeToCache((items) => setEntries(items))
onCleanup(unsubscribe)
})
const directoryCache = new Map<string, FileSystemEntry[]>()
const metadataCache = new Map<string, FileSystemListingMetadata>()
const inFlightLoads = new Map<string, Promise<FileSystemListingMetadata>>()
createEffect(() => {
const query = searchQuery().trim()
if (!query) {
return
function resetDialogState() {
directoryCache.clear()
metadataCache.clear()
inFlightLoads.clear()
setEntries([])
setCurrentMetadata(null)
setLoadingPath(null)
}
async function fetchDirectory(path: string, makeCurrent = false): Promise<FileSystemListingMetadata> {
const normalized = normalizeEntryPath(path)
if (directoryCache.has(normalized) && metadataCache.has(normalized)) {
if (makeCurrent) {
setCurrentMetadata(metadataCache.get(normalized) ?? null)
setEntries(directoryCache.get(normalized) ?? [])
}
return metadataCache.get(normalized) as FileSystemListingMetadata
}
prioritizeDirectoriesForQuery(query)
})
if (inFlightLoads.has(normalized)) {
const metadata = await inFlightLoads.get(normalized)!
if (makeCurrent) {
setCurrentMetadata(metadata)
setEntries(directoryCache.get(normalized) ?? [])
}
return metadata
}
const loadPromise = (async () => {
setLoadingPath(normalized)
const response = await serverApi.listFileSystem(normalized === "." ? "." : normalized, {
includeFiles: props.mode === "files",
})
directoryCache.set(normalized, response.entries)
metadataCache.set(normalized, response.metadata)
if (!rootPath()) {
setRootPath(response.metadata.rootPath)
}
if (loadingPath() === normalized) {
setLoadingPath(null)
}
return response.metadata
})().catch((err) => {
if (loadingPath() === normalized) {
setLoadingPath(null)
}
throw err
})
inFlightLoads.set(normalized, loadPromise)
try {
const metadata = await loadPromise
if (makeCurrent) {
const key = normalizeEntryPath(metadata.currentPath)
setCurrentMetadata(metadata)
setEntries(directoryCache.get(key) ?? directoryCache.get(normalized) ?? [])
}
return metadata
} finally {
inFlightLoads.delete(normalized)
}
}
async function refreshEntries() {
setLoading(true)
setError(null)
resetDialogState()
try {
const meta = await getServerMeta()
setRootPath(meta.workspaceRoot)
await ensureWorkspaceFilesystemLoaded(meta.workspaceRoot)
const metadata = await fetchDirectory(".", true)
setRootPath(metadata.rootPath)
setEntries(directoryCache.get(normalizeEntryPath(metadata.currentPath)) ?? [])
} catch (err) {
const message = err instanceof Error ? err.message : "Unable to load filesystem"
setError(message)
} finally {
setLoading(false)
}
}
function describeLoadingPath() {
const path = loadingPath()
if (!path) {
return "filesystem"
}
if (path === ".") {
return rootPath() || "workspace root"
}
return resolveAbsolutePath(rootPath(), path)
}
function currentAbsolutePath(): string {
const metadata = currentMetadata()
if (!metadata) {
return rootPath()
}
if (metadata.pathKind === "relative") {
return resolveAbsolutePath(rootPath(), metadata.currentPath)
}
return metadata.displayPath
}
function handleOverlayClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
props.onClose()
}
}
function handleEntrySelect(entry: FileSystemEntry) {
const absolute = resolveAbsolutePath(rootPath(), entry.path)
props.onSelect(absolute)
}
function handleNavigateTo(path: string) {
void fetchDirectory(path, true).catch((err) => {
log.error("Failed to open directory", err)
setError(err instanceof Error ? err.message : "Unable to open directory")
})
}
function handleNavigateUp() {
const parent = currentMetadata()?.parentPath
if (!parent) {
return
}
handleNavigateTo(parent)
}
const filteredEntries = createMemo(() => {
const query = searchQuery().trim().toLowerCase()
const mode = props.mode
const root = rootPath()
const matchesType = entries().filter((entry) => (mode === "directories" ? entry.type === "directory" : entry.type === "file"))
const baseEntries = mode === "directories" && root
? [
{
name: formatRootLabel(root),
path: ".",
type: "directory" as const,
},
...matchesType,
]
: matchesType
const subset = entries().filter((entry) => (props.mode === "directories" ? entry.type === "directory" : true))
if (!query) {
return baseEntries
return subset
}
return baseEntries.filter((entry) => {
const absolute = resolveAbsolutePath(root, entry.path)
return subset.filter((entry) => {
const absolute = resolveAbsolutePath(rootPath(), entry.path)
return absolute.toLowerCase().includes(query) || entry.name.toLowerCase().includes(query)
})
})
const visibleEntries = createMemo(() => filteredEntries().slice(0, MAX_RESULTS))
const folderRows = createMemo<FolderRow[]>(() => {
const rows: FolderRow[] = []
const metadata = currentMetadata()
if (metadata?.parentPath) {
rows.push({ type: "up", path: metadata.parentPath })
}
for (const entry of visibleEntries()) {
rows.push({ type: "entry", entry })
}
return rows
})
createEffect(() => {
const list = visibleEntries()
if (list.length === 0) {
@@ -338,20 +263,12 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
window.addEventListener("keydown", handleKeyDown)
onCleanup(() => {
window.removeEventListener("keydown", handleKeyDown)
resetDialogState()
setRootPath("")
setError(null)
})
})
function handleEntrySelect(entry: FileSystemEntry) {
const absolute = resolveAbsolutePath(rootPath(), entry.path)
props.onSelect(absolute)
}
function handleOverlayClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
props.onClose()
}
}
return (
<Show when={props.open}>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6" onClick={handleOverlayClick}>
@@ -360,9 +277,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
<div class="panel-header flex items-start justify-between gap-4">
<div>
<h3 class="panel-title">{props.title}</h3>
<p class="panel-subtitle">
{props.description || "Search for a path under the configured workspace root."}
</p>
<p class="panel-subtitle">{props.description || "Search for a path under the configured workspace root."}</p>
<Show when={rootPath()}>
<p class="text-xs text-muted mt-1 font-mono break-all">Root: {rootPath()}</p>
</Show>
@@ -392,56 +307,117 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
</div>
</div>
<Show when={props.mode === "directories"}>
<div class="px-4 pb-2">
<div class="flex items-center justify-between gap-3 rounded-md border border-border-subtle px-4 py-3">
<div>
<p class="text-xs text-secondary uppercase tracking-wide">Current folder</p>
<p class="text-sm font-mono text-primary break-all">{currentAbsolutePath()}</p>
</div>
<button
type="button"
class="selector-button selector-button-secondary whitespace-nowrap"
onClick={() => props.onSelect(currentAbsolutePath())}
>
Select Current
</button>
</div>
</div>
</Show>
<div class="panel-list panel-list--fill max-h-96 overflow-auto">
<Show
when={!loading() && !error()}
when={entries().length > 0}
fallback={
<div class="flex items-center justify-center py-6 text-sm text-secondary">
<Show
when={loading()}
when={loadingPath() !== null}
fallback={<span class="text-red-500">{error()}</span>}
>
<div class="flex items-center gap-2">
<Loader2 class="w-4 h-4 animate-spin" />
<span>Loading filesystem</span>
<span>Loading {describeLoadingPath()}</span>
</div>
</Show>
</div>
}
>
<Show when={loadingPath()}>
<div class="flex items-center gap-2 px-4 py-2 text-xs text-secondary">
<Loader2 class="w-3.5 h-3.5 animate-spin" />
<span>Loading {describeLoadingPath()}</span>
</div>
</Show>
<Show
when={visibleEntries().length > 0}
when={folderRows().length > 0}
fallback={
<div class="flex flex-col items-center justify-center gap-2 py-10 text-sm text-secondary">
<p>No matches.</p>
<Show when={searchQuery().trim().length === 0}>
<button type="button" class="selector-button selector-button-secondary" onClick={refreshEntries}>
Retry
</button>
</Show>
<p>No entries found.</p>
<button type="button" class="selector-button selector-button-secondary" onClick={refreshEntries}>
Retry
</button>
</div>
}
>
<For each={visibleEntries()}>
{(entry, index) => (
<button
type="button"
class="panel-list-item flex items-center gap-3 text-left"
classList={{ "panel-list-item-highlight": selectedIndex() === index() }}
onMouseEnter={() => setSelectedIndex(index())}
onClick={() => handleEntrySelect(entry)}
>
<div class="flex h-8 w-8 items-center justify-center rounded-md bg-surface-secondary text-muted">
<Show when={entry.type === "directory"} fallback={<FileIcon class="w-4 h-4" />}>
<FolderIcon class="w-4 h-4" />
</Show>
<For each={folderRows()}>
{(row) => {
if (row.type === "up") {
return (
<div class="panel-list-item" role="button">
<div class="panel-list-item-content directory-browser-row">
<button type="button" class="directory-browser-row-main" onClick={handleNavigateUp}>
<div class="directory-browser-row-icon">
<ArrowUpLeft class="w-4 h-4" />
</div>
<div class="directory-browser-row-text">
<span class="directory-browser-row-name">Up one level</span>
</div>
</button>
</div>
</div>
)
}
const entry = row.entry
const selectEntry = () => handleEntrySelect(entry)
const activateEntry = () => {
if (entry.type === "directory") {
handleNavigateTo(entry.path)
} else {
selectEntry()
}
}
return (
<div class="panel-list-item" role="listitem">
<div class="panel-list-item-content directory-browser-row">
<button type="button" class="directory-browser-row-main" onClick={activateEntry}>
<div class="directory-browser-row-icon">
<Show when={entry.type === "directory"} fallback={<FileIcon class="w-4 h-4" />}>
<FolderIcon class="w-4 h-4" />
</Show>
</div>
<div class="directory-browser-row-text">
<span class="directory-browser-row-name">{entry.name || entry.path}</span>
<span class="directory-browser-row-sub">
{resolveAbsolutePath(rootPath(), entry.path)}
</span>
</div>
</button>
<button
type="button"
class="selector-button selector-button-secondary directory-browser-select"
onClick={(event) => {
event.stopPropagation()
selectEntry()
}}
>
Select
</button>
</div>
</div>
<div class="flex flex-col">
<span class="text-sm font-medium text-primary">{entry.name || entry.path}</span>
<span class="text-xs font-mono text-muted">{resolveAbsolutePath(rootPath(), entry.path)}</span>
</div>
</button>
)}
)
}}
</For>
</Show>
</Show>
@@ -472,3 +448,4 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
}
export default FileSystemBrowserDialog

View File

@@ -1,26 +1,30 @@
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight } from "lucide-solid"
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp } from "lucide-solid"
import { useConfig } from "../stores/preferences"
import AdvancedSettingsModal from "./advanced-settings-modal"
import DirectoryBrowserDialog from "./directory-browser-dialog"
import Kbd from "./kbd"
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
interface FolderSelectionViewProps {
onSelectFolder: (folder: string, binaryPath?: string) => void
isLoading?: boolean
advancedSettingsOpen?: boolean
onAdvancedSettingsOpen?: () => void
onAdvancedSettingsClose?: () => void
onOpenRemoteAccess?: () => void
}
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const { recentFolders, removeRecentFolder, preferences, updateLastUsedBinary } = useConfig()
const { recentFolders, removeRecentFolder, preferences } = useConfig()
const [selectedIndex, setSelectedIndex] = createSignal(0)
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
const nativeDialogsAvailable = supportsNativeDialogs()
let recentListRef: HTMLDivElement | undefined
const folders = () => recentFolders()
@@ -29,9 +33,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
// Update selected binary when preferences change
createEffect(() => {
const lastUsed = preferences().lastUsedBinary
if (lastUsed && lastUsed !== selectedBinary()) {
setSelectedBinary(lastUsed)
}
if (!lastUsed) return
setSelectedBinary((current) => (current === lastUsed ? current : lastUsed))
})
@@ -78,7 +81,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
if (isBrowseShortcut) {
e.preventDefault()
handleBrowse()
void handleBrowse()
return
}
@@ -169,13 +172,23 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
function handleFolderSelect(path: string) {
if (isLoading()) return
updateLastUsedBinary(selectedBinary())
props.onSelectFolder(path, selectedBinary())
}
function handleBrowse() {
async function handleBrowse() {
if (isLoading()) return
setFocusMode("new")
if (nativeDialogsAvailable) {
const fallbackPath = folders()[0]?.path
const selected = await openNativeFolderDialog({
title: "Select Workspace",
defaultPath: fallbackPath,
})
if (selected) {
handleFolderSelect(selected)
}
return
}
setIsFolderBrowserOpen(true)
}
@@ -218,9 +231,20 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
class="w-full max-w-3xl h-full px-8 pb-2 flex flex-col overflow-hidden"
aria-busy={isLoading() ? "true" : "false"}
>
<Show when={props.onOpenRemoteAccess}>
<div class="absolute top-4 right-6">
<button
type="button"
class="selector-button selector-button-secondary inline-flex items-center justify-center"
onClick={() => props.onOpenRemoteAccess?.()}
>
<MonitorUp class="w-4 h-4" />
</button>
</div>
</Show>
<div class="mb-6 text-center shrink-0">
<div class="mb-3 flex justify-center">
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-48 w-auto" loading="lazy" />
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-32 w-auto sm:h-48" loading="lazy" />
</div>
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
<p class="text-base text-secondary">Select a folder to start coding with AI</p>
@@ -230,6 +254,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<div class="space-y-4 flex-1 min-h-0 overflow-hidden flex flex-col">
<Show
when={folders().length > 0}
fallback={
<div class="panel panel-empty-state flex-1">
@@ -307,14 +333,14 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</Show>
<div class="panel shrink-0">
<div class="panel-header">
<div class="panel-header hidden sm:block">
<h2 class="panel-title">Browse for Folder</h2>
<p class="panel-subtitle">Select any folder on your computer</p>
</div>
<div class="panel-body">
<button
onClick={handleBrowse}
onClick={() => void handleBrowse()}
disabled={props.isLoading}
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
onMouseEnter={() => setFocusMode("new")}
@@ -343,7 +369,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</div>
</div>
<div class="mt-1 panel panel-footer shrink-0">
<div class="mt-1 panel panel-footer shrink-0 hidden sm:block">
<div class="panel-footer-hints">
<Show when={folders().length > 0}>
<div class="flex items-center gap-1.5">

View File

@@ -19,7 +19,7 @@ export default function InstanceDisconnectedModal(props: InstanceDisconnectedMod
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6">
<div>
<Dialog.Title class="text-xl font-semibold text-primary">Instance Disconnected</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-2">
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
{folderLabel} can no longer be reached. Close the tab to continue working.
</Dialog.Description>
</div>

View File

@@ -1,6 +1,9 @@
import { Component, Show, For, createSignal, createEffect, onCleanup } from "solid-js"
import type { Instance, RawMcpStatus } from "../types/instance"
import { fetchLspStatus, updateInstance } from "../stores/instances"
import { getLogger } from "../lib/logger"
const log = getLogger("session")
interface InstanceInfoProps {
instance: Instance
@@ -48,6 +51,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
const [isLoadingMetadata, setIsLoadingMetadata] = createSignal(true)
const metadata = () => props.instance.metadata
const binaryVersion = () => props.instance.binaryVersion || metadata()?.version
const mcpServers = () => {
const status = metadata()?.mcpStatus
return status ? parseMcpStatus(status) : []
@@ -104,14 +108,15 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
...(lspStatus ? { lspStatus } : {}),
}
if (!nextMetadata.version) {
nextMetadata.version = "0.15.8"
if (!nextMetadata.version && instance.binaryVersion) {
nextMetadata.version = instance.binaryVersion
}
updateInstance(instanceId, { metadata: nextMetadata })
} catch (error) {
if (!cancelled) {
console.error("Failed to load instance metadata:", error)
log.error("Failed to load instance metadata", error)
}
} finally {
pendingMetadataRequests.delete(instanceId)
@@ -173,13 +178,13 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
)}
</Show>
<Show when={metadata()?.version}>
<Show when={binaryVersion()}>
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
OpenCode Version
</div>
<div class="text-xs px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
v{metadata()?.version}
v{binaryVersion()}
</div>
</div>
</Show>

View File

@@ -2,7 +2,7 @@ import { Component, For, Show } from "solid-js"
import type { Instance } from "../types/instance"
import InstanceTab from "./instance-tab"
import KeyboardHint from "./keyboard-hint"
import { Plus } from "lucide-solid"
import { Plus, MonitorUp } from "lucide-solid"
import { keyboardRegistry } from "../lib/keyboard-registry"
interface InstanceTabsProps {
@@ -11,43 +11,60 @@ interface InstanceTabsProps {
onSelect: (instanceId: string) => void
onClose: (instanceId: string) => void
onNew: () => void
onOpenRemoteAccess?: () => void
}
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
return (
<div class="tab-bar tab-bar-instance">
<div class="tab-container" role="tablist">
<div class="flex items-center gap-1 overflow-x-auto">
<For each={Array.from(props.instances.entries())}>
{([id, instance]) => (
<InstanceTab
instance={instance}
active={id === props.activeInstanceId}
onSelect={() => props.onSelect(id)}
onClose={() => props.onClose(id)}
/>
)}
</For>
<button
class="new-tab-button"
onClick={props.onNew}
title="New instance (Cmd/Ctrl+N)"
aria-label="New instance"
>
<Plus class="w-4 h-4" />
</button>
</div>
<Show when={Array.from(props.instances.entries()).length > 1}>
<div class="flex-shrink-0 ml-4">
<KeyboardHint
shortcuts={[keyboardRegistry.get("instance-prev")!, keyboardRegistry.get("instance-next")!].filter(
Boolean,
)}
/>
<div class="tab-scroll">
<div class="tab-strip">
<div class="tab-strip-tabs">
<For each={Array.from(props.instances.entries())}>
{([id, instance]) => (
<InstanceTab
instance={instance}
active={id === props.activeInstanceId}
onSelect={() => props.onSelect(id)}
onClose={() => props.onClose(id)}
/>
)}
</For>
<button
class="new-tab-button"
onClick={props.onNew}
title="New instance (Cmd/Ctrl+N)"
aria-label="New instance"
>
<Plus class="w-4 h-4" />
</button>
</div>
<div class="tab-strip-spacer" />
<Show when={Array.from(props.instances.entries()).length > 1}>
<div class="tab-shortcuts">
<KeyboardHint
shortcuts={[keyboardRegistry.get("instance-prev")!, keyboardRegistry.get("instance-next")!].filter(
Boolean,
)}
/>
</div>
</Show>
<Show when={Boolean(props.onOpenRemoteAccess)}>
<button
class="new-tab-button tab-remote-button"
onClick={() => props.onOpenRemoteAccess?.()}
title="Remote connect"
aria-label="Remote connect"
>
<MonitorUp class="w-4 h-4" />
</button>
</Show>
</div>
</Show>
</div>
</div>
</div>
)
}

View File

@@ -1,11 +1,15 @@
import { Component, createSignal, Show, For, createEffect, onMount, onCleanup, createMemo } from "solid-js"
import { Loader2, Trash2 } from "lucide-solid"
import type { Instance } from "../types/instance"
import { getParentSessions, createSession, setActiveParentSession } from "../stores/sessions"
import { getParentSessions, createSession, setActiveParentSession, deleteSession, loading } from "../stores/sessions"
import InstanceInfo from "./instance-info"
import KeyboardHint from "./keyboard-hint"
import Kbd from "./kbd"
import { keyboardRegistry, type KeyboardShortcut } from "../lib/keyboard-registry"
import { isMac } from "../lib/keyboard-utils"
import { getLogger } from "../lib/logger"
const log = getLogger("actions")
interface InstanceWelcomeViewProps {
@@ -16,8 +20,17 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
const [isCreating, setIsCreating] = createSignal(false)
const [selectedIndex, setSelectedIndex] = createSignal(0)
const [focusMode, setFocusMode] = createSignal<"sessions" | "new-session" | null>("sessions")
const [showInstanceInfoOverlay, setShowInstanceInfoOverlay] = createSignal(false)
const [isDesktopLayout, setIsDesktopLayout] = createSignal(
typeof window !== "undefined" ? window.matchMedia("(min-width: 1024px)").matches : false,
)
const parentSessions = () => getParentSessions(props.instance.id)
const isFetchingSessions = createMemo(() => Boolean(loading().fetchingSessions.get(props.instance.id)))
const isSessionDeleting = (sessionId: string) => {
const deleting = loading().deletingSession.get(props.instance.id)
return deleting ? deleting.has(sessionId) : false
}
const newSessionShortcut = createMemo<KeyboardShortcut>(() => {
const registered = keyboardRegistry.get("session-new")
if (registered) return registered
@@ -47,6 +60,12 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
}
})
const openInstanceInfoOverlay = () => {
if (isDesktopLayout()) return
setShowInstanceInfoOverlay(true)
}
const closeInstanceInfoOverlay = () => setShowInstanceInfoOverlay(false)
function scrollToIndex(index: number) {
const element = document.querySelector(`[data-session-index="${index}"]`)
if (element) {
@@ -55,6 +74,14 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
}
function handleKeyDown(e: KeyboardEvent) {
if (showInstanceInfoOverlay()) {
if (e.key === "Escape") {
e.preventDefault()
closeInstanceInfoOverlay()
}
return
}
const sessions = parentSessions()
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "n") {
@@ -104,26 +131,79 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
scrollToIndex(newIndex)
} else if (e.key === "Enter") {
e.preventDefault()
handleEnterKey()
void handleEnterKey()
} else if (e.key === "Delete" || e.key === "Backspace") {
e.preventDefault()
void handleDeleteKey()
}
}
async function handleEnterKey() {
const sessions = parentSessions()
const index = selectedIndex()
if (index < sessions.length) {
await handleSessionSelect(sessions[index].id)
}
}
onMount(() => {
async function handleDeleteKey() {
const sessions = parentSessions()
const index = selectedIndex()
if (index >= sessions.length) {
return
}
await handleSessionDelete(sessions[index].id)
const updatedSessions = parentSessions()
if (updatedSessions.length === 0) {
setFocusMode("new-session")
setSelectedIndex(0)
return
}
const nextIndex = Math.min(index, updatedSessions.length - 1)
setSelectedIndex(nextIndex)
setFocusMode("sessions")
scrollToIndex(nextIndex)
}
onMount(() => {
window.addEventListener("keydown", handleKeyDown)
onCleanup(() => {
window.removeEventListener("keydown", handleKeyDown)
})
})
onMount(() => {
const mediaQuery = window.matchMedia("(min-width: 1024px)")
const handleMediaChange = (matches: boolean) => {
setIsDesktopLayout(matches)
if (matches) {
closeInstanceInfoOverlay()
}
}
const listener = (event: MediaQueryListEvent) => handleMediaChange(event.matches)
if (typeof mediaQuery.addEventListener === "function") {
mediaQuery.addEventListener("change", listener)
onCleanup(() => {
mediaQuery.removeEventListener("change", listener)
})
} else {
mediaQuery.addListener(listener)
onCleanup(() => {
mediaQuery.removeListener(listener)
})
}
handleMediaChange(mediaQuery.matches)
})
function formatRelativeTime(timestamp: number): string {
const seconds = Math.floor((Date.now() - timestamp) / 1000)
const minutes = Math.floor(seconds / 60)
@@ -144,15 +224,26 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
setActiveParentSession(props.instance.id, sessionId)
}
async function handleSessionDelete(sessionId: string) {
if (isSessionDeleting(sessionId)) return
try {
await deleteSession(props.instance.id, sessionId)
} catch (error) {
log.error("Failed to delete session:", error)
}
}
async function handleNewSession() {
if (isCreating()) return
setIsCreating(true)
try {
const session = await createSession(props.instance.id)
setActiveParentSession(props.instance.id, session.id)
} catch (error) {
console.error("Failed to create session:", error)
log.error("Failed to create session:", error)
} finally {
setIsCreating(false)
}
@@ -165,73 +256,138 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
<Show
when={parentSessions().length > 0}
fallback={
<div class="panel panel-empty-state flex-1 flex flex-col justify-center">
<div class="panel-empty-state-icon">
<svg class="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
<Show
when={isFetchingSessions()}
fallback={
<div class="panel panel-empty-state flex-1 flex flex-col justify-center">
<div class="panel-empty-state-icon">
<svg class="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
</div>
<p class="panel-empty-state-title">No Previous Sessions</p>
<p class="panel-empty-state-description">Create a new session below to get started</p>
<Show when={!isDesktopLayout() && !showInstanceInfoOverlay()}>
<button type="button" class="button-tertiary mt-4 lg:hidden" onClick={openInstanceInfoOverlay}>
View Instance Info
</button>
</Show>
</div>
}
>
<div class="panel panel-empty-state flex-1 flex flex-col justify-center">
<div class="panel-empty-state-icon">
<Loader2 class="w-12 h-12 mx-auto animate-spin text-muted" />
</div>
<p class="panel-empty-state-title">Loading Sessions</p>
<p class="panel-empty-state-description">Fetching your previous sessions...</p>
</div>
<p class="panel-empty-state-title">No Previous Sessions</p>
<p class="panel-empty-state-description">Create a new session below to get started</p>
</div>
</Show>
}
>
<div class="panel flex flex-col flex-1 min-h-0">
<div class="panel-header">
<h2 class="panel-title">Resume Session</h2>
<p class="panel-subtitle">
{parentSessions().length} {parentSessions().length === 1 ? "session" : "sessions"} available
</p>
<div class="flex flex-row flex-wrap items-center gap-2 justify-between">
<div>
<h2 class="panel-title">Resume Session</h2>
<p class="panel-subtitle">
{parentSessions().length} {parentSessions().length === 1 ? "session" : "sessions"} available
</p>
</div>
<Show when={!isDesktopLayout() && !showInstanceInfoOverlay()}>
<button
type="button"
class="button-tertiary lg:hidden flex-shrink-0"
onClick={openInstanceInfoOverlay}
>
View Instance Info
</button>
</Show>
</div>
</div>
<div class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto">
<For each={parentSessions()}>
{(session, index) => (
<div
class="panel-list-item"
classList={{
"panel-list-item-highlight": focusMode() === "sessions" && selectedIndex() === index(),
}}
>
<button
data-session-index={index()}
class="panel-list-item-content group w-full"
onClick={() => handleSessionSelect(session.id)}
onMouseEnter={() => {
setFocusMode("sessions")
setSelectedIndex(index())
{(session, index) => {
const isFocused = () => focusMode() === "sessions" && selectedIndex() === index()
return (
<div
class="panel-list-item"
classList={{
"panel-list-item-highlight": isFocused(),
}}
>
<div class="flex items-center justify-between gap-3 w-full">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span
class="text-sm font-medium text-primary truncate transition-colors"
classList={{
"text-accent":
focusMode() === "sessions" && selectedIndex() === index(),
<div class="flex items-center gap-2 w-full px-1">
<button
type="button"
data-session-index={index()}
class="panel-list-item-content group flex-1"
onClick={() => handleSessionSelect(session.id)}
onMouseEnter={() => {
setFocusMode("sessions")
setSelectedIndex(index())
}}
>
<div class="flex items-center justify-between gap-3 w-full">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span
class="text-sm font-medium text-primary truncate transition-colors"
classList={{
"text-accent": isFocused(),
}}
>
{session.title || "Untitled Session"}
</span>
</div>
<div class="flex items-center gap-3 text-xs text-muted mt-0.5">
<span>{session.agent}</span>
<span></span>
<span>{formatRelativeTime(session.time.updated)}</span>
</div>
</div>
</div>
</button>
<Show when={isFocused()}>
<div class="flex items-center gap-2 flex-shrink-0">
<kbd class="kbd flex-shrink-0"></kbd>
<button
type="button"
class="p-1.5 rounded transition-colors text-muted hover:text-red-500 dark:hover:text-red-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
title="Delete session"
disabled={isSessionDeleting(session.id)}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void handleSessionDelete(session.id)
}}
>
{session.title || "Untitled Session"}
</span>
<Show
when={!isSessionDeleting(session.id)}
fallback={
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
}
>
<Trash2 class="w-4 h-4" />
</Show>
</button>
</div>
<div class="flex items-center gap-3 text-xs text-muted mt-0.5">
<span>{session.agent}</span>
<span></span>
<span>{formatRelativeTime(session.time.updated)}</span>
</div>
</div>
<Show when={focusMode() === "sessions" && selectedIndex() === index()}>
<kbd class="kbd flex-shrink-0"></kbd>
</Show>
</div>
</button>
</div>
)}
</div>
)
}}
</For>
</div>
</div>
@@ -274,14 +430,38 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
</div>
</div>
<div class="lg:w-80 flex-shrink-0">
<div class="hidden lg:block lg:w-80 flex-shrink-0">
<div class="sticky top-0">
<InstanceInfo instance={props.instance} />
</div>
</div>
</div>
<div class="panel-footer">
<Show when={!isDesktopLayout() && showInstanceInfoOverlay()}>
<div
class="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm lg:hidden"
onClick={closeInstanceInfoOverlay}
>
<div class="flex min-h-full items-start justify-center p-4 overflow-y-auto">
<div
class="w-full max-w-md space-y-3"
onClick={(event) => event.stopPropagation()}
>
<div class="flex justify-end">
<button type="button" class="button-tertiary" onClick={closeInstanceInfoOverlay}>
Close
</button>
</div>
<div class="max-h-[85vh] overflow-y-auto pr-1">
<InstanceInfo instance={props.instance} />
</div>
</div>
</div>
</div>
</Show>
<div class="panel-footer hidden sm:block">
<div class="panel-footer-hints">
<div class="flex items-center gap-1.5">
<kbd class="kbd"></kbd>
@@ -302,12 +482,16 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
<kbd class="kbd">Enter</kbd>
<span>Resume</span>
</div>
<KeyboardHint shortcuts={[newSessionShortcut()]} separator="" />
<div class="flex items-center gap-1.5">
<kbd class="kbd">Del</kbd>
<span>Delete</span>
</div>
</div>
</div>
</div>
)
}
export default InstanceWelcomeView
export default InstanceWelcomeView

View File

@@ -1,9 +1,11 @@
import { Show, createMemo, createSignal, type Component } from "solid-js"
import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount, type Component } from "solid-js"
import type { Accessor } from "solid-js"
import type { Instance } from "../../types/instance"
import type { Command } from "../../lib/commands"
import { activeParentSessionId, activeSessionId as activeSessionMap, getSessionFamily, setActiveSession } from "../../stores/sessions"
import { keyboardRegistry, type KeyboardShortcut } from "../../lib/keyboard-registry"
import { messageStoreBus } from "../../stores/message-v2/bus"
import { clearSessionRenderCache } from "../message-block"
import { buildCustomCommandEntries } from "../../lib/command-utils"
import { getCommands as getInstanceCommands } from "../../stores/commands"
import { isOpen as isCommandPaletteOpen, hideCommandPalette } from "../../stores/command-palette"
@@ -14,8 +16,12 @@ import InfoView from "../info-view"
import AgentSelector from "../agent-selector"
import ModelSelector from "../model-selector"
import CommandPalette from "../command-palette"
import Kbd from "../kbd"
import ContextUsagePanel from "../session/context-usage-panel"
import SessionView from "../session/session-view"
import { getLogger } from "../../lib/logger"
const log = getLogger("session")
interface InstanceShellProps {
instance: Instance
@@ -28,10 +34,42 @@ interface InstanceShellProps {
onExecuteCommand: (command: Command) => void
}
const DEFAULT_SESSION_SIDEBAR_WIDTH = 280
const DEFAULT_SESSION_SIDEBAR_WIDTH = 350
const MOBILE_SIDEBAR_BREAKPOINT = 1024
const SESSION_CACHE_LIMIT = 2
const InstanceShell: Component<InstanceShellProps> = (props) => {
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
const [isCompactLayout, setIsCompactLayout] = createSignal(false)
const [isSidebarOpen, setIsSidebarOpen] = createSignal(true)
const [cachedSessionIds, setCachedSessionIds] = createSignal<string[]>([])
const [pendingEvictions, setPendingEvictions] = createSignal<string[]>([])
const sidebarId = `session-sidebar-${props.instance.id}`
let previousIsCompact = false
const shouldShowSidebarToggle = () => isCompactLayout() && !isSidebarOpen()
onMount(() => {
if (typeof window === "undefined") return
const handleResize = () => {
const compact = window.innerWidth < MOBILE_SIDEBAR_BREAKPOINT
setIsCompactLayout(compact)
if (!compact) {
setIsSidebarOpen(true)
} else if (!previousIsCompact && compact) {
setIsSidebarOpen(false)
}
previousIsCompact = compact
}
handleResize()
window.addEventListener("resize", handleResize)
onCleanup(() => {
window.removeEventListener("resize", handleResize)
})
})
const activeSessions = createMemo(() => {
const parentId = activeParentSessionId().get(props.instance.id)
@@ -44,12 +82,17 @@ const InstanceShell: Component<InstanceShellProps> = (props) => {
return activeSessionMap().get(props.instance.id) || null
})
const parentSessionIdForInstance = createMemo(() => {
return activeParentSessionId().get(props.instance.id) || null
})
const activeSessionForInstance = createMemo(() => {
const sessionId = activeSessionIdForInstance()
if (!sessionId || sessionId === "info") return null
return activeSessions().get(sessionId) ?? null
})
const customCommands = createMemo(() => buildCustomCommandEntries(props.instance.id, getInstanceCommands(props.instance.id)))
const instancePaletteCommands = createMemo(() => [...props.paletteCommands(), ...customCommands()])
const paletteOpen = createMemo(() => isCommandPaletteOpen(props.instance.id))
@@ -64,11 +107,91 @@ const InstanceShell: Component<InstanceShellProps> = (props) => {
setActiveSession(props.instance.id, sessionId)
}
const evictSession = (sessionId: string) => {
if (!sessionId) return
log.info("Evicting cached session", { instanceId: props.instance.id, sessionId })
const store = messageStoreBus.getInstance(props.instance.id)
store?.clearSession(sessionId)
clearSessionRenderCache(props.instance.id, sessionId)
}
const scheduleEvictions = (ids: string[]) => {
if (!ids.length) return
setPendingEvictions((current) => {
const existing = new Set(current)
const next = [...current]
ids.forEach((id) => {
if (!existing.has(id)) {
next.push(id)
existing.add(id)
}
})
return next
})
}
createEffect(() => {
const pending = pendingEvictions()
if (!pending.length) return
const cached = new Set(cachedSessionIds())
const remaining: string[] = []
pending.forEach((id) => {
if (cached.has(id)) {
remaining.push(id)
} else {
evictSession(id)
}
})
if (remaining.length !== pending.length) {
setPendingEvictions(remaining)
}
})
createEffect(() => {
const sessionsMap = activeSessions()
const parentId = parentSessionIdForInstance()
const activeId = activeSessionIdForInstance()
setCachedSessionIds((current) => {
const next: string[] = []
const append = (id: string | null) => {
if (!id || id === "info") return
if (!sessionsMap.has(id)) return
if (next.includes(id)) return
next.push(id)
}
append(parentId)
append(activeId)
current.forEach((id) => append(id))
const limit = parentId ? SESSION_CACHE_LIMIT + 1 : SESSION_CACHE_LIMIT
const trimmed = next.length > limit ? next.slice(0, limit) : next
const trimmedSet = new Set(trimmed)
const removed = current.filter((id) => !trimmedSet.has(id))
if (removed.length) {
scheduleEvictions(removed)
}
return trimmed
})
})
return (
<>
<Show when={activeSessions().size > 0} fallback={<InstanceWelcomeView instance={props.instance} />}>
<div class="flex flex-1 min-h-0">
<div class="session-sidebar flex flex-col bg-surface-secondary" style={{ width: `${sessionSidebarWidth()}px` }}>
<div
class="flex flex-1 min-h-0 relative"
classList={{ "session-layout-compact": isCompactLayout() }}
>
<div
id={sidebarId}
class="session-sidebar flex flex-col bg-surface-secondary"
classList={{
"session-sidebar-overlay": isCompactLayout(),
"session-sidebar-collapsed": isCompactLayout() && !isSidebarOpen(),
}}
style={!isCompactLayout() ? { width: `${sessionSidebarWidth()}px` } : undefined}
aria-hidden={isCompactLayout() && !isSidebarOpen()}
>
<SessionList
instanceId={props.instance.id}
sessions={activeSessions()}
@@ -77,20 +200,32 @@ const InstanceShell: Component<InstanceShellProps> = (props) => {
onClose={(id) => {
const result = props.onCloseSession(id)
if (result instanceof Promise) {
void result.catch((error) => console.error("Failed to close session:", error))
void result.catch((error) => log.error("Failed to close session:", error))
}
}}
onNew={() => {
const result = props.onNewSession()
if (result instanceof Promise) {
void result.catch((error) => console.error("Failed to create session:", error))
void result.catch((error) => log.error("Failed to create session:", error))
}
}}
showHeader
showFooter={false}
headerContent={
<div class="session-sidebar-header">
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">Sessions</span>
<div class="session-sidebar-header-row">
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">Sessions</span>
<Show when={isCompactLayout()}>
<button
type="button"
class="session-sidebar-close"
onClick={() => setIsSidebarOpen(false)}
aria-label="Close session sidebar"
>
Close
</button>
</Show>
</div>
<div class="session-sidebar-shortcuts">
{keyboardShortcuts().length ? (
<KeyboardHint shortcuts={keyboardShortcuts()} separator=" " showDescription={false} />
@@ -114,12 +249,22 @@ const InstanceShell: Component<InstanceShellProps> = (props) => {
onAgentChange={(agent) => props.handleSidebarAgentChange(activeSession().id, agent)}
/>
<div class="sidebar-selector-hints" aria-hidden="true">
<span class="hint sidebar-selector-hint sidebar-selector-hint--left">
<Kbd shortcut="cmd+shift+a" />
</span>
<span class="hint sidebar-selector-hint sidebar-selector-hint--right">
<Kbd shortcut="cmd+shift+m" />
</span>
</div>
<ModelSelector
instanceId={props.instance.id}
sessionId={activeSession().id}
currentModel={activeSession().model}
onModelChange={(model) => props.handleSidebarModelChange(activeSession().id, model)}
/>
</div>
</>
)}
@@ -127,12 +272,25 @@ const InstanceShell: Component<InstanceShellProps> = (props) => {
</div>
<div class="content-area flex-1 min-h-0 overflow-hidden flex flex-col">
<Show
when={shouldShowSidebarToggle() && (!activeSessionIdForInstance() || activeSessionIdForInstance() === "info")}
>
<button
type="button"
class="session-sidebar-menu-button session-sidebar-menu-button--floating"
onClick={() => setIsSidebarOpen(true)}
aria-controls={sidebarId}
aria-expanded={isSidebarOpen()}
aria-label="Open session list"
>
<span aria-hidden="true" class="session-sidebar-menu-icon"></span>
</button>
</Show>
<Show
when={activeSessionIdForInstance() === "info"}
fallback={
<Show
when={activeSessionIdForInstance()}
keyed
when={cachedSessionIds().length > 0 && activeSessionIdForInstance()}
fallback={
<div class="flex items-center justify-center h-full">
<div class="text-center text-gray-500 dark:text-gray-400">
@@ -142,21 +300,46 @@ const InstanceShell: Component<InstanceShellProps> = (props) => {
</div>
}
>
{(sessionId) => (
<SessionView
sessionId={sessionId}
activeSessions={activeSessions()}
instanceId={props.instance.id}
instanceFolder={props.instance.folder}
escapeInDebounce={props.escapeInDebounce}
/>
)}
<For each={cachedSessionIds()}>
{(sessionId) => {
const isActive = () => activeSessionIdForInstance() === sessionId
return (
<div
class="session-cache-pane flex flex-col flex-1 min-h-0"
style={{ display: isActive() ? "flex" : "none" }}
data-session-id={sessionId}
aria-hidden={!isActive()}
>
<SessionView
sessionId={sessionId}
activeSessions={activeSessions()}
instanceId={props.instance.id}
instanceFolder={props.instance.folder}
escapeInDebounce={props.escapeInDebounce}
showSidebarToggle={shouldShowSidebarToggle()}
onSidebarToggle={() => setIsSidebarOpen(true)}
forceCompactStatusLayout={shouldShowSidebarToggle()}
isActive={isActive()}
/>
</div>
)
}}
</For>
</Show>
}
>
<InfoView instanceId={props.instance.id} />
</Show>
</div>
<Show when={isCompactLayout() && isSidebarOpen()}>
<button
type="button"
class="session-sidebar-backdrop"
aria-label="Close session sidebar"
onClick={() => setIsSidebarOpen(false)}
/>
</Show>
</div>
</Show>

View File

@@ -1,6 +1,9 @@
import { createEffect, createSignal, onMount, onCleanup } from "solid-js"
import { renderMarkdown, onLanguagesLoaded, initMarkdown, decodeHtmlEntities } from "../lib/markdown"
import type { TextPart } from "../types/message"
import { getLogger } from "../lib/logger"
const log = getLogger("session")
interface MarkdownProps {
part: TextPart
@@ -29,7 +32,8 @@ export function Markdown(props: MarkdownProps) {
latestRequestedText = text
await initMarkdown(dark)
// Markdown initialization is now handled globally in App.
// initMarkdown is idempotent but we avoid per-part calls here.
if (!highlightEnabled) {
part.renderCache = undefined
@@ -42,7 +46,7 @@ export function Markdown(props: MarkdownProps) {
notifyRendered()
}
} catch (error) {
console.error("Failed to render markdown:", error)
log.error("Failed to render markdown:", error)
if (latestRequestedText === text) {
setHtml(text)
notifyRendered()
@@ -67,7 +71,7 @@ export function Markdown(props: MarkdownProps) {
notifyRendered()
}
} catch (error) {
console.error("Failed to render markdown:", error)
log.error("Failed to render markdown:", error)
if (latestRequestedText === text) {
setHtml(text)
part.renderCache = { text, html: text, theme: themeKey }
@@ -123,7 +127,7 @@ export function Markdown(props: MarkdownProps) {
notifyRendered()
}
} catch (error) {
console.error("Failed to re-render markdown after language load:", error)
log.error("Failed to re-render markdown after language load:", error)
}
})

View File

@@ -0,0 +1,90 @@
import { Index, createEffect, createSignal, type Accessor } from "solid-js"
import VirtualItem from "./virtual-item"
import MessageBlock from "./message-block"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
export function getMessageAnchorId(messageId: string) {
return `message-anchor-${messageId}`
}
const VIRTUAL_ITEM_MARGIN_PX = 800
interface MessageBlockListProps {
instanceId: string
sessionId: string
store: () => InstanceMessageStore
messageIds: () => string[]
messageIndexMap: () => Map<string, number>
lastAssistantIndex: () => number
showThinking: () => boolean
thinkingDefaultExpanded: () => boolean
showUsageMetrics: () => boolean
scrollContainer: Accessor<HTMLDivElement | undefined>
loading?: boolean
onRevert?: (messageId: string) => void
onFork?: (messageId?: string) => void
onContentRendered?: () => void
setBottomSentinel: (element: HTMLDivElement | null) => void
suspendMeasurements?: () => boolean
onInitialRenderComplete?: () => void
}
export default function MessageBlockList(props: MessageBlockListProps) {
const totalMessages = () => props.messageIds().length
let renderedCount = 0
let initialRenderReported = false
const handleBlockRendered = () => {
if (initialRenderReported) return
renderedCount += 1
if (renderedCount >= totalMessages() && totalMessages() > 0) {
initialRenderReported = true
renderedCount = 0
props.onInitialRenderComplete?.()
}
}
createEffect(() => {
if (props.loading) {
renderedCount = 0
initialRenderReported = false
}
})
return (
<>
<Index each={props.messageIds()}>
{(messageId) => {
return (
<VirtualItem
id={getMessageAnchorId(messageId())}
cacheKey={messageId()}
scrollContainer={props.scrollContainer}
threshold={VIRTUAL_ITEM_MARGIN_PX}
placeholderClass="message-stream-placeholder"
virtualizationEnabled={() => !props.loading}
suspendMeasurements={props.suspendMeasurements}
onMeasured={handleBlockRendered}
>
<MessageBlock
messageId={messageId()}
instanceId={props.instanceId}
sessionId={props.sessionId}
store={props.store}
messageIndexMap={props.messageIndexMap}
lastAssistantIndex={props.lastAssistantIndex}
showThinking={props.showThinking}
thinkingDefaultExpanded={props.thinkingDefaultExpanded}
showUsageMetrics={props.showUsageMetrics}
onRevert={props.onRevert}
onFork={props.onFork}
onContentRendered={props.onContentRendered}
/>
</VirtualItem>
)
}}
</Index>
<div ref={props.setBottomSentinel} aria-hidden="true" style={{ height: "1px" }} />
</>
)
}

View File

@@ -0,0 +1,722 @@
import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js"
import MessageItem from "./message-item"
import ToolCall from "./tool-call"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
import type { ClientPart, MessageInfo } from "../types/message"
import { partHasRenderableText } from "../types/message"
import { buildRecordDisplayData, clearRecordDisplayCacheForInstance } from "../stores/message-v2/record-display-cache"
import type { MessageRecord } from "../stores/message-v2/types"
import { messageStoreBus } from "../stores/message-v2/bus"
import { formatTokenTotal } from "../lib/formatters"
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
import { setActiveInstanceId } from "../stores/instances"
const TOOL_ICON = "🔧"
const USER_BORDER_COLOR = "var(--message-user-border)"
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
const TOOL_BORDER_COLOR = "var(--message-tool-border)"
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
type ToolState = import("@opencode-ai/sdk").ToolState
type ToolStateRunning = import("@opencode-ai/sdk").ToolStateRunning
type ToolStateCompleted = import("@opencode-ai/sdk").ToolStateCompleted
type ToolStateError = import("@opencode-ai/sdk").ToolStateError
function isToolStateRunning(state: ToolState | undefined): state is ToolStateRunning {
return Boolean(state && state.status === "running")
}
function isToolStateCompleted(state: ToolState | undefined): state is ToolStateCompleted {
return Boolean(state && state.status === "completed")
}
function isToolStateError(state: ToolState | undefined): state is ToolStateError {
return Boolean(state && state.status === "error")
}
function extractTaskSessionId(state: ToolState | undefined): string {
if (!state) return ""
const metadata = (state as unknown as { metadata?: Record<string, unknown> }).metadata ?? {}
const directId = metadata?.sessionId ?? metadata?.sessionID
return typeof directId === "string" ? directId : ""
}
function reasoningHasRenderableContent(part: ClientPart): boolean {
if (!part || part.type !== "reasoning") {
return false
}
const checkSegment = (segment: unknown): boolean => {
if (typeof segment === "string") {
return segment.trim().length > 0
}
if (segment && typeof segment === "object") {
const candidate = segment as { text?: unknown; value?: unknown; content?: unknown[] }
if (typeof candidate.text === "string" && candidate.text.trim().length > 0) {
return true
}
if (typeof candidate.value === "string" && candidate.value.trim().length > 0) {
return true
}
if (Array.isArray(candidate.content)) {
return candidate.content.some((entry) => checkSegment(entry))
}
}
return false
}
if (checkSegment((part as any).text)) {
return true
}
if (Array.isArray((part as any).content)) {
return (part as any).content.some((entry: unknown) => checkSegment(entry))
}
return false
}
interface TaskSessionLocation {
sessionId: string
instanceId: string
parentId: string | null
}
function findTaskSessionLocation(sessionId: string): TaskSessionLocation | null {
if (!sessionId) return null
const allSessions = sessions()
for (const [instanceId, sessionMap] of allSessions) {
const session = sessionMap?.get(sessionId)
if (session) {
return {
sessionId: session.id,
instanceId,
parentId: session.parentId ?? null,
}
}
}
return null
}
function navigateToTaskSession(location: TaskSessionLocation) {
setActiveInstanceId(location.instanceId)
const parentToActivate = location.parentId ?? location.sessionId
setActiveParentSession(location.instanceId, parentToActivate)
if (location.parentId) {
setActiveSession(location.instanceId, location.sessionId)
}
}
interface CachedBlockEntry {
signature: string
block: MessageDisplayBlock
contentKeys: string[]
toolKeys: string[]
}
interface SessionRenderCache {
messageItems: Map<string, ContentDisplayItem>
toolItems: Map<string, ToolDisplayItem>
messageBlocks: Map<string, CachedBlockEntry>
}
const renderCaches = new Map<string, SessionRenderCache>()
function makeSessionCacheKey(instanceId: string, sessionId: string) {
return `${instanceId}:${sessionId}`
}
export function clearSessionRenderCache(instanceId: string, sessionId: string) {
renderCaches.delete(makeSessionCacheKey(instanceId, sessionId))
}
function getSessionRenderCache(instanceId: string, sessionId: string): SessionRenderCache {
const key = makeSessionCacheKey(instanceId, sessionId)
let cache = renderCaches.get(key)
if (!cache) {
cache = {
messageItems: new Map(),
toolItems: new Map(),
messageBlocks: new Map(),
}
renderCaches.set(key, cache)
}
return cache
}
function clearInstanceCaches(instanceId: string) {
clearRecordDisplayCacheForInstance(instanceId)
const prefix = `${instanceId}:`
for (const key of renderCaches.keys()) {
if (key.startsWith(prefix)) {
renderCaches.delete(key)
}
}
}
messageStoreBus.onInstanceDestroyed(clearInstanceCaches)
interface ContentDisplayItem {
type: "content"
key: string
record: MessageRecord
parts: ClientPart[]
messageInfo?: MessageInfo
isQueued: boolean
showAgentMeta?: boolean
}
interface ToolDisplayItem {
type: "tool"
key: string
toolPart: ToolCallPart
messageInfo?: MessageInfo
messageId: string
messageVersion: number
partVersion: number
}
interface StepDisplayItem {
type: "step-start" | "step-finish"
key: string
part: ClientPart
messageInfo?: MessageInfo
accentColor?: string
}
type ReasoningDisplayItem = {
type: "reasoning"
key: string
part: ClientPart
messageInfo?: MessageInfo
showAgentMeta?: boolean
defaultExpanded: boolean
}
type MessageBlockItem = ContentDisplayItem | ToolDisplayItem | StepDisplayItem | ReasoningDisplayItem
interface MessageDisplayBlock {
record: MessageRecord
items: MessageBlockItem[]
}
interface MessageBlockProps {
messageId: string
instanceId: string
sessionId: string
store: () => InstanceMessageStore
messageIndexMap: () => Map<string, number>
lastAssistantIndex: () => number
showThinking: () => boolean
thinkingDefaultExpanded: () => boolean
showUsageMetrics: () => boolean
onRevert?: (messageId: string) => void
onFork?: (messageId?: string) => void
onContentRendered?: () => void
}
export default function MessageBlock(props: MessageBlockProps) {
const record = createMemo(() => props.store().getMessage(props.messageId))
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId)
const block = createMemo<MessageDisplayBlock | null>(() => {
const current = record()
if (!current) return null
const index = props.messageIndexMap().get(current.id) ?? 0
const lastAssistantIdx = props.lastAssistantIndex()
const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx)
const info = messageInfo()
const infoTime = (info?.time ?? {}) as { created?: number; updated?: number; completed?: number }
const infoTimestamp =
typeof infoTime.completed === "number"
? infoTime.completed
: typeof infoTime.updated === "number"
? infoTime.updated
: infoTime.created ?? 0
const infoError = (info as { error?: { name?: string } } | undefined)?.error
const infoErrorName = typeof infoError?.name === "string" ? infoError.name : ""
const cacheSignature = [
current.id,
current.revision,
isQueued ? 1 : 0,
props.showThinking() ? 1 : 0,
props.thinkingDefaultExpanded() ? 1 : 0,
props.showUsageMetrics() ? 1 : 0,
infoTimestamp,
infoErrorName,
].join("|")
const cachedBlock = sessionCache.messageBlocks.get(current.id)
if (cachedBlock && cachedBlock.signature === cacheSignature) {
return cachedBlock.block
}
const { orderedParts } = buildRecordDisplayData(props.instanceId, current)
const items: MessageBlockItem[] = []
const blockContentKeys: string[] = []
const blockToolKeys: string[] = []
let segmentIndex = 0
let pendingParts: ClientPart[] = []
let agentMetaAttached = current.role !== "assistant"
const defaultAccentColor = current.role === "user" ? USER_BORDER_COLOR : ASSISTANT_BORDER_COLOR
let lastAccentColor = defaultAccentColor
const flushContent = () => {
if (pendingParts.length === 0) return
const segmentKey = `${current.id}:segment:${segmentIndex}`
segmentIndex += 1
const shouldShowAgentMeta =
current.role === "assistant" &&
!agentMetaAttached &&
pendingParts.some((part) => partHasRenderableText(part))
let cached = sessionCache.messageItems.get(segmentKey)
if (!cached) {
cached = {
type: "content",
key: segmentKey,
record: current,
parts: pendingParts.slice(),
messageInfo: info,
isQueued,
showAgentMeta: shouldShowAgentMeta,
}
sessionCache.messageItems.set(segmentKey, cached)
} else {
cached.record = current
cached.parts = pendingParts.slice()
cached.messageInfo = info
cached.isQueued = isQueued
cached.showAgentMeta = shouldShowAgentMeta
}
if (shouldShowAgentMeta) {
agentMetaAttached = true
}
items.push(cached)
blockContentKeys.push(segmentKey)
lastAccentColor = defaultAccentColor
pendingParts = []
}
orderedParts.forEach((part, partIndex) => {
if (part.type === "tool") {
flushContent()
const partVersion = typeof (part as any).revision === "number" ? (part as any).revision : 0
const messageVersion = current.revision
const key = `${current.id}:${part.id ?? partIndex}`
let toolItem = sessionCache.toolItems.get(key)
if (!toolItem) {
toolItem = {
type: "tool",
key,
toolPart: part as ToolCallPart,
messageInfo: info,
messageId: current.id,
messageVersion,
partVersion,
}
sessionCache.toolItems.set(key, toolItem)
} else {
toolItem.key = key
toolItem.toolPart = part as ToolCallPart
toolItem.messageInfo = info
toolItem.messageId = current.id
toolItem.messageVersion = messageVersion
toolItem.partVersion = partVersion
}
items.push(toolItem)
blockToolKeys.push(key)
lastAccentColor = TOOL_BORDER_COLOR
return
}
if (part.type === "step-start") {
flushContent()
return
}
if (part.type === "step-finish") {
flushContent()
if (props.showUsageMetrics()) {
const key = `${current.id}:${part.id ?? partIndex}:${part.type}`
const accentColor = lastAccentColor || defaultAccentColor
items.push({ type: part.type, key, part, messageInfo: info, accentColor })
lastAccentColor = accentColor
}
return
}
if (part.type === "reasoning") {
flushContent()
if (props.showThinking() && reasoningHasRenderableContent(part)) {
const key = `${current.id}:${part.id ?? partIndex}:reasoning`
const showAgentMeta = current.role === "assistant" && !agentMetaAttached
if (showAgentMeta) {
agentMetaAttached = true
}
items.push({
type: "reasoning",
key,
part,
messageInfo: info,
showAgentMeta,
defaultExpanded: props.thinkingDefaultExpanded(),
})
lastAccentColor = ASSISTANT_BORDER_COLOR
}
return
}
pendingParts.push(part)
})
flushContent()
const resultBlock: MessageDisplayBlock = { record: current, items }
sessionCache.messageBlocks.set(current.id, {
signature: cacheSignature,
block: resultBlock,
contentKeys: blockContentKeys.slice(),
toolKeys: blockToolKeys.slice(),
})
const messagePrefix = `${current.id}:`
for (const [key] of sessionCache.messageItems) {
if (key.startsWith(messagePrefix) && !blockContentKeys.includes(key)) {
sessionCache.messageItems.delete(key)
}
}
for (const [key] of sessionCache.toolItems) {
if (key.startsWith(messagePrefix) && !blockToolKeys.includes(key)) {
sessionCache.toolItems.delete(key)
}
}
return resultBlock
})
return (
<Show when={block()} keyed>
{(resolvedBlock) => (
<div class="message-stream-block" data-message-id={resolvedBlock.record.id}>
<For each={resolvedBlock.items}>
{(item) => (
<Switch>
<Match when={item.type === "content"}>
<MessageItem
record={(item as ContentDisplayItem).record}
messageInfo={(item as ContentDisplayItem).messageInfo}
parts={(item as ContentDisplayItem).parts}
instanceId={props.instanceId}
sessionId={props.sessionId}
isQueued={(item as ContentDisplayItem).isQueued}
showAgentMeta={(item as ContentDisplayItem).showAgentMeta}
onRevert={props.onRevert}
onFork={props.onFork}
onContentRendered={props.onContentRendered}
/>
</Match>
<Match when={item.type === "tool"}>
{(() => {
const toolItem = item as ToolDisplayItem
const toolState = toolItem.toolPart.state as ToolState | undefined
const hasToolState =
Boolean(toolState) && (isToolStateRunning(toolState) || isToolStateCompleted(toolState) || isToolStateError(toolState))
const taskSessionId = hasToolState ? extractTaskSessionId(toolState) : ""
const taskLocation = taskSessionId ? findTaskSessionLocation(taskSessionId) : null
const handleGoToTaskSession = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (!taskLocation) return
navigateToTaskSession(taskLocation)
}
return (
<div class="tool-call-message" data-key={toolItem.key}>
<div class="tool-call-header-label">
<div class="tool-call-header-meta">
<span class="tool-call-icon">{TOOL_ICON}</span>
<span>Tool Call</span>
<span class="tool-name">{toolItem.toolPart.tool || "unknown"}</span>
</div>
<Show when={taskSessionId}>
<button
class="tool-call-header-button"
type="button"
disabled={!taskLocation}
onClick={handleGoToTaskSession}
title={!taskLocation ? "Session not available yet" : "Go to session"}
>
Go to Session
</button>
</Show>
</div>
<ToolCall
toolCall={toolItem.toolPart}
toolCallId={toolItem.key}
messageId={toolItem.messageId}
messageVersion={toolItem.messageVersion}
partVersion={toolItem.partVersion}
instanceId={props.instanceId}
sessionId={props.sessionId}
onContentRendered={props.onContentRendered}
/>
</div>
)
})()}
</Match>
<Match when={item.type === "step-start"}>
<StepCard kind="start" part={(item as StepDisplayItem).part} messageInfo={(item as StepDisplayItem).messageInfo} showAgentMeta />
</Match>
<Match when={item.type === "step-finish"}>
<StepCard
kind="finish"
part={(item as StepDisplayItem).part}
messageInfo={(item as StepDisplayItem).messageInfo}
showUsage={props.showUsageMetrics()}
borderColor={(item as StepDisplayItem).accentColor}
/>
</Match>
<Match when={item.type === "reasoning"}>
<ReasoningCard
part={(item as ReasoningDisplayItem).part}
messageInfo={(item as ReasoningDisplayItem).messageInfo}
instanceId={props.instanceId}
sessionId={props.sessionId}
showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta}
defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded}
/>
</Match>
</Switch>
)}
</For>
</div>
)}
</Show>
)
}
interface StepCardProps {
kind: "start" | "finish"
part: ClientPart
messageInfo?: MessageInfo
showAgentMeta?: boolean
showUsage?: boolean
borderColor?: string
}
function StepCard(props: StepCardProps) {
const timestamp = () => {
const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now()
const date = new Date(value)
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
}
const agentIdentifier = () => {
if (!props.showAgentMeta) return ""
const info = props.messageInfo
if (!info || info.role !== "assistant") return ""
return info.mode || ""
}
const modelIdentifier = () => {
if (!props.showAgentMeta) return ""
const info = props.messageInfo
if (!info || info.role !== "assistant") return ""
const modelID = info.modelID || ""
const providerID = info.providerID || ""
if (modelID && providerID) return `${providerID}/${modelID}`
return modelID
}
const usageStats = () => {
if (props.kind !== "finish" || !props.showUsage) {
return null
}
const info = props.messageInfo
if (!info || info.role !== "assistant" || !info.tokens) {
return null
}
const tokens = info.tokens
return {
input: tokens.input ?? 0,
output: tokens.output ?? 0,
reasoning: tokens.reasoning ?? 0,
cacheRead: tokens.cache?.read ?? 0,
cacheWrite: tokens.cache?.write ?? 0,
cost: info.cost ?? 0,
}
}
const finishStyle = () => (props.borderColor ? { "border-left-color": props.borderColor } : undefined)
const renderUsageChips = (usage: NonNullable<ReturnType<typeof usageStats>>) => {
const entries = [
{ label: "Input", value: usage.input, formatter: formatTokenTotal },
{ label: "Output", value: usage.output, formatter: formatTokenTotal },
{ label: "Reasoning", value: usage.reasoning, formatter: formatTokenTotal },
{ label: "Cache Read", value: usage.cacheRead, formatter: formatTokenTotal },
{ label: "Cache Write", value: usage.cacheWrite, formatter: formatTokenTotal },
{ label: "Cost", value: usage.cost, formatter: formatCostValue },
]
return (
<div class="message-step-usage">
<For each={entries}>
{(entry) => (
<span class="message-step-usage-chip" data-label={entry.label}>
{entry.formatter(entry.value)}
</span>
)}
</For>
</div>
)
}
if (props.kind === "finish") {
const usage = usageStats()
if (!usage) {
return null
}
return (
<div class={`message-step-card message-step-finish message-step-finish-flush`} style={finishStyle()}>
{renderUsageChips(usage)}
</div>
)
}
return (
<div class={`message-step-card message-step-start`}>
<div class="message-step-heading">
<div class="message-step-title">
<div class="message-step-title-left">
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
<span class="message-step-meta-inline">
<Show when={agentIdentifier()}>{(value) => <span>Agent: {value()}</span>}</Show>
<Show when={modelIdentifier()}>{(value) => <span>Model: {value()}</span>}</Show>
</span>
</Show>
</div>
<span class="message-step-time">{timestamp()}</span>
</div>
</div>
</div>
)
}
function formatCostValue(value: number) {
if (!value) return "$0.00"
if (value < 0.01) return `$${value.toPrecision(2)}`
return `$${value.toFixed(2)}`
}
interface ReasoningCardProps {
part: ClientPart
messageInfo?: MessageInfo
instanceId: string
sessionId: string
showAgentMeta?: boolean
defaultExpanded?: boolean
}
function ReasoningCard(props: ReasoningCardProps) {
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
createEffect(() => {
setExpanded(Boolean(props.defaultExpanded))
})
const timestamp = () => {
const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now()
const date = new Date(value)
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
}
const agentIdentifier = () => {
const info = props.messageInfo
if (!info || info.role !== "assistant") return ""
return info.mode || ""
}
const modelIdentifier = () => {
const info = props.messageInfo
if (!info || info.role !== "assistant") return ""
const modelID = info.modelID || ""
const providerID = info.providerID || ""
if (modelID && providerID) return `${providerID}/${modelID}`
return modelID
}
const reasoningText = () => {
const part = props.part as any
if (!part) return ""
const stringifySegment = (segment: unknown): string => {
if (typeof segment === "string") {
return segment
}
if (segment && typeof segment === "object") {
const obj = segment as { text?: unknown; value?: unknown; content?: unknown[] }
const pieces: string[] = []
if (typeof obj.text === "string") {
pieces.push(obj.text)
}
if (typeof obj.value === "string") {
pieces.push(obj.value)
}
if (Array.isArray(obj.content)) {
pieces.push(obj.content.map((entry) => stringifySegment(entry)).join("\n"))
}
return pieces.filter((piece) => piece && piece.trim().length > 0).join("\n")
}
return ""
}
const textValue = stringifySegment(part.text)
if (textValue.trim().length > 0) {
return textValue
}
if (Array.isArray(part.content)) {
return part.content.map((entry: unknown) => stringifySegment(entry)).join("\n")
}
return ""
}
const toggle = () => setExpanded((prev) => !prev)
return (
<div class="message-reasoning-card">
<button
type="button"
class="message-reasoning-toggle"
onClick={toggle}
aria-expanded={expanded()}
aria-label={expanded() ? "Collapse thinking" : "Expand thinking"}
>
<span class="message-reasoning-label flex flex-wrap items-center gap-2">
<span>Thinking</span>
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
<span class="message-step-meta-inline">
<Show when={agentIdentifier()}>{(value) => <span class="font-medium text-[var(--message-assistant-border)]">Agent: {value()}</span>}</Show>
<Show when={modelIdentifier()}>{(value) => <span class="font-medium text-[var(--message-assistant-border)]">Model: {value()}</span>}</Show>
</span>
</Show>
</span>
<span class="message-reasoning-meta">
<span class="message-reasoning-indicator">{expanded() ? "Hide" : "View"}</span>
<span class="message-reasoning-time">{timestamp()}</span>
</span>
</button>
<Show when={expanded()}>
<div class="message-reasoning-expanded">
<div class="message-reasoning-body">
<div class="message-reasoning-output" role="region" aria-label="Reasoning details">
<pre class="message-reasoning-text">{reasoningText() || ""}</pre>
</div>
</div>
</div>
</Show>
</div>
)
}

View File

@@ -1,27 +1,108 @@
import { For, Show } from "solid-js"
import type { Message, SDKPart, MessageInfo, ClientPart } from "../types/message"
import type { MessageInfo, ClientPart } from "../types/message"
import { partHasRenderableText } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types"
import MessagePart from "./message-part"
interface MessageItemProps {
message: Message
record: MessageRecord
messageInfo?: MessageInfo
instanceId: string
sessionId: string
isQueued?: boolean
parts?: ClientPart[]
parts: ClientPart[]
onRevert?: (messageId: string) => void
onFork?: (messageId?: string) => void
}
showAgentMeta?: boolean
onContentRendered?: () => void
}
export default function MessageItem(props: MessageItemProps) {
const isUser = () => props.record.role === "user"
const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt
export default function MessageItem(props: MessageItemProps) {
const isUser = () => props.message.type === "user"
const timestamp = () => {
const date = new Date(props.message.timestamp)
const date = new Date(createdTimestamp())
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
}
const messageParts = () => props.parts ?? props.message.parts
const timestampIso = () => new Date(createdTimestamp()).toISOString()
type FilePart = Extract<ClientPart, { type: "file" }> & {
url?: string
mime?: string
filename?: string
}
const messageParts = () => props.parts
const fileAttachments = () =>
messageParts().filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string")
const getAttachmentName = (part: FilePart) => {
if (part.filename && part.filename.trim().length > 0) {
return part.filename
}
const url = part.url || ""
if (url.startsWith("data:")) {
return "attachment"
}
try {
const parsed = new URL(url)
const segments = parsed.pathname.split("/")
return segments.pop() || "attachment"
} catch (error) {
const fallback = url.split("/").pop()
return fallback && fallback.length > 0 ? fallback : "attachment"
}
}
const isImageAttachment = (part: FilePart) => {
if (part.mime && typeof part.mime === "string" && part.mime.startsWith("image/")) {
return true
}
return typeof part.url === "string" && part.url.startsWith("data:image/")
}
const handleAttachmentDownload = async (part: FilePart) => {
const url = part.url
if (!url) return
const filename = getAttachmentName(part)
const directDownload = (href: string) => {
const anchor = document.createElement("a")
anchor.href = href
anchor.download = filename
anchor.target = "_blank"
anchor.rel = "noopener"
document.body.appendChild(anchor)
anchor.click()
document.body.removeChild(anchor)
}
if (url.startsWith("data:")) {
directDownload(url)
return
}
if (url.startsWith("file://")) {
window.open(url, "_blank", "noopener")
return
}
try {
const response = await fetch(url)
if (!response.ok) throw new Error(`Failed to fetch attachment: ${response.status}`)
const blob = await response.blob()
const objectUrl = URL.createObjectURL(blob)
directDownload(objectUrl)
URL.revokeObjectURL(objectUrl)
} catch (error) {
directDownload(url)
}
}
const errorMessage = () => {
const info = props.messageInfo
@@ -58,15 +139,21 @@ export default function MessageItem(props: MessageItemProps) {
const handleRevert = () => {
if (props.onRevert && isUser()) {
props.onRevert(props.message.id)
props.onRevert(props.record.id)
}
}
if (!isUser() && !hasContent()) {
return null
}
const containerClass = () =>
isUser()
? "message-item-base bg-[var(--message-user-bg)] border-l-4 border-[var(--message-user-border)]"
: "message-item-base assistant-message bg-[var(--message-assistant-bg)] border-l-4 border-[var(--message-assistant-border)]"
const speakerLabel = () => (isUser() ? "You" : "Assistant")
const agentIdentifier = () => {
if (isUser()) return ""
const info = props.messageInfo
@@ -83,50 +170,64 @@ export default function MessageItem(props: MessageItemProps) {
if (modelID && providerID) return `${providerID}/${modelID}`
return modelID
}
const agentMeta = () => {
if (isUser() || !props.showAgentMeta) return ""
const segments: string[] = []
const agent = agentIdentifier()
const model = modelIdentifier()
if (agent) {
segments.push(`Agent: ${agent}`)
}
if (model) {
segments.push(`Model: ${model}`)
}
return segments.join(" • ")
}
return (
<div class={containerClass()}>
<div class="flex justify-between items-center gap-2.5 pb-0.5">
<div class="flex flex-col">
<header class={`message-item-header ${isUser() ? "pb-0.5" : "pb-0"}`}>
<div class="message-speaker">
<span class="message-speaker-label" data-role={isUser() ? "user" : "assistant"}>
{speakerLabel()}
</span>
<Show when={agentMeta()}>{(meta) => <span class="message-agent-meta">{meta()}</span>}</Show>
</div>
<div class="message-item-actions">
<Show when={isUser()}>
<span class="font-semibold text-xs text-[var(--message-user-border)]">You</span>
</Show>
<Show when={!isUser()}>
<div class="flex flex-wrap gap-x-3 gap-y-0.5 text-[11px] text-[var(--message-assistant-border)]">
<Show when={agentIdentifier()}>{(value) => <span>Agent: {value()}</span>}</Show>
<Show when={modelIdentifier()}>{(value) => <span>Model: {value()}</span>}</Show>
<div class="message-action-group">
<Show when={props.onRevert}>
<button
class="message-action-button"
onClick={handleRevert}
title="Revert to this message"
aria-label="Revert to this message"
>
Revert
</button>
</Show>
<Show when={props.onFork}>
<button
class="message-action-button"
onClick={() => props.onFork?.(props.record.id)}
title="Fork from this message"
aria-label="Fork from this message"
>
Fork
</button>
</Show>
</div>
</Show>
<time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>
</div>
<div class="flex items-center gap-2">
<Show when={isUser() && props.onRevert}>
<button
class="bg-transparent border border-[var(--border-base)] text-[var(--text-muted)] cursor-pointer px-3 py-0.5 rounded text-xs font-semibold leading-none transition-all duration-200 flex items-center justify-center h-6 hover:bg-[var(--surface-hover)] hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)] active:scale-95"
onClick={handleRevert}
title="Revert to this message"
aria-label="Revert to this message"
>
Revert to
</button>
</Show>
<Show when={isUser() && props.onFork}>
<button
class="bg-transparent border border-[var(--border-base)] text-[var(--text-muted)] cursor-pointer px-3 py-0.5 rounded text-xs font-semibold leading-none transition-all duration-200 flex items-center justify-center h-6 hover:bg-[var(--surface-hover)] hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)] active:scale-95"
onClick={() => props.onFork?.(props.message.id)}
title="Fork from this message"
aria-label="Fork from this message"
>
Fork
</button>
</Show>
<span class="text-[11px] text-[var(--text-muted)]">{timestamp()}</span>
</div>
</div>
</header>
<div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]">
<Show when={props.isQueued && isUser()}>
<div class="message-queued-badge">QUEUED</div>
</Show>
@@ -141,25 +242,72 @@ export default function MessageItem(props: MessageItemProps) {
</div>
</Show>
<For each={messageParts()}>{(part) => (
<MessagePart
part={part}
messageType={props.message.type}
instanceId={props.instanceId}
sessionId={props.sessionId}
/>
)}</For>
<For each={messageParts()}>
{(part) => (
<MessagePart
part={part}
messageType={props.record.role}
instanceId={props.instanceId}
sessionId={props.sessionId}
onRendered={props.onContentRendered}
/>
)}
</For>
<Show when={fileAttachments().length > 0}>
<div class="message-attachments mt-1">
<For each={fileAttachments()}>
{(attachment) => {
const name = getAttachmentName(attachment)
const isImage = isImageAttachment(attachment)
return (
<div class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`} title={name}>
<Show when={isImage} fallback={
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
}>
<img src={attachment.url} alt={name} class="h-5 w-5 rounded object-cover" />
</Show>
<span class="truncate max-w-[180px]">{name}</span>
<button
type="button"
onClick={() => void handleAttachmentDownload(attachment)}
class="attachment-download"
aria-label={`Download ${name}`}
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12l4 4 4-4m-4-8v12" />
</svg>
</button>
<Show when={isImage}>
<div class="attachment-chip-preview">
<img src={attachment.url} alt={name} />
</div>
</Show>
</div>
)
}}
</For>
</div>
</Show>
<Show when={props.record.status === "sending"}>
<div class="message-sending">
<span class="generating-spinner"></span> Sending...
</div>
</Show>
<Show when={props.record.status === "error"}>
<div class="message-error"> Message failed to send</div>
</Show>
</div>
<Show when={props.message.status === "sending"}>
<div class="message-sending">
<span class="generating-spinner"></span> Sending...
</div>
</Show>
<Show when={props.message.status === "error"}>
<div class="message-error"> Message failed to send</div>
</Show>
</div>
)
}

View File

@@ -0,0 +1,85 @@
import { Show } from "solid-js"
import Kbd from "./kbd"
const METRIC_CHIP_CLASS = "inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary"
const METRIC_LABEL_CLASS = "uppercase text-[10px] tracking-wide text-primary/70"
interface MessageListHeaderProps {
usedTokens: number
availableTokens?: number | null
connectionStatus: "connected" | "connecting" | "error" | "disconnected" | "unknown" | null
onCommandPalette: () => void
formatTokens: (value: number) => string
showSidebarToggle?: boolean
onSidebarToggle?: () => void
forceCompactStatusLayout?: boolean
}
export default function MessageListHeader(props: MessageListHeaderProps) {
const hasAvailableTokens = () => typeof props.availableTokens === "number"
const availableDisplay = () => (hasAvailableTokens() ? props.formatTokens(props.availableTokens as number) : "--")
return (
<div class={props.forceCompactStatusLayout ? "connection-status connection-status--compact" : "connection-status"}>
<Show when={props.showSidebarToggle}>
<div class="connection-status-menu">
<button
type="button"
class="session-sidebar-menu-button"
onClick={() => props.onSidebarToggle?.()}
aria-label="Open session list"
>
<span aria-hidden="true" class="session-sidebar-menu-icon"></span>
</button>
</div>
</Show>
<div class="connection-status-text connection-status-info">
<div class="connection-status-usage">
<div class={METRIC_CHIP_CLASS}>
<span class={METRIC_LABEL_CLASS}>Used</span>
<span class="font-semibold text-primary">{props.formatTokens(props.usedTokens)}</span>
</div>
<div class={METRIC_CHIP_CLASS}>
<span class={METRIC_LABEL_CLASS}>Avail</span>
<span class="font-semibold text-primary">{hasAvailableTokens() ? availableDisplay() : "--"}</span>
</div>
</div>
</div>
<div class="connection-status-text connection-status-shortcut">
<div class="connection-status-shortcut-action">
<button type="button" class="connection-status-button" onClick={props.onCommandPalette} aria-label="Open command palette">
Command Palette
</button>
<span class="connection-status-shortcut-hint">
<Kbd shortcut="cmd+shift+p" />
</span>
</div>
</div>
<div class="connection-status-meta flex items-center justify-end gap-3">
<Show when={props.connectionStatus === "connected"}>
<span class="status-indicator connected">
<span class="status-dot" />
<span class="status-text">Connected</span>
</span>
</Show>
<Show when={props.connectionStatus === "connecting"}>
<span class="status-indicator connecting">
<span class="status-dot" />
<span class="status-text">Connecting...</span>
</span>
</Show>
<Show when={props.connectionStatus === "error" || props.connectionStatus === "disconnected"}>
<span class="status-indicator disconnected">
<span class="status-dot" />
<span class="status-text">Disconnected</span>
</span>
</Show>
</div>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More