Compare commits

..

60 Commits

Author SHA1 Message Date
Shantur Rathore
0ef57df3bc fix(ui): show token stats and simplify context window calculation
- Track messageInfoVersion in cache signature to rebuild when tokens arrive via SSE
- Read tokens from step-finish part directly (embedded in SSE events)
- Simplify available tokens to show full context window when no explicit input limit
2026-04-08 22:19:10 +01:00
Shantur Rathore
0739ec857c Reapply "fix(ui): support unified diff patch format in session changes viewer"
This reverts commit af6429162f.
2026-04-08 20:57:23 +01:00
Shantur Rathore
b060ab45ff Revert "feat(tauri): add zip bundle target for macOS and Windows"
This reverts commit 197898c01c.
2026-04-08 20:57:23 +01:00
Shantur Rathore
af6429162f Revert "fix(ui): support unified diff patch format in session changes viewer"
This reverts commit 2e9ee2cde6.
2026-04-08 20:57:12 +01:00
Shantur Rathore
2e9ee2cde6 fix(ui): support unified diff patch format in session changes viewer
Session diffs now use a compact patch field instead of storing full
before/after content. Added parsePatchToBeforeAfter utility to extract
before/after from unified diff format, and updated MonacoDiffViewer to
accept patch prop as alternative to before/after strings.
2026-04-08 20:48:13 +01:00
Shantur Rathore
d45c0b9367 fix(tauri): prevent Windows zoom freeze with debouncing and transparent window
- Add 50ms debounce to zoom operations to prevent WebView2 IPC bottleneck
- Enable transparent window mode for better Windows resize/zoom performance
- Reduce zoom step from 0.2 to 0.1 for finer control
2026-04-08 20:47:49 +01:00
Shantur Rathore
197898c01c feat(tauri): add zip bundle target for macOS and Windows
- Add build scripts for platform-specific builds with zip bundles
- Update CI workflow to use --bundles flag for explicit target selection
- macOS: use app,zip (removed dmg)
- Windows: use nsis,zip
- Linux: use appimage,deb,rpm
2026-04-08 20:34:08 +01:00
Shantur Rathore
0c0cfd2d22 fix(ui): keep speech input chained and scrolled to bottom 2026-04-08 19:02:06 +01:00
Shantur Rathore
5107ac207e feat(ui): show background process notify state 2026-04-08 16:09:17 +01:00
Shantur Rathore
1130066a33 feat(background-process): notify sessions when tasks end
Send synthetic session notifications when background processes finish, fail, stop, or terminate so the originating agent can react without polling. Hide synthetic text-only prompts from the UI stream so operational notifications stay out of the visible transcript.
2026-04-08 15:48:50 +01:00
Shantur Rathore
403a3ff189 Scroll fixes - Improve scroll to bottom handling for reasoning, bash and task tools (#288)
Fixes #286 and more
2026-04-04 15:11:45 +01:00
codenomadbot[bot]
7996e514c4 fix(ui): preserve prompt text when dismissing mention picker (#285)
## Summary
- preserve the current prompt text when dismissing the `@` mention/file
picker with `Esc`
- let `Enter` fall back to normal prompt submission when the mention
picker is open but there is no selectable result

## Verification
- source inspection of the prompt input and picker flow
- local `npm run typecheck --workspace @codenomad/ui` is blocked in this
environment because workspace dependencies are not installed

--
Yours,
[CodeNomadBot](https://github.com/NeuralNomadsAI/CodeNomad)

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-04-04 00:48:37 +01:00
Pascal André
141be2cde0 perf(ui): fix O(n²) reactive subscriptions in timeline effects (HUGE SPEED IMPROVEMENT) (#274)
## Summary

- Wraps store-proxied array iteration in `untrack()` in two
`createEffect` blocks and one `createMemo` in `message-section.tsx` to
prevent SolidJS from creating O(n) per-element reactive subscriptions on
every run
- Replaces `ids.includes()` with `Set.has()` for O(1) cleanup lookups in
the part-count tracking effect

## Problem

Two `createEffect` blocks in `message-section.tsx` iterate the
`messageIds()` store proxy array inside a tracked reactive context. This
causes SolidJS to create **O(n) per-element subscriptions** on every
run. When any element changes, all n subscriptions fire, re-running the
entire effect — resulting in **O(n²) total work**.

Additionally, the cleanup loop in the part-count tracking effect uses
`ids.includes(trackedId)` which is O(n) per tracked ID, compounding to
O(n²).

For long-running sessions with large message history (e.g. 7569
messages), this caused **~4.8 seconds of input latency** when sending a
new prompt.

## Fix

1. **Timeline sync effect (~line 738):** Wrap entire body in
`untrack()`, replace `ids.slice()` with `[...ids]` to snapshot without
proxy tracking
2. **Part-count tracking effect (~line 891):** Wrap iteration in
`untrack()`, replace `ids.includes()` with `new Set(ids).has()` for O(1)
lookups
3. **`lastAssistantIndex` memo:** Read message records via `untrack()`
to avoid O(n) subscriptions on part-level updates

## Result

On a 7569-message session: prompt input latency reduced from **~4.8s to
~42ms** (114x improvement).
2026-04-03 23:01:13 +01:00
codenomadbot[bot]
259d457209 fix(desktop): launch server with unrestricted root (#283)
## Summary
- launch the Electron-managed server with `--unrestricted-root` by
default
- launch the Tauri-managed server with `--unrestricted-root` by default
- stop relying on the server's `process.cwd()` fallback for desktop
filesystem browsing

--
Yours,
[CodeNomadBot](https://github.com/NeuralNomadsAI/CodeNomad)

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-04-03 16:47:34 +01:00
Shantur Rathore
d0a0325d7e feat(sidecars): add proxied sidecar tabs (#279)
## Summary
- add SideCar support across the server and UI, including proxied tabs,
picker/settings flows, and websocket-aware proxying
- unify top-level tab handling so workspace instances and SideCars share
the same tab model and navigation flows
- limit SideCars to port-based services only, removing server-managed
process control from the final API and UI

---------

Co-authored-by: Shantur <shantur@Mac.home>
Co-authored-by: Shantur <shantur@Shanturs-MacBook-Pro-M5.local>
2026-04-02 23:00:17 +01:00
Shantur Rathore
19a4c3df16 add remote server launcher flow (#277)
## Summary
- add a remote CodeNomad server launcher flow in the home screen,
including saved server profiles, probe-before-connect behavior, and
desktop bridge APIs for opening remote windows
- add Electron support for remote server windows with per-window origin
handling and self-signed certificate bypass, plus Tauri support for
remote windows with clearer self-signed guidance
- fix Tauri dev server resolution and window shutdown behavior so dev
mode prefers the source server entry and the app only exits after the
last window closes
2026-04-02 21:29:19 +01:00
Shantur Rathore
10506920ac fix electron remote tls exception scoping 2026-04-02 18:46:16 +01:00
Shantur Rathore
92c029d744 fix remote server keyboard and reconnect flows 2026-04-02 18:20:17 +01:00
Shantur Rathore
6eb3246d37 update tauri self-signed guidance 2026-04-02 17:18:23 +01:00
Shantur Rathore
5c90de84de fix tauri window shutdown behavior 2026-04-02 17:15:25 +01:00
Shantur Rathore
455a59f693 fix tauri dev server resolution 2026-04-02 17:10:10 +01:00
Shantur Rathore
a89da02d6b fix(tauri): stabilize dev CLI shell startup 2026-04-02 17:01:10 +01:00
Shantur Rathore
69d9e95bee add remote server launcher flow 2026-04-02 16:08:54 +01:00
bluelovers
893d5f9296 Add log level configuration support (#272)
Add log level configuration support via config.yaml and UI settings.

---------

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-04-02 11:12:33 +01:00
Shantur Rathore
e82e529a8f Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev 2026-04-01 23:16:33 +01:00
VooDisss
4f236ce36f Implement shared compact split and unified tool-call diff layout (#270)
# PR Title

Implement shared compact split and unified tool-call diff layout

---
Fixes #268 
# PR Description

## Summary

This PR makes tool-call diffs more compact in both `Unified` and `Split`
views by reducing wasted horizontal space in line-number gutters and
content indentation.

## What changed

- introduced a shared compact-diff framework for tool-call diffs
- kept mobile-specific policy limited to:
  - forcing unified mode below the breakpoint
  - enabling wrap only in mobile unified mode
- added mode-specific compact applicators in the diff viewer:
  - unified applicator
  - split applicator
- reduced gutter width waste by measuring rendered line-number text and
tightening column width around it
- removed unnecessary right-side content padding
- aligned `+` / `-` markers closer to the left edge across both views
- simplified cleanup after gatekeeper review by removing extra plumbing
and residue

## Screenshots

### Before

<img width="581" height="341" alt="image"
src="https://github.com/user-attachments/assets/ec47b256-749a-4afc-8879-aaf33f0b46b6"
/>

### After

<img width="470" height="586" alt="image"
src="https://github.com/user-attachments/assets/7258a5a2-47c4-408d-84bc-1b497761c7ad"
/>

## Architectural approach

This change intentionally uses:

- shared policy in
`packages/ui/src/components/tool-call/diff-render.tsx`
- shared helper/measurement logic in
`packages/ui/src/components/diff-viewer.tsx`
- mode-specific applicators where unified and split DOM differ
- CSS for shared visual spacing and alignment cleanup

The goal was to keep the implementation architecturally clean and avoid
building separate duplicated compact-diff features for:

- mobile vs desktop
- unified vs split

Instead, the feature shares one compact-diff concept and only diverges
where the upstream diff DOM requires separate handling.

## Files changed

- `packages/ui/src/components/tool-call/diff-render.tsx`
- `packages/ui/src/components/diff-viewer.tsx`
- `packages/ui/src/styles/messaging/tool-call.css`
- `packages/ui/src/types/message.ts`

## Validation

Manual validation was performed in the running UI.

Verified manually:

- compact unified gutters on mobile
- compact unified gutters on desktop
- compact split gutters on desktop
- tighter operator alignment in both modes

Also verified:

- `npm run typecheck` passes

## Notes

- This PR is intended to address the compact diff layout problem
described in the related issue.
- Diff-specific CSS still lives in `tool-call.css`; future extraction
into a smaller dedicated stylesheet is possible but not required for
this change.

---------

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-04-01 23:13:32 +01:00
Shantur Rathore
2ffeb45a9c fix(workflows): recheck non-dev PR authorization by author 2026-04-01 23:11:25 +01:00
Shantur Rathore
df16b64a95 Merge remote-tracking branch 'origin/main' into dev 2026-04-01 22:13:57 +01:00
VooDisss
f3c54df283 fix(server): show sane remote URLs for 0.0.0.0 binds (#262)
Closes #261

## Summary

- improve startup remote URL selection when the server binds to
`0.0.0.0`
- print additional reachable remote URLs instead of advertising only the
first external address
- add targeted tests for address ordering and advertisability behavior

## Problem

When CodeNomad was started with `--host 0.0.0.0`, the CLI chose the
first external IPv4 address it discovered and displayed only that one as
the remote URL.

On Windows machines with WSL, Hyper-V, Docker, or other virtual
adapters, that often surfaced a virtual `172.x.x.x` address even though
a more useful LAN address such as `192.168.x.x` was also reachable and
usable from other devices.

That made remote access look broken or confusing even though the server
itself was accessible.

## What changed

- reuse the resolved network-address list for both:
  - primary remote URL selection
  - startup logging of additional reachable URLs
- choose the primary remote URL from the **advertisable** external
addresses instead of any external address
- print `Other Accessible URLs` when multiple useful remote URLs are
available
- avoid hard-coding a preference like `192.168 > 10 > 172`
- suppress link-local `169.254.*` addresses from user-facing advertised
URLs
- add tests covering:
  - stable ordering across RFC1918 address ranges
  - link-local addresses being non-advertisable
  - link-local-first discovery not stealing the primary LAN URL

## Why this approach

This keeps address derivation in the network-address resolver layer and
limits `index.ts` to startup wiring and presentation.

It also fixes the misleading terminal output without redesigning binding
behavior, TLS behavior, or the server API contract.

## Validation

- `npm run typecheck --workspace @neuralnomads/codenomad`
- `npx tsx --test
'.\\src\\server\\__tests__\\network-addresses.test.ts'`

## Notes

- this change is intentionally focused on selection and presentation of
reachable addresses
- it does not attempt a broader virtual-adapter classification policy
beyond suppressing clearly low-value link-local addresses in user-facing
output

---------

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-04-01 22:12:28 +01:00
Shantur Rathore
5658a9f62d Merge remote-tracking branch 'origin/main' into dev 2026-04-01 21:35:09 +01:00
Shantur Rathore
278b563c1a Release 0.13.3 - Voice conversation mode, File editing, YOLO mode (#264)
## Thanks for contributions
- PR #252 “feat: Enable file editing and saving” by @jchadwick
- PR #256 “feat(ui): add session yolo mode controls” by @pascalandr
- PR #257 “fix(tauri): sync native app version with package releases” by
@pascalandr
- PR #258 “fix(tauri): stop stale UI assets from shadowing desktop
builds” by @pascalandr
- PR #260 “fix(ui): escape raw HTML in user prompt messages” by
@app/codenomadbot

## Highlights
- **Edit and save files directly in CodeNomad**: Update workspace files
in the built-in editor, save them without leaving the app, and get safer
handling for unsaved changes or edit conflicts.
- **More control over session automation**: Turn on per-session YOLO
mode from the Status tab, keep it visible with a clear badge, and let
long-running sessions continue auto-accepting prompts as expected.
- **Better voice conversation options**: Use spoken summary mode for
replies and keep conversation speech settings isolated per client, so
one device’s voice preferences do not unexpectedly affect another.
- **Faster session recovery**: Reload a session transcript from the
sidebar and see when a session is retrying, including live status
feedback.

## What’s Improved
- **Smoother desktop setup**: Desktop builds now bundle the right CLI
resources and handle microphone access more cleanly.
- **More reliable cross-platform desktop behavior**: Windows process
handling and npm invocation are safer, reducing environment-specific
issues.
- **Clearer session status visibility**: Retrying sessions now show more
useful state in the sidebar and header, so it is easier to tell what is
happening.
- **Cleaner in-app feedback**: Long toast messages wrap properly, GitHub
star counts display more cleanly, and message/code rendering behaves
more predictably.

## Fixes
- **Safer prompt rendering**: Raw HTML in user prompts is escaped so
messages display safely instead of being interpreted.
- **More reliable code previews**: Incomplete syntax highlighting
results are no longer cached, which helps prevent broken-looking file
views.
- **Better voice handoff**: Conversation playback stops when voice input
starts, avoiding overlapping speech.
- **More dependable desktop releases**: Native app versions now stay
aligned with package releases, and stale UI assets no longer shadow new
desktop builds.

### Contributors
- @jchadwick
- @pascalandr
2026-03-31 20:33:43 +01:00
Shantur Rathore
27bccb8d6b Release v0.13.1 - Voice mode, Super speedy streaming, and a lot more (#255)
## Thanks for contributions

- PR [#249](https://github.com/NeuralNomadsAI/CodeNomad/pull/249)
"feat(speech): add prompt voice input" by
[@shantur](https://github.com/shantur)
- PR [#243](https://github.com/NeuralNomadsAI/CodeNomad/pull/243)
"feat(i18n): Hebrew locale + full RTL support" by
[@MusiCode1](https://github.com/MusiCode1)
- PR [#241](https://github.com/NeuralNomadsAI/CodeNomad/pull/241)
"feat(lazy loading): Implement virtual list with virtua" by
[@pixellos](https://github.com/pixellos)
- PR [#240](https://github.com/NeuralNomadsAI/CodeNomad/pull/240)
"fix(tauri): force Windows process tree shutdown" by
[@pascalandr](https://github.com/pascalandr)
- PR [#239](https://github.com/NeuralNomadsAI/CodeNomad/pull/239)
"perf(ui): split right panel and secondary viewer chunks" by
[@pascalandr](https://github.com/pascalandr)
- PR [#238](https://github.com/NeuralNomadsAI/CodeNomad/pull/238)
"perf(ui): defer locale and overlay bundles" by
[@pascalandr](https://github.com/pascalandr)
- PR [#236](https://github.com/NeuralNomadsAI/CodeNomad/pull/236)
"Suppress OS notifications for subagent (child) sessions" by
`@app/codenomadbot`
- PR [#235](https://github.com/NeuralNomadsAI/CodeNomad/pull/235)
"fix(ui): unwrap pasted placeholders in slash commands" by
`@app/codenomadbot`
- PR [#232](https://github.com/NeuralNomadsAI/CodeNomad/pull/232)
"fix(tauri): stop CLI process group on exit" by `@app/codenomadbot`
- PR [#229](https://github.com/NeuralNomadsAI/CodeNomad/pull/229)
"feat(ui): add RTL support for Hebrew/Arabic text" by
[@MusiCode1](https://github.com/MusiCode1)
- PR [#227](https://github.com/NeuralNomadsAI/CodeNomad/pull/227)
"fix(tauri): improve Windows desktop runtime behavior" by
[@pascalandr](https://github.com/pascalandr)
- PR [#226](https://github.com/NeuralNomadsAI/CodeNomad/pull/226)
"fix(tauri): restore desktop menu controls and fullscreen shortcut" by
[@pascalandr](https://github.com/pascalandr)
- PR [#225](https://github.com/NeuralNomadsAI/CodeNomad/pull/225)
"fix(tauri): restore external links in the folder picker" by
[@pascalandr](https://github.com/pascalandr)
- PR [#224](https://github.com/NeuralNomadsAI/CodeNomad/pull/224)
"fix(tauri): sync server UI bundle during prebuild" by
[@pascalandr](https://github.com/pascalandr)
- PR [#215](https://github.com/NeuralNomadsAI/CodeNomad/pull/215)
"perf(ui): lazy-load markdown and defer diff rendering" by
[@pascalandr](https://github.com/pascalandr)

## Highlights

- **Voice-first conversations**: Start prompts with voice input,
configure speech behavior from settings, and listen back to assistant
responses with message playback and conversation playback controls.
- **A complete Hebrew + RTL experience**: CodeNomad now ships with a
full Hebrew locale and much broader right-to-left support, making the
app feel natural for Hebrew users while improving Arabic text rendering
too.
- **A much faster experience in long chats**: The new virtualized
message list, deferred markdown and diff rendering, and more selective
loading for heavy UI surfaces make large sessions feel noticeably
smoother.

## What's Improved

- **More flexible speech controls**: Speech settings and playback modes
now adapt better to different browsers and platform capabilities.
- **Cleaner prompt workflow**: The prompt includes a quick clear action,
a simpler recording indicator, and a more polished mic control layout.
- **Faster startup and lighter heavy views**: Locale bundles, overlays,
right-panel viewers, picker flows, markdown, and diff surfaces all load
more lazily to reduce upfront UI work.
- **Less notification spam**: Subagent sessions no longer fire OS
notifications, so important interruptions are easier to notice.
- **Better RTL behavior across the whole interface**: Session names,
tool outputs, markdown blocks, file views, selectors, and layout
controls behave more consistently in right-to-left contexts.

## Fixes

- **More reliable Windows desktop behavior**: Process cleanup is
stronger during app shutdown, background CLI process trees are
terminated more reliably, desktop identity/metadata is aligned more
cleanly, and stray console windows are hidden during startup and exit.
- **Cleaner shutdown on macOS and Linux**: Desktop quit/close now stops
the spawned CLI process group more reliably, reducing leftover
background processes after exit.
- **Restored desktop actions**: External links in the folder picker work
again, and the desktop View/Window controls plus the fullscreen shortcut
are back.
- **More stable streaming and scrolling**: Reasoning streams stay pinned
more consistently, follow behavior is less jumpy, spacing is cleaner in
virtualized conversations, and session switching retains position more
smoothly.
- **Safer slash command pasting**: Pasted placeholders are resolved
correctly before slash commands run, so long pasted inputs behave like
normal prompts.
- **More dependable desktop packaging**: Tauri prebuild now refreshes
the server UI bundle correctly, which avoids packaged desktop builds
picking up stale UI assets.
- **Clearer speech compatibility handling**: Streaming playback
limitations are surfaced more cleanly instead of failing in a confusing
way.

### Contributors

- [@pascalandr](https://github.com/pascalandr)
- [@MusiCode1](https://github.com/MusiCode1)
- [@pixellos](https://github.com/pixellos)
2026-03-27 19:58:35 +00:00
Shantur Rathore
153065d025 Merge pull request #214 from Pagecran/ready/tauri-auth-cookie-isolation
fix(tauri): isolate desktop auth cookies per app
2026-03-15 17:53:06 +00:00
Pascal André
2abda0e6b4 fix(desktop): isolate Electron auth cookies per app
Make the legacy Electron desktop client generate and pass a per-launch auth cookie name too, so parallel desktop instances stop clobbering each other's localhost session cookie just like the Tauri client.
2026-03-15 09:38:00 +01:00
Pascal André
800133361d fix(tauri): remove stray perf emission from auth cookie PR
Drop the startup instrumentation call that leaked into the auth-cookie isolation branch. The helper is not defined on this PR branch, and the PR does not need to serialize the generated cookie name to fix the multi-instance auth collision.
2026-03-15 01:10:05 +01:00
Pascal André
034cb5dea9 fix(tauri): isolate desktop auth cookies per app 2026-03-14 23:31:46 +01:00
Shantur Rathore
d7ab84f245 Merge pull request #213 from NeuralNomadsAI/dev
Release v0.12.3
2026-03-13 21:27:30 +00:00
Shantur Rathore
201988b97c Merge pull request #205 from NeuralNomadsAI/dev
Release v0.12.1 - Histogram, bulk delete, snappier long sessions and more
2026-03-04 10:42:43 +00:00
Shantur Rathore
6a6fcff2c8 Merge pull request #195 from NeuralNomadsAI/dev
Release v0.11.4 - Mobile Fullscreen mode and lots of improvements
2026-02-22 17:15:22 +00:00
Shantur Rathore
f29f197b9a Merge pull request #177 from NeuralNomadsAI/dev
v0.11.1 Release - Latest OC Support, Improved file/folder picker, Dev Releases and lot more
2026-02-16 16:31:17 +00:00
Shantur Rathore
dbde403b3e Merge pull request #150 from NeuralNomadsAI/dev
Release v0.10.3 - Viewer for Changes, Git Diff and workspace files along with UX fixes
2026-02-11 16:09:49 +00:00
Shantur Rathore
230c981cc2 Merge pull request #134 from NeuralNomadsAI/dev
Release v0.10.2
2026-02-09 01:08:06 +00:00
Shantur Rathore
34978c87fb Merge pull request #125 from NeuralNomadsAI/dev
Release v0.10.1 - Worktrees, HTTPS, PWA and more
2026-02-08 18:07:08 +00:00
Shantur Rathore
3e6d0a402c Merge pull request #116 from NeuralNomadsAI/dev
Release v0.9.4 - Context manipulation, Session search, Themes and more
2026-02-03 20:26:17 +00:00
Shantur Rathore
e81c5f6443 Merge pull request #105 from NeuralNomadsAI/dev
Release v0.9.3 -  Tauri fixes, Skip Auth, Better Question tool and more
2026-01-30 09:18:20 +00:00
Shantur Rathore
b0d27bd127 Merge pull request #99 from NeuralNomadsAI/dev
Release v0.9.2 - Model Favourites and Multi-Lang UI
2026-01-26 21:02:29 +00:00
Shantur Rathore
7576470295 Merge pull request #96 from NeuralNomadsAI/dev
Release v0.9.1 - Thinking variant, Robust process cleanup
2026-01-25 18:08:18 +00:00
Shantur Rathore
6d32e09db0 Merge pull request #94 from NeuralNomadsAI/dev
Release 0.9.0
2026-01-24 16:47:37 +00:00
Shantur Rathore
503cb3a02e Merge pull request #91 from NeuralNomadsAI/dev
Release v0.8.1 - Support apply_patch tool
2026-01-22 23:07:37 +00:00
Shantur Rathore
0250c6350f Merge pull request #89 from NeuralNomadsAI/dev
Change minVersion to 0.8.0
2026-01-22 19:17:20 +00:00
Shantur Rathore
24cc8fe939 Merge pull request #88 from NeuralNomadsAI/dev
Release v0.8.0 - Auto update UI and more fixes
2026-01-22 18:58:51 +00:00
Shantur Rathore
282b234a7c Merge pull request #87 from NeuralNomadsAI/dev
Release 0.7.6 - Question tool fixes + Split test
2026-01-22 17:20:19 +00:00
Shantur Rathore
4ba088a876 Merge pull request #82 from NeuralNomadsAI/dev
Release 0.7.5
2026-01-21 12:27:47 +00:00
Shantur Rathore
7b1817d606 Merge pull request #80 from NeuralNomadsAI/dev
Release 0.7.4
2026-01-20 19:30:19 +00:00
Shantur Rathore
5bc3c23ec5 Merge pull request #79 from NeuralNomadsAI/dev
Release 0.7.3 - Bug fixes and minor improvements
2026-01-20 18:53:39 +00:00
Shantur Rathore
127a51e3c3 Merge pull request #72 from NeuralNomadsAI/dev
Release v0.7.2 - Test1
2026-01-15 20:59:06 +00:00
Shantur Rathore
daa22b6d8c Merge pull request #68 from NeuralNomadsAI/dev
Release v0.7.1
2026-01-15 08:42:55 +00:00
Shantur Rathore
23f2de2d7e Merge pull request #66 from NeuralNomadsAI/dev
Actually Release 0.7.0
2026-01-14 21:56:13 +00:00
Shantur Rathore
80c9b76709 Merge pull request #65 from NeuralNomadsAI/dev
Release v0.7.0
2026-01-14 21:46:38 +00:00
Shantur Rathore
a29b77d60b Merge pull request #59 from NeuralNomadsAI/dev
v0.6.0 Release
2026-01-09 21:55:50 +00:00
127 changed files with 5994 additions and 1054 deletions

View File

@@ -4,6 +4,7 @@ on:
pull_request_target:
types:
- opened
- edited
- synchronize
- reopened
- ready_for_review
@@ -19,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
env:
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
ACTOR: ${{ github.actor }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
IS_DRAFT: ${{ github.event.pull_request.draft }}
PR_NUMBER: ${{ github.event.pull_request.number }}
@@ -37,7 +38,7 @@ jobs:
fi
normalized=",${ALLOWED_ACTORS},"
if [[ "$normalized" == *",${ACTOR},"* ]]; then
if [[ "$normalized" == *",${PR_AUTHOR},"* ]]; then
echo "allowed=true" >> "$GITHUB_OUTPUT"
else
echo "allowed=false" >> "$GITHUB_OUTPUT"

View File

@@ -4,6 +4,7 @@ on:
pull_request:
types:
- opened
- edited
- synchronize
- reopened
- ready_for_review
@@ -23,7 +24,7 @@ jobs:
allowed: ${{ steps.auth.outputs.allowed }}
env:
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
ACTOR: ${{ github.actor }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
steps:
- name: Check PR authorization
@@ -37,11 +38,11 @@ jobs:
fi
normalized=",${ALLOWED_ACTORS},"
if [[ "$normalized" == *",${ACTOR},"* ]]; then
if [[ "$normalized" == *",${PR_AUTHOR},"* ]]; then
echo "allowed=true" >> "$GITHUB_OUTPUT"
else
echo "allowed=false" >> "$GITHUB_OUTPUT"
echo "Skipping builds for unauthorized PR targeting $BASE_REF" >&2
echo "Skipping builds for PR by unauthorized author targeting $BASE_REF" >&2
fi
build:

View File

@@ -4,6 +4,7 @@ on:
pull_request_target:
types:
- opened
- edited
- reopened
- synchronize
@@ -17,7 +18,7 @@ jobs:
runs-on: ubuntu-latest
env:
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
ACTOR: ${{ github.actor }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
PR_NUMBER: ${{ github.event.pull_request.number }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
steps:
@@ -27,7 +28,7 @@ jobs:
run: |
set -euo pipefail
normalized=",${ALLOWED_ACTORS},"
if [[ "$normalized" == *",${ACTOR},"* ]]; then
if [[ "$normalized" == *",${PR_AUTHOR},"* ]]; then
echo "authorized=true" >> "$GITHUB_OUTPUT"
else
echo "authorized=false" >> "$GITHUB_OUTPUT"
@@ -50,5 +51,5 @@ jobs:
- name: Fail unauthorized PR
if: ${{ steps.auth.outputs.authorized != 'true' }}
run: |
echo "Actor $ACTOR is not allowed to open PRs targeting $BASE_REF" >&2
echo "PR author $PR_AUTHOR is not allowed to open PRs targeting $BASE_REF" >&2
exit 1

View File

@@ -117,6 +117,28 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
async (): Promise<{ granted: boolean }> => ({ granted: await requestMicrophoneAccess() }),
)
ipcMain.handle(
"remote:openWindow",
async (
_event,
payload: { id: string; name: string; baseUrl: string; skipTlsVerify: boolean },
): Promise<{ ok: boolean }> => {
const opener = (mainWindow as BrowserWindow & {
__codenomadOpenRemoteWindow?: (payload: {
id: string
name: string
baseUrl: string
skipTlsVerify: boolean
}) => Promise<void>
}).__codenomadOpenRemoteWindow
if (!opener) {
throw new Error("Remote window opening is not available")
}
await opener(payload)
return { ok: true }
},
)
ipcMain.handle(
"notifications:show",
async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => {

View File

@@ -1,7 +1,7 @@
import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron"
import http from "node:http"
import https from "node:https"
import { existsSync } from "fs"
import { existsSync, mkdirSync } from "fs"
import { dirname, join } from "path"
import { fileURLToPath } from "url"
import { createApplicationMenu } from "./menu"
@@ -14,6 +14,31 @@ const mainDirname = dirname(mainFilename)
const isMac = process.platform === "darwin"
function configureDevStoragePaths() {
if (app.isPackaged) {
return
}
const appName = "CodeNomad"
try {
app.setName(appName)
const userDataPath = join(app.getPath("appData"), appName)
const sessionDataPath = join(userDataPath, "session-data")
mkdirSync(userDataPath, { recursive: true })
mkdirSync(sessionDataPath, { recursive: true })
app.setPath("userData", userDataPath)
app.setPath("sessionData", sessionDataPath)
} catch (error) {
console.warn("[cli] failed to configure dev storage paths", error)
}
}
configureDevStoragePaths()
const cliManager = new CliProcessManager()
let mainWindow: BrowserWindow | null = null
let currentCliUrl: string | null = null
@@ -21,6 +46,8 @@ let pendingCliUrl: string | null = null
let pendingBootstrapToken: string | null = null
let showingLoadingScreen = false
let preloadingView: BrowserView | null = null
const remoteWindowOrigins = new Map<number, Set<string>>()
const insecureWindowOrigins = new Map<number, Set<string>>()
if (isMac) {
app.commandLine.appendSwitch("disable-spell-checking")
@@ -93,8 +120,13 @@ function loadLoadingScreen(window: BrowserWindow) {
})
}
function getAllowedRendererOrigins(): string[] {
function getAllowedRendererOrigins(window?: BrowserWindow | null): string[] {
const origins = new Set<string>()
if (window) {
for (const origin of remoteWindowOrigins.get(window.id) ?? []) {
origins.add(origin)
}
}
const rendererCandidates = [currentCliUrl, process.env.VITE_DEV_SERVER_URL, process.env.ELECTRON_RENDERER_URL]
for (const candidate of rendererCandidates) {
if (!candidate) {
@@ -109,13 +141,13 @@ function getAllowedRendererOrigins(): string[] {
return Array.from(origins)
}
function shouldOpenExternally(url: string): boolean {
function shouldOpenExternally(url: string, window?: BrowserWindow | null): boolean {
try {
const parsed = new URL(url)
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return true
}
const allowedOrigins = getAllowedRendererOrigins()
const allowedOrigins = getAllowedRendererOrigins(window)
return !allowedOrigins.includes(parsed.origin)
} catch {
return false
@@ -128,7 +160,7 @@ function setupNavigationGuards(window: BrowserWindow) {
}
window.webContents.setWindowOpenHandler(({ url }) => {
if (shouldOpenExternally(url)) {
if (shouldOpenExternally(url, window)) {
handleExternal(url)
return { action: "deny" }
}
@@ -136,13 +168,54 @@ function setupNavigationGuards(window: BrowserWindow) {
})
window.webContents.on("will-navigate", (event, url) => {
if (shouldOpenExternally(url)) {
if (shouldOpenExternally(url, window)) {
event.preventDefault()
handleExternal(url)
}
})
}
function setWindowAllowedOrigin(window: BrowserWindow, url: string) {
try {
const origin = new URL(url).origin
remoteWindowOrigins.set(window.id, new Set([origin]))
} catch (error) {
console.warn("[cli] failed to store allowed origin", url, error)
}
}
function clearWindowAllowedOrigin(window: BrowserWindow) {
remoteWindowOrigins.delete(window.id)
}
function addWindowInsecureOrigin(window: BrowserWindow, url: string) {
try {
const origin = new URL(url).origin
insecureWindowOrigins.set(window.id, new Set([origin]))
} catch (error) {
console.warn("[cli] failed to store insecure origin", url, error)
}
}
function clearWindowInsecureOrigin(window: BrowserWindow) {
insecureWindowOrigins.delete(window.id)
}
function isInsecureOriginAllowed(url: string) {
try {
const targetOrigin = new URL(url).origin
for (const origins of insecureWindowOrigins.values()) {
if (origins.has(targetOrigin)) {
return true
}
}
} catch {
return false
}
return false
}
let cachedPreloadPath: string | null = null
function getPreloadPath() {
if (cachedPreloadPath && existsSync(cachedPreloadPath)) {
@@ -207,25 +280,30 @@ function createWindow() {
},
})
setupNavigationGuards(mainWindow)
const window = mainWindow
setupNavigationGuards(window)
if (isMac) {
mainWindow.webContents.session.setSpellCheckerEnabled(false)
window.webContents.session.setSpellCheckerEnabled(false)
}
showingLoadingScreen = true
currentCliUrl = null
loadLoadingScreen(mainWindow)
clearWindowAllowedOrigin(window)
loadLoadingScreen(window)
if (process.env.NODE_ENV === "development") {
mainWindow.webContents.openDevTools({ mode: "detach" })
window.webContents.openDevTools({ mode: "detach" })
}
createApplicationMenu(mainWindow)
setupCliIPC(mainWindow, cliManager)
createApplicationMenu(window)
setupCliIPC(window, cliManager)
mainWindow.on("closed", () => {
window.on("closed", () => {
destroyPreloadingView()
clearWindowAllowedOrigin(window)
clearWindowInsecureOrigin(window)
mainWindow = null
currentCliUrl = null
pendingCliUrl = null
@@ -322,13 +400,68 @@ function finalizeCliSwap(url: string) {
return
}
const window = mainWindow
showingLoadingScreen = false
currentCliUrl = url
setWindowAllowedOrigin(window, url)
pendingCliUrl = null
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
window.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
}
function buildRemoteWindowTitle(name: string, baseUrl: string) {
try {
const parsed = new URL(baseUrl)
return `${name} - ${parsed.host}`
} catch {
return `${name} - ${baseUrl}`
}
}
function buildRemoteErrorHtml(name: string, baseUrl: string, message: string) {
const escapedName = name.replace(/[&<>"]/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[char] ?? char))
const escapedUrl = baseUrl.replace(/[&<>"]/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[char] ?? char))
const escapedMessage = message.replace(/[&<>"]/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[char] ?? char))
return `<!doctype html><html><head><meta charset="utf-8" /><title>${escapedName}</title><style>body{margin:0;background:#111827;color:#f9fafb;font-family:Inter,system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:24px}main{max-width:560px;width:100%;background:rgba(17,24,39,.88);border:1px solid rgba(255,255,255,.08);border-radius:20px;padding:28px;box-shadow:0 25px 60px rgba(0,0,0,.45)}h1{margin:0 0 10px;font-size:1.5rem}p{margin:0 0 10px;color:#cbd5e1;line-height:1.5}code{display:block;margin-top:16px;padding:12px 14px;border-radius:12px;background:#0f172a;color:#bfdbfe;overflow:auto}</style></head><body><main><h1>${escapedName}</h1><p>Could not connect to the remote server.</p><p>${escapedMessage}</p><code>${escapedUrl}</code></main></body></html>`
}
async function openRemoteWindow(payload: { id: string; name: string; baseUrl: string; skipTlsVerify: boolean }) {
const targetUrl = new URL(payload.baseUrl)
const title = buildRemoteWindowTitle(payload.name, payload.baseUrl)
const window = new BrowserWindow({
width: 1400,
height: 900,
minWidth: 800,
minHeight: 600,
backgroundColor: "#1a1a1a",
icon: getIconPath(),
title,
webPreferences: {
preload: getPreloadPath(),
contextIsolation: true,
nodeIntegration: false,
spellcheck: !isMac,
},
})
setWindowAllowedOrigin(window, targetUrl.toString())
if (payload.skipTlsVerify) {
addWindowInsecureOrigin(window, targetUrl.toString())
}
setupNavigationGuards(window)
window.on("closed", () => {
clearWindowAllowedOrigin(window)
clearWindowInsecureOrigin(window)
})
try {
await window.loadURL(targetUrl.toString())
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
await window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(buildRemoteErrorHtml(payload.name, payload.baseUrl, message))}`)
}
}
const SESSION_COOKIE_NAME = "codenomad_session"
let bootstrapExchangeInFlight = false
function extractCookieValue(setCookieHeader: string | string[] | undefined, name: string): string | null {
@@ -351,6 +484,7 @@ function extractCookieValue(setCookieHeader: string | string[] | undefined, name
}
async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<boolean> {
const sessionCookieName = cliManager.getAuthCookieName()
const target = new URL("/api/auth/token", baseUrl)
const body = JSON.stringify({ token })
@@ -381,14 +515,14 @@ async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<b
return false
}
const sessionId = extractCookieValue(result.setCookie, SESSION_COOKIE_NAME)
const sessionId = extractCookieValue(result.setCookie, sessionCookieName)
if (!sessionId) {
return false
}
await session.defaultSession.cookies.set({
url: baseUrl,
name: SESSION_COOKIE_NAME,
name: sessionCookieName,
value: sessionId,
httpOnly: true,
path: "/",
@@ -504,6 +638,17 @@ app.whenReady().then(() => {
}
createWindow()
;(mainWindow as BrowserWindow & { __codenomadOpenRemoteWindow?: typeof openRemoteWindow }).__codenomadOpenRemoteWindow = openRemoteWindow
app.on("certificate-error", (event, _webContents, url, error, _certificate, callback) => {
if (isInsecureOriginAllowed(url)) {
event.preventDefault()
console.warn("[cli] allowing insecure remote certificate for", url, error)
callback(true)
return
}
callback(false)
})
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {

View File

@@ -14,6 +14,7 @@ const mainFilename = fileURLToPath(import.meta.url)
const mainDirname = path.dirname(mainFilename)
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
const SESSION_COOKIE_NAME_PREFIX = "codenomad_session"
type CliState = "starting" | "ready" | "error" | "stopped"
type ListeningMode = "local" | "all"
@@ -129,6 +130,7 @@ export class CliProcessManager extends EventEmitter {
private stdoutBuffer = ""
private stderrBuffer = ""
private bootstrapToken: string | null = null
private authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}`
private requestedStop = false
async start(options: StartOptions): Promise<CliStatus> {
@@ -139,6 +141,7 @@ export class CliProcessManager extends EventEmitter {
this.stdoutBuffer = ""
this.stderrBuffer = ""
this.bootstrapToken = null
this.authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}`
this.requestedStop = false
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
@@ -436,6 +439,10 @@ export class CliProcessManager extends EventEmitter {
return { ...this.status }
}
getAuthCookieName(): string {
return this.authCookieName
}
private resolveListeningMode(): ListeningMode {
return readListeningModeFromConfig()
}
@@ -532,7 +539,7 @@ export class CliProcessManager extends EventEmitter {
}
private buildCliArgs(options: StartOptions, host: string): string[] {
const args = ["serve", "--host", host, "--generate-token"]
const args = ["serve", "--host", host, "--generate-token", "--auth-cookie-name", this.authCookieName, "--unrestricted-root"]
if (options.dev) {
// Dev: run plain HTTP + Vite dev server proxy.

View File

@@ -23,6 +23,7 @@ const electronAPI = {
requestMicrophoneAccess: () => ipcRenderer.invoke("media:requestMicrophoneAccess"),
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
openRemoteWindow: (payload) => ipcRenderer.invoke("remote:openWindow", payload),
}
contextBridge.exposeInMainWorld("electronAPI", electronAPI)

View File

@@ -13,6 +13,11 @@ type BackgroundProcess = {
outputSizeBytes?: number
}
type BackgroundProcessNotificationRequest = {
sessionID: string
directory: string
}
type BackgroundProcessOptions = {
baseDir: string
}
@@ -36,12 +41,19 @@ export function createBackgroundProcessTools(config: CodeNomadConfig, options: B
args: {
title: tool.schema.string().describe("Short label for the process (e.g. Dev server, DB server)"),
command: tool.schema.string().describe("Shell command to run in the workspace"),
notify: tool.schema.boolean().optional().describe("Notify the current session when the process ends"),
},
async execute(args) {
async execute(args, context) {
assertCommandWithinBase(args.command, options.baseDir)
const notification: BackgroundProcessNotificationRequest | undefined = args.notify
? {
sessionID: context.sessionID,
directory: context.directory,
}
: undefined
const process = await request<BackgroundProcess>("", {
method: "POST",
body: JSON.stringify({ title: args.title, command: args.command }),
body: JSON.stringify({ title: args.title, command: args.command, notify: args.notify, notification }),
})
return `Started background process ${process.id} (${process.title})\nStatus: ${process.status}\nCommand: ${process.command}`

View File

@@ -170,6 +170,24 @@ export interface InstanceStreamEvent {
[key: string]: unknown
}
export type SideCarKind = "port"
export type SideCarPrefixMode = "strip" | "preserve"
export type SideCarStatus = "running" | "stopped"
export interface SideCar {
id: string
kind: SideCarKind
name: string
port: number
insecure: boolean
prefixMode: SideCarPrefixMode
status: SideCarStatus
createdAt: string
updatedAt: string
}
export interface BinaryRecord {
id: string
path: string
@@ -244,12 +262,40 @@ export interface VoiceModeStateResponse {
enabled: boolean
}
export interface RemoteServerProfile {
id: string
name: string
baseUrl: string
skipTlsVerify: boolean
createdAt: string
updatedAt: string
lastConnectedAt?: string
}
export interface RemoteServerProbeRequest {
baseUrl: string
skipTlsVerify?: boolean
}
export interface RemoteServerProbeResponse {
ok: boolean
reachable: boolean
normalizedUrl: string
skipTlsVerify: boolean
requiresAuth: boolean
authenticated: boolean
error?: string
errorCode?: string
}
export type WorkspaceEventType =
| "workspace.created"
| "workspace.started"
| "workspace.error"
| "workspace.stopped"
| "workspace.log"
| "sidecar.updated"
| "sidecar.removed"
| "storage.configChanged"
| "storage.stateChanged"
| "instance.dataChanged"
@@ -262,6 +308,8 @@ export type WorkspaceEventPayload =
| { type: "workspace.error"; workspace: WorkspaceDescriptor }
| { type: "workspace.stopped"; workspaceId: string }
| { type: "workspace.log"; entry: WorkspaceLogEntry }
| { type: "sidecar.updated"; sidecar: SideCar }
| { type: "sidecar.removed"; sidecarId: string }
| { type: "storage.configChanged"; owner: SettingsOwner; value: SettingsBucket }
| { type: "storage.stateChanged"; owner: SettingsOwner; value: SettingsBucket }
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
@@ -328,6 +376,8 @@ export interface ServerMeta {
export type BackgroundProcessStatus = "running" | "stopped" | "error"
export type BackgroundProcessTerminalReason = "finished" | "failed" | "user_stopped" | "user_terminated"
export interface BackgroundProcess {
id: string
workspaceId: string
@@ -340,6 +390,8 @@ export interface BackgroundProcess {
stoppedAt?: string
exitCode?: number
outputSizeBytes?: number
terminalReason?: BackgroundProcessTerminalReason
notifyEnabled?: boolean
}
export interface BackgroundProcessListResponse {

View File

@@ -16,16 +16,18 @@ export interface AuthManagerInit {
password?: string
generateToken: boolean
dangerouslySkipAuth?: boolean
cookieName?: string
}
export class AuthManager {
private readonly authStore: AuthStore | null
private readonly tokenManager: TokenManager | null
private readonly sessionManager = new SessionManager()
private readonly cookieName = DEFAULT_AUTH_COOKIE_NAME
private readonly cookieName: string
private readonly authEnabled: boolean
constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) {
this.cookieName = sanitizeCookieName(init.cookieName)
this.authEnabled = !Boolean(init.dangerouslySkipAuth)
if (!this.authEnabled) {
@@ -102,13 +104,18 @@ export class AuthManager {
}
getSessionFromRequest(request: FastifyRequest): { username: string; sessionId: string } | null {
return this.getSessionFromHeaders(request.headers)
}
getSessionFromHeaders(headers: { cookie?: string | string[] | undefined }): { username: string; sessionId: string } | null {
if (!this.authEnabled) {
// When auth is disabled, treat all requests as authenticated.
// We still return a stable username so callers can display it.
return { username: this.init.username, sessionId: "auth-disabled" }
}
const cookies = parseCookies(request.headers.cookie)
const cookieHeader = Array.isArray(headers.cookie) ? headers.cookie.join("; ") : headers.cookie
const cookies = parseCookies(cookieHeader)
const sessionId = cookies[this.cookieName]
const session = this.sessionManager.getSession(sessionId)
if (!session) return null
@@ -139,6 +146,16 @@ export class AuthManager {
}
}
function sanitizeCookieName(value: string | undefined): string {
const trimmed = value?.trim()
if (!trimmed) {
return DEFAULT_AUTH_COOKIE_NAME
}
const sanitized = trimmed.replace(/[^A-Za-z0-9_-]/g, "_")
return sanitized.length > 0 ? sanitized : DEFAULT_AUTH_COOKIE_NAME
}
function resolveAuthFilePath(configPath: string) {
const resolvedConfigPath = resolvePath(configPath)
return path.join(path.dirname(resolvedConfigPath), "auth.json")

View File

@@ -5,7 +5,7 @@ import { randomBytes } from "crypto"
import type { EventBus } from "../events/bus"
import type { WorkspaceManager } from "../workspaces/manager"
import type { Logger } from "../logger"
import type { BackgroundProcess, BackgroundProcessStatus } from "../api-types"
import type { BackgroundProcess, BackgroundProcessStatus, BackgroundProcessTerminalReason } from "../api-types"
const ROOT_DIR = ".codenomad/background_processes"
const INDEX_FILE = "index.json"
@@ -27,6 +27,31 @@ interface RunningProcess {
outputPath: string
exitPromise: Promise<void>
workspaceId: string
completion?: ProcessCompletion
}
interface ProcessCompletion {
reason: BackgroundProcessTerminalReason
endContext: "normal" | "workspace_cleanup"
removeAfterFinalize?: boolean
}
interface BackgroundProcessNotificationState {
sessionID: string
directory: string
sentAt?: string
}
interface PersistedBackgroundProcess extends BackgroundProcess {
notify?: BackgroundProcessNotificationState
}
interface StartOptions {
notify?: boolean
notification?: {
sessionID: string
directory: string
}
}
export class BackgroundProcessManager {
@@ -41,14 +66,14 @@ export class BackgroundProcessManager {
const records = await this.readIndex(workspaceId)
const enriched = await Promise.all(
records.map(async (record) => ({
...record,
...this.toPublicProcess(record),
outputSizeBytes: await this.getOutputSize(workspaceId, record.id),
})),
)
return enriched
}
async start(workspaceId: string, title: string, command: string): Promise<BackgroundProcess> {
async start(workspaceId: string, title: string, command: string, options: StartOptions = {}): Promise<BackgroundProcess> {
const workspace = this.deps.workspaceManager.get(workspaceId)
if (!workspace) {
throw new Error("Workspace not found")
@@ -73,8 +98,7 @@ export class BackgroundProcessManager {
this.killProcessTree(child, "SIGTERM")
})
const record: BackgroundProcess = {
const record: PersistedBackgroundProcess = {
id,
workspaceId,
title,
@@ -84,6 +108,20 @@ export class BackgroundProcessManager {
pid: child.pid,
startedAt: new Date().toISOString(),
outputSizeBytes: 0,
notify: options.notify && options.notification
? {
sessionID: options.notification.sessionID,
directory: options.notification.directory,
}
: undefined,
}
const runningState: RunningProcess = {
id,
child,
outputPath,
exitPromise: Promise.resolve(),
workspaceId,
}
const exitPromise = new Promise<void>((resolve) => {
@@ -91,18 +129,21 @@ export class BackgroundProcessManager {
await new Promise<void>((resolve) => outputStream.end(resolve))
this.running.delete(id)
record.status = this.statusFromExit(code)
const completion = runningState.completion ?? this.completionFromExit(code)
record.terminalReason = completion.reason
record.status = this.statusFromReason(completion.reason)
record.exitCode = code === null ? undefined : code
record.stoppedAt = new Date().toISOString()
await this.upsertIndex(workspaceId, record)
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
this.publishUpdate(workspaceId, record)
await this.finalizeRecord(workspaceId, record, completion)
resolve()
})
})
this.running.set(id, { id, child, outputPath, exitPromise, workspaceId })
runningState.exitPromise = exitPromise
this.running.set(id, runningState)
let lastPublishAt = 0
const maybePublishSize = () => {
@@ -128,7 +169,7 @@ export class BackgroundProcessManager {
await this.upsertIndex(workspaceId, record)
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
this.publishUpdate(workspaceId, record)
return record
return this.toPublicProcess(record)
}
async stop(workspaceId: string, processId: string): Promise<BackgroundProcess | null> {
@@ -139,19 +180,21 @@ export class BackgroundProcessManager {
const running = this.running.get(processId)
if (running?.child && !running.child.killed) {
running.completion = { reason: "user_stopped", endContext: "normal" }
this.killProcessTree(running.child, "SIGTERM")
await this.waitForExit(running)
const updated = await this.findProcess(workspaceId, processId)
return updated ? this.toPublicProcess(updated) : this.toPublicProcess(record)
}
if (record.status === "running") {
record.status = "stopped"
record.terminalReason = "user_stopped"
record.stoppedAt = new Date().toISOString()
await this.upsertIndex(workspaceId, record)
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
this.publishUpdate(workspaceId, record)
await this.finalizeRecord(workspaceId, record, { reason: "user_stopped", endContext: "normal" })
}
return record
return this.toPublicProcess(record)
}
async terminate(workspaceId: string, processId: string): Promise<void> {
@@ -160,17 +203,19 @@ export class BackgroundProcessManager {
const running = this.running.get(processId)
if (running?.child && !running.child.killed) {
running.completion = { reason: "user_terminated", endContext: "normal", removeAfterFinalize: true }
this.killProcessTree(running.child, "SIGTERM")
await this.waitForExit(running)
return
}
await this.removeFromIndex(workspaceId, processId)
await this.removeProcessDir(workspaceId, processId)
this.deps.eventBus.publish({
type: "instance.event",
instanceId: workspaceId,
event: { type: "background.process.removed", properties: { processId } },
record.status = "stopped"
record.terminalReason = "user_terminated"
record.stoppedAt = new Date().toISOString()
await this.finalizeRecord(workspaceId, record, {
reason: "user_terminated",
endContext: "normal",
removeAfterFinalize: true,
})
}
@@ -266,6 +311,11 @@ export class BackgroundProcessManager {
private async cleanupWorkspace(workspaceId: string) {
for (const [, running] of this.running.entries()) {
if (running.workspaceId !== workspaceId) continue
running.completion = {
reason: "user_terminated",
endContext: "workspace_cleanup",
removeAfterFinalize: true,
}
this.killProcessTree(running.child, "SIGTERM")
await this.waitForExit(running)
}
@@ -356,10 +406,17 @@ export class BackgroundProcessManager {
return args
}
private statusFromExit(code: number | null): BackgroundProcessStatus {
if (code === null) return "stopped"
if (code === 0) return "stopped"
return "error"
private completionFromExit(code: number | null): ProcessCompletion {
if (code === 0) {
return { reason: "finished", endContext: "normal" }
}
return { reason: "failed", endContext: "normal" }
}
private statusFromReason(reason: BackgroundProcessTerminalReason): BackgroundProcessStatus {
if (reason === "failed") return "error"
return "stopped"
}
private async readOutputBytes(outputPath: string, sizeBytes: number, maxBytes?: number): Promise<string> {
@@ -423,25 +480,25 @@ export class BackgroundProcessManager {
return path.join(workspace.path, ROOT_DIR, workspaceId, processId, OUTPUT_FILE)
}
private async findProcess(workspaceId: string, processId: string): Promise<BackgroundProcess | null> {
private async findProcess(workspaceId: string, processId: string): Promise<PersistedBackgroundProcess | null> {
const records = await this.readIndex(workspaceId)
return records.find((entry) => entry.id === processId) ?? null
}
private async readIndex(workspaceId: string): Promise<BackgroundProcess[]> {
private async readIndex(workspaceId: string): Promise<PersistedBackgroundProcess[]> {
const indexPath = await this.getIndexPath(workspaceId)
if (!existsSync(indexPath)) return []
try {
const raw = await fs.readFile(indexPath, "utf-8")
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? (parsed as BackgroundProcess[]) : []
return Array.isArray(parsed) ? (parsed as PersistedBackgroundProcess[]) : []
} catch {
return []
}
}
private async upsertIndex(workspaceId: string, record: BackgroundProcess) {
private async upsertIndex(workspaceId: string, record: PersistedBackgroundProcess) {
const records = await this.readIndex(workspaceId)
const index = records.findIndex((entry) => entry.id === record.id)
if (index >= 0) {
@@ -458,7 +515,7 @@ export class BackgroundProcessManager {
await this.writeIndex(workspaceId, next)
}
private async writeIndex(workspaceId: string, records: BackgroundProcess[]) {
private async writeIndex(workspaceId: string, records: PersistedBackgroundProcess[]) {
const indexPath = await this.getIndexPath(workspaceId)
await fs.mkdir(path.dirname(indexPath), { recursive: true })
await fs.writeFile(indexPath, JSON.stringify(records, null, 2))
@@ -503,14 +560,139 @@ export class BackgroundProcessManager {
}
}
private publishUpdate(workspaceId: string, record: BackgroundProcess) {
private publishUpdate(workspaceId: string, record: PersistedBackgroundProcess) {
this.deps.eventBus.publish({
type: "instance.event",
instanceId: workspaceId,
event: { type: "background.process.updated", properties: { process: record } },
event: { type: "background.process.updated", properties: { process: this.toPublicProcess(record) } },
})
}
private toPublicProcess(record: PersistedBackgroundProcess): BackgroundProcess {
return {
id: record.id,
workspaceId: record.workspaceId,
title: record.title,
command: record.command,
cwd: record.cwd,
status: record.status,
pid: record.pid,
startedAt: record.startedAt,
stoppedAt: record.stoppedAt,
exitCode: record.exitCode,
outputSizeBytes: record.outputSizeBytes,
terminalReason: record.terminalReason,
notifyEnabled: Boolean(record.notify),
}
}
private async finalizeRecord(workspaceId: string, record: PersistedBackgroundProcess, completion: ProcessCompletion) {
if (this.shouldSendCompletionPrompt(record, completion)) {
try {
await this.sendCompletionPrompt(workspaceId, record)
if (record.notify) {
record.notify.sentAt = new Date().toISOString()
}
} catch (error) {
this.deps.logger.warn({ err: error, workspaceId, processId: record.id }, "Failed to send background process completion prompt")
}
}
if (completion.removeAfterFinalize) {
await this.removeFromIndex(workspaceId, record.id)
await this.removeProcessDir(workspaceId, record.id)
this.deps.eventBus.publish({
type: "instance.event",
instanceId: workspaceId,
event: { type: "background.process.removed", properties: { processId: record.id } },
})
return
}
await this.upsertIndex(workspaceId, record)
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
this.publishUpdate(workspaceId, record)
}
private shouldSendCompletionPrompt(record: PersistedBackgroundProcess, completion: ProcessCompletion) {
if (completion.endContext === "workspace_cleanup") return false
if (!record.notify) return false
return !record.notify.sentAt
}
private async sendCompletionPrompt(workspaceId: string, record: PersistedBackgroundProcess) {
const notify = record.notify
if (!notify || !record.terminalReason) return
if (!this.deps.workspaceManager.get(workspaceId)) {
throw new Error("Workspace not found")
}
const port = this.deps.workspaceManager.getInstancePort(workspaceId)
if (!port) {
throw new Error("Workspace instance is not ready")
}
const targetUrl = `http://127.0.0.1:${port}/session/${encodeURIComponent(notify.sessionID)}/prompt_async`
const headers: Record<string, string> = {
"content-type": "application/json",
"x-opencode-directory": /[^\x00-\x7F]/.test(notify.directory) ? encodeURIComponent(notify.directory) : notify.directory,
}
const authorization = this.deps.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
if (authorization) {
headers.authorization = authorization
}
const response = await fetch(targetUrl, {
method: "POST",
headers,
body: JSON.stringify({
parts: [
{
type: "text",
text: this.buildSyntheticCompletionPrompt(record),
synthetic: true,
},
],
}),
})
if (!response.ok) {
const message = await response.text().catch(() => "")
throw new Error(message || `Prompt request failed with ${response.status}`)
}
}
private buildCompletionPrompt(record: PersistedBackgroundProcess): string {
const ref = `Background process "${record.title}" (${record.id})`
switch (record.terminalReason) {
case "finished":
return `${ref} finished successfully.`
case "failed":
return record.exitCode === undefined ? `${ref} failed.` : `${ref} failed with exit code ${record.exitCode}.`
case "user_stopped":
return `${ref} was stopped by user.`
case "user_terminated":
return `${ref} was terminated by user.`
}
return `${ref} ended.`
}
private buildSyntheticCompletionPrompt(record: PersistedBackgroundProcess): string {
return `<system-message>${this.escapeTaggedText(this.buildCompletionPrompt(record))}</system-message>`
}
private escapeTaggedText(input: string): string {
return input
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
}
private generateId(): string {
const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15)
const random = randomBytes(3).toString("hex")

View File

@@ -26,6 +26,7 @@ const PreferencesSchema = z
showUsageMetrics: z.boolean().default(true),
autoCleanupBlankSessions: z.boolean().default(true),
listeningMode: z.enum(["local", "all"]).default("local"),
logLevel: z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).default("DEBUG"),
// OS notifications
osNotificationsEnabled: z.boolean().default(false),

View File

@@ -24,6 +24,8 @@ export class EventBus extends EventEmitter {
this.on("workspace.error", handler)
this.on("workspace.stopped", handler)
this.on("workspace.log", handler)
this.on("sidecar.updated", handler)
this.on("sidecar.removed", handler)
this.on("storage.configChanged", handler)
this.on("storage.stateChanged", handler)
this.on("instance.dataChanged", handler)
@@ -35,6 +37,8 @@ export class EventBus extends EventEmitter {
this.off("workspace.error", handler)
this.off("workspace.stopped", handler)
this.off("workspace.log", handler)
this.off("sidecar.updated", handler)
this.off("sidecar.removed", handler)
this.off("storage.configChanged", handler)
this.off("storage.stateChanged", handler)
this.off("instance.dataChanged", handler)

View File

@@ -19,11 +19,12 @@ import { InstanceEventBridge } from "./workspaces/instance-events"
import { createLogger } from "./logger"
import { launchInBrowser } from "./launcher"
import { resolveUi } from "./ui/remote-ui"
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager"
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_COOKIE_NAME, DEFAULT_AUTH_USERNAME } from "./auth/manager"
import { resolveHttpsOptions } from "./server/tls"
import { resolveNetworkAddresses } from "./server/network-addresses"
import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses"
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
import { SpeechService } from "./speech/service"
import { SideCarManager } from "./sidecars/manager"
const require = createRequire(import.meta.url)
@@ -55,6 +56,7 @@ interface CliOptions {
launch: boolean
authUsername: string
authPassword?: string
authCookieName: string
generateToken: boolean
dangerouslySkipAuth: boolean
}
@@ -100,6 +102,11 @@ function parseCliOptions(argv: string[]): CliOptions {
.default(DEFAULT_AUTH_USERNAME),
)
.addOption(new Option("--password <password>", "Password for server authentication").env("CODENOMAD_SERVER_PASSWORD"))
.addOption(
new Option("--auth-cookie-name <name>", "Cookie name for server authentication")
.env("CODENOMAD_AUTH_COOKIE_NAME")
.default(DEFAULT_AUTH_COOKIE_NAME),
)
.addOption(
new Option("--generate-token", "Emit a one-time bootstrap token for desktop")
.env("CODENOMAD_GENERATE_TOKEN")
@@ -139,6 +146,7 @@ function parseCliOptions(argv: string[]): CliOptions {
launch?: boolean
username: string
password?: string
authCookieName: string
generateToken?: boolean
dangerouslySkipAuth?: boolean
}>()
@@ -185,6 +193,7 @@ function parseCliOptions(argv: string[]): CliOptions {
launch: Boolean(parsed.launch),
authUsername: parsed.username,
authPassword: parsed.password,
authCookieName: parsed.authCookieName,
generateToken: Boolean(parsed.generateToken),
dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth),
}
@@ -266,6 +275,7 @@ async function main() {
configPath: configLocation.configYamlPath,
username: options.authUsername,
password: options.authPassword,
cookieName: options.authCookieName,
generateToken: options.generateToken,
dangerouslySkipAuth: options.dangerouslySkipAuth,
},
@@ -306,6 +316,11 @@ async function main() {
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
const instanceStore = new InstanceStore(configLocation.instancesDir)
const speechService = new SpeechService(settings, logger.child({ component: "speech" }))
const sidecarManager = new SideCarManager({
settings,
eventBus,
logger: logger.child({ component: "sidecars" }),
})
const instanceEventBridge = new InstanceEventBridge({
workspaceManager,
eventBus,
@@ -391,6 +406,7 @@ async function main() {
serverMeta,
instanceStore,
speechService,
sidecarManager,
authManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: uiResolution.uiDevServerUrl,
@@ -412,6 +428,7 @@ async function main() {
serverMeta,
instanceStore,
speechService,
sidecarManager,
authManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: undefined,
@@ -442,18 +459,22 @@ async function main() {
// which can lead clients to talk to the wrong process.
const localUrl = `${localProtocol}://127.0.0.1:${localStart.port}`
let remoteUrl: string | undefined
let remoteAddresses = [] as ReturnType<typeof resolveNetworkAddresses>
if (remoteStart) {
const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
let remoteHost = options.host
if (wantsAll) {
if (options.host === "0.0.0.0") {
const candidates = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
remoteHost = candidates.find((addr) => addr.scope === "external")?.ip ?? "localhost"
const resolved = resolveRemoteAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
remoteAddresses = resolved.userVisible
remoteUrl = resolved.primaryRemoteUrl ?? `${remoteProtocol}://localhost:${remoteStart.port}`
}
} else {
remoteHost = "localhost"
}
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}`
if (!remoteUrl) {
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}`
}
}
serverMeta.localUrl = localUrl
@@ -464,7 +485,9 @@ async function main() {
serverMeta.listeningMode = options.host === "0.0.0.0" || !isLoopbackHost(options.host) ? "all" : "local"
if (serverMeta.remotePort && remoteUrl) {
serverMeta.addresses = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort })
serverMeta.addresses = remoteAddresses.length
? remoteAddresses
: resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort })
} else {
serverMeta.addresses = []
}
@@ -472,6 +495,16 @@ async function main() {
console.log(`Local Connection URL : ${serverMeta.localUrl}`)
if (serverMeta.remoteUrl) {
console.log(`Remote Connection URL : ${serverMeta.remoteUrl}`)
const additionalRemoteUrls = serverMeta.addresses
.map((addr) => addr.remoteUrl)
.filter((url) => url !== serverMeta.remoteUrl)
if (additionalRemoteUrls.length > 0) {
console.log("Other Accessible URLs:")
for (const url of additionalRemoteUrls) {
console.log(` - ${url}`)
}
}
}
if (options.launch) {
@@ -495,6 +528,12 @@ async function main() {
logger.warn({ err: error }, "Instance event bridge shutdown failed")
}
try {
await sidecarManager.shutdown()
} catch (error) {
logger.error({ err: error }, "SideCar manager shutdown failed")
}
try {
await workspaceManager.shutdown()
logger.info("Workspace manager shutdown complete")

View File

@@ -0,0 +1,94 @@
import assert from "node:assert/strict"
import os from "node:os"
import { describe, it } from "node:test"
import { resolveNetworkAddresses, resolveRemoteAddresses } from "../network-addresses"
describe("resolveNetworkAddresses", () => {
it("preserves interface order among external addresses", () => {
const addresses = [
{ address: "172.24.0.1", family: "IPv4", internal: false },
{ address: "192.168.1.128", family: "IPv4", internal: false },
{ address: "10.0.0.8", family: 4, internal: false },
{ address: "127.0.0.1", family: "IPv4", internal: true },
{ address: "169.254.10.20", family: "IPv4", internal: false },
]
usingMockedNetworkInterfaces(addresses, () => {
const result = resolveNetworkAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
assert.deepEqual(
result.map((entry) => entry.ip),
["172.24.0.1", "192.168.1.128", "10.0.0.8", "169.254.10.20", "127.0.0.1"],
)
})
})
})
describe("resolveRemoteAddresses", () => {
it("keeps all external addresses user-visible while preferring non-link-local addresses for the primary URL", () => {
const addresses = [
{ address: "169.254.10.20", family: "IPv4", internal: false },
{ address: "192.168.1.128", family: "IPv4", internal: false },
{ address: "172.24.0.1", family: "IPv4", internal: false },
]
usingMockedNetworkInterfaces(addresses, () => {
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
assert.deepEqual(
result.userVisible.map((entry) => entry.ip),
["192.168.1.128", "172.24.0.1", "169.254.10.20"],
)
assert.equal(result.primaryRemoteUrl, "https://192.168.1.128:9898")
})
})
it("prefers private LAN addresses over public addresses", () => {
const addresses = [
{ address: "203.0.113.40", family: "IPv4", internal: false },
{ address: "192.168.1.128", family: "IPv4", internal: false },
{ address: "8.8.8.8", family: "IPv4", internal: false },
]
usingMockedNetworkInterfaces(addresses, () => {
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
assert.deepEqual(
result.userVisible.map((entry) => entry.ip),
["192.168.1.128", "203.0.113.40", "8.8.8.8"],
)
assert.equal(result.primaryRemoteUrl, "https://192.168.1.128:9898")
})
})
it("uses a public address when no private LAN address is available", () => {
const addresses = [
{ address: "169.254.10.20", family: "IPv4", internal: false },
{ address: "203.0.113.40", family: "IPv4", internal: false },
]
usingMockedNetworkInterfaces(addresses, () => {
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
assert.deepEqual(result.userVisible.map((entry) => entry.ip), ["203.0.113.40", "169.254.10.20"])
assert.equal(result.primaryRemoteUrl, "https://203.0.113.40:9898")
})
})
})
function usingMockedNetworkInterfaces(
addresses: Array<{ address: string; family: string | number; internal: boolean }>,
callback: () => void,
) {
const original = os.networkInterfaces
os.networkInterfaces = (() => ({
ethernet0: addresses as unknown as ReturnType<typeof os.networkInterfaces>[string],
})) as typeof os.networkInterfaces
try {
callback()
} finally {
os.networkInterfaces = original
}
}

View File

@@ -3,7 +3,9 @@ import cors from "@fastify/cors"
import fastifyStatic from "@fastify/static"
import replyFrom from "@fastify/reply-from"
import fs from "fs"
import { connect as connectTcp, type Socket } from "net"
import path from "path"
import { connect as connectTls, type TLSSocket } from "tls"
import { fetch } from "undici"
import type { Logger } from "../logger"
import { WorkspaceManager } from "../workspaces/manager"
@@ -22,6 +24,8 @@ import { registerPluginRoutes } from "./routes/plugin"
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
import { registerWorktreeRoutes } from "./routes/worktrees"
import { registerSpeechRoutes } from "./routes/speech"
import { registerRemoteServerRoutes } from "./routes/remote-servers"
import { registerSideCarRoutes } from "./routes/sidecars"
import { ServerMeta } from "../api-types"
import { InstanceStore } from "../storage/instance-store"
import { BackgroundProcessManager } from "../background-processes/manager"
@@ -32,6 +36,7 @@ import type { SpeechService } from "../speech/service"
import { ClientConnectionManager } from "../clients/connection-manager"
import { PluginChannelManager } from "../plugins/channel"
import { VoiceModeManager } from "../plugins/voice-mode"
import type { SideCarManager } from "../sidecars/manager"
interface HttpServerDeps {
bindHost: string
@@ -47,6 +52,7 @@ interface HttpServerDeps {
serverMeta: ServerMeta
instanceStore: InstanceStore
speechService: SpeechService
sidecarManager: SideCarManager
authManager: AuthManager
uiStaticDir: string
uiDevServerUrl?: string
@@ -203,7 +209,7 @@ export function createHttpServer(deps: HttpServerDeps) {
const session = deps.authManager.getSessionFromRequest(request)
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/")
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/") || pathname.startsWith("/sidecars/")
if (requiresAuthForApi && !session) {
// Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth.
const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/)
@@ -270,7 +276,15 @@ export function createHttpServer(deps: HttpServerDeps) {
eventBus: deps.eventBus,
workspaceManager: deps.workspaceManager,
})
registerRemoteServerRoutes(app, { logger: apiLogger })
registerSpeechRoutes(app, { speechService: deps.speechService })
registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager })
registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger })
setupSideCarWebSocketProxy(app, {
sidecarManager: deps.sidecarManager,
authManager: deps.authManager,
logger: proxyLogger,
})
registerPluginRoutes(app, {
workspaceManager: deps.workspaceManager,
eventBus: deps.eventBus,
@@ -353,6 +367,68 @@ interface InstanceProxyDeps {
logger: Logger
}
interface SideCarProxyDeps {
sidecarManager: SideCarManager
logger: Logger
}
interface SideCarWebSocketProxyDeps extends SideCarProxyDeps {
authManager: AuthManager
}
function registerSideCarProxyRoutes(app: FastifyInstance, deps: SideCarProxyDeps) {
const proxyBaseHandler = async (
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply,
) => {
await proxySideCarRequest({
request,
reply,
sidecarManager: deps.sidecarManager,
logger: deps.logger,
pathSuffix: "",
})
}
const proxyWildcardHandler = async (
request: FastifyRequest<{ Params: { id: string; "*": string } }>,
reply: FastifyReply,
) => {
await proxySideCarRequest({
request,
reply,
sidecarManager: deps.sidecarManager,
logger: deps.logger,
pathSuffix: request.params["*"] ?? "",
})
}
app.all("/sidecars/:id", proxyBaseHandler)
app.all("/sidecars/:id/*", proxyWildcardHandler)
}
function setupSideCarWebSocketProxy(app: FastifyInstance, deps: SideCarWebSocketProxyDeps) {
app.server.on("upgrade", (request, socket, head) => {
const rawUrl = request.url ?? "/"
const parsed = parseSideCarUpgradePath(rawUrl)
if (!parsed) {
return
}
void proxySideCarWebSocketUpgrade({
request,
socket: socket as Socket,
head,
sidecarId: parsed.sidecarId,
incomingPath: parsed.pathname,
search: parsed.search,
sidecarManager: deps.sidecarManager,
authManager: deps.authManager,
logger: deps.logger,
})
})
}
function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDeps) {
app.register(async (instance) => {
instance.removeAllContentTypeParsers()
@@ -837,3 +913,281 @@ function buildProxyHeaders(headers: FastifyRequest["headers"]): Record<string, s
}
return result
}
async function proxySideCarRequest(args: {
request: FastifyRequest
reply: FastifyReply
sidecarManager: SideCarManager
logger: Logger
pathSuffix?: string
}) {
const sidecarId = (args.request.params as { id?: string }).id ?? ""
const sidecar = await args.sidecarManager.get(sidecarId)
if (!sidecar) {
args.reply.code(404).send({ error: "SideCar not found" })
return
}
const pathname = (args.request.raw.url ?? args.request.url ?? "").split("?")[0] ?? ""
const queryIndex = (args.request.raw.url ?? args.request.url ?? "").indexOf("?")
const search = queryIndex >= 0 ? (args.request.raw.url ?? args.request.url ?? "").slice(queryIndex) : ""
const pathSuffix = args.pathSuffix ?? ""
const requestPath = pathSuffix ? `${args.sidecarManager.buildProxyBasePath(sidecarId)}/${pathSuffix.replace(/^\/+/, "")}` : args.sidecarManager.buildProxyBasePath(sidecarId)
const targetPath = args.sidecarManager.buildTargetPath(sidecarId, requestPath, search)
const targetOrigin = args.sidecarManager.buildTargetOrigin(sidecar)
const targetUrl = `${targetOrigin}${targetPath}`
args.logger.debug({ sidecarId: sidecar.id, targetUrl, pathname, prefixMode: sidecar.prefixMode }, "Proxying request to SideCar")
await args.reply.from(targetUrl, {
rewriteRequestHeaders: (_originalRequest, headers) =>
sanitizeSideCarProxyRequestHeaders(headers as Record<string, string | string[] | undefined>, targetOrigin),
rewriteHeaders: (headers) => rewriteSideCarResponseHeaders(headers, sidecarId, targetOrigin, sidecar.prefixMode),
onError: (reply, { error }) => {
args.logger.error({ sidecarId: sidecar.id, err: error, targetUrl }, "Failed to proxy SideCar request")
if (!reply.sent) {
reply.code(502).send({ error: "SideCar proxy failed" })
}
},
})
}
function parseSideCarUpgradePath(rawUrl: string): { sidecarId: string; pathname: string; search: string } | null {
let parsed: URL
try {
parsed = new URL(rawUrl, "http://localhost")
} catch {
return null
}
const match = parsed.pathname.match(/^\/sidecars\/([^/]+)(?:\/.*)?$/)
if (!match) {
return null
}
try {
return {
sidecarId: decodeURIComponent(match[1] ?? ""),
pathname: parsed.pathname,
search: parsed.search,
}
} catch {
return null
}
}
async function proxySideCarWebSocketUpgrade(args: {
request: import("http").IncomingMessage
socket: Socket
head: Buffer
sidecarId: string
incomingPath: string
search: string
sidecarManager: SideCarManager
authManager: AuthManager
logger: Logger
}) {
const { request, socket, head, sidecarId, incomingPath, search, sidecarManager, authManager, logger } = args
if (!isWebSocketUpgradeRequest(request)) {
rejectUpgrade(socket, 400, "Bad Request")
return
}
const session = authManager.getSessionFromHeaders(request.headers)
if (!session) {
rejectUpgrade(socket, 401, "Unauthorized")
return
}
const sidecar = await sidecarManager.get(sidecarId)
if (!sidecar) {
rejectUpgrade(socket, 404, "Not Found")
return
}
const targetOrigin = sidecarManager.buildTargetOrigin(sidecar)
const targetPath = sidecarManager.buildTargetPath(sidecarId, incomingPath, search)
const targetUrl = new URL(`${targetOrigin}${targetPath}`)
logger.debug({ sidecarId, targetUrl: targetUrl.toString(), prefixMode: sidecar.prefixMode }, "Proxying websocket to SideCar")
const { socket: upstream, readyEvent } = createSideCarUpstreamSocket(targetUrl)
const closeBoth = () => {
if (!socket.destroyed) {
socket.destroy()
}
if (!upstream.destroyed) {
upstream.destroy()
}
}
upstream.once("error", (error) => {
logger.error({ sidecarId, err: error, targetUrl: targetUrl.toString() }, "Failed to proxy SideCar websocket")
rejectUpgrade(socket, 502, "Bad Gateway")
if (!upstream.destroyed) {
upstream.destroy()
}
})
socket.once("error", (error) => {
logger.debug({ sidecarId, err: error }, "SideCar websocket client socket errored")
if (!upstream.destroyed) {
upstream.destroy()
}
})
upstream.once(readyEvent, () => {
try {
upstream.write(buildSideCarWebSocketRequest(request, targetUrl))
if (head.length > 0) {
upstream.write(head)
}
upstream.pipe(socket)
socket.pipe(upstream)
} catch (error) {
logger.error({ sidecarId, err: error, targetUrl: targetUrl.toString() }, "Failed to forward SideCar websocket upgrade")
closeBoth()
}
})
upstream.once("close", () => {
if (!socket.destroyed) {
socket.end()
}
})
socket.once("close", () => {
if (!upstream.destroyed) {
upstream.end()
}
})
}
function createSideCarUpstreamSocket(targetUrl: URL): { socket: Socket | TLSSocket; readyEvent: "connect" | "secureConnect" } {
const port = Number(targetUrl.port || (targetUrl.protocol === "https:" ? 443 : 80))
if (targetUrl.protocol === "https:") {
return {
socket: connectTls({
host: targetUrl.hostname,
port,
servername: targetUrl.hostname,
}),
readyEvent: "secureConnect",
}
}
return {
socket: connectTcp(port, targetUrl.hostname),
readyEvent: "connect",
}
}
function buildSideCarWebSocketRequest(request: import("http").IncomingMessage, targetUrl: URL): string {
const pathWithQuery = `${targetUrl.pathname}${targetUrl.search}`
const requestLine = `${request.method ?? "GET"} ${pathWithQuery} HTTP/${request.httpVersion}\r\n`
const headerLines: string[] = []
const rawHeaders = request.rawHeaders ?? []
const blockedHeaders = getBlockedSideCarRequestHeaders()
for (let index = 0; index < rawHeaders.length; index += 2) {
const key = rawHeaders[index]
const value = rawHeaders[index + 1]
if (!key || value === undefined) continue
const lower = key.toLowerCase()
if (blockedHeaders.has(lower)) continue
if (lower === "origin") {
headerLines.push(`Origin: ${targetUrl.origin}\r\n`)
continue
}
headerLines.push(`${key}: ${value}\r\n`)
}
const hostValue = targetUrl.port ? `${targetUrl.hostname}:${targetUrl.port}` : targetUrl.hostname
headerLines.push(`Host: ${hostValue}\r\n`)
headerLines.push("\r\n")
return requestLine + headerLines.join("")
}
function isWebSocketUpgradeRequest(request: import("http").IncomingMessage): boolean {
const upgrade = request.headers.upgrade
if (typeof upgrade !== "string" || upgrade.toLowerCase() !== "websocket") {
return false
}
const connection = request.headers.connection
const connectionValue = Array.isArray(connection) ? connection.join(",") : connection ?? ""
return connectionValue.toLowerCase().split(",").map((part) => part.trim()).includes("upgrade")
}
function rejectUpgrade(socket: Socket, statusCode: number, statusText: string) {
if (socket.destroyed) {
return
}
socket.write(`HTTP/1.1 ${statusCode} ${statusText}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`)
socket.destroy()
}
function rewriteSideCarResponseHeaders(
headers: Record<string, string | string[] | undefined>,
sidecarId: string,
targetOrigin: string,
prefixMode: "strip" | "preserve",
) {
if (prefixMode === "preserve") {
return headers
}
const next = { ...headers }
const locationHeader = next.location
const location = Array.isArray(locationHeader) ? locationHeader[0] : locationHeader
if (!location) {
return next
}
const publicBase = `/sidecars/${encodeURIComponent(sidecarId)}`
if (location.startsWith("/")) {
next.location = `${publicBase}${location}`
return next
}
try {
const parsed = new URL(location)
if (parsed.origin === targetOrigin) {
next.location = `${publicBase}${parsed.pathname}${parsed.search}${parsed.hash}`
}
} catch {
// Relative redirects should continue to resolve against the public sidecar path.
}
return next
}
function sanitizeSideCarProxyRequestHeaders(
headers: Record<string, string | string[] | undefined>,
targetOrigin: string,
): Record<string, string | string[] | undefined> {
const blockedHeaders = getBlockedSideCarRequestHeaders()
const next: Record<string, string | string[] | undefined> = {}
for (const [key, value] of Object.entries(headers)) {
if (!value) continue
if (blockedHeaders.has(key.toLowerCase())) continue
next[key] = value
}
next.origin = targetOrigin
return next
}
function getBlockedSideCarRequestHeaders(): Set<string> {
return new Set([
"host",
"authorization",
"proxy-authorization",
"forwarded",
"x-forwarded-for",
"x-forwarded-host",
"x-forwarded-port",
"x-forwarded-proto",
])
}

View File

@@ -1,6 +1,12 @@
import os from "os"
import type { NetworkAddress } from "../api-types"
export interface ResolvedRemoteAddresses {
all: NetworkAddress[]
userVisible: NetworkAddress[]
primaryRemoteUrl?: string
}
export function resolveNetworkAddresses(args: {
host: string
protocol: "http" | "https"
@@ -58,10 +64,57 @@ export function resolveNetworkAddresses(args: {
return results.sort((a, b) => {
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
if (scopeDelta !== 0) return scopeDelta
return a.ip.localeCompare(b.ip)
return 0
})
}
export function resolveRemoteAddresses(args: {
host: string
protocol: "http" | "https"
port: number
}): ResolvedRemoteAddresses {
const all = resolveNetworkAddresses(args)
const userVisible = sortUserVisibleAddresses(all.filter((address) => address.scope === "external"))
return {
all,
userVisible,
primaryRemoteUrl: userVisible[0]?.remoteUrl,
}
}
function sortUserVisibleAddresses(addresses: NetworkAddress[]): NetworkAddress[] {
return [...addresses].sort((left, right) => getUserVisiblePriority(left.ip) - getUserVisiblePriority(right.ip))
}
function getUserVisiblePriority(ip: string): number {
if (isPrivateIPv4(ip)) return 0
if (isLinkLocalIPv4(ip)) return 2
return 1
}
function isLinkLocalIPv4(ip: string): boolean {
const octets = parseIPv4(ip)
if (!octets) return false
const [first, second] = octets
return first === 169 && second === 254
}
function isPrivateIPv4(ip: string): boolean {
const octets = parseIPv4(ip)
if (!octets) return false
const [first, second] = octets
if (first === 10) return true
if (first === 192 && second === 168) return true
return first === 172 && second >= 16 && second <= 31
}
function parseIPv4(value: string): number[] | null {
if (!isIPv4Address(value)) return null
return value.split(".").map((part) => Number(part))
}
function isIPv4Address(value: string | undefined): value is string {
if (!value) return false
const parts = value.split(".")

View File

@@ -9,6 +9,21 @@ interface RouteDeps {
const StartSchema = z.object({
title: z.string().trim().min(1),
command: z.string().trim().min(1),
notify: z.boolean().optional(),
notification: z
.object({
sessionID: z.string().trim().min(1),
directory: z.string().trim().min(1),
})
.optional(),
}).superRefine((value, ctx) => {
if (value.notify && !value.notification) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Notification metadata is required when notify is enabled",
path: ["notification"],
})
}
})
const OutputQuerySchema = z.object({
@@ -27,7 +42,10 @@ export function registerBackgroundProcessRoutes(app: FastifyInstance, deps: Rout
app.post<{ Params: { id: string } }>("/workspaces/:id/plugin/background-processes", async (request, reply) => {
const payload = StartSchema.parse(request.body ?? {})
const process = await deps.backgroundProcessManager.start(request.params.id, payload.title, payload.command)
const process = await deps.backgroundProcessManager.start(request.params.id, payload.title, payload.command, {
notify: payload.notify,
notification: payload.notification,
})
reply.code(201)
return process
})

View File

@@ -1,6 +1,6 @@
import { FastifyInstance } from "fastify"
import { ServerMeta } from "../../api-types"
import { resolveNetworkAddresses } from "../network-addresses"
interface RouteDeps {
serverMeta: ServerMeta
@@ -13,14 +13,12 @@ export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
function buildMetaResponse(meta: ServerMeta): ServerMeta {
const localPort = resolveLocalPort(meta)
const remote = resolveRemote(meta)
const addresses = remote && remote.port > 0 ? resolveNetworkAddresses({ host: meta.host, protocol: remote.protocol, port: remote.port }) : []
return {
...meta,
localPort,
remotePort: remote?.port,
listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local",
addresses,
}
}

View File

@@ -0,0 +1,166 @@
import { Agent, fetch } from "undici"
import type { FastifyInstance } from "fastify"
import { z } from "zod"
import type { Logger } from "../../logger"
import type { RemoteServerProbeResponse } from "../../api-types"
interface RouteDeps {
logger: Logger
}
const ProbeSchema = z.object({
baseUrl: z.string().min(1),
skipTlsVerify: z.boolean().optional(),
})
const PROBE_TIMEOUT_MS = 8_000
export function registerRemoteServerRoutes(app: FastifyInstance, deps: RouteDeps) {
app.post("/api/remote-servers/probe", async (request, reply) => {
try {
const body = ProbeSchema.parse(request.body ?? {})
return await probeRemoteServer(body.baseUrl, Boolean(body.skipTlsVerify))
} catch (error) {
deps.logger.warn({ err: error }, "Failed to probe remote server")
reply.code(400)
return { error: error instanceof Error ? error.message : "Invalid request" }
}
})
}
async function probeRemoteServer(baseUrl: string, skipTlsVerify: boolean): Promise<RemoteServerProbeResponse> {
const normalizedUrl = normalizeBaseUrl(baseUrl)
const probeUrl = new URL("./api/auth/status", `${normalizedUrl}/`)
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS)
const dispatcher = skipTlsVerify ? new Agent({ connect: { rejectUnauthorized: false } }) : undefined
try {
const response = await fetch(probeUrl, {
method: "GET",
dispatcher,
signal: controller.signal,
headers: {
Accept: "application/json",
},
})
if (!response.ok) {
return {
ok: false,
reachable: true,
normalizedUrl,
skipTlsVerify,
requiresAuth: false,
authenticated: false,
error: `Remote server returned HTTP ${response.status}`,
errorCode: "http_error",
}
}
const payload = (await response.json()) as { authenticated?: unknown }
if (typeof payload?.authenticated !== "boolean") {
return {
ok: false,
reachable: true,
normalizedUrl,
skipTlsVerify,
requiresAuth: false,
authenticated: false,
error: "Remote server did not return a valid CodeNomad auth response",
errorCode: "invalid_server",
}
}
return {
ok: true,
reachable: true,
normalizedUrl,
skipTlsVerify,
requiresAuth: !payload.authenticated,
authenticated: payload.authenticated,
}
} catch (error) {
const message = describeProbeError(error)
return {
ok: false,
reachable: false,
normalizedUrl,
skipTlsVerify,
requiresAuth: false,
authenticated: false,
error: message.message,
errorCode: message.code,
}
} finally {
clearTimeout(timeout)
await dispatcher?.close().catch(() => {})
}
}
function normalizeBaseUrl(input: string): string {
const parsed = new URL(input.trim())
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error("Server URL must use http:// or https://")
}
parsed.hash = ""
parsed.search = ""
parsed.pathname = parsed.pathname === "/" ? "/" : parsed.pathname.replace(/\/+$/, "") || "/"
const value = parsed.toString()
return parsed.pathname === "/" ? value.replace(/\/$/, "") : value.replace(/\/$/, "")
}
function describeProbeError(error: unknown): { code: string; message: string } {
const chain = unwrapErrorChain(error)
const detailed =
chain.find((entry) => {
const code = (entry?.code ?? "").toString()
return Boolean(code) && code !== "UND_ERR_RESPONSE_STATUS_CODE"
}) ?? chain[0]
const code = (detailed?.code ?? "").toString()
const exactMessage = detailed?.message?.trim() || chain.find((entry) => entry.message?.trim())?.message?.trim()
if (code === "DEPTH_ZERO_SELF_SIGNED_CERT" || code === "SELF_SIGNED_CERT_IN_CHAIN" || code === "CERT_HAS_EXPIRED") {
return {
code: "tls_error",
message: "Certificate check failed while connecting to the remote server.",
}
}
return {
code:
code === "ERR_INVALID_URL"
? "invalid_url"
: code === "ECONNREFUSED"
? "connection_refused"
: code === "ENOTFOUND"
? "dns_error"
: code === "UND_ERR_CONNECT_TIMEOUT" || code === "ABORT_ERR"
? "timeout"
: code
? code.toLowerCase()
: "probe_failed",
message: exactMessage || "Failed to connect to the remote server.",
}
}
function unwrapErrorChain(error: unknown): Array<{ code?: unknown; message?: string }> {
const results: Array<{ code?: unknown; message?: string }> = []
let current: unknown = error
const seen = new Set<unknown>()
while (current && typeof current === "object" && !seen.has(current)) {
seen.add(current)
const entry = current as { code?: unknown; message?: string; cause?: unknown }
results.push({ code: entry.code, message: entry.message })
current = entry.cause
}
if (results.length === 0 && error instanceof Error) {
results.push({ message: error.message })
}
return results
}

View File

@@ -0,0 +1,56 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import type { SideCarManager } from "../../sidecars/manager"
interface RouteDeps {
sidecarManager: SideCarManager
}
const SideCarCreateSchema = z.object({
kind: z.literal("port").default("port"),
name: z.string().trim().min(1),
port: z.number().int().min(1).max(65535),
insecure: z.boolean().default(false),
prefixMode: z.enum(["strip", "preserve"]).default("strip"),
})
const SideCarUpdateSchema = SideCarCreateSchema.omit({ kind: true }).partial().refine((value) => Object.keys(value).length > 0, {
message: "At least one field is required",
})
export function registerSideCarRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/sidecars", async () => {
return { sidecars: await deps.sidecarManager.list() }
})
app.post("/api/sidecars", async (request, reply) => {
try {
const body = SideCarCreateSchema.parse(request.body ?? {})
const sidecar = await deps.sidecarManager.create(body)
reply.code(201)
return sidecar
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Failed to create SideCar" }
}
})
app.put<{ Params: { id: string } }>("/api/sidecars/:id", async (request, reply) => {
try {
const body = SideCarUpdateSchema.parse(request.body ?? {})
return await deps.sidecarManager.update(request.params.id, body)
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Failed to update SideCar" }
}
})
app.delete<{ Params: { id: string } }>("/api/sidecars/:id", async (request, reply) => {
const removed = await deps.sidecarManager.delete(request.params.id)
if (!removed) {
reply.code(404)
return { error: "SideCar not found" }
}
reply.code(204)
})
}

View File

@@ -107,6 +107,10 @@ function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { co
if (typeof listeningMode === "string") {
serverConfig.listeningMode = listeningMode
}
const logLevel = preferences.logLevel
if (typeof logLevel === "string") {
serverConfig.logLevel = logLevel
}
const lastUsedBinary = preferences.lastUsedBinary
if (typeof lastUsedBinary === "string") {
serverConfig.opencodeBinary = lastUsedBinary
@@ -135,6 +139,7 @@ function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { co
const moved = new Set([
"environmentVariables",
"listeningMode",
"logLevel",
"lastUsedBinary",
"modelRecents",
"modelFavorites",

View File

@@ -1,6 +1,7 @@
import type { Logger } from "../logger"
import type { EventBus } from "../events/bus"
import type { ConfigLocation } from "../config/location"
import { z } from "zod"
import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store"
import { migrateSettingsLayout } from "./migrate"
import type { WorkspaceEventPayload } from "../api-types"
@@ -8,6 +9,54 @@ import { sanitizeConfigOwner } from "./public-config"
export type DocKind = "config" | "state"
const CanonicalLogLevelSchema = z.preprocess(
(value) => (typeof value === "string" ? value.trim().toUpperCase() : value),
z.enum(["DEBUG", "INFO", "WARN", "ERROR"]),
)
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function isDeepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true
try {
return JSON.stringify(a) === JSON.stringify(b)
} catch {
return false
}
}
function normalizeServerConfigOwner(value: SettingsDoc): SettingsDoc {
if (!isPlainObject(value)) {
return {}
}
const next: SettingsDoc = { ...value }
const parsedLogLevel = CanonicalLogLevelSchema.safeParse(next.logLevel)
if (parsedLogLevel.success) {
next.logLevel = parsedLogLevel.data
} else if (next.logLevel !== undefined) {
next.logLevel = "DEBUG"
}
return next
}
function normalizeConfigDoc(doc: SettingsDoc): SettingsDoc {
if (!isPlainObject(doc)) {
return {}
}
if (!isPlainObject(doc.server)) {
return doc
}
return {
...doc,
server: normalizeServerConfigOwner(doc.server as SettingsDoc),
}
}
export class SettingsService {
private readonly configStore: YamlDocStore
private readonly stateStore: YamlDocStore
@@ -23,22 +72,44 @@ export class SettingsService {
}
getDoc(kind: DocKind): SettingsDoc {
return kind === "config" ? this.configStore.get() : this.stateStore.get()
if (kind !== "config") {
return this.stateStore.get()
}
const current = this.configStore.get()
const normalized = normalizeConfigDoc(current)
if (!isDeepEqual(current, normalized)) {
this.configStore.replace(normalized)
}
return normalized
}
mergePatchDoc(kind: DocKind, patch: unknown): SettingsDoc {
const updated = kind === "config" ? this.configStore.mergePatch(patch) : this.stateStore.mergePatch(patch)
const updated =
kind === "config"
? this.configStore.replace(normalizeConfigDoc(this.configStore.mergePatch(patch)))
: this.stateStore.mergePatch(patch)
this.publish(kind, "*")
return updated
}
getOwner(kind: DocKind, owner: string): SettingsDoc {
return kind === "config" ? this.configStore.getOwner(owner) : this.stateStore.getOwner(owner)
if (kind !== "config") {
return this.stateStore.getOwner(owner)
}
return owner === "server"
? normalizeServerConfigOwner(this.getDoc("config").server as SettingsDoc)
: this.getDoc("config")[owner] as SettingsDoc
}
mergePatchOwner(kind: DocKind, owner: string, patch: unknown): SettingsDoc {
const updated =
kind === "config" ? this.configStore.mergePatchOwner(owner, patch) : this.stateStore.mergePatchOwner(owner, patch)
kind === "config"
? owner === "server"
? this.configStore.replaceOwner(owner, normalizeServerConfigOwner(this.configStore.mergePatchOwner(owner, patch)))
: this.configStore.mergePatchOwner(owner, patch)
: this.stateStore.mergePatchOwner(owner, patch)
this.publish(kind, owner, updated)
return updated
}

View File

@@ -0,0 +1,256 @@
import { connect } from "net"
import type { EventBus } from "../events/bus"
import type { Logger } from "../logger"
import type { SettingsService } from "../settings/service"
import type { SideCar, SideCarKind, SideCarPrefixMode, SideCarStatus } from "../api-types"
interface SideCarManagerOptions {
settings: SettingsService
eventBus: EventBus
logger: Logger
}
interface SideCarConfigRecord {
id: string
kind: SideCarKind
name: string
port: number
insecure: boolean
prefixMode: SideCarPrefixMode
createdAt: string
updatedAt: string
}
interface SideCarRuntimeRecord {
status: SideCarStatus
}
export class SideCarManager {
private readonly configs = new Map<string, SideCarConfigRecord>()
private readonly runtime = new Map<string, SideCarRuntimeRecord>()
constructor(private readonly options: SideCarManagerOptions) {
for (const record of this.loadConfiguredSideCars()) {
this.configs.set(record.id, record)
this.runtime.set(record.id, { status: "stopped" })
}
queueMicrotask(() => {
for (const record of this.configs.values()) {
void this.refreshPortSideCar(record.id).catch((error) => {
this.options.logger.warn({ sidecarId: record.id, err: error }, "Failed to probe sidecar port")
})
}
})
}
async list(): Promise<SideCar[]> {
await this.refreshPortStatuses()
return Array.from(this.configs.values()).map((record) => this.toSideCar(record))
}
async get(id: string): Promise<SideCar | undefined> {
if (!this.configs.has(id)) return undefined
await this.refreshPortSideCar(id)
return this.toSideCar(this.requireConfig(id))
}
async create(input: {
kind: SideCarKind
name: string
port: number
insecure: boolean
prefixMode: SideCarPrefixMode
}): Promise<SideCar> {
const normalizedName = input.name.trim()
const id = this.buildSideCarId(normalizedName)
if (this.configs.has(id)) {
throw new Error(`SideCar '${id}' already exists`)
}
const now = new Date().toISOString()
const record: SideCarConfigRecord = {
id,
kind: input.kind,
name: normalizedName,
port: input.port,
insecure: input.insecure,
prefixMode: input.prefixMode,
createdAt: now,
updatedAt: now,
}
this.configs.set(record.id, record)
this.runtime.set(record.id, { status: "stopped" })
this.persistConfigs()
await this.refreshPortSideCar(record.id)
return this.toSideCar(record)
}
async update(
id: string,
input: Partial<{
name: string
port: number
insecure: boolean
prefixMode: SideCarPrefixMode
}>,
): Promise<SideCar> {
const record = this.requireConfig(id)
record.name = typeof input.name === "string" ? input.name.trim() : record.name
record.port = typeof input.port === "number" ? input.port : record.port
record.insecure = typeof input.insecure === "boolean" ? input.insecure : record.insecure
record.prefixMode = typeof input.prefixMode === "string" ? input.prefixMode : record.prefixMode
record.updatedAt = new Date().toISOString()
this.persistConfigs()
await this.refreshPortSideCar(id)
return this.toSideCar(record)
}
async delete(id: string): Promise<boolean> {
const record = this.configs.get(id)
if (!record) return false
this.configs.delete(id)
this.runtime.delete(id)
this.persistConfigs()
this.options.eventBus.publish({ type: "sidecar.removed", sidecarId: id })
return true
}
async shutdown() {
return
}
buildTargetOrigin(sidecar: Pick<SideCar, "port" | "insecure">): string {
const protocol = sidecar.insecure ? "http" : "https"
return `${protocol}://127.0.0.1:${sidecar.port}`
}
buildProxyBasePath(id: string): string {
return `/sidecars/${encodeURIComponent(id)}`
}
buildTargetPath(id: string, incomingPath: string, search = ""): string {
const record = this.requireConfig(id)
const publicBase = this.buildProxyBasePath(id)
const normalizedPath = incomingPath || publicBase
if (record.prefixMode === "preserve") {
return `${normalizedPath}${search}`
}
let stripped = normalizedPath.startsWith(publicBase) ? normalizedPath.slice(publicBase.length) : normalizedPath
if (!stripped || stripped === "/") {
stripped = "/"
} else if (!stripped.startsWith("/")) {
stripped = `/${stripped}`
}
return `${stripped}${search}`
}
private async refreshPortStatuses() {
await Promise.all(Array.from(this.configs.values()).map((record) => this.refreshPortSideCar(record.id)))
}
private async refreshPortSideCar(id: string) {
const record = this.configs.get(id)
if (!record) return
const isAvailable = await this.isPortAvailable(record.port)
const current = this.runtime.get(id)
const nextStatus: SideCarStatus = isAvailable ? "running" : "stopped"
if (current?.status === nextStatus) {
return
}
this.runtime.set(id, { status: nextStatus })
record.updatedAt = new Date().toISOString()
this.publish(id)
}
private publish(id: string) {
const record = this.configs.get(id)
if (!record) return
this.options.eventBus.publish({ type: "sidecar.updated", sidecar: this.toSideCar(record) })
}
private toSideCar(record: SideCarConfigRecord): SideCar {
const runtime = this.runtime.get(record.id)
return {
id: record.id,
kind: record.kind,
name: record.name,
port: record.port,
insecure: record.insecure,
prefixMode: record.prefixMode,
status: runtime?.status ?? "stopped",
createdAt: record.createdAt,
updatedAt: record.updatedAt,
}
}
private requireConfig(id: string): SideCarConfigRecord {
const record = this.configs.get(id)
if (!record) {
throw new Error("SideCar not found")
}
return record
}
private persistConfigs() {
const sidecars = Array.from(this.configs.values()).map((record) => ({ ...record }))
this.options.settings.mergePatchOwner("config", "server", { sidecars })
}
private loadConfiguredSideCars(): SideCarConfigRecord[] {
const serverConfig = this.options.settings.getOwner("config", "server") as { sidecars?: unknown }
const list = Array.isArray(serverConfig?.sidecars) ? serverConfig.sidecars : []
const records: SideCarConfigRecord[] = []
for (const item of list) {
if (!item || typeof item !== "object") continue
const record = item as Record<string, unknown>
const kind = record.kind === "port" ? "port" : null
const id = typeof record.id === "string" && record.id.trim() ? record.id.trim() : null
const name = typeof record.name === "string" && record.name.trim() ? record.name.trim() : null
const port = typeof record.port === "number" && Number.isInteger(record.port) ? record.port : null
if (!kind || !id || !name || !port) continue
const insecure = record.insecure === true
const prefixMode = record.prefixMode === "preserve" ? "preserve" : "strip"
const createdAt = typeof record.createdAt === "string" && record.createdAt ? record.createdAt : new Date().toISOString()
const updatedAt = typeof record.updatedAt === "string" && record.updatedAt ? record.updatedAt : createdAt
records.push({ id, kind, name, port, insecure, prefixMode, createdAt, updatedAt })
}
return records
}
private isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const socket = connect({ port, host: "127.0.0.1" }, () => {
socket.end()
resolve(true)
})
socket.once("error", () => {
socket.destroy()
resolve(false)
})
})
}
private buildSideCarId(name: string): string {
const normalized = name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/-{2,}/g, "-")
.replace(/^-|-$/g, "")
if (!normalized) {
throw new Error("SideCar name must include letters or numbers")
}
return normalized
}
}

View File

@@ -142,12 +142,15 @@ export class WorkspaceManager {
[OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
}
const logLevel = (serverConfig as any)?.logLevel
try {
const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({
workspaceId: id,
folder: workspacePath,
binaryPath: resolvedBinaryPath,
environment,
logLevel,
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
})

View File

@@ -116,6 +116,7 @@ interface LaunchOptions {
folder: string
binaryPath: string
environment?: Record<string, string>
logLevel?: string
onExit?: (info: ProcessExitInfo) => void
}
@@ -139,7 +140,8 @@ export class WorkspaceRuntime {
async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise<ProcessExitInfo>; getLastOutput: () => string }> {
this.validateFolder(options.folder)
const args = ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"]
const logLevel = typeof options.logLevel === "string" ? options.logLevel.toUpperCase() : "DEBUG"
const args = ["serve", "--port", "0", "--print-logs", "--log-level", logLevel]
const env = { ...process.env, ...(options.environment ?? {}) }
let exitResolve: ((info: ProcessExitInfo) => void) | null = null

View File

@@ -1 +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:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","opener:allow-open-url","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}
{"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:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","opener:allow-open-url","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}

View File

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

View File

@@ -16,7 +16,7 @@ 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 std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
#[cfg(windows)]
@@ -48,7 +48,7 @@ fn workspace_root() -> Option<PathBuf> {
})
}
const SESSION_COOKIE_NAME: &str = "codenomad_session";
const SESSION_COOKIE_NAME_PREFIX: &str = "codenomad_session";
const CLI_STOP_GRACE_SECS: u64 = 30;
#[cfg(windows)]
@@ -124,7 +124,11 @@ fn extract_cookie_value(set_cookie: &str, name: &str) -> Option<String> {
Some(value.to_string())
}
fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Option<String>> {
fn exchange_bootstrap_token(
base_url: &str,
token: &str,
cookie_name: &str,
) -> anyhow::Result<Option<String>> {
let parsed = Url::parse(base_url)?;
let host = parsed.host_str().unwrap_or("127.0.0.1");
let port = parsed.port_or_known_default().unwrap_or(80);
@@ -159,11 +163,11 @@ fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Optio
for line in lines {
// handle case-insensitive header name
if let Some(value) = line.strip_prefix("Set-Cookie:") {
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
if let Some(session_id) = extract_cookie_value(value.trim(), cookie_name) {
return Ok(Some(session_id));
}
} else if let Some(value) = line.strip_prefix("set-cookie:") {
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
if let Some(session_id) = extract_cookie_value(value.trim(), cookie_name) {
return Ok(Some(session_id));
}
}
@@ -172,11 +176,16 @@ fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Optio
Ok(None)
}
fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyhow::Result<()> {
fn set_session_cookie(
app: &AppHandle,
base_url: &str,
cookie_name: &str,
session_id: &str,
) -> anyhow::Result<()> {
let parsed = Url::parse(base_url)?;
let domain = parsed.host_str().unwrap_or("127.0.0.1").to_string();
let cookie = Cookie::build((SESSION_COOKIE_NAME, session_id))
let cookie = Cookie::build((cookie_name.to_string(), session_id.to_string()))
.domain(domain)
.path("/")
.http_only(true)
@@ -190,6 +199,16 @@ fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyh
Ok(())
}
fn generate_auth_cookie_name() -> String {
let pid = std::process::id();
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or(0);
format!("{SESSION_COOKIE_NAME_PREFIX}_{pid}_{timestamp}")
}
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
#[derive(Debug, Deserialize)]
@@ -503,7 +522,8 @@ impl CliProcessManager {
"resolved CLI entry runner={:?} entry={} host={}",
resolution.runner, resolution.entry, host
));
let args = resolution.build_args(dev, &host);
let auth_cookie_name = Arc::new(generate_auth_cookie_name());
let args = resolution.build_args(dev, &host, auth_cookie_name.as_str());
log_line(&format!("CLI args: {:?}", args));
if dev {
log_line("development mode: will prefer tsx + source if present");
@@ -514,7 +534,9 @@ impl CliProcessManager {
log_line(&format!("using cwd={}", c.display()));
}
let command_info = if supports_user_shell() {
let use_user_shell = supports_user_shell();
let command_info = if use_user_shell {
log_line("spawning via user shell");
ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?)
} else {
@@ -525,7 +547,7 @@ impl CliProcessManager {
})
};
if !supports_user_shell() {
if !use_user_shell {
if which::which(&resolution.node_binary).is_err() {
return Err(anyhow::anyhow!(
"Node binary not found. Make sure Node.js is installed."
@@ -539,6 +561,8 @@ impl CliProcessManager {
let mut c = Command::new(&cmd.shell);
c.args(&cmd.args)
.env("ELECTRON_RUN_AS_NODE", "1")
.env_remove("npm_config_prefix")
.env_remove("NPM_CONFIG_PREFIX")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
configure_spawn(&mut c);
@@ -584,6 +608,7 @@ impl CliProcessManager {
let app_clone = app.clone();
let ready_clone = ready.clone();
let token_clone = bootstrap_token.clone();
let auth_cookie_name_clone = auth_cookie_name.clone();
thread::spawn(move || {
let stdout = child_clone
@@ -598,24 +623,41 @@ impl CliProcessManager {
.map(BufReader::new);
if let Some(reader) = stdout {
Self::process_stream(
reader,
"stdout",
&app_clone,
&status_clone,
&ready_clone,
&token_clone,
);
let app = app_clone.clone();
let status = status_clone.clone();
let ready = ready_clone.clone();
let token = token_clone.clone();
let auth_cookie_name = auth_cookie_name_clone.clone();
thread::spawn(move || {
Self::process_stream(
reader,
"stdout",
&app,
&status,
&ready,
&token,
auth_cookie_name.as_str(),
);
});
}
if let Some(reader) = stderr {
Self::process_stream(
reader,
"stderr",
&app_clone,
&status_clone,
&ready_clone,
&token_clone,
);
let app = app_clone.clone();
let status = status_clone.clone();
let ready = ready_clone.clone();
let token = token_clone.clone();
let auth_cookie_name = auth_cookie_name_clone.clone();
thread::spawn(move || {
Self::process_stream(
reader,
"stderr",
&app,
&status,
&ready,
&token,
auth_cookie_name.as_str(),
);
});
}
});
@@ -731,10 +773,10 @@ impl CliProcessManager {
status: &Arc<Mutex<CliStatus>>,
ready: &Arc<AtomicBool>,
bootstrap_token: &Arc<Mutex<Option<String>>>,
auth_cookie_name: &str,
) {
let mut buffer = String::new();
let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)").ok();
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)\s*$").ok();
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
loop {
@@ -766,39 +808,17 @@ impl CliProcessManager {
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
.map(|m| m.as_str().to_string())
{
Self::mark_ready(app, status, ready, bootstrap_token, url);
Self::mark_ready(
app,
status,
ready,
bootstrap_token,
auth_cookie_name,
url,
);
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,
bootstrap_token,
format!("http://localhost:{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,
bootstrap_token,
format!("http://localhost:{}", port),
);
continue;
}
}
}
}
}
Err(_) => break,
@@ -811,6 +831,7 @@ impl CliProcessManager {
status: &Arc<Mutex<CliStatus>>,
ready: &Arc<AtomicBool>,
bootstrap_token: &Arc<Mutex<Option<String>>>,
auth_cookie_name: &str,
base_url: String,
) {
ready.store(true, Ordering::SeqCst);
@@ -834,9 +855,11 @@ impl CliProcessManager {
if scheme.as_deref() != Some("http") {
navigate_main(app, &base_url);
} else {
match exchange_bootstrap_token(&base_url, &token) {
match exchange_bootstrap_token(&base_url, &token, &auth_cookie_name) {
Ok(Some(session_id)) => {
if let Err(err) = set_session_cookie(app, &base_url, &session_id) {
if let Err(err) =
set_session_cookie(app, &base_url, &auth_cookie_name, &session_id)
{
log_line(&format!("failed to set session cookie: {err}"));
navigate_main(app, &format!("{base_url}/login"));
} else {
@@ -932,12 +955,15 @@ impl CliEntry {
))
}
fn build_args(&self, dev: bool, host: &str) -> Vec<String> {
fn build_args(&self, dev: bool, host: &str, auth_cookie_name: &str) -> Vec<String> {
let mut args = vec![
"serve".to_string(),
"--host".to_string(),
host.to_string(),
"--auth-cookie-name".to_string(),
auth_cookie_name.to_string(),
"--generate-token".to_string(),
"--unrestricted-root".to_string(),
];
if dev {
@@ -993,27 +1019,50 @@ impl CliEntry {
}
fn resolve_tsx(_app: &AppHandle) -> Option<String> {
let candidates = vec![
std::env::current_dir()
.ok()
let cwd = std::env::current_dir().ok();
let workspace = workspace_root();
let mut candidates = vec![
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.mjs")),
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.cjs")),
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.js")),
cwd.as_ref().map(|p| p.join("../node_modules/tsx/dist/cli.mjs")),
cwd.as_ref().map(|p| p.join("../node_modules/tsx/dist/cli.cjs")),
cwd.as_ref().map(|p| p.join("../node_modules/tsx/dist/cli.js")),
cwd.as_ref().map(|p| p.join("../../node_modules/tsx/dist/cli.mjs")),
cwd.as_ref().map(|p| p.join("../../node_modules/tsx/dist/cli.cjs")),
cwd.as_ref().map(|p| p.join("../../node_modules/tsx/dist/cli.js")),
workspace
.as_ref()
.map(|p| p.join("node_modules/tsx/dist/cli.mjs")),
workspace
.as_ref()
.map(|p| p.join("node_modules/tsx/dist/cli.cjs")),
workspace
.as_ref()
.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"))
}),
];
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.mjs")));
candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.cjs")));
candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.js")));
}
}
first_existing(candidates)
}
fn resolve_dev_entry(_app: &AppHandle) -> Option<String> {
let cwd = std::env::current_dir().ok();
let workspace = workspace_root();
let candidates = vec![
std::env::current_dir()
.ok()
workspace
.as_ref()
.map(|p| p.join("packages/server/src/index.ts")),
std::env::current_dir()
.ok()
.map(|p| p.join("../server/src/index.ts")),
cwd.as_ref().map(|p| p.join("packages/server/src/index.ts")),
cwd.as_ref().map(|p| p.join("../server/src/index.ts")),
cwd.as_ref().map(|p| p.join("../../server/src/index.ts")),
];
first_existing(candidates)
@@ -1115,11 +1164,8 @@ fn build_shell_args(shell: &str, command: &str) -> Vec<String> {
.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()]
}
let _ = shell_name;
vec!["-l".into(), "-c".into(), command.into()]
}
fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> {

View File

@@ -6,13 +6,16 @@ use cli_manager::{CliProcessManager, CliStatus};
use keepawake::KeepAwake;
use serde::Deserialize;
use serde_json::json;
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
use tauri::webview::Webview;
use tauri::{AppHandle, Emitter, Manager, Runtime, WindowEvent, Wry};
use tauri::{
AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry,
};
use tauri_plugin_global_shortcut::{
Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState,
};
@@ -29,10 +32,12 @@ use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
static LAST_ZOOM_TIME: Mutex<Option<Instant>> = Mutex::new(None);
const DEFAULT_ZOOM_LEVEL: f64 = 1.0;
const ZOOM_STEP: f64 = 0.2;
const ZOOM_STEP: f64 = 0.1;
const MIN_ZOOM_LEVEL: f64 = 0.2;
const MAX_ZOOM_LEVEL: f64 = 5.0;
const ZOOM_DEBOUNCE_MS: u64 = 50;
#[cfg(windows)]
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
@@ -41,6 +46,16 @@ pub struct AppState {
pub manager: CliProcessManager,
pub wake_lock: Mutex<Option<KeepAwake>>,
pub zoom_level: Mutex<f64>,
pub remote_origins: Mutex<HashMap<String, String>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RemoteWindowPayload {
id: String,
name: String,
base_url: String,
skip_tls_verify: bool,
}
#[derive(Debug, Default, Deserialize)]
@@ -118,11 +133,32 @@ fn should_allow_internal(url: &Url) -> bool {
}
}
fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
fn should_allow_window_origin<R: Runtime>(
app_handle: &AppHandle<R>,
window_label: &str,
url: &Url,
) -> bool {
if should_allow_internal(url) {
return true;
}
let state = app_handle.state::<AppState>();
let Ok(allowed) = state.remote_origins.lock() else {
return false;
};
if let Some(origin) = allowed.get(window_label) {
return origin == &url.origin().ascii_serialization();
}
false
}
fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
let window_label = webview.label().to_string();
if should_allow_window_origin(&webview.app_handle(), &window_label, url) {
return true;
}
if let Err(err) = webview
.app_handle()
.opener()
@@ -133,6 +169,58 @@ fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
false
}
#[tauri::command]
fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> {
if payload.skip_tls_verify && payload.base_url.starts_with("https://") {
return Err(
"Tauri cannot bypass self-signed HTTPS certificates automatically yet. Trust the certificate in your OS first, then reconnect, or use the CodeNomad Electron app."
.to_string(),
);
}
let parsed = Url::parse(&payload.base_url).map_err(|err| err.to_string())?;
let label = format!("remote-{}", payload.id);
let title = format!(
"{} - {}",
payload.name,
parsed.host_str().unwrap_or(payload.base_url.as_str())
);
if let Some(existing) = app.get_webview_window(&label) {
let _ = existing.navigate(parsed.clone());
let _ = existing.set_title(&title);
let _ = existing.show();
let _ = existing.unminimize();
let _ = existing.set_focus();
return Ok(());
}
app.state::<AppState>()
.remote_origins
.lock()
.map_err(|err| err.to_string())?
.insert(label.clone(), parsed.origin().ascii_serialization());
let window =
WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(parsed.clone()))
.title(title)
.inner_size(1400.0, 900.0)
.min_inner_size(800.0, 600.0)
.build()
.map_err(|err| err.to_string())?;
let app_handle = app.clone();
window.on_window_event(move |event| {
if let WindowEvent::Destroyed = event {
if let Ok(mut origins) = app_handle.state::<AppState>().remote_origins.lock() {
origins.remove(&label);
}
}
});
Ok(())
}
fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec<String> {
paths
.iter()
@@ -171,6 +259,15 @@ fn clamp_zoom_level(value: f64) -> f64 {
}
fn set_main_window_zoom(app_handle: &AppHandle, next_zoom: f64) {
if let Ok(mut last_zoom_time) = LAST_ZOOM_TIME.lock() {
if let Some(last_time) = *last_zoom_time {
if last_time.elapsed().as_millis() < ZOOM_DEBOUNCE_MS as u128 {
return;
}
}
*last_zoom_time = Some(Instant::now());
}
if let Some(window) = app_handle.get_webview_window("main") {
let normalized = clamp_zoom_level(next_zoom);
if window.set_zoom(normalized).is_ok() {
@@ -286,6 +383,7 @@ fn main() {
manager: CliProcessManager::new(),
wake_lock: Mutex::new(None),
zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL),
remote_origins: Mutex::new(HashMap::new()),
})
.setup(|app| {
set_windows_app_user_model_id();
@@ -323,7 +421,8 @@ fn main() {
cli_get_status,
cli_restart,
wake_lock_start,
wake_lock_stop
wake_lock_stop,
open_remote_window
])
.on_menu_event(|app_handle, event| {
match event.id().0.as_str() {
@@ -455,11 +554,24 @@ fn main() {
event: tauri::WindowEvent::CloseRequested { api, .. },
..
} => {
// Ensure we have time to stop the CLI process before the app exits.
// Let windows close normally. App shutdown is handled only after the
// last window is actually gone so remote windows can outlive `main`.
let _ = api;
}
tauri::RunEvent::WindowEvent {
event: tauri::WindowEvent::Destroyed,
..
} => {
if !app_handle.webview_windows().is_empty() {
return;
}
// Stop the CLI only when the final window is gone and the app is
// truly exiting.
if QUIT_REQUESTED.swap(true, Ordering::SeqCst) {
return;
}
api.prevent_close();
let app = app_handle.clone();
std::thread::spawn(move || {
if let Some(state) = app.try_state::<AppState>() {

View File

@@ -23,6 +23,7 @@
"resizable": true,
"fullscreen": false,
"decorations": true,
"transparent": true,
"theme": "Dark",
"backgroundColor": "#1a1a1a",
"zoomHotkeysEnabled": true

View File

@@ -10,7 +10,10 @@ import InstanceTabs from "./components/instance-tabs"
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
import InstanceShell from "./components/instance/instance-shell2"
import { SettingsScreen } from "./components/settings-screen"
import { SideCarPickerDialog } from "./components/sidecar-picker-dialog"
import { SideCarView } from "./components/sidecar-view"
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
import { showAlertDialog } from "./stores/alerts"
import { initGithubStars } from "./stores/github-stars"
import { useCommands } from "./lib/hooks/use-commands"
@@ -23,7 +26,6 @@ import { runtimeEnv } from "./lib/runtime-env"
import { useI18n } from "./lib/i18n"
import { setWakeLockDesired } from "./lib/native/wake-lock"
import {
hasInstances,
isSelectingFolder,
setIsSelectingFolder,
showFolderSelection,
@@ -33,10 +35,7 @@ import { useConfig } from "./stores/preferences"
import {
createInstance,
instances,
activeInstanceId,
setActiveInstanceId,
stopInstance,
getActiveInstance,
disconnectedInstance,
acknowledgeDisconnectedInstance,
} from "./stores/instances"
@@ -53,6 +52,22 @@ import {
import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
import { openSettings } from "./stores/settings-screen"
import {
closeSidecarTab,
ensureSidecarsLoaded,
openSidecarTab,
} from "./stores/sidecars"
import {
activeAppTab,
activeAppTabId,
appTabs,
ensureActiveAppTab,
getAdjacentAppTabId,
getAppTabById,
selectAppTab,
selectInstanceTab,
selectSidecarTab,
} from "./stores/app-tabs"
const log = getLogger("actions")
@@ -77,6 +92,7 @@ const App: Component = () => {
} = useConfig()
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
const [sidecarPickerOpen, setSidecarPickerOpen] = createSignal(false)
const phoneQuery = useMediaQuery("(max-width: 767px)")
const isPhoneLayout = createMemo(() => phoneQuery())
@@ -206,8 +222,7 @@ const App: Component = () => {
})
createEffect(() => {
instances()
hasInstances()
appTabs()
requestAnimationFrame(() => updateInstanceTabBarHeight())
})
@@ -219,7 +234,15 @@ const App: Component = () => {
onCleanup(() => window.removeEventListener("resize", handleResize))
})
const activeInstance = createMemo(() => getActiveInstance())
createEffect(() => {
appTabs()
ensureActiveAppTab()
})
const activeInstance = createMemo(() => {
const tab = activeAppTab()
return tab?.kind === "instance" ? tab.instance : null
})
const activeSessionIdForInstance = createMemo(() => {
const instance = activeInstance()
if (!instance) return null
@@ -244,6 +267,7 @@ const App: Component = () => {
recordWorkspaceLaunch(folderPath, selectedBinary)
clearLaunchError()
const instanceId = await createInstance(folderPath, selectedBinary)
selectInstanceTab(instanceId)
setShowFolderSelection(false)
log.info("Created instance", {
@@ -270,8 +294,27 @@ const App: Component = () => {
}
function handleNewInstanceRequest() {
if (hasInstances()) {
setShowFolderSelection(true)
setShowFolderSelection(true)
}
function handleOpenSidecarPicker() {
setSidecarPickerOpen(true)
void ensureSidecarsLoaded()
}
async function handleOpenSidecar(sidecarId: string) {
try {
const tab = await openSidecarTab(sidecarId)
selectSidecarTab(tab.token)
setShowFolderSelection(false)
setSidecarPickerOpen(false)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
showAlertDialog(message, {
variant: "error",
title: t("sidecars.open.errorTitle"),
})
log.error("Failed to open SideCar", error)
}
}
@@ -332,6 +375,23 @@ const App: Component = () => {
}
}
async function handleCloseAppTab(tabId: string) {
const tab = getAppTabById(tabId)
if (!tab) return
const fallbackTabId = activeAppTabId() === tabId ? getAdjacentAppTabId(tabId) : activeAppTabId()
if (tab.kind === "instance") {
await handleCloseInstance(tab.instance.id)
} else {
closeSidecarTab(tab.sidecarTab.token)
}
if (!getAppTabById(tabId)) {
ensureActiveAppTab(fallbackTabId)
}
}
const handleSidebarAgentChange = async (instanceId: string, sessionId: string, agent: string) => {
if (!instanceId || !sessionId || sessionId === "info") return
await updateSessionAgent(instanceId, sessionId, agent)
@@ -361,6 +421,7 @@ const App: Component = () => {
setThinkingBlocksExpansion,
setToolInputsVisibility,
handleNewInstanceRequest,
handleCloseActiveTab: () => handleCloseAppTab(activeAppTabId() ?? ""),
handleCloseInstance,
handleNewSession,
handleCloseSession,
@@ -371,6 +432,7 @@ const App: Component = () => {
useAppLifecycle({
setEscapeInDebounce,
handleNewInstanceRequest,
handleCloseActiveTab: () => handleCloseAppTab(activeAppTabId() ?? ""),
handleCloseInstance,
handleNewSession,
handleCloseSession,
@@ -470,52 +532,60 @@ const App: Component = () => {
</div>
</Show>
<Show
when={!hasInstances()}
when={appTabs().length === 0}
fallback={
<>
<Show when={!isPhoneLayout() || !mobileFullscreenMode()}>
<InstanceTabs
instances={instances()}
activeInstanceId={activeInstanceId()}
onSelect={setActiveInstanceId}
onClose={handleCloseInstance}
tabs={appTabs()}
activeTabId={activeAppTabId()}
onSelect={selectAppTab}
onClose={(tabId) => void handleCloseAppTab(tabId)}
onNew={handleNewInstanceRequest}
/>
</Show>
<For each={Array.from(instances().values())}>
{(instance) => {
const isActiveInstance = () => activeInstanceId() === instance.id
const isVisible = () => isActiveInstance() && !showFolderSelection()
return (
<div
class="flex-1 min-h-0 overflow-hidden"
style={{ display: isVisible() ? "flex" : "none" }}
data-instance-id={instance.id}
data-instance-active={isActiveInstance() ? "true" : "false"}
data-instance-visible={isVisible() ? "true" : "false"}
>
<InstanceMetadataProvider instance={instance}>
<InstanceShell
instance={instance}
isActiveInstance={isActiveInstance()}
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}
tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()}
mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()}
onEnterMobileFullscreen={() => void enterMobileFullscreen()}
onExitMobileFullscreen={() => void exitMobileFullscreen()}
/>
</InstanceMetadataProvider>
</div>
)
<For each={appTabs()}>
{(tab) => {
const isVisible = () => activeAppTabId() === tab.id && !showFolderSelection()
return tab.kind === "instance" ? (
<div
class="flex-1 min-h-0 overflow-hidden"
style={{ display: isVisible() ? "flex" : "none" }}
data-instance-id={tab.instance.id}
data-tab-id={tab.id}
data-tab-kind={tab.kind}
data-tab-visible={isVisible() ? "true" : "false"}
>
<InstanceMetadataProvider instance={tab.instance}>
<InstanceShell
instance={tab.instance}
isActiveInstance={isVisible()}
escapeInDebounce={escapeInDebounce()}
paletteCommands={paletteCommands}
onCloseSession={(sessionId) => handleCloseSession(tab.instance.id, sessionId)}
onNewSession={() => handleNewSession(tab.instance.id)}
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(tab.instance.id, sessionId, agent)}
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(tab.instance.id, sessionId, model)}
onExecuteCommand={executeCommand}
tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()}
mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()}
onEnterMobileFullscreen={() => void enterMobileFullscreen()}
onExitMobileFullscreen={() => void exitMobileFullscreen()}
/>
</InstanceMetadataProvider>
</div>
) : (
<div
class="flex-1 min-h-0 overflow-hidden"
style={{ display: isVisible() ? "flex" : "none" }}
data-tab-id={tab.id}
data-tab-kind={tab.kind}
data-tab-visible={isVisible() ? "true" : "false"}
>
<SideCarView tab={tab.sidecarTab} />
</div>
)
}}
</For>
@@ -525,6 +595,7 @@ const App: Component = () => {
<FolderSelectionView
onSelectFolder={handleSelectFolder}
isLoading={isSelectingFolder()}
onOpenSidecar={handleOpenSidecarPicker}
/>
</Show>
@@ -534,6 +605,7 @@ const App: Component = () => {
<FolderSelectionView
onSelectFolder={handleSelectFolder}
isLoading={isSelectingFolder()}
onOpenSidecar={handleOpenSidecarPicker}
onClose={() => {
setShowFolderSelection(false)
clearLaunchError()
@@ -544,6 +616,7 @@ const App: Component = () => {
</Show>
<SettingsScreen />
<SideCarPickerDialog open={sidecarPickerOpen()} onClose={() => setSidecarPickerOpen(false)} onOpenSidecar={handleOpenSidecar} />
<AlertDialog />

View File

@@ -1,4 +1,4 @@
import { createMemo, Show, createEffect, onCleanup } from "solid-js"
import { createMemo, Show, createEffect } from "solid-js"
import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
import "@git-diff-view/solid/styles/diff-view-pure.css"
import { disableCache } from "@git-diff-view/core"
@@ -20,6 +20,7 @@ interface ToolCallDiffViewerProps {
filePath?: string
theme: "light" | "dark"
mode: DiffViewMode
wrap?: boolean
onRendered?: () => void
cachedHtml?: string
cacheEntryParams?: CacheEntryParams
@@ -31,11 +32,183 @@ type DiffData = {
hunks: string[]
}
type CaptureContext = {
theme: ToolCallDiffViewerProps["theme"]
mode: DiffViewMode
diffText: string
cacheEntryParams?: CacheEntryParams
function measureTextWidth(container: HTMLElement, text: string, source: HTMLElement) {
const computed = window.getComputedStyle(source)
const probe = document.createElement("span")
probe.textContent = text || ""
probe.style.position = "absolute"
probe.style.visibility = "hidden"
probe.style.pointerEvents = "none"
probe.style.display = "inline-block"
probe.style.width = "auto"
probe.style.maxWidth = "none"
probe.style.whiteSpace = "nowrap"
probe.style.fontFamily = computed.fontFamily
probe.style.fontSize = computed.fontSize
probe.style.fontWeight = computed.fontWeight
probe.style.fontStyle = computed.fontStyle
probe.style.letterSpacing = computed.letterSpacing
probe.style.fontVariant = computed.fontVariant
probe.style.textTransform = computed.textTransform
probe.style.lineHeight = computed.lineHeight
container.appendChild(probe)
const width = Math.ceil(probe.getBoundingClientRect().width)
probe.remove()
return width
}
function computeCompactWidth(
container: HTMLElement,
entries: Array<{ text: string; source: HTMLElement }>,
maxWidthPx = 40,
) {
const measuredLabelWidthPx = entries.reduce((max, entry) => {
return Math.max(max, measureTextWidth(container, entry.text, entry.source))
}, 0)
const fallbackTextLength = entries.reduce((max, entry) => Math.max(max, entry.text.length), 1)
const fallbackWidthPx = Math.round(fallbackTextLength * 7 + 4)
return Math.max(2, Math.min(maxWidthPx, measuredLabelWidthPx > 0 ? measuredLabelWidthPx + 2 : fallbackWidthPx))
}
function applyCompactUnifiedGutter(container: HTMLElement, wrap: boolean) {
const tableWrapper = container.querySelector<HTMLElement>(".unified-diff-table-wrapper")
const table = container.querySelector<HTMLTableElement>(".unified-diff-table")
const numberCol = container.querySelector<HTMLTableColElement>(".unified-diff-table-num-col")
const gutterRows = container.querySelectorAll<HTMLElement>(".diff-line-num")
const hunkGutters = container.querySelectorAll<HTMLElement>(".diff-line-hunk-action, .diff-line-widget-wrapper, .diff-line-extend-wrapper")
const entries: Array<{ gutter: HTMLElement; label: HTMLElement; text: string }> = []
if (table) {
if (wrap) {
table.classList.add("table-fixed")
table.style.tableLayout = "fixed"
table.style.width = "100%"
table.style.minWidth = "100%"
} else {
table.classList.remove("table-fixed")
table.style.tableLayout = "auto"
table.style.width = "max-content"
table.style.minWidth = "100%"
}
}
gutterRows.forEach((gutter) => {
const oldSpan = gutter.querySelector<HTMLElement>("[data-line-old-num]")
const newSpan = gutter.querySelector<HTMLElement>("[data-line-new-num]")
const spacer = gutter.querySelector<HTMLElement>(".shrink-0")
const flexWrapper = gutter.querySelector<HTMLElement>(":scope > .flex")
const currentLabel = gutter.querySelector<HTMLElement>(":scope > .tool-call-diff-compact-line-number")
const oldText = oldSpan?.textContent?.trim() ?? ""
const newText = newSpan?.textContent?.trim() ?? ""
const hasUsableNew = newText.length > 0 && newText !== "0"
const hasUsableOld = oldText.length > 0 && oldText !== "0"
const visibleText = hasUsableNew ? newText : hasUsableOld ? oldText : newText || oldText
if (flexWrapper) flexWrapper.style.display = "none"
if (spacer) spacer.style.display = "none"
if (oldSpan) { oldSpan.style.display = "none"; oldSpan.style.width = "auto" }
if (newSpan) { newSpan.style.display = "none"; newSpan.style.width = "auto" }
gutter.style.paddingLeft = "1px"
gutter.style.paddingRight = "1px"
gutter.style.textAlign = "left"
const label = currentLabel ?? document.createElement("span")
label.className = "tool-call-diff-compact-line-number"
label.textContent = visibleText
label.setAttribute("aria-hidden", visibleText ? "false" : "true")
if (!currentLabel) gutter.appendChild(label)
entries.push({ gutter, label, text: visibleText })
})
const gutterWidthPx = computeCompactWidth(container, entries.map((entry) => ({ text: entry.text, source: entry.label })))
const gutterWidth = `${gutterWidthPx}px`
const compactAsideWidth = `${Math.max(8, gutterWidthPx - 10)}px`
if (tableWrapper) {
tableWrapper.style.setProperty("--diff-aside-width", compactAsideWidth)
tableWrapper.style.setProperty("--diff-aside-width--", compactAsideWidth)
}
if (numberCol) {
numberCol.style.width = gutterWidth
}
entries.forEach(({ gutter, label }) => {
gutter.style.width = gutterWidth
gutter.style.minWidth = gutterWidth
gutter.style.maxWidth = gutterWidth
label.style.width = "auto"
label.style.maxWidth = "none"
})
hunkGutters.forEach((gutter) => {
gutter.style.width = gutterWidth
gutter.style.minWidth = gutterWidth
gutter.style.maxWidth = gutterWidth
gutter.style.paddingLeft = "0"
gutter.style.paddingRight = "0"
})
}
function applyCompactSplitGutter(container: HTMLElement) {
const oldWrapper = container.querySelector<HTMLElement>(".old-diff-table-wrapper")
const newWrapper = container.querySelector<HTMLElement>(".new-diff-table-wrapper")
const numberCells = Array.from(container.querySelectorAll<HTMLElement>(".diff-line-old-num, .diff-line-new-num"))
const hunkActions = Array.from(container.querySelectorAll<HTMLElement>(".diff-line-hunk-action, .diff-line-widget-wrapper, .diff-line-extend-wrapper"))
const numberSpans = numberCells
.map((cell) => ({ cell, span: cell.querySelector<HTMLElement>("[data-line-num]") }))
.filter((entry): entry is { cell: HTMLElement; span: HTMLElement } => Boolean(entry.span))
const gutterWidthPx = computeCompactWidth(
container,
numberSpans.map(({ span }) => ({ text: span.textContent?.trim() ?? "", source: span })),
64,
)
const gutterWidth = `${gutterWidthPx}px`
;[oldWrapper, newWrapper].forEach((wrapper) => {
if (wrapper) {
wrapper.style.setProperty("--diff-aside-width", gutterWidth)
}
})
numberCells.forEach((cell) => {
cell.style.width = gutterWidth
cell.style.minWidth = gutterWidth
cell.style.maxWidth = gutterWidth
cell.style.paddingLeft = "2px"
cell.style.paddingRight = "2px"
cell.style.textAlign = "left"
cell.style.whiteSpace = "nowrap"
cell.style.overflowWrap = "normal"
cell.style.wordBreak = "normal"
})
numberSpans.forEach(({ span }) => {
span.style.whiteSpace = "nowrap"
span.style.overflowWrap = "normal"
span.style.wordBreak = "normal"
})
hunkActions.forEach((cell) => {
cell.style.width = gutterWidth
cell.style.minWidth = gutterWidth
cell.style.maxWidth = gutterWidth
cell.style.paddingLeft = "0"
cell.style.paddingRight = "0"
})
}
function applyCompactDiffLayout(container: HTMLElement, mode: DiffViewMode, wrap = false) {
if (mode === "unified") {
applyCompactUnifiedGutter(container, wrap)
return
}
if (mode === "split") {
applyCompactSplitGutter(container)
}
}
export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
@@ -67,12 +240,15 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
const contextKey = createMemo(() => {
const data = diffData()
if (!data) return ""
return `${props.theme}|${props.mode}|${props.diffText}`
return `${props.theme}|${props.mode}|${props.wrap ? "wrap" : "nowrap"}|${props.diffText}`
})
createEffect(() => {
const cachedHtml = props.cachedHtml
if (cachedHtml) {
if (diffContainerRef) {
applyCompactDiffLayout(diffContainerRef, props.mode, Boolean(props.wrap))
}
// When we are given cached HTML, we rely on the caller's cache
// and simply notify once rendered.
props.onRendered?.()
@@ -83,9 +259,10 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
if (!key) return
if (!diffContainerRef) return
if (lastCapturedKey === key) return
requestAnimationFrame(() => {
if (!diffContainerRef) return
applyCompactDiffLayout(diffContainerRef, props.mode, Boolean(props.wrap))
const markup = diffContainerRef.innerHTML
if (!markup) return
lastCapturedKey = key
@@ -95,6 +272,7 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
html: markup,
theme: props.theme,
mode: props.mode,
wrap: props.wrap,
})
}
props.onRendered?.()
@@ -122,7 +300,7 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
diffViewMode={props.mode === "split" ? DiffModeEnum.Split : DiffModeEnum.Unified}
diffViewTheme={props.theme}
diffViewHighlight
diffViewWrap={false}
diffViewWrap={Boolean(props.wrap)}
diffViewFontSize={13}
/>
</ErrorBoundary>
@@ -131,7 +309,7 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
</div>
}
>
<div innerHTML={props.cachedHtml} />
<div ref={diffContainerRef} innerHTML={props.cachedHtml} />
</Show>
</div>
)

View File

@@ -1,15 +1,17 @@
import { createEffect, createSignal, onCleanup, onMount } from "solid-js"
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { loadMonaco } from "../../lib/monaco/setup"
import { getOrCreateTextModel } from "../../lib/monaco/model-cache"
import { inferMonacoLanguageId } from "../../lib/monaco/language"
import { ensureMonacoLanguageLoaded } from "../../lib/monaco/setup"
import { useTheme } from "../../lib/theme"
import { parsePatchToBeforeAfter } from "../../lib/diff-utils"
interface MonacoDiffViewerProps {
scopeKey: string
path: string
before: string
after: string
patch?: string
before?: string
after?: string
viewMode?: "split" | "unified"
contextMode?: "expanded" | "collapsed"
wordWrap?: "on" | "off"
@@ -23,6 +25,16 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
let monaco: any = null
const [ready, setReady] = createSignal(false)
const resolvedContent = createMemo(() => {
if (props.patch !== undefined && props.patch !== null) {
return parsePatchToBeforeAfter(props.patch)
}
return {
before: props.before ?? "",
after: props.after ?? "",
}
})
const disposeEditor = () => {
try {
diffEditor?.setModel(null as any)
@@ -115,11 +127,12 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
createEffect(() => {
if (!ready() || !monaco || !diffEditor) return
const languageId = inferMonacoLanguageId(monaco, props.path)
const { before, after } = resolvedContent()
const beforeKey = `${props.scopeKey}:diff:${props.path}:before`
const afterKey = `${props.scopeKey}:diff:${props.path}:after`
const original = getOrCreateTextModel({ monaco, cacheKey: beforeKey, value: props.before, languageId })
const modified = getOrCreateTextModel({ monaco, cacheKey: afterKey, value: props.after, languageId })
const original = getOrCreateTextModel({ monaco, cacheKey: beforeKey, value: before, languageId })
const modified = getOrCreateTextModel({ monaco, cacheKey: afterKey, value: after, languageId })
diffEditor.setModel({ original, modified })
void ensureMonacoLanguageLoaded(languageId).then(() => {

View File

@@ -1,6 +1,7 @@
import { Dialog } from "@kobalte/core/dialog"
import { Select } from "@kobalte/core/select"
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X } from "lucide-solid"
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X, Globe, Loader2 } from "lucide-solid"
import { useConfig } from "../stores/preferences"
import DirectoryBrowserDialog from "./directory-browser-dialog"
import Kbd from "./kbd"
@@ -14,25 +15,48 @@ import { useI18n, type Locale } from "../lib/i18n"
import { showAlertDialog } from "../stores/alerts"
import { openSettings, settingsOpen } from "../stores/settings-screen"
import { openExternalUrl } from "../lib/external-url"
import { serverApi } from "../lib/api-client"
import { openRemoteServerWindow } from "../lib/native/remote-window"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
const GITHUB_URL = "https://github.com/NeuralNomadsAI/CodeNomad"
const DISCORD_URL = "https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
type HomeTab = "local" | "servers"
interface FolderSelectionViewProps {
onSelectFolder: (folder: string, binaryPath?: string) => void
onOpenSidecar?: () => void
isLoading?: boolean
onClose?: () => void
}
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const { recentFolders, removeRecentFolder, preferences, updatePreferences, serverSettings } = useConfig()
const {
recentFolders,
removeRecentFolder,
preferences,
updatePreferences,
serverSettings,
remoteServers,
saveRemoteServerProfile,
markRemoteServerConnected,
removeRemoteServerProfile,
} = useConfig()
const { t, locale } = useI18n()
const [selectedIndex, setSelectedIndex] = createSignal(0)
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode")
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
const [activeTab, setActiveTab] = createSignal<HomeTab>("local")
const [isServerDialogOpen, setIsServerDialogOpen] = createSignal(false)
const [serverName, setServerName] = createSignal("")
const [serverUrl, setServerUrl] = createSignal("")
const [skipTlsVerify, setSkipTlsVerify] = createSignal(false)
const [serverDialogError, setServerDialogError] = createSignal<string | null>(null)
const [isSavingServer, setIsSavingServer] = createSignal(false)
const [connectingServerId, setConnectingServerId] = createSignal<string | null>(null)
const nativeDialogsAvailable = supportsNativeDialogs()
let recentListRef: HTMLDivElement | undefined
@@ -49,10 +73,15 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
]
const selectedLanguageOption = () => languageOptions.find((opt) => opt.value === locale()) ?? languageOptions[0]
const folders = () => recentFolders()
const serverList = () => remoteServers()
const isLoading = () => Boolean(props.isLoading)
function getActiveListLength() {
return activeTab() === "local" ? folders().length : serverList().length
}
// Update selected binary when preferences change
createEffect(() => {
const lastUsed = serverSettings().opencodeBinary
@@ -64,7 +93,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
function scrollToIndex(index: number) {
const container = recentListRef
if (!container) return
const element = container.querySelector(`[data-folder-index="${index}"]`) as HTMLElement | null
const element = container.querySelector(`[data-list-index="${index}"]`) as HTMLElement | null
if (!element) return
const containerRect = container.getBoundingClientRect()
@@ -113,19 +142,18 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
return
}
const folderList = folders()
if (isBrowseShortcut) {
e.preventDefault()
void handleBrowse()
return
}
if (folderList.length === 0) return
const listLength = getActiveListLength()
if (listLength === 0) return
if (e.key === "ArrowDown") {
e.preventDefault()
const newIndex = Math.min(selectedIndex() + 1, folderList.length - 1)
const newIndex = Math.min(selectedIndex() + 1, listLength - 1)
setSelectedIndex(newIndex)
setFocusMode("recent")
scrollToIndex(newIndex)
@@ -138,7 +166,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
} else if (e.key === "PageDown") {
e.preventDefault()
const pageSize = 5
const newIndex = Math.min(selectedIndex() + pageSize, folderList.length - 1)
const newIndex = Math.min(selectedIndex() + pageSize, listLength - 1)
setSelectedIndex(newIndex)
setFocusMode("recent")
scrollToIndex(newIndex)
@@ -156,7 +184,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
scrollToIndex(0)
} else if (e.key === "End") {
e.preventDefault()
const newIndex = folderList.length - 1
const newIndex = listLength - 1
setSelectedIndex(newIndex)
setFocusMode("recent")
scrollToIndex(newIndex)
@@ -165,10 +193,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
handleEnterKey()
} else if (e.key === "Backspace" || e.key === "Delete") {
e.preventDefault()
if (folderList.length > 0 && focusMode() === "recent") {
const folder = folderList[selectedIndex()]
if (folder) {
handleRemove(folder.path)
if (listLength > 0 && focusMode() === "recent") {
if (activeTab() === "local") {
const folder = folders()[selectedIndex()]
if (folder) {
handleRemove(folder.path)
}
} else {
const server = serverList()[selectedIndex()]
if (server) {
removeRemoteServerProfile(server.id)
}
}
}
}
@@ -177,15 +212,40 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
function handleEnterKey() {
if (isLoading()) return
const folderList = folders()
const index = selectedIndex()
const folder = folderList[index]
if (folder) {
handleFolderSelect(folder.path)
if (activeTab() === "local") {
const folder = folders()[index]
if (folder) {
handleFolderSelect(folder.path)
}
return
}
const server = serverList()[index]
if (server) {
void handleConnectSavedServer(server.id)
}
}
createEffect(() => {
activeTab()
setSelectedIndex(0)
setFocusMode("recent")
})
createEffect(() => {
const length = getActiveListLength()
if (length === 0) {
setSelectedIndex(0)
return
}
if (selectedIndex() >= length) {
setSelectedIndex(length - 1)
}
})
onMount(() => {
window.addEventListener("keydown", handleKeyDown)
@@ -236,6 +296,87 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
props.onSelectFolder(path, selectedBinary())
}
function resetServerDialog() {
setServerName("")
setServerUrl("")
setSkipTlsVerify(false)
setServerDialogError(null)
}
function openServerDialog() {
resetServerDialog()
setIsServerDialogOpen(true)
}
async function probeAndOpenServer(input: { id?: string; name: string; baseUrl: string; skipTlsVerify: boolean }, openWindow: boolean) {
const trimmedName = input.name.trim()
const trimmedUrl = input.baseUrl.trim()
if (!trimmedName || !trimmedUrl) {
throw new Error(t("folderSelection.servers.dialog.errorRequired"))
}
const probe = await serverApi.probeRemoteServer({
baseUrl: trimmedUrl,
skipTlsVerify: input.skipTlsVerify,
})
if (!probe.ok) {
throw new Error(probe.error || t("folderSelection.servers.dialog.errorConnect"))
}
const profile = await saveRemoteServerProfile({
id: input.id,
name: trimmedName,
baseUrl: probe.normalizedUrl,
skipTlsVerify: input.skipTlsVerify,
})
if (openWindow) {
await openRemoteServerWindow(profile)
await markRemoteServerConnected(profile.id)
}
return profile
}
async function handleSaveServer(openWindow: boolean) {
if (isSavingServer()) return
setIsSavingServer(true)
setServerDialogError(null)
try {
await probeAndOpenServer(
{
name: serverName(),
baseUrl: serverUrl(),
skipTlsVerify: skipTlsVerify(),
},
openWindow,
)
setIsServerDialogOpen(false)
resetServerDialog()
} catch (error) {
setServerDialogError(error instanceof Error ? error.message : String(error))
} finally {
setIsSavingServer(false)
}
}
async function handleConnectSavedServer(id: string) {
const target = remoteServers().find((entry) => entry.id === id)
if (!target || connectingServerId()) return
setConnectingServerId(id)
try {
await probeAndOpenServer(target, true)
} catch (error) {
showAlertDialog(error instanceof Error ? error.message : String(error), {
title: t("folderSelection.servers.errorTitle"),
variant: "warning",
})
} finally {
setConnectingServerId(null)
}
}
async function handleBrowse() {
if (isLoading()) return
setFocusMode("new")
@@ -476,90 +617,223 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<div class="flex-1 min-h-0 overflow-hidden flex flex-col lg:flex-row gap-4">
{/* Right column: recent folders */}
<div class="order-1 lg:order-2 flex flex-col gap-4 flex-1 min-h-0 overflow-hidden">
<Show
when={folders().length > 0}
fallback={
<div class="panel panel-empty-state flex-1">
<div class="panel-empty-state-icon">
<Clock class="w-12 h-12 mx-auto" />
</div>
<p class="panel-empty-state-title">{t("folderSelection.empty.title")}</p>
<p class="panel-empty-state-description">{t("folderSelection.empty.description")}</p>
</div>
}
>
<div class="panel flex flex-col flex-1 min-h-0">
<div class="panel-header">
<h2 class="panel-title">{t("folderSelection.recent.title")}</h2>
<p class="panel-subtitle">
{t(
folders().length === 1
? "folderSelection.recent.subtitle.one"
: "folderSelection.recent.subtitle.other",
{ count: folders().length },
)}
</p>
</div>
<div
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
ref={(el) => (recentListRef = el)}
>
<For each={folders()}>
{(folder, index) => (
<div class="panel-header !gap-0 !p-0">
<div class="grid grid-cols-2 gap-0 overflow-hidden border border-base rounded-t-lg rounded-b-none">
<button
type="button"
class="border-r border-base px-4 py-3 text-left transition-colors"
classList={{
"text-primary": activeTab() === "local",
"text-muted hover:text-secondary": activeTab() !== "local",
}}
style={{
"background-color": "var(--surface-secondary)",
}}
onClick={() => setActiveTab("local")}
>
<div
class="panel-list-item"
classList={{
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
"panel-list-item-disabled": isLoading(),
class="panel-title text-base"
style={{
color: activeTab() === "local" ? "var(--text-primary)" : "var(--text-secondary)",
}}
>
<div class="flex items-center gap-2 w-full px-1">
{t("folderSelection.recent.title")}
</div>
<p
class="panel-subtitle mt-1"
style={{
color: activeTab() === "local" ? "var(--text-muted)" : "var(--text-secondary)",
}}
>
{t(
folders().length === 1
? "folderSelection.recent.subtitle.one"
: "folderSelection.recent.subtitle.other",
{ count: folders().length },
)}
</p>
</button>
<button
type="button"
class="px-4 py-3 text-left transition-colors"
classList={{
"text-primary": activeTab() === "servers",
"text-muted hover:text-secondary": activeTab() !== "servers",
}}
style={{
"background-color": "var(--surface-secondary)",
}}
onClick={() => setActiveTab("servers")}
>
<div
class="panel-title text-base"
style={{
color: activeTab() === "servers" ? "var(--text-primary)" : "var(--text-secondary)",
}}
>
{t("folderSelection.tabs.servers")}
</div>
<p
class="panel-subtitle mt-1"
style={{
color: activeTab() === "servers" ? "var(--text-muted)" : "var(--text-secondary)",
}}
>
{t("folderSelection.servers.count", { count: remoteServers().length })}
</p>
</button>
</div>
</div>
<Show
when={activeTab() === "local"}
fallback={
<Show
when={remoteServers().length > 0}
fallback={
<div class="panel-empty-state flex-1">
<div class="panel-empty-state-icon">
<Globe class="w-12 h-12 mx-auto" />
</div>
<p class="panel-empty-state-title">{t("folderSelection.servers.empty.title")}</p>
<p class="panel-empty-state-description">{t("folderSelection.servers.empty.description")}</p>
<button
data-folder-index={index()}
class="panel-list-item-content flex-1"
disabled={isLoading()}
onClick={() => handleFolderSelect(folder.path)}
onMouseEnter={() => {
if (isLoading()) return
setFocusMode("recent")
setSelectedIndex(index())
}}
type="button"
class="button-primary mt-4 w-auto self-center inline-flex items-center justify-center gap-2 px-4"
onClick={openServerDialog}
>
<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 mb-1">
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
<span class="text-sm font-medium truncate text-primary">
{splitFolderPath(folder.path).baseName}
</span>
</div>
<div class="flex items-center gap-2 pl-6 text-xs text-muted min-w-0">
<span class="font-mono truncate-start flex-1 min-w-0">
{getDisplayPath(folder.path)}
</span>
<span class="flex-shrink-0">{formatRelativeTime(folder.lastAccessed)}</span>
</div>
</div>
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
<kbd class="kbd"></kbd>
</Show>
</div>
</button>
<button
onClick={(e) => handleRemove(folder.path, e)}
disabled={isLoading()}
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
title={t("folderSelection.recent.remove")}
>
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
<Globe class="w-4 h-4" />
<span>{t("folderSelection.actions.connectButton")}</span>
</button>
</div>
}
>
<div
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
ref={(el) => (recentListRef = el)}
>
<For each={remoteServers()}>
{(server, index) => (
<div
class="panel-list-item"
classList={{
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
}}
>
<div class="flex items-center gap-2 w-full px-1">
<button
data-list-index={index()}
class="panel-list-item-content flex-1"
onClick={() => void handleConnectSavedServer(server.id)}
onMouseEnter={() => {
setFocusMode("recent")
setSelectedIndex(index())
}}
>
<div class="flex items-center justify-between gap-3 w-full">
<div class="flex-1 min-w-0 text-left">
<div class="flex items-center gap-2 mb-1">
<Globe class="w-4 h-4 flex-shrink-0 icon-muted" />
<span class="text-sm font-medium truncate text-primary">{server.name}</span>
</div>
<div class="flex items-center gap-2 pl-6 text-xs text-muted min-w-0">
<span class="font-mono truncate-start flex-1 min-w-0">{server.baseUrl}</span>
</div>
</div>
<Show when={connectingServerId() === server.id} fallback={<Show when={focusMode() === "recent" && selectedIndex() === index()}><kbd class="kbd"></kbd></Show>}>
<Loader2 class="w-4 h-4 animate-spin icon-muted" />
</Show>
</div>
</button>
<button
onClick={() => removeRemoteServerProfile(server.id)}
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
title={t("folderSelection.servers.remove")}
>
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
</button>
</div>
</div>
)}
</For>
</div>
)}
</For>
</div>
</Show>
}
>
<Show
when={folders().length > 0}
fallback={
<div class="panel-empty-state flex-1">
<div class="panel-empty-state-icon">
<Clock class="w-12 h-12 mx-auto" />
</div>
<p class="panel-empty-state-title">{t("folderSelection.empty.title")}</p>
<p class="panel-empty-state-description">{t("folderSelection.empty.description")}</p>
</div>
}
>
<div
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
ref={(el) => (recentListRef = el)}
>
<For each={folders()}>
{(folder, index) => (
<div
class="panel-list-item"
classList={{
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
"panel-list-item-disabled": isLoading(),
}}
>
<div class="flex items-center gap-2 w-full px-1">
<button
data-list-index={index()}
class="panel-list-item-content flex-1"
disabled={isLoading()}
onClick={() => handleFolderSelect(folder.path)}
onMouseEnter={() => {
if (isLoading()) return
setFocusMode("recent")
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 mb-1">
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
<span class="text-sm font-medium truncate text-primary">
{splitFolderPath(folder.path).baseName}
</span>
</div>
<div class="flex items-center gap-2 pl-6 text-xs text-muted min-w-0">
<span class="font-mono truncate-start flex-1 min-w-0">
{getDisplayPath(folder.path)}
</span>
<span class="flex-shrink-0">{formatRelativeTime(folder.lastAccessed)}</span>
</div>
</div>
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
<kbd class="kbd"></kbd>
</Show>
</div>
</button>
<button
onClick={(e) => handleRemove(folder.path, e)}
disabled={isLoading()}
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
title={t("folderSelection.recent.remove")}
>
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
</button>
</div>
</div>
)}
</For>
</div>
</Show>
</Show>
</div>
</Show>
</div>
@@ -567,11 +841,11 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<div class="order-2 lg:order-1 flex flex-col gap-4 flex-1 min-h-0">
<div class="panel shrink-0">
<div class="panel-header hidden sm:block">
<h2 class="panel-title">{t("folderSelection.browse.title")}</h2>
<p class="panel-subtitle">{t("folderSelection.browse.subtitle")}</p>
<h2 class="panel-title">{t("folderSelection.actions.title")}</h2>
<p class="panel-subtitle">{t("folderSelection.actions.subtitle")}</p>
</div>
<div class="panel-body">
<div class="panel-body flex flex-col gap-3">
<button
onClick={() => void handleBrowse()}
disabled={props.isLoading}
@@ -588,6 +862,27 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</div>
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
</button>
<button
type="button"
onClick={() => props.onOpenSidecar?.()}
class="button-primary mt-3 w-full flex items-center justify-center text-sm"
>
<div class="flex items-center gap-2">
<MonitorUp class="w-4 h-4" />
<span>{t("folderSelection.sidecars.button")}</span>
</div>
</button>
<button
onClick={openServerDialog}
class="button-primary w-full flex items-center justify-center text-sm"
>
<div class="flex items-center gap-2">
<Globe class="w-4 h-4" />
<span>{t("folderSelection.actions.connectButton")}</span>
</div>
</button>
</div>
{/* OpenCode settings section */}
@@ -663,6 +958,82 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
onClose={() => setIsFolderBrowserOpen(false)}
onSelect={handleBrowserSelect}
/>
<Dialog open={isServerDialogOpen()} onOpenChange={(open) => !open && setIsServerDialogOpen(false)}>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-[1300] flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-lg p-6 flex flex-col gap-5" tabIndex={-1}>
<div>
<Dialog.Title class="text-xl font-semibold text-primary">
{t("folderSelection.servers.dialog.title")}
</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-2">
{t("folderSelection.servers.dialog.description")}
</Dialog.Description>
</div>
<label class="flex flex-col gap-2 text-sm text-secondary">
<span>{t("folderSelection.servers.dialog.name")}</span>
<input
class="selector-input w-full"
value={serverName()}
onInput={(event) => setServerName(event.currentTarget.value)}
placeholder={t("folderSelection.servers.dialog.namePlaceholder")}
/>
</label>
<label class="flex flex-col gap-2 text-sm text-secondary">
<span>{t("folderSelection.servers.dialog.url")}</span>
<input
class="selector-input w-full"
value={serverUrl()}
onInput={(event) => setServerUrl(event.currentTarget.value)}
placeholder={t("folderSelection.servers.dialog.urlPlaceholder")}
/>
</label>
<label class="flex items-start gap-3 text-sm text-secondary">
<input
type="checkbox"
checked={skipTlsVerify()}
onChange={(event) => setSkipTlsVerify(event.currentTarget.checked)}
/>
<span>{t("folderSelection.servers.dialog.skipTls")}</span>
</label>
<Show when={serverDialogError()}>
{(message) => <p class="text-sm text-red-500 break-words">{message()}</p>}
</Show>
<div class="flex items-center justify-end gap-3">
<button class="selector-button selector-button-secondary w-auto px-4" onClick={() => setIsServerDialogOpen(false)}>
{t("folderSelection.servers.dialog.cancel")}
</button>
<button
class="selector-button selector-button-secondary w-auto px-4"
disabled={isSavingServer()}
onClick={() => void handleSaveServer(false)}
>
{t("folderSelection.servers.dialog.save")}
</button>
<button
class="selector-button selector-button-secondary w-auto px-4"
disabled={isSavingServer()}
onClick={() => void handleSaveServer(true)}
>
<Show when={isSavingServer()} fallback={<span>{t("folderSelection.servers.dialog.connect")}</span>}>
<span class="inline-flex items-center gap-2">
<Loader2 class="w-4 h-4 animate-spin" />
{t("folderSelection.servers.dialog.connecting")}
</span>
</Show>
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
</>
)
}

View File

@@ -1,6 +1,5 @@
import { Component, For, Show, createMemo } from "solid-js"
import { Dynamic } from "solid-js/web"
import type { Instance } from "../types/instance"
import InstanceTab from "./instance-tab"
import KeyboardHint from "./keyboard-hint"
import { Plus, MonitorUp, Bell, BellOff, Settings } from "lucide-solid"
@@ -9,12 +8,13 @@ import { useI18n } from "../lib/i18n"
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
import { useConfig } from "../stores/preferences"
import { openSettings } from "../stores/settings-screen"
import type { AppTabRecord } from "../stores/app-tabs"
interface InstanceTabsProps {
instances: Map<string, Instance>
activeInstanceId: string | null
onSelect: (instanceId: string) => void
onClose: (instanceId: string) => void
tabs: AppTabRecord[]
activeTabId: string | null
onSelect: (tabId: string) => void
onClose: (tabId: string) => void
onNew: () => void
}
@@ -42,15 +42,25 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
<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 each={props.tabs}>
{(tab) =>
tab.kind === "instance" ? (
<InstanceTab
instance={tab.instance}
active={tab.id === props.activeTabId}
onSelect={() => props.onSelect(tab.id)}
onClose={() => props.onClose(tab.id)}
/>
) : (
<div class={`tab-pill ${tab.id === props.activeTabId ? "tab-pill-active" : ""}`}>
<button class="tab-pill-button" onClick={() => props.onSelect(tab.id)}>
<span class="truncate max-w-[180px]">{tab.sidecarTab.name}</span>
</button>
<button class="tab-pill-close" onClick={() => props.onClose(tab.id)} aria-label={tab.sidecarTab.name}>
×
</button>
</div>
)}
</For>
<button
class="new-tab-button"
@@ -62,7 +72,7 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
</button>
</div>
<div class="tab-strip-spacer" />
<Show when={Array.from(props.instances.entries()).length > 1}>
<Show when={props.tabs.length > 1}>
<div class="tab-shortcuts">
<KeyboardHint
shortcuts={[keyboardRegistry.get("instance-prev")!, keyboardRegistry.get("instance-next")!].filter(

View File

@@ -115,23 +115,22 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
}
>
{(file) => (
<Suspense
fallback={
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
</div>
}
>
<LazyMonacoDiffViewer
scopeKey={scopeKey()}
path={String(file().file || "")}
before={String((file() as any).before || "")}
after={String((file() as any).after || "")}
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
wordWrap={props.diffWordWrapMode()}
/>
</Suspense>
<Suspense
fallback={
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
</div>
}
>
<LazyMonacoDiffViewer
scopeKey={scopeKey()}
path={String(file().file || "")}
patch={String((file() as any).patch || "")}
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
wordWrap={props.diffWordWrapMode()}
/>
</Suspense>
)}
</Show>
</div>

View File

@@ -4,7 +4,7 @@ import { Accordion } from "@kobalte/core"
import { Tooltip } from "@kobalte/core/tooltip"
import Switch from "@suid/material/Switch"
import { ChevronDown, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
import { BellRing, ChevronDown, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
import type { Instance } from "../../../../../types/instance"
import type { BackgroundProcess } from "../../../../../../../server/src/api-types"
@@ -187,6 +187,24 @@ const StatusTab: Component<StatusTabProps> = (props) => {
<div class="status-process-header">
<span class="status-process-title">{process.title}</span>
<div class="status-process-meta">
<span
classList={{
"text-success": Boolean(process.notifyEnabled),
"text-tertiary": !process.notifyEnabled,
}}
aria-label={props.t(
process.notifyEnabled
? "instanceShell.backgroundProcesses.notify.enabled"
: "instanceShell.backgroundProcesses.notify.disabled",
)}
title={props.t(
process.notifyEnabled
? "instanceShell.backgroundProcesses.notify.enabled"
: "instanceShell.backgroundProcesses.notify.disabled",
)}
>
<BellRing class="h-3.5 w-3.5" />
</span>
<span>{props.t("instanceShell.backgroundProcesses.status", { status: process.status })}</span>
<Show when={typeof process.outputSizeBytes === "number"}>
<span>

View File

@@ -1,21 +1,22 @@
import { For, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, onCleanup, untrack } from "solid-js"
import { For, Index, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, onCleanup, untrack, type Accessor } from "solid-js"
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid"
import MessageItem from "./message-item"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
import type { ClientPart, MessageInfo } from "../types/message"
import { partHasRenderableText } from "../types/message"
import { isHiddenSyntheticTextPart, 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"
import { selectInstanceTab } from "../stores/app-tabs"
import { showAlertDialog } from "../stores/alerts"
import { deleteMessage } from "../stores/session-actions"
import { useI18n } from "../lib/i18n"
import type { DeleteHoverState } from "../types/delete-hover"
import { useSpeech } from "../lib/hooks/use-speech"
import SpeechActionButton from "./speech-action-button"
import { createFollowScroll } from "../lib/follow-scroll"
function DeleteUpToIcon() {
return (
@@ -29,6 +30,7 @@ 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)"
const REASONING_SCROLL_SENTINEL_MARGIN_PX = 48
const LazyToolCall = lazy(() => import("./tool-call"))
@@ -130,7 +132,7 @@ function findTaskSessionLocation(sessionId: string, preferredInstanceId?: string
}
function navigateToTaskSession(location: TaskSessionLocation) {
setActiveInstanceId(location.instanceId)
selectInstanceTab(location.instanceId)
const parentToActivate = location.parentId ?? location.sessionId
setActiveParentSession(location.instanceId, parentToActivate)
if (location.parentId) {
@@ -229,6 +231,12 @@ function isContentPartType(type: unknown): boolean {
return type === "text" || type === "file"
}
function isVisibleContentPart(part: ClientPart): boolean {
if (!part || !isContentPartType((part as any).type)) return false
if (isHiddenSyntheticTextPart(part)) return false
return partHasRenderableText(part)
}
function MessageContentItem(props: MessageContentItemProps) {
const record = createMemo(() => props.store().getMessage(props.messageId))
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
@@ -262,13 +270,15 @@ function MessageContentItem(props: MessageContentItemProps) {
return resolved
})
const visibleParts = createMemo(() => parts().filter((part) => isVisibleContentPart(part)))
const showAgentMeta = createMemo(() => {
const current = record()
if (!current) return false
if (current.role !== "assistant") return false
const currentParts = parts()
if (!currentParts.some((part) => partHasRenderableText(part))) {
if (visibleParts().length === 0) {
return false
}
@@ -284,10 +294,10 @@ function MessageContentItem(props: MessageContentItemProps) {
if (!isSupportedPartType(part)) continue
if (!isContentPartType((part as any).type)) continue
if (partHasRenderableText(part)) {
return false
if (isVisibleContentPart(part)) {
return false
}
}
}
return true
})
@@ -298,7 +308,7 @@ function MessageContentItem(props: MessageContentItemProps) {
<MessageItem
record={resolvedRecord()}
messageInfo={messageInfo()}
parts={parts()}
parts={visibleParts()}
instanceId={props.instanceId}
sessionId={props.sessionId}
isQueued={isQueued()}
@@ -619,13 +629,12 @@ export default function MessageBlock(props: MessageBlockProps) {
const lastAssistantIdx = props.lastAssistantIndex()
const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx)
// Intentionally untracked: messageInfoVersion updates should not trigger
// a full message block rebuild; record revision is the invalidation key.
const info = untrack(messageInfo)
const messageInfoVersion = props.store().state.messageInfoVersion[current.id] ?? 0
const cacheSignature = [
current.id,
current.revision,
messageInfoVersion,
isQueued ? 1 : 0,
props.showThinking() ? 1 : 0,
props.thinkingDefaultExpanded() ? 1 : 0,
@@ -637,6 +646,9 @@ export default function MessageBlock(props: MessageBlockProps) {
return cachedBlock.block
}
// Only capture info after cache check fails - ensures fresh data on version bump
const info = untrack(messageInfo)
const { orderedParts } = buildRecordDisplayData(props.instanceId, current)
const items: MessageBlockItem[] = []
const blockContentKeys: string[] = []
@@ -803,19 +815,19 @@ export default function MessageBlock(props: MessageBlockProps) {
data-message-id={resolvedBlock().record.id}
data-delete-message-hover={isDeleteMessageHovered() ? "true" : undefined}
>
<For each={resolvedBlock().items}>
<Index each={resolvedBlock().items}>
{(item, index) => (
<Switch>
<Match when={item.type === "content"}>
<Match when={item().type === "content"}>
<MessageContentItem
instanceId={props.instanceId}
sessionId={props.sessionId}
store={props.store}
messageId={(item as ContentDisplayItem).messageId}
startPartId={(item as ContentDisplayItem).startPartId}
messageId={(item() as ContentDisplayItem).messageId}
startPartId={(item() as ContentDisplayItem).startPartId}
messageIndex={props.messageIndex}
lastAssistantIndex={props.lastAssistantIndex}
showDeleteMessage={index() === 0}
showDeleteMessage={index === 0}
onDeleteHoverChange={props.onDeleteHoverChange}
onRevert={props.onRevert}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
@@ -825,18 +837,18 @@ export default function MessageBlock(props: MessageBlockProps) {
onContentRendered={props.onContentRendered}
/>
</Match>
<Match when={item.type === "tool"}>
<Match when={item().type === "tool"}>
{(() => {
const toolItem = item as ToolDisplayItem
const toolItem = item() as ToolDisplayItem
return (
<div class="tool-call-message" data-key={toolItem.key}>
<ToolCallItem
instanceId={props.instanceId}
sessionId={props.sessionId}
store={props.store}
messageId={toolItem.messageId}
partId={toolItem.partId}
showDeleteMessage={index() === 0}
<ToolCallItem
instanceId={props.instanceId}
sessionId={props.sessionId}
store={props.store}
messageId={toolItem.messageId}
partId={toolItem.partId}
showDeleteMessage={index === 0}
deleteHover={props.deleteHover}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
@@ -849,13 +861,13 @@ export default function MessageBlock(props: MessageBlockProps) {
)
})()}
</Match>
<Match when={item.type === "step-start"}>
<Match when={item().type === "step-start"}>
<StepCard
kind="start"
part={(item as StepDisplayItem).part}
messageInfo={(item as StepDisplayItem).messageInfo}
part={(item() as StepDisplayItem).part}
messageInfo={(item() as StepDisplayItem).messageInfo}
showAgentMeta
showDeleteMessage={index() === 0}
showDeleteMessage={index === 0}
instanceId={props.instanceId}
sessionId={props.sessionId}
messageId={props.messageId}
@@ -865,14 +877,14 @@ export default function MessageBlock(props: MessageBlockProps) {
onToggleSelectedMessage={props.onToggleSelectedMessage}
/>
</Match>
<Match when={item.type === "step-finish"}>
<Match when={item().type === "step-finish"}>
<StepCard
kind="finish"
part={(item as StepDisplayItem).part}
messageInfo={(item as StepDisplayItem).messageInfo}
part={(item() as StepDisplayItem).part}
messageInfo={(item() as StepDisplayItem).messageInfo}
showUsage={props.showUsageMetrics()}
borderColor={(item as StepDisplayItem).accentColor}
showDeleteMessage={index() === 0}
borderColor={(item() as StepDisplayItem).accentColor}
showDeleteMessage={index === 0}
instanceId={props.instanceId}
sessionId={props.sessionId}
messageId={props.messageId}
@@ -882,31 +894,31 @@ export default function MessageBlock(props: MessageBlockProps) {
onToggleSelectedMessage={props.onToggleSelectedMessage}
/>
</Match>
<Match when={item.type === "compaction"}>
<Match when={item().type === "compaction"}>
<CompactionCard
part={(item as CompactionDisplayItem).part}
messageInfo={(item as CompactionDisplayItem).messageInfo}
borderColor={(item as CompactionDisplayItem).accentColor}
part={(item() as CompactionDisplayItem).part}
messageInfo={(item() as CompactionDisplayItem).messageInfo}
borderColor={(item() as CompactionDisplayItem).accentColor}
instanceId={props.instanceId}
sessionId={props.sessionId}
messageId={(item as CompactionDisplayItem).messageId}
showDeleteMessage={index() === 0}
messageId={(item() as CompactionDisplayItem).messageId}
showDeleteMessage={index === 0}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage}
/>
</Match>
<Match when={item.type === "reasoning"}>
<Match when={item().type === "reasoning"}>
<ReasoningCard
part={(item as ReasoningDisplayItem).part}
messageInfo={(item as ReasoningDisplayItem).messageInfo}
part={(item() as ReasoningDisplayItem).part}
messageInfo={(item() as ReasoningDisplayItem).messageInfo}
instanceId={props.instanceId}
sessionId={props.sessionId}
messageId={(item as ReasoningDisplayItem).messageId}
showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta}
defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded}
showDeleteMessage={index() === 0}
messageId={(item() as ReasoningDisplayItem).messageId}
showAgentMeta={(item() as ReasoningDisplayItem).showAgentMeta}
defaultExpanded={(item() as ReasoningDisplayItem).defaultExpanded}
showDeleteMessage={index === 0}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
@@ -916,7 +928,7 @@ export default function MessageBlock(props: MessageBlockProps) {
</Match>
</Switch>
)}
</For>
</Index>
</div>
)}
</Show>
@@ -1098,17 +1110,23 @@ function StepCard(props: StepCardProps) {
return null
}
const info = props.messageInfo
if (!info || info.role !== "assistant" || !info.tokens) {
const part = props.part as any
// step-finish parts have tokens embedded; also check messageInfo
const partTokens = part?.tokens
const infoTokens = info && info.role === "assistant" ? info.tokens : undefined
const tokens = partTokens ?? infoTokens
if (!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,
cost: (part?.cost ?? (info && info.role === "assistant" ? info.cost : 0)) ?? 0,
}
}
@@ -1293,14 +1311,23 @@ interface ReasoningCardProps {
onContentRendered?: () => void
}
function ReasoningCard(props: ReasoningCardProps) {
const { t } = useI18n()
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
const [deletingMessage, setDeletingMessage] = createSignal(false)
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
function ReasoningStreamOutput(props: {
text: Accessor<string>
scrollTopSnapshot: Accessor<number>
setScrollTopSnapshot: (next: number) => void
onContentRendered?: () => void
ariaLabel: string
}) {
let preRef: HTMLPreElement | undefined
let pendingRenderNotificationFrame: number | null = null
const followScroll = createFollowScroll({
getScrollTopSnapshot: props.scrollTopSnapshot,
setScrollTopSnapshot: props.setScrollTopSnapshot,
sentinelMarginPx: REASONING_SCROLL_SENTINEL_MARGIN_PX,
sentinelClassName: "reasoning-scroll-sentinel",
})
const notifyContentRendered = () => {
if (!props.onContentRendered || typeof requestAnimationFrame !== "function") return
if (pendingRenderNotificationFrame !== null) {
@@ -1312,6 +1339,17 @@ function ReasoningCard(props: ReasoningCardProps) {
})
}
createEffect(() => {
const nextText = props.text()
if (preRef && preRef.textContent !== nextText) {
preRef.textContent = nextText
}
if (followScroll.autoScroll()) {
followScroll.restoreAfterRender({ forceBottom: true })
}
notifyContentRendered()
})
onCleanup(() => {
if (pendingRenderNotificationFrame !== null) {
cancelAnimationFrame(pendingRenderNotificationFrame)
@@ -1319,6 +1357,37 @@ function ReasoningCard(props: ReasoningCardProps) {
}
})
return (
<div
ref={followScroll.registerContainer}
class="message-reasoning-output"
role="region"
aria-label={props.ariaLabel}
onScroll={followScroll.handleScroll}
>
<pre
ref={(element) => {
preRef = element || undefined
if (preRef) {
preRef.textContent = props.text() || ""
}
}}
class="message-reasoning-text"
dir="auto"
/>
{followScroll.renderSentinel()}
</div>
)
}
function ReasoningCard(props: ReasoningCardProps) {
const { t } = useI18n()
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
const [deletingMessage, setDeletingMessage] = createSignal(false)
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
const [scrollTopSnapshot, setScrollTopSnapshot] = createSignal(0)
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
createEffect(() => {
setExpanded(Boolean(props.defaultExpanded))
})
@@ -1393,12 +1462,6 @@ function ReasoningCard(props: ReasoningCardProps) {
const canSpeakReasoning = () => reasoningText().trim().length > 0 && speech.canUseSpeech()
createEffect(() => {
if (!expanded()) return
reasoningText()
notifyContentRendered()
})
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
const handleDeleteMessage = async (event: MouseEvent) => {
@@ -1553,9 +1616,13 @@ function ReasoningCard(props: ReasoningCardProps) {
<Show when={expanded()}>
<div class="message-reasoning-expanded">
<div class="message-reasoning-body">
<div class="message-reasoning-output" role="region" aria-label={t("messageBlock.reasoning.detailsAriaLabel")}>
<pre class="message-reasoning-text" dir="auto">{reasoningText() || ""}</pre>
</div>
<ReasoningStreamOutput
text={reasoningText}
scrollTopSnapshot={scrollTopSnapshot}
setScrollTopSnapshot={setScrollTopSnapshot}
onContentRendered={props.onContentRendered}
ariaLabel={t("messageBlock.reasoning.detailsAriaLabel")}
/>
</div>
</div>
</Show>

View File

@@ -2,7 +2,7 @@ import { For, Show, createEffect, createSignal, onCleanup } from "solid-js"
import { Portal } from "solid-js/web"
import { Copy, ListStart, Split, Trash, Undo } from "lucide-solid"
import type { MessageInfo, ClientPart, SDKAssistantMessageV2 } from "../types/message"
import { partHasRenderableText } from "../types/message"
import { isHiddenSyntheticTextPart, partHasRenderableText } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types"
import MessagePart from "./message-part"
import { copyToClipboard } from "../lib/clipboard"
@@ -290,9 +290,9 @@ export default function MessageItem(props: MessageItemProps) {
const getRawContent = () => {
return props.parts
.filter(part => part.type === "text")
.map(part => (part as { text?: string }).text || "")
.filter(text => text.trim().length > 0)
.filter((part) => part.type === "text" && !isHiddenSyntheticTextPart(part))
.map((part) => (part as { text?: string }).text || "")
.filter((text) => text.trim().length > 0)
.join("\n\n")
}
@@ -338,7 +338,7 @@ export default function MessageItem(props: MessageItemProps) {
}
}
if (!isUser() && !hasContent() && !isGenerating()) {
if (!hasContent() && !isGenerating()) {
return null
}

View File

@@ -33,19 +33,7 @@ export default function MessagePart(props: MessagePartProps) {
const shouldHideTextPart = () => {
const part = props.part
if (!part || part.type !== "text") return false
const isSynthetic = Boolean((part as any).synthetic)
if (!isSynthetic) return false
// Keep optimistic user prompts visible; hide other synthetic user helper parts.
if (props.messageType === "user") {
const primaryId = props.primaryUserTextPartId
if (!primaryId) return false
return part.id !== primaryId
}
// Hide synthetic assistant text.
return true
return Boolean((part as any).synthetic)
}

View File

@@ -46,6 +46,33 @@ export default function MessageSection(props: MessageSectionProps) {
const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true
const store = createMemo<InstanceMessageStore>(() => messageStoreBus.getOrCreate(props.instanceId))
const messageIds = createMemo(() => store().getSessionMessageIds(props.sessionId))
const visibleMessageIds = createMemo(() => {
const resolvedStore = store()
return messageIds().filter((messageId) => {
const record = resolvedStore.getMessage(messageId)
if (!record) return false
if (buildTimelineSegments(props.instanceId, record, t).length > 0) {
return true
}
if (record.role !== "assistant") {
return false
}
const info = resolvedStore.getMessageInfo(messageId)
if (!info || info.role !== "assistant") {
return false
}
if (info.error) {
return true
}
const timeInfo = info.time as { created: number; end?: number } | undefined
return Boolean(timeInfo && (timeInfo.end === undefined || timeInfo.end === 0))
})
})
const scrollCache = useScrollCache({
instanceId: props.instanceId,
@@ -129,6 +156,8 @@ export default function MessageSection(props: MessageSectionProps) {
return map
})
const lastAssistantMessageId = createMemo(() => store().getLastAssistantMessageId(props.sessionId))
const lastCompactionIndex = createMemo(() => {
// Depend on a single session revision signal (not every message/part read)
// to keep reactive overhead small.
@@ -315,15 +344,9 @@ export default function MessageSection(props: MessageSectionProps) {
}
const lastAssistantIndex = createMemo(() => {
const ids = messageIds()
const resolvedStore = store()
for (let index = ids.length - 1; index >= 0; index--) {
const record = resolvedStore.getMessage(ids[index])
if (record?.role === "assistant") {
return index
}
}
return -1
const messageId = lastAssistantMessageId()
if (!messageId) return -1
return messageIndexById().get(messageId) ?? -1
})
const [timelineSegments, setTimelineSegments] = createSignal<TimelineSegment[]>([])
@@ -615,7 +638,7 @@ export default function MessageSection(props: MessageSectionProps) {
const api = listApi()
if (!element || !api) return
if (props.loading) return
if (messageIds().length === 0) return
if (visibleMessageIds().length === 0) return
if (didRestoreScroll()) return
scrollCache.restore(element, {
@@ -734,88 +757,93 @@ export default function MessageSection(props: MessageSectionProps) {
const loading = Boolean(props.loading)
const ids = messageIds()
if (loading) {
handleClearTimelineSelection()
previousTimelineIds = []
setTimelineSegments([])
seenTimelineMessageIds.clear()
seenTimelineSegmentKeys.clear()
timelinePartCountsByMessageId.clear()
pendingTimelineMessagePartUpdates.clear()
if (pendingTimelinePartUpdateFrame !== null) {
cancelAnimationFrame(pendingTimelinePartUpdateFrame)
pendingTimelinePartUpdateFrame = null
}
return
}
if (previousTimelineIds.length === 0 && ids.length > 0) {
seedTimeline()
previousTimelineIds = ids.slice()
return
}
if (ids.length < previousTimelineIds.length) {
seedTimeline()
previousTimelineIds = ids.slice()
return
}
if (ids.length === previousTimelineIds.length) {
let changedIndex = -1
let changeCount = 0
for (let index = 0; index < ids.length; index++) {
if (ids[index] !== previousTimelineIds[index]) {
changedIndex = index
changeCount += 1
if (changeCount > 1) break
// Wrap all iteration of the store-proxied `ids` array in untrack()
// to prevent O(n) per-element reactive subscriptions. The effect
// only needs to re-run when `messageIds` (memo) changes.
untrack(() => {
if (loading) {
handleClearTimelineSelection()
previousTimelineIds = []
setTimelineSegments([])
seenTimelineMessageIds.clear()
seenTimelineSegmentKeys.clear()
timelinePartCountsByMessageId.clear()
pendingTimelineMessagePartUpdates.clear()
if (pendingTimelinePartUpdateFrame !== null) {
cancelAnimationFrame(pendingTimelinePartUpdateFrame)
pendingTimelinePartUpdateFrame = null
}
return
}
if (changeCount === 1 && changedIndex >= 0) {
const oldId = previousTimelineIds[changedIndex]
const newId = ids[changedIndex]
if (seenTimelineMessageIds.has(oldId) && !seenTimelineMessageIds.has(newId)) {
seenTimelineMessageIds.delete(oldId)
seenTimelineMessageIds.add(newId)
setTimelineSegments((prev) => {
const next = prev.map((segment) => {
if (segment.messageId !== oldId) return segment
const updatedId = segment.id.replace(oldId, newId)
return { ...segment, messageId: newId, id: updatedId }
})
seenTimelineSegmentKeys.clear()
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
return next
})
// Keep part count tracking in sync with id replacement.
const existingPartCount = timelinePartCountsByMessageId.get(oldId)
if (existingPartCount !== undefined) {
timelinePartCountsByMessageId.delete(oldId)
timelinePartCountsByMessageId.set(newId, existingPartCount)
if (previousTimelineIds.length === 0 && ids.length > 0) {
seedTimeline()
previousTimelineIds = [...ids]
return
}
if (ids.length < previousTimelineIds.length) {
seedTimeline()
previousTimelineIds = [...ids]
return
}
if (ids.length === previousTimelineIds.length) {
let changedIndex = -1
let changeCount = 0
for (let index = 0; index < ids.length; index++) {
if (ids[index] !== previousTimelineIds[index]) {
changedIndex = index
changeCount += 1
if (changeCount > 1) break
}
}
if (changeCount === 1 && changedIndex >= 0) {
const oldId = previousTimelineIds[changedIndex]
const newId = ids[changedIndex]
if (seenTimelineMessageIds.has(oldId) && !seenTimelineMessageIds.has(newId)) {
seenTimelineMessageIds.delete(oldId)
seenTimelineMessageIds.add(newId)
setTimelineSegments((prev) => {
const next = prev.map((segment) => {
if (segment.messageId !== oldId) return segment
const updatedId = segment.id.replace(oldId, newId)
return { ...segment, messageId: newId, id: updatedId }
})
seenTimelineSegmentKeys.clear()
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
return next
})
previousTimelineIds = ids.slice()
return
// Keep part count tracking in sync with id replacement.
const existingPartCount = timelinePartCountsByMessageId.get(oldId)
if (existingPartCount !== undefined) {
timelinePartCountsByMessageId.delete(oldId)
timelinePartCountsByMessageId.set(newId, existingPartCount)
}
previousTimelineIds = [...ids]
return
}
}
}
}
const newIds: string[] = []
ids.forEach((id) => {
if (!seenTimelineMessageIds.has(id)) {
newIds.push(id)
}
})
if (newIds.length > 0) {
newIds.forEach((id) => {
seenTimelineMessageIds.add(id)
appendTimelineForMessage(id)
const newIds: string[] = []
ids.forEach((id) => {
if (!seenTimelineMessageIds.has(id)) {
newIds.push(id)
}
})
}
previousTimelineIds = ids.slice()
if (newIds.length > 0) {
newIds.forEach((id) => {
seenTimelineMessageIds.add(id)
appendTimelineForMessage(id)
})
}
previousTimelineIds = [...ids]
})
})
function clearPendingTimelinePartUpdateFrame() {
@@ -886,36 +914,49 @@ export default function MessageSection(props: MessageSectionProps) {
createEffect(() => {
if (props.loading) return
const ids = messageIds()
const resolvedStore = store()
// Also re-run when sessionRevision bumps (covers part additions within
// existing messages) but read individual records inside untrack() to
// avoid creating O(n) fine-grained subscriptions.
sessionRevision()
let hasChanges = false
for (const messageId of ids) {
const record = resolvedStore.getMessage(messageId)
const partCount = record?.partIds.length ?? 0
const previousCount = timelinePartCountsByMessageId.get(messageId)
// Wrap the iteration in untrack() so that accessing individual elements
// of the store-proxied `ids` array does not create O(n) per-element
// reactive subscriptions. We only need to re-run when the memo
// (messageIds) or sessionRevision changes — not per-element.
untrack(() => {
const resolvedStore = store()
const idsSet = new Set(ids)
let hasChanges = false
if (previousCount === undefined) {
timelinePartCountsByMessageId.set(messageId, partCount)
continue
for (const messageId of ids) {
const record = resolvedStore.getMessage(messageId)
const partCount = record?.partIds.length ?? 0
const previousCount = timelinePartCountsByMessageId.get(messageId)
if (previousCount === undefined) {
timelinePartCountsByMessageId.set(messageId, partCount)
continue
}
if (previousCount !== partCount) {
timelinePartCountsByMessageId.set(messageId, partCount)
pendingTimelineMessagePartUpdates.add(messageId)
hasChanges = true
}
}
if (previousCount !== partCount) {
timelinePartCountsByMessageId.set(messageId, partCount)
pendingTimelineMessagePartUpdates.add(messageId)
hasChanges = true
// Drop tracking for ids that are no longer present.
// Use the Set for O(1) lookups instead of ids.includes() which is O(n).
for (const trackedId of Array.from(timelinePartCountsByMessageId.keys())) {
if (!idsSet.has(trackedId)) {
timelinePartCountsByMessageId.delete(trackedId)
}
}
}
// Drop tracking for ids that are no longer present.
for (const trackedId of Array.from(timelinePartCountsByMessageId.keys())) {
if (!ids.includes(trackedId)) {
timelinePartCountsByMessageId.delete(trackedId)
if (hasChanges) {
scheduleTimelinePartUpdateFlush()
}
}
if (hasChanges) {
scheduleTimelinePartUpdateFlush()
}
})
})
createEffect(() => {
@@ -989,7 +1030,7 @@ export default function MessageSection(props: MessageSectionProps) {
data-scroll-buttons={scrollButtonsCount()}
>
<VirtualFollowList
items={messageIds}
items={visibleMessageIds}
getKey={(messageId) => messageId}
getAnchorId={getMessageAnchorId}
getKeyFromAnchorId={getMessageIdFromAnchorId}
@@ -1035,7 +1076,7 @@ export default function MessageSection(props: MessageSectionProps) {
registerState={(state) => setListState(state)}
renderBeforeItems={() => (
<>
<Show when={!props.loading && messageIds().length === 0}>
<Show when={!props.loading && visibleMessageIds().length === 0}>
<div class="empty-state">
<div class="empty-state-content">
<div class="flex flex-col items-center gap-3 mb-6">

View File

@@ -2,6 +2,7 @@ import { For, Show, createEffect, createMemo, createSignal, onCleanup, on, untra
import MessagePreview from "./message-preview"
import { messageStoreBus } from "../stores/message-v2/bus"
import type { ClientPart } from "../types/message"
import { isHiddenSyntheticTextPart } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types"
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
import { getPartCharCount } from "../lib/token-utils"
@@ -105,6 +106,7 @@ function collectReasoningText(part: ClientPart): string {
function collectTextFromPart(part: ClientPart, t: (key: string, params?: Record<string, unknown>) => string): string {
if (!part) return ""
if (isHiddenSyntheticTextPart(part)) return ""
if (typeof (part as any).text === "string") {
return (part as any).text as string
}

View File

@@ -540,6 +540,10 @@ export default function PromptInput(props: PromptInputProps) {
mode={pickerMode()}
onClose={handlePickerClose}
onSelect={handlePickerSelect}
onSubmitWithoutSelection={() => {
handlePickerClose()
void handleSend()
}}
agents={instanceAgents()}
commands={getCommands(props.instanceId)}
instanceClient={instance()!.client}

View File

@@ -324,28 +324,6 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
const pos = atPosition()
if (pickerMode() === "mention" && pos !== null) {
setIgnoredAtPositions((prev) => new Set(prev).add(pos))
// Remove the partial @mention text from the textarea when ESC is pressed
const textarea = options.getTextarea()
if (textarea) {
const currentPrompt = options.prompt()
const cursorPos = textarea.selectionStart
// Remove text from @ position to cursor position
const before = currentPrompt.substring(0, pos)
const after = currentPrompt.substring(cursorPos)
options.setPrompt(before + after)
// Restore cursor position to where @ was
setTimeout(() => {
const nextTextarea = options.getTextarea()
if (nextTextarea) {
nextTextarea.setSelectionRange(pos, pos)
}
}, 0)
// Clear ignoredAtPositions so typing @ again will work
setIgnoredAtPositions(new Set<number>())
}
}
setShowPicker(false)
setAtPosition(null)

View File

@@ -169,18 +169,25 @@ export function usePromptVoiceInput(options: UsePromptVoiceInputOptions) {
const textarea = options.getTextarea()
const start = textarea ? textarea.selectionStart : current.length
const end = textarea ? textarea.selectionEnd : current.length
const wasCursorAtEnd = end === current.length
const wasScrolledToBottom = textarea
? textarea.scrollHeight - (textarea.scrollTop + textarea.clientHeight) <= 4
: false
const before = current.slice(0, start)
const after = current.slice(end)
const prefix = before.length > 0 && !/\s$/.test(before) ? " " : ""
const suffix = after.length > 0 && !/^\s/.test(after) ? " " : ""
const prefix = ""
const suffix = after.length > 0 && !after.startsWith("\n") ? "\n" : ""
const nextValue = `${before}${prefix}${text}${suffix}${after}`
const cursor = before.length + prefix.length + text.length
const cursor = before.length + prefix.length + text.length + suffix.length
options.setPrompt(nextValue)
if (textarea) {
setTimeout(() => {
textarea.focus()
textarea.setSelectionRange(cursor, cursor)
if (wasCursorAtEnd || wasScrolledToBottom) {
textarea.scrollTop = textarea.scrollHeight
}
}, 0)
}
}

View File

@@ -2,7 +2,7 @@ import { Dialog } from "@kobalte/core/dialog"
import { Switch } from "@kobalte/core/switch"
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
import { toDataURL } from "qrcode"
import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
import { ChevronDown, ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
import type { NetworkAddress, ServerMeta } from "../../../server/src/api-types"
import { serverApi } from "../lib/api-client"
import { restartCli } from "../lib/native/cli"
@@ -10,6 +10,7 @@ import { serverSettings, setListeningMode } from "../stores/preferences"
import { showConfirmDialog } from "../stores/alerts"
import { getLogger } from "../lib/logger"
import { useI18n } from "../lib/i18n"
import { splitRemoteAddresses, type RemoteAddressGroups } from "../lib/remote-access-addresses"
const log = getLogger("actions")
@@ -32,17 +33,17 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
const [passwordConfirm, setPasswordConfirm] = createSignal("")
const [passwordError, setPasswordError] = createSignal<string | null>(null)
const [savingPassword, setSavingPassword] = createSignal(false)
const [showAllAddresses, setShowAllAddresses] = createSignal(false)
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode)
const allowExternalConnections = createMemo(() => currentMode() === "all")
const displayAddresses = createMemo(() => {
const displayAddresses = createMemo<RemoteAddressGroups>(() => {
const list = addresses()
if (!allowExternalConnections()) {
return []
return { recommended: null, hidden: [] }
}
// Local URL is displayed separately; list only remote-friendly addresses.
return list.filter((address) => address.scope !== "loopback")
return splitRemoteAddresses(list)
})
const refreshMeta = async () => {
@@ -53,6 +54,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()])
setMeta(metaResult)
setAuthStatus(authResult)
setShowAllAddresses(false)
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
@@ -326,7 +328,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
<Show when={!loading()} fallback={<div class="remote-card">{t("remoteAccess.addresses.loading")}</div>}>
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
<Show when={displayAddresses().length > 0} fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}>
<Show when={displayAddresses().recommended || meta()?.localUrl} fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}>
<div class="remote-address-list">
<Show when={meta()?.localUrl}>
{(url) => {
@@ -373,8 +375,9 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
)
}}
</Show>
<For each={displayAddresses()}>
{(address) => {
<Show when={displayAddresses().recommended}>
{(addressAccessor) => {
const address = addressAccessor()
const url = address.remoteUrl
const expandedState = () => expandedUrl() === url
const qr = () => qrCodes()[url]
@@ -384,13 +387,14 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
: address.scope === "loopback"
? t("remoteAccess.address.scope.loopback")
: t("remoteAccess.address.scope.internal")
return (
<div class="remote-address">
<div class="remote-address-main">
<div>
<p class="remote-address-url">{url}</p>
<p class="remote-address-meta">
{address.family.toUpperCase()} {scopeLabel()} {address.ip}
{address.family.toUpperCase()} - {scopeLabel()} - {address.ip}
</p>
</div>
<div class="remote-actions">
@@ -425,7 +429,83 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
</div>
)
}}
</For>
</Show>
<Show when={displayAddresses().hidden.length > 0}>
<div class="remote-address-disclosure" data-expanded={showAllAddresses()}>
<button
class="remote-address-disclosure-trigger"
type="button"
onClick={() => setShowAllAddresses(!showAllAddresses())}
aria-expanded={showAllAddresses()}
>
<span class="remote-address-disclosure-label">
{showAllAddresses()
? t("remoteAccess.addresses.actions.hideOther")
: t("remoteAccess.addresses.actions.showOther", { count: String(displayAddresses().hidden.length) })}
</span>
<ChevronDown class={`remote-address-disclosure-chevron ${showAllAddresses() ? "is-expanded" : ""}`} />
</button>
<Show when={showAllAddresses()}>
<div class="remote-address-disclosure-content">
<For each={displayAddresses().hidden}>
{(address) => {
const url = address.remoteUrl
const expandedState = () => expandedUrl() === url
const qr = () => qrCodes()[url]
const scopeLabel = () =>
address.scope === "external"
? t("remoteAccess.address.scope.network")
: address.scope === "loopback"
? t("remoteAccess.address.scope.loopback")
: t("remoteAccess.address.scope.internal")
return (
<div class="remote-address">
<div class="remote-address-main">
<div>
<p class="remote-address-url">{url}</p>
<p class="remote-address-meta">
{address.family.toUpperCase()} {scopeLabel()} {address.ip}
</p>
</div>
<div class="remote-actions">
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(url)}>
<ExternalLink class="remote-icon" />
{t("remoteAccess.address.open")}
</button>
<button
class="remote-pill"
type="button"
onClick={() => void toggleExpanded(url)}
aria-expanded={expandedState()}
>
<Link2 class="remote-icon" />
{expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
</button>
</div>
</div>
<Show when={expandedState()}>
<div class="remote-qr">
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
{(dataUrl) => (
<img
src={dataUrl()}
alt={t("remoteAccess.address.qrAlt", { url })}
class="remote-qr-img"
/>
)}
</Show>
</div>
</Show>
</div>
)
}}
</For>
</div>
</Show>
</div>
</Show>
</div>
</Show>
</Show>

View File

@@ -1,5 +1,5 @@
import { Dialog } from "@kobalte/core/dialog"
import { Settings, Bell, MonitorUp, Paintbrush, Terminal, Volume2, X } from "lucide-solid"
import { Settings, Bell, MonitorUp, Paintbrush, Terminal, Volume2, Globe, X } from "lucide-solid"
import { createMemo, For, type Component } from "solid-js"
import { useI18n } from "../lib/i18n"
import {
@@ -14,6 +14,7 @@ import { NotificationsSettingsSection } from "./settings/notifications-settings-
import { OpenCodeSettingsSection } from "./settings/opencode-settings-section"
import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section"
import { SpeechSettingsSection } from "./settings/speech-settings-section"
import { SideCarsSettingsSection } from "./settings/sidecars-settings-section"
export const SettingsScreen: Component = () => {
const { t } = useI18n()
@@ -23,6 +24,7 @@ export const SettingsScreen: Component = () => {
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
{ id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") },
{ id: "speech" as SettingsSectionId, icon: Volume2, label: t("settings.nav.speech") },
{ id: "sidecars" as SettingsSectionId, icon: Globe, label: t("settings.nav.sidecars") },
{ id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") },
])
@@ -34,6 +36,8 @@ export const SettingsScreen: Component = () => {
return <RemoteAccessSettingsSection />
case "speech":
return <SpeechSettingsSection />
case "sidecars":
return <SideCarsSettingsSection />
case "opencode":
return <OpenCodeSettingsSection />
case "appearance":

View File

@@ -1,14 +1,30 @@
import { createEffect, createSignal, type Component } from "solid-js"
import { Terminal } from "lucide-solid"
import { Select } from "@kobalte/core/select"
import { createEffect, createMemo, createSignal, type Component } from "solid-js"
import { ChevronDown, Terminal } from "lucide-solid"
import OpenCodeBinarySelector from "../opencode-binary-selector"
import EnvironmentVariablesEditor from "../environment-variables-editor"
import { useConfig } from "../../stores/preferences"
import type { ServerLogLevel } from "../../stores/preferences"
import { useI18n } from "../../lib/i18n"
type LogLevelOption = {
value: ServerLogLevel
label: string
}
export const OpenCodeSettingsSection: Component = () => {
const { t } = useI18n()
const { serverSettings, updateLastUsedBinary } = useConfig()
const { serverSettings, updateLastUsedBinary, updateLogLevel } = useConfig()
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode")
const logLevelOptions = createMemo<LogLevelOption[]>(() => [
{ value: "DEBUG", label: t("settings.opencode.logLevel.option.debug") },
{ value: "INFO", label: t("settings.opencode.logLevel.option.info") },
{ value: "WARN", label: t("settings.opencode.logLevel.option.warn") },
{ value: "ERROR", label: t("settings.opencode.logLevel.option.error") },
])
const selectedLogLevel = createMemo(
() => logLevelOptions().find((option) => option.value === serverSettings().logLevel) ?? logLevelOptions()[0],
)
createEffect(() => {
const binary = serverSettings().opencodeBinary || "opencode"
@@ -37,6 +53,60 @@ export const OpenCodeSettingsSection: Component = () => {
<OpenCodeBinarySelector selectedBinary={selectedBinary()} onBinaryChange={handleBinaryChange} isVisible />
</div>
<div class="settings-card">
<div class="settings-card-header">
<div>
<h3 class="settings-card-title">{t("settings.opencode.logLevel.title")}</h3>
<p class="settings-card-subtitle">{t("settings.opencode.logLevel.subtitle")}</p>
</div>
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
</div>
<div class="settings-card-body">
<div class="settings-toggle-row settings-toggle-row-compact">
<div>
<div class="settings-toggle-title">{t("settings.opencode.logLevel.selector.title")}</div>
<div class="settings-toggle-caption">{t("settings.opencode.logLevel.selector.subtitle")}</div>
</div>
<Select<LogLevelOption>
value={selectedLogLevel()}
onChange={(option) => {
if (!option) return
updateLogLevel(option.value)
}}
options={logLevelOptions()}
optionValue="value"
optionTextValue="label"
itemComponent={(itemProps) => (
<Select.Item item={itemProps.item} class="selector-option">
<Select.ItemLabel class="selector-option-label">{itemProps.item.rawValue.label}</Select.ItemLabel>
</Select.Item>
)}
>
<Select.Trigger class="selector-trigger" aria-label={t("settings.opencode.logLevel.title")}>
<div class="flex-1 min-w-0">
<Select.Value<LogLevelOption>>
{(state) => (
<span class="selector-trigger-primary selector-trigger-primary--align-left">
{state.selectedOption()?.label}
</span>
)}
</Select.Value>
</div>
<Select.Icon class="selector-trigger-icon">
<ChevronDown class="w-3 h-3" />
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content class="selector-popover">
<Select.Listbox class="selector-listbox" />
</Select.Content>
</Select.Portal>
</Select>
</div>
</div>
</div>
<div class="settings-card">
<div class="settings-card-header">
<div>

View File

@@ -1,7 +1,7 @@
import { Switch } from "@kobalte/core/switch"
import { For, Show, createMemo, createSignal, type Component, onMount } from "solid-js"
import { toDataURL } from "qrcode"
import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
import { ChevronDown, ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
import type { NetworkAddress, ServerMeta } from "../../../../server/src/api-types"
import { serverApi } from "../../lib/api-client"
import { restartCli } from "../../lib/native/cli"
@@ -9,6 +9,7 @@ import { serverSettings, setListeningMode } from "../../stores/preferences"
import { showConfirmDialog } from "../../stores/alerts"
import { getLogger } from "../../lib/logger"
import { useI18n } from "../../lib/i18n"
import { splitRemoteAddresses, type RemoteAddressGroups } from "../../lib/remote-access-addresses"
const log = getLogger("actions")
@@ -30,14 +31,15 @@ export const RemoteAccessSettingsSection: Component = () => {
const [passwordConfirm, setPasswordConfirm] = createSignal("")
const [passwordError, setPasswordError] = createSignal<string | null>(null)
const [savingPassword, setSavingPassword] = createSignal(false)
const [showAllAddresses, setShowAllAddresses] = createSignal(false)
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode)
const allowExternalConnections = createMemo(() => currentMode() === "all")
const displayAddresses = createMemo(() => {
const displayAddresses = createMemo<RemoteAddressGroups>(() => {
const list = addresses()
if (!allowExternalConnections()) return []
return list.filter((address) => address.scope !== "loopback")
if (!allowExternalConnections()) return { recommended: null, hidden: [] }
return splitRemoteAddresses(list)
})
const refreshMeta = async () => {
@@ -48,6 +50,7 @@ export const RemoteAccessSettingsSection: Component = () => {
const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()])
setMeta(metaResult)
setAuthStatus(authResult)
setShowAllAddresses(false)
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
@@ -218,31 +221,35 @@ export const RemoteAccessSettingsSection: Component = () => {
fallback={<div class="settings-card-message">{t("remoteAccess.authStatus.unavailable")}</div>}
>
<div class="settings-card-content">
<p class="settings-help-text">{t("remoteAccess.username", { username: authStatus()!.username ?? "codenomad" })}</p>
<p class="settings-help-text">
{authStatus()!.passwordUserProvided
? t("remoteAccess.password.status.set")
: t("remoteAccess.password.status.unset")}
</p>
<div class="settings-password-summary-row">
<div class="settings-password-summary-copy">
<p class="settings-help-text">{t("remoteAccess.username", { username: authStatus()!.username ?? "codenomad" })}</p>
<p class="settings-help-text">
{authStatus()!.passwordUserProvided
? t("remoteAccess.password.status.set")
: t("remoteAccess.password.status.unset")}
</p>
</div>
<div class="settings-password-actions">
<button
class="settings-pill-button"
type="button"
onClick={() => {
setPasswordFormOpen(!passwordFormOpen())
setPasswordError(null)
}}
>
{passwordFormOpen()
? t("remoteAccess.password.actions.cancel")
: authStatus()!.passwordUserProvided
? t("remoteAccess.password.actions.change")
: t("remoteAccess.password.actions.set")}
</button>
<div class="settings-password-actions">
<button
class="settings-pill-button"
type="button"
onClick={() => {
setPasswordFormOpen(!passwordFormOpen())
setPasswordError(null)
}}
>
{passwordFormOpen()
? t("remoteAccess.password.actions.cancel")
: authStatus()!.passwordUserProvided
? t("remoteAccess.password.actions.change")
: t("remoteAccess.password.actions.set")}
</button>
</div>
</div>
<Show when={passwordFormOpen()}>
<Show when={passwordFormOpen()}>
<div class="settings-form-group">
<label class="settings-form-label">{t("remoteAccess.password.form.newPassword")}</label>
<input
@@ -292,7 +299,7 @@ export const RemoteAccessSettingsSection: Component = () => {
<Show when={!loading()} fallback={<div class="remote-card">{t("remoteAccess.addresses.loading")}</div>}>
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
<Show
when={displayAddresses().length > 0 || meta()?.localUrl}
when={Boolean(displayAddresses().recommended) || meta()?.localUrl}
fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}
>
<div class="remote-address-list">
@@ -342,8 +349,9 @@ export const RemoteAccessSettingsSection: Component = () => {
}}
</Show>
<For each={displayAddresses()}>
{(address) => {
<Show when={displayAddresses().recommended}>
{(addressAccessor) => {
const address = addressAccessor()
const url = address.remoteUrl
const expandedState = () => expandedUrl() === url
const qr = () => qrCodes()[url]
@@ -383,7 +391,11 @@ export const RemoteAccessSettingsSection: Component = () => {
<div class="remote-qr">
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
{(dataUrl) => (
<img src={dataUrl()} alt={t("remoteAccess.address.qrAlt", { url })} class="remote-qr-img" />
<img
src={dataUrl()}
alt={t("remoteAccess.address.qrAlt", { url })}
class="remote-qr-img"
/>
)}
</Show>
</div>
@@ -391,7 +403,80 @@ export const RemoteAccessSettingsSection: Component = () => {
</div>
)
}}
</For>
</Show>
<Show when={displayAddresses().hidden.length > 0}>
<div class="remote-address-disclosure" data-expanded={showAllAddresses()}>
<button
class="remote-address-disclosure-trigger"
type="button"
onClick={() => setShowAllAddresses(!showAllAddresses())}
aria-expanded={showAllAddresses()}
>
<span class="remote-address-disclosure-label">
{showAllAddresses()
? t("remoteAccess.addresses.actions.hideOther")
: t("remoteAccess.addresses.actions.showOther", { count: String(displayAddresses().hidden.length) })}
</span>
<ChevronDown class={`remote-address-disclosure-chevron ${showAllAddresses() ? "is-expanded" : ""}`} />
</button>
<Show when={showAllAddresses()}>
<div class="remote-address-disclosure-content">
<For each={displayAddresses().hidden}>
{(address) => {
const url = address.remoteUrl
const expandedState = () => expandedUrl() === url
const qr = () => qrCodes()[url]
const scopeLabel = () =>
address.scope === "external"
? t("remoteAccess.address.scope.network")
: address.scope === "loopback"
? t("remoteAccess.address.scope.loopback")
: t("remoteAccess.address.scope.internal")
return (
<div class="remote-address">
<div class="remote-address-main">
<div>
<p class="remote-address-url">{url}</p>
<p class="remote-address-meta">
{address.family.toUpperCase()} - {scopeLabel()} - {address.ip}
</p>
</div>
<div class="remote-actions">
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(url)}>
<ExternalLink class="remote-icon" />
{t("remoteAccess.address.open")}
</button>
<button
class="remote-pill"
type="button"
onClick={() => void toggleExpanded(url)}
aria-expanded={expandedState()}
>
<Link2 class="remote-icon" />
{expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
</button>
</div>
</div>
<Show when={expandedState()}>
<div class="remote-qr">
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
{(dataUrl) => (
<img src={dataUrl()} alt={t("remoteAccess.address.qrAlt", { url })} class="remote-qr-img" />
)}
</Show>
</div>
</Show>
</div>
)
}}
</For>
</div>
</Show>
</div>
</Show>
</div>
</Show>
</Show>

View File

@@ -0,0 +1,201 @@
import { createMemo, createSignal, For, Show, onMount, type Component } from "solid-js"
import { Globe, Loader2, Plus, Trash2 } from "lucide-solid"
import { useI18n } from "../../lib/i18n"
import { serverApi } from "../../lib/api-client"
import { ensureSidecarsLoaded, sidecars, sidecarsLoading } from "../../stores/sidecars"
function deriveSidecarId(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/-{2,}/g, "-")
.replace(/^-|-$/g, "")
}
export const SideCarsSettingsSection: Component = () => {
const { t } = useI18n()
const [name, setName] = createSignal("")
const [port, setPort] = createSignal("3000")
const [insecure, setInsecure] = createSignal(false)
const [prefixMode, setPrefixMode] = createSignal<"strip" | "preserve">("strip")
const [busyId, setBusyId] = createSignal<string | null>(null)
const [creating, setCreating] = createSignal(false)
const [formError, setFormError] = createSignal<string | null>(null)
const [actionError, setActionError] = createSignal<string | null>(null)
onMount(() => {
void ensureSidecarsLoaded()
})
const orderedSidecars = createMemo(() => Array.from(sidecars().values()).sort((a, b) => a.name.localeCompare(b.name)))
const derivedId = createMemo(() => deriveSidecarId(name()) || "your-sidecar")
async function handleCreate() {
const trimmedName = name().trim()
const nextPort = Number(port())
if (!trimmedName || !Number.isInteger(nextPort) || nextPort <= 0 || nextPort > 65535) {
setFormError(t("sidecars.form.validation"))
return
}
setCreating(true)
setFormError(null)
try {
await serverApi.createSidecar({
kind: "port",
name: trimmedName,
port: nextPort,
insecure: insecure(),
prefixMode: prefixMode(),
})
setName("")
setPort("3000")
setInsecure(false)
setPrefixMode("strip")
} catch (error) {
setFormError(error instanceof Error ? error.message : String(error))
} finally {
setCreating(false)
}
}
async function handleDelete(id: string) {
setBusyId(id)
setActionError(null)
try {
await serverApi.deleteSidecar(id)
} catch (error) {
setActionError(error instanceof Error ? error.message : String(error))
} finally {
setBusyId(null)
}
}
return (
<div class="settings-section-stack">
<div class="settings-card">
<div class="settings-card-header">
<div class="settings-card-heading-with-icon">
<Globe class="settings-card-heading-icon" />
<div>
<h3 class="settings-card-title">{t("settings.section.sidecars.title")}</h3>
<p class="settings-card-subtitle">{t("settings.section.sidecars.subtitle")}</p>
</div>
</div>
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
</div>
<div class="settings-card-content">
<div class="settings-toggle-row settings-toggle-row-compact">
<div>
<div class="settings-toggle-title">{t("sidecars.form.name")}</div>
<div class="settings-toggle-caption">{t("sidecars.basePath")}: <code>/sidecars/{derivedId()}</code></div>
</div>
<input
class="selector-input w-full max-w-xs"
value={name()}
onInput={(event) => {
setFormError(null)
setName(event.currentTarget.value)
}}
/>
</div>
<div class="settings-toggle-row settings-toggle-row-compact">
<div>
<div class="settings-toggle-title">{t("sidecars.form.port")}</div>
<div class="settings-toggle-caption">127.0.0.1</div>
</div>
<input
class="selector-input w-full max-w-xs"
value={port()}
onInput={(event) => {
setFormError(null)
setPort(event.currentTarget.value)
}}
inputMode="numeric"
/>
</div>
<div class="settings-toggle-row settings-toggle-row-compact">
<div>
<div class="settings-toggle-title">{t("sidecars.form.protocol")}</div>
<div class="settings-toggle-caption">{t("sidecars.form.protocol.help")}</div>
</div>
<select class="selector-input w-full max-w-xs" value={insecure() ? "http" : "https"} onChange={(event) => setInsecure(event.currentTarget.value === "http") }>
<option value="https">{t("sidecars.form.protocol.https")}</option>
<option value="http">{t("sidecars.form.protocol.http")}</option>
</select>
</div>
<div class="settings-toggle-row settings-toggle-row-compact">
<div>
<div class="settings-toggle-title">{t("sidecars.form.prefixMode")}</div>
<div class="settings-toggle-caption">{t("sidecars.form.prefixMode.help")}</div>
</div>
<select class="selector-input w-full max-w-xs" value={prefixMode()} onChange={(event) => setPrefixMode(event.currentTarget.value as "strip" | "preserve") }>
<option value="strip">{t("sidecars.form.prefixMode.strip")}</option>
<option value="preserve">{t("sidecars.form.prefixMode.preserve")}</option>
</select>
</div>
<Show when={formError()}>
<div class="text-sm text-red-500">{formError()}</div>
</Show>
<div class="flex justify-end">
<button type="button" class="selector-button selector-button-primary" disabled={creating()} onClick={() => void handleCreate()}>
<Show when={creating()} fallback={<Plus class="w-4 h-4" />}>
<Loader2 class="w-4 h-4 animate-spin" />
</Show>
<span>{t("sidecars.form.add")}</span>
</button>
</div>
</div>
</div>
<div class="settings-card">
<div class="settings-card-header">
<div>
<h3 class="settings-card-title">{t("sidecars.settings.listTitle")}</h3>
<p class="settings-card-subtitle">{t("sidecars.settings.listSubtitle")}</p>
</div>
</div>
<div class="settings-card-content">
<Show when={actionError()}>
<div class="text-sm text-red-500">{actionError()}</div>
</Show>
<Show when={!sidecarsLoading()} fallback={<div class="settings-card-message">{t("sidecars.picker.loading")}</div>}>
<Show when={orderedSidecars().length > 0} fallback={<div class="settings-card-message">{t("sidecars.settings.empty")}</div>}>
<For each={orderedSidecars()}>
{(sidecar) => (
<div class="settings-toggle-row settings-toggle-row-compact">
<div>
<div class="settings-toggle-title">{sidecar.name}</div>
<div class="settings-toggle-caption">
{t("sidecars.kind.port")} · {sidecar.insecure ? "http" : "https"}://127.0.0.1:{sidecar.port}
</div>
<div class="settings-toggle-caption">
{t("sidecars.basePath")}: <code>/sidecars/{sidecar.id}</code> · {t(`sidecars.form.prefixMode.${sidecar.prefixMode}`)}
</div>
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-secondary min-w-[4.5rem] text-right">{t(`sidecars.status.${sidecar.status}`)}</span>
<button type="button" class="selector-button selector-button-secondary" disabled={busyId() === sidecar.id} onClick={() => void handleDelete(sidecar.id)}>
<Trash2 class="w-4 h-4" />
</button>
</div>
</div>
)}
</For>
</Show>
</Show>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,82 @@
import { Dialog } from "@kobalte/core/dialog"
import { For, Show, createEffect, createMemo, type Component } from "solid-js"
import { Globe, Square } from "lucide-solid"
import { useI18n } from "../lib/i18n"
import { ensureSidecarsLoaded, sidecars, sidecarsLoading } from "../stores/sidecars"
interface SideCarPickerDialogProps {
open: boolean
onClose: () => void
onOpenSidecar: (sidecarId: string) => void | Promise<void>
}
export const SideCarPickerDialog: Component<SideCarPickerDialogProps> = (props) => {
const { t } = useI18n()
const orderedSidecars = createMemo(() => Array.from(sidecars().values()).sort((a, b) => a.name.localeCompare(b.name)))
createEffect(() => {
if (props.open) {
void ensureSidecarsLoaded()
}
})
return (
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()}>
<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-2xl p-6 flex flex-col gap-4 max-h-[80vh] overflow-hidden">
<div>
<Dialog.Title class="text-xl font-semibold text-primary">{t("sidecars.picker.title")}</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-2">
{t("sidecars.picker.subtitle")}
</Dialog.Description>
</div>
<div class="flex-1 overflow-auto flex flex-col gap-3">
<Show when={!sidecarsLoading()} fallback={<div class="panel panel-empty-state">{t("sidecars.picker.loading")}</div>}>
<Show when={orderedSidecars().length > 0} fallback={<div class="panel panel-empty-state">{t("sidecars.picker.empty")}</div>}>
<For each={orderedSidecars()}>
{(sidecar) => (
<button
type="button"
class="panel-list-item panel-list-item-content text-left disabled:cursor-not-allowed disabled:opacity-60"
disabled={sidecar.status !== "running"}
onClick={() => void props.onOpenSidecar(sidecar.id)}
>
<div class="flex items-center justify-between gap-4 w-full">
<div class="flex items-center gap-3 min-w-0">
<span class="panel-empty-state-icon !w-10 !h-10">
<Globe class="w-5 h-5" />
</span>
<div class="min-w-0">
<div class="text-sm font-medium text-primary truncate">{sidecar.name}</div>
<div class="text-xs text-muted">
{t("sidecars.kind.port")} - {sidecar.insecure ? "http" : "https"}://127.0.0.1:{sidecar.port}
</div>
<div class="text-xs text-muted mt-1">{t("sidecars.basePath")}: <code>/sidecars/{sidecar.id}</code></div>
</div>
</div>
<div class="text-xs text-secondary flex items-center gap-2">
<Square class="w-4 h-4" />
<span>{t(`sidecars.status.${sidecar.status}`)}</span>
</div>
</div>
</button>
)}
</For>
</Show>
</Show>
</div>
<div class="flex justify-end">
<button type="button" class="selector-button selector-button-secondary" onClick={props.onClose}>
{t("sidecars.picker.close")}
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
)
}

View File

@@ -0,0 +1,197 @@
import { ArrowLeft, ArrowRight, RefreshCw } from "lucide-solid"
import { createEffect, createMemo, createSignal, type Component } from "solid-js"
import type { SideCarTabRecord } from "../stores/sidecars"
import { useI18n } from "../lib/i18n"
interface SideCarViewProps {
tab: SideCarTabRecord
}
export const SideCarView: Component<SideCarViewProps> = (props) => {
const { t } = useI18n()
const [frameSrc, setFrameSrc] = createSignal(props.tab.shellUrl)
const [pathInput, setPathInput] = createSignal("/")
let iframeRef: HTMLIFrameElement | undefined
const lockedBaseLabel = createMemo(() => {
const hostLabel = props.tab.port ? `${props.tab.name}:${props.tab.port}` : props.tab.name
if (props.tab.prefixMode === "preserve") {
return `${hostLabel}${props.tab.proxyBasePath}`
}
return hostLabel
})
const getEditablePathFromUrl = (url: string): string => {
try {
const parsed = new URL(url, window.location.origin)
const basePath = props.tab.proxyBasePath
let pathname = parsed.pathname
if (basePath && pathname.startsWith(basePath)) {
pathname = pathname.slice(basePath.length) || "/"
}
if (!pathname.startsWith("/")) {
pathname = `/${pathname}`
}
return `${pathname}${parsed.search}${parsed.hash}`
} catch {
return "/"
}
}
const buildNormalizedTargetUrl = (rawInput: string): string => {
const trimmed = rawInput.trim()
const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`
const parsed = new URL(withLeadingSlash || "/", window.location.origin)
const safeSegments: string[] = []
for (const segment of parsed.pathname.split("/")) {
if (!segment || segment === ".") {
continue
}
if (segment === "..") {
if (safeSegments.length > 0) {
safeSegments.pop()
}
continue
}
safeSegments.push(segment)
}
const normalizedPath = `/${safeSegments.join("/")}` || "/"
const basePath = props.tab.proxyBasePath
return `${basePath}${normalizedPath}${parsed.search}${parsed.hash}`
}
const syncPathInputFromFrame = () => {
try {
const currentHref = iframeRef?.contentWindow?.location.href
if (!currentHref) {
return
}
setPathInput(getEditablePathFromUrl(currentHref))
} catch {
setPathInput(getEditablePathFromUrl(frameSrc()))
}
}
createEffect(() => {
setFrameSrc(props.tab.shellUrl)
setPathInput(getEditablePathFromUrl(props.tab.shellUrl))
})
const handleBack = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
try {
const frameWindow = iframeRef?.contentWindow
if (!frameWindow) {
return
}
if (frameWindow.history.length <= 1) {
return
}
frameWindow.focus()
frameWindow.history.go(-1)
} catch {
// Ignore navigation errors from pages that do not expose history access.
}
}
const handleRefresh = () => {
try {
iframeRef?.contentWindow?.location.reload()
return
} catch {
// Fall back to resetting the iframe source if the frame cannot be reloaded directly.
}
setFrameSrc("about:blank")
requestAnimationFrame(() => setFrameSrc(props.tab.shellUrl))
}
const handleGo = (event?: Event) => {
event?.preventDefault()
const nextUrl = buildNormalizedTargetUrl(pathInput())
setFrameSrc(nextUrl)
setPathInput(getEditablePathFromUrl(nextUrl))
}
return (
<div class="flex h-full min-h-0 w-full flex-col bg-surface">
<div
class="flex shrink-0 items-center gap-2 px-3 py-2"
style={{ "border-bottom": "1px solid var(--border-base)" }}
>
<button
type="button"
class="new-tab-button"
onClick={handleBack}
title={t("sidecars.back")}
aria-label={t("sidecars.back")}
>
<ArrowLeft class="h-4 w-4" />
</button>
<button
type="button"
class="new-tab-button"
onClick={handleRefresh}
title={t("sidecars.refresh")}
aria-label={t("sidecars.refresh")}
>
<RefreshCw class="h-4 w-4" />
</button>
<div
class="shrink-0 rounded-md px-3 py-1.5 text-sm"
style={{
background: "var(--surface-secondary)",
color: "var(--text-secondary)",
border: "1px solid var(--border-base)",
}}
>
{lockedBaseLabel()}
</div>
<form class="flex min-w-0 flex-1 items-center gap-2" onSubmit={(event) => handleGo(event)}>
<input
type="text"
class="min-w-0 flex-1 rounded-md px-3 py-1.5 text-sm outline-none"
style={{
background: "var(--surface-secondary)",
color: "var(--text-primary)",
border: "1px solid var(--border-base)",
}}
value={pathInput()}
onInput={(event) => setPathInput(event.currentTarget.value)}
spellcheck={false}
autocomplete="off"
autocorrect="off"
autocapitalize="off"
aria-label={t("sidecars.path")}
/>
<button
type="submit"
class="new-tab-button"
title={t("sidecars.go")}
aria-label={t("sidecars.go")}
>
<ArrowRight class="h-4 w-4" />
</button>
</form>
</div>
<iframe
ref={iframeRef}
src={frameSrc()}
title={props.tab.name}
class="min-h-0 flex-1 w-full border-0 bg-surface"
referrerPolicy="same-origin"
onLoad={syncPathInputFromFrame}
/>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { createSignal, Show, createEffect, createMemo, onCleanup } from "solid-js"
import { createSignal, Show, createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
import { ArrowRightSquare, Check, Copy, Hourglass, Loader2, XCircle } from "lucide-solid"
import { stringify as stringifyYaml } from "yaml"
import { messageStoreBus } from "../stores/message-v2/bus"
@@ -44,6 +44,7 @@ import { resolveTitleForTool } from "./tool-call/tool-title"
import { getLogger } from "../lib/logger"
import { useSpeech } from "../lib/hooks/use-speech"
import SpeechActionButton from "./speech-action-button"
import { createFollowScroll } from "../lib/follow-scroll"
const log = getLogger("session")
@@ -51,8 +52,6 @@ type ToolState = import("@opencode-ai/sdk/v2").ToolState
const TOOL_CALL_CACHE_SCOPE = "tool-call"
const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48
const TOOL_SCROLL_INTENT_WINDOW_MS = 600
const TOOL_SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
function makeRenderCacheKey(
toolCallId?: string | null,
@@ -82,6 +81,27 @@ interface ToolCallProps {
forceCollapsed?: boolean
}
function ToolStatusIndicator(props: { status: Accessor<string> }) {
const isVisible = (value: string) => props.status() === value
return (
<span class="tool-call-header-status" aria-hidden="true" data-status={props.status() || "pending"}>
<span style={{ display: isVisible("pending") ? "inline-flex" : "none" }}>
<Hourglass class="w-4 h-4" />
</span>
<span style={{ display: isVisible("running") ? "inline-flex" : "none" }}>
<Loader2 class="w-4 h-4 animate-spin" />
</span>
<span style={{ display: isVisible("completed") ? "inline-flex" : "none" }}>
<Check class="w-4 h-4" />
</span>
<span style={{ display: isVisible("error") ? "inline-flex" : "none" }}>
<XCircle class="w-4 h-4" />
</span>
</span>
)
}
function ToolCallDetails(props: {
toolCallMemo: () => ToolCallPart
toolState: () => ToolState | undefined
@@ -166,179 +186,25 @@ function ToolCallDetails(props: {
const [permissionSubmitting, setPermissionSubmitting] = createSignal(false)
const [permissionError, setPermissionError] = createSignal<string | null>(null)
const [scrollContainer, setScrollContainer] = createSignal<HTMLDivElement | undefined>()
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null)
const [autoScroll, setAutoScroll] = createSignal(true)
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true)
let scrollContainerRef: HTMLDivElement | undefined
let detachScrollIntentListeners: (() => void) | undefined
let pendingScrollFrame: number | null = null
let pendingAnchorScroll: number | null = null
let userScrollIntentUntil = 0
let lastKnownScrollTop = props.scrollTopSnapshot()
function restoreScrollPosition(forceBottom = false) {
const container = scrollContainerRef
if (!container) return
if (forceBottom) {
container.scrollTop = container.scrollHeight
lastKnownScrollTop = container.scrollTop
props.setScrollTopSnapshot(lastKnownScrollTop)
} else {
container.scrollTop = lastKnownScrollTop
}
}
const persistScrollSnapshot = (element?: HTMLElement | null) => {
if (!element) return
lastKnownScrollTop = element.scrollTop
props.setScrollTopSnapshot(lastKnownScrollTop)
}
function markUserScrollIntent() {
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
userScrollIntentUntil = now + TOOL_SCROLL_INTENT_WINDOW_MS
}
function hasUserScrollIntent() {
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
return now <= userScrollIntentUntil
}
function attachScrollIntentListeners(element: HTMLDivElement) {
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
detachScrollIntentListeners = undefined
}
const handlePointerIntent = () => markUserScrollIntent()
const handleKeyIntent = (event: KeyboardEvent) => {
if (TOOL_SCROLL_INTENT_KEYS.has(event.key)) {
markUserScrollIntent()
}
}
element.addEventListener("wheel", handlePointerIntent, { passive: true })
element.addEventListener("pointerdown", handlePointerIntent)
element.addEventListener("touchstart", handlePointerIntent, { passive: true })
element.addEventListener("keydown", handleKeyIntent)
detachScrollIntentListeners = () => {
element.removeEventListener("wheel", handlePointerIntent)
element.removeEventListener("pointerdown", handlePointerIntent)
element.removeEventListener("touchstart", handlePointerIntent)
element.removeEventListener("keydown", handleKeyIntent)
}
}
function scheduleAnchorScroll(immediate = false) {
if (!autoScroll()) return
const sentinel = bottomSentinel()
const container = scrollContainerRef
if (!sentinel || !container) return
if (pendingAnchorScroll !== null) {
cancelAnimationFrame(pendingAnchorScroll)
pendingAnchorScroll = null
}
pendingAnchorScroll = requestAnimationFrame(() => {
pendingAnchorScroll = null
const containerRect = container.getBoundingClientRect()
const sentinelRect = sentinel.getBoundingClientRect()
const delta = sentinelRect.bottom - containerRect.bottom + TOOL_SCROLL_SENTINEL_MARGIN_PX
if (Math.abs(delta) > 1) {
container.scrollBy({ top: delta, behavior: immediate ? "auto" : "smooth" })
}
lastKnownScrollTop = container.scrollTop
props.setScrollTopSnapshot(lastKnownScrollTop)
})
}
function handleScroll() {
const container = scrollContainer()
if (!container) return
if (pendingScrollFrame !== null) {
cancelAnimationFrame(pendingScrollFrame)
}
const isUserScroll = hasUserScrollIntent()
pendingScrollFrame = requestAnimationFrame(() => {
pendingScrollFrame = null
const atBottom = bottomSentinelVisible()
if (isUserScroll) {
if (atBottom) {
if (!autoScroll()) setAutoScroll(true)
} else if (autoScroll()) {
setAutoScroll(false)
}
}
})
}
const handleScrollEvent = (event: Event & { currentTarget: HTMLDivElement }) => {
handleScroll()
persistScrollSnapshot(event.currentTarget)
}
const handleScrollRendered = () => {
requestAnimationFrame(() => {
restoreScrollPosition(autoScroll())
scheduleAnchorScroll(true)
})
}
const initializeScrollContainer = (element: HTMLDivElement | null | undefined) => {
const next = element || undefined
if (next === scrollContainerRef) {
return
}
scrollContainerRef = next
setScrollContainer(scrollContainerRef)
if (scrollContainerRef) {
// Refresh our snapshot on mount (e.g. when remounting after collapse)
lastKnownScrollTop = props.scrollTopSnapshot()
restoreScrollPosition(autoScroll())
}
}
const followScroll = createFollowScroll({
getScrollTopSnapshot: props.scrollTopSnapshot,
setScrollTopSnapshot: props.setScrollTopSnapshot,
sentinelMarginPx: TOOL_SCROLL_SENTINEL_MARGIN_PX,
sentinelClassName: "tool-call-scroll-sentinel",
})
const scrollHelpers: ToolScrollHelpers = {
registerContainer: (element, options) => {
if (options?.disableTracking) return
initializeScrollContainer(element)
},
handleScroll: handleScrollEvent,
renderSentinel: (options) => {
if (options?.disableTracking) return null
return <div ref={setBottomSentinel} aria-hidden="true" class="tool-call-scroll-sentinel" style={{ height: "1px" }} />
followScroll.registerContainer(element, options)
},
handleScroll: followScroll.handleScroll,
renderSentinel: followScroll.renderSentinel,
restoreAfterRender: followScroll.restoreAfterRender,
}
createEffect(() => {
const container = scrollContainer()
if (!container) return
attachScrollIntentListeners(container)
onCleanup(() => {
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
detachScrollIntentListeners = undefined
}
})
})
createEffect(() => {
const container = scrollContainer()
const sentinel = bottomSentinel()
if (!container || !sentinel) return
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.target === sentinel) {
setBottomSentinelVisible(entry.isIntersecting)
}
})
},
{ root: container, threshold: 0, rootMargin: `0px 0px ${TOOL_SCROLL_SENTINEL_MARGIN_PX}px 0px` },
)
observer.observe(sentinel)
onCleanup(() => observer.disconnect())
})
const handleScrollRendered = () => {
scrollHelpers.restoreAfterRender()
}
createEffect(() => {
const permission = permissionDetails()
@@ -564,11 +430,13 @@ function ToolCallDetails(props: {
partVersion={options.partVersion}
instanceId={props.instanceId}
sessionId={options.sessionId}
onContentRendered={props.onContentRendered}
forceCollapsed={options.forceCollapsed}
/>
)
},
scrollHelpers,
onContentRendered: props.onContentRendered,
}
let previousPartVersion: number | undefined
@@ -581,12 +449,12 @@ function ToolCallDetails(props: {
return
}
previousPartVersion = version
scheduleAnchorScroll(true)
scrollHelpers.restoreAfterRender()
})
createEffect(() => {
if (autoScroll()) {
scheduleAnchorScroll(true)
if (followScroll.autoScroll()) {
scrollHelpers.restoreAfterRender({ forceBottom: true })
}
})
@@ -634,21 +502,6 @@ function ToolCallDetails(props: {
/>
)
onCleanup(() => {
if (pendingScrollFrame !== null) {
cancelAnimationFrame(pendingScrollFrame)
pendingScrollFrame = null
}
if (pendingAnchorScroll !== null) {
cancelAnimationFrame(pendingAnchorScroll)
pendingAnchorScroll = null
}
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
detachScrollIntentListeners = undefined
}
})
return (
<div class="tool-call-details">
<Show
@@ -850,24 +703,6 @@ export default function ToolCall(props: ToolCallProps) {
return !current
})
}
const statusIcon = () => {
const status = toolState()?.status || ""
switch (status) {
case "pending":
return <Hourglass class="w-4 h-4" />
case "running":
return <Loader2 class="w-4 h-4 animate-spin" />
case "completed":
return <Check class="w-4 h-4" />
case "error":
return <XCircle class="w-4 h-4" />
default:
return ""
}
}
const statusClass = () => {
const status = toolState()?.status || "pending"
return `tool-call-status-${status}`
@@ -1051,9 +886,7 @@ export default function ToolCall(props: ToolCallProps) {
/>
</Show>
<span class="tool-call-header-status" aria-hidden="true">
{statusIcon()}
</span>
<ToolStatusIndicator status={status} />
</div>
<Show when={expanded()}>

View File

@@ -1,4 +1,4 @@
import type { Accessor, JSXElement } from "solid-js"
import { createEffect, onCleanup, type Accessor, type JSXElement } from "solid-js"
import type { RenderCache } from "../../types/message"
import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../../lib/ansi"
import { escapeHtml } from "../../lib/text-render-utils"
@@ -11,6 +11,97 @@ type CacheHandle = {
set(value: unknown): void
}
export interface StableAnsiStreamUpdater {
update: (element: HTMLElement, content: string) => void
reset: () => void
}
export function createStableAnsiStreamUpdater(): StableAnsiStreamUpdater {
const renderer = createAnsiStreamRenderer()
let previousContent = ""
let ansiActive = false
return {
update(element: HTMLElement, content: string) {
const resetStreaming = !previousContent || !content.startsWith(previousContent)
if (resetStreaming) {
ansiActive = hasAnsi(content)
renderer.reset()
element.innerHTML = ansiActive ? renderer.render(content) : escapeHtml(content)
previousContent = content
return
}
const delta = content.slice(previousContent.length)
if (delta.length === 0) {
return
}
if (!ansiActive && hasAnsi(delta)) {
ansiActive = true
renderer.reset()
element.innerHTML = renderer.render(content)
previousContent = content
return
}
if (ansiActive) {
const htmlChunk = renderer.render(delta)
if (htmlChunk.length > 0) {
element.insertAdjacentHTML("beforeend", htmlChunk)
}
} else {
const escapedDelta = escapeHtml(delta)
if (escapedDelta.length > 0) {
element.insertAdjacentHTML("beforeend", escapedDelta)
}
}
previousContent = content
},
reset() {
previousContent = ""
ansiActive = false
renderer.reset()
},
}
}
function StreamingAnsiContent(props: {
html: string
htmlChunk?: string
updateMode: "replace" | "append" | "noop"
}) {
let preRef: HTMLPreElement | undefined
createEffect(() => {
const element = preRef
if (!element) return
if (props.updateMode === "noop") return
if (props.updateMode === "append") {
if (element.innerHTML.length === 0) {
element.innerHTML = props.html
return
}
const chunk = props.htmlChunk ?? ""
if (chunk.length > 0) {
element.insertAdjacentHTML("beforeend", chunk)
}
return
}
if (element.innerHTML !== props.html) {
element.innerHTML = props.html
}
})
onCleanup(() => {
preRef = undefined
})
return <pre ref={preRef} class="tool-call-content tool-call-ansi" dir="auto" />
}
export function createAnsiContentRenderer(params: {
ansiRunningCache: CacheHandle
ansiFinalCache: CacheHandle
@@ -46,6 +137,8 @@ export function createAnsiContentRenderer(params: {
const isRunningVariant = options.variant === "running"
const disableScrollTracking = !isRunningVariant
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
let updateMode: "replace" | "append" | "noop" = "replace"
let htmlChunk = ""
let nextCache: AnsiRenderCache
@@ -54,6 +147,7 @@ export function createAnsiContentRenderer(params: {
const resetStreaming = !cached || !cached.text || !content.startsWith(cached.text) || cached.text !== runningAnsiSource
if (resetStreaming) {
updateMode = "replace"
const detectedAnsi = hasAnsi(content)
if (detectedAnsi) {
runningAnsiRenderer.reset()
@@ -66,15 +160,21 @@ export function createAnsiContentRenderer(params: {
} else {
const delta = content.slice(cached.text.length)
if (delta.length === 0) {
updateMode = "noop"
nextCache = { ...cached, mode }
} else if (!cached.hasAnsi && hasAnsi(delta)) {
updateMode = "replace"
runningAnsiRenderer.reset()
const html = runningAnsiRenderer.render(content)
nextCache = { text: content, html, mode, hasAnsi: true }
} else if (cached.hasAnsi) {
const htmlChunk = runningAnsiRenderer.render(delta)
nextCache = { text: content, html: `${cached.html}${htmlChunk}`, mode, hasAnsi: true }
const appendedHtml = runningAnsiRenderer.render(delta)
updateMode = "append"
htmlChunk = appendedHtml
nextCache = { text: content, html: `${cached.html}${appendedHtml}`, mode, hasAnsi: true }
} else {
updateMode = "append"
htmlChunk = escapeHtml(delta)
nextCache = { text: content, html: `${cached.html}${escapeHtml(delta)}`, mode, hasAnsi: false }
}
}
@@ -98,7 +198,7 @@ export function createAnsiContentRenderer(params: {
return (
<div class={messageClass} ref={registerRef} onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}>
<pre class="tool-call-content tool-call-ansi" dir="auto" innerHTML={nextCache.html} />
<StreamingAnsiContent html={nextCache.html} htmlChunk={htmlChunk} updateMode={updateMode} />
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
</div>
)

View File

@@ -1,10 +1,13 @@
import { Suspense, lazy, onMount, type Accessor, type JSXElement } from "solid-js"
import { Suspense, createEffect, createMemo, createSignal, lazy, onMount, type Accessor, type JSXElement } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk/v2"
import useMediaQuery from "@suid/material/useMediaQuery"
import { AlignJustify, Copy, Split, WrapText } from "lucide-solid"
import type { RenderCache } from "../../types/message"
import type { DiffViewMode } from "../../stores/preferences"
import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types"
import { getRelativePath } from "./utils"
import { getCacheEntry } from "../../lib/global-cache"
import { copyToClipboard } from "../../lib/clipboard"
const LazyToolCallDiffViewer = lazy(() =>
import("../diff-viewer").then((module) => ({ default: module.ToolCallDiffViewer })),
@@ -43,6 +46,16 @@ export function createDiffContentRenderer(params: {
handleScrollRendered: () => void
onContentRendered?: () => void
}) {
const compactDiffQuery = useMediaQuery("(max-width: 640px)")
const [mobileModeOverride, setMobileModeOverride] = createSignal<DiffViewMode | undefined>(undefined)
const [wordWrapEnabled, setWordWrapEnabled] = createSignal(true)
createEffect(() => {
if (!compactDiffQuery()) {
setMobileModeOverride(undefined)
}
})
const registerTracked = (element: HTMLDivElement | null) => {
params.scrollHelpers.registerContainer(element)
}
@@ -58,7 +71,12 @@ export function createDiffContentRenderer(params: {
: params.t("toolCall.diff.label"))
const selectedVariant = options?.variant === "permission-diff" ? "permission-diff" : "diff"
const cacheHandle = selectedVariant === "permission-diff" ? params.permissionDiffCache : params.diffCache
const diffMode = () => (params.preferences().diffViewMode || "split") as DiffViewMode
const preferredMode = () => (params.preferences().diffViewMode || "split") as DiffViewMode
const effectiveMode = () => {
if (!compactDiffQuery()) return preferredMode()
return mobileModeOverride() || "unified"
}
const shouldWrap = () => wordWrapEnabled()
const themeKey = params.isDark() ? "dark" : "light"
const state = params.toolState()
const disableScrollTracking = Boolean(
@@ -76,60 +94,94 @@ export function createDiffContentRenderer(params: {
}
})()
let cachedHtml: string | undefined
const cached = getCacheEntry<RenderCache>(cacheEntryParams)
const currentMode = diffMode()
if (cached && cached.text === payload.diffText && cached.theme === themeKey && cached.mode === currentMode) {
cachedHtml = cached.html
}
const currentMode = createMemo(() => effectiveMode())
const currentWrap = createMemo(() => shouldWrap())
const cachedHtml = createMemo(() => {
const cached = getCacheEntry<RenderCache>(cacheEntryParams)
if (
cached
&& cached.text === payload.diffText
&& cached.theme === themeKey
&& cached.mode === currentMode()
&& cached.wrap === currentWrap()
) {
return cached.html
}
return undefined
})
const handleModeChange = (mode: DiffViewMode) => {
if (compactDiffQuery()) {
setMobileModeOverride(mode)
}
params.setDiffViewMode(mode)
}
const nextViewMode = (): DiffViewMode => (currentMode() === "split" ? "unified" : "split")
const viewModeTitle = () =>
nextViewMode() === "split"
? params.t("toolCall.diff.switchToSplit")
: params.t("toolCall.diff.switchToUnified")
const wordWrapTitle = () =>
wordWrapEnabled()
? params.t("toolCall.diff.disableWordWrap")
: params.t("toolCall.diff.enableWordWrap")
const copyPatchTitle = () => params.t("toolCall.diff.copyPatch")
const handleDiffRendered = () => {
if (!disableScrollTracking) {
params.handleScrollRendered()
}
params.handleScrollRendered()
params.onContentRendered?.()
}
return (
<div
class="message-text tool-call-markdown tool-call-markdown-large tool-call-diff-shell"
ref={registerRef}
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
>
<div
class="message-text tool-call-markdown tool-call-markdown-large tool-call-diff-shell"
data-diff-mode={currentMode()}
ref={registerRef}
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
>
<div class="tool-call-diff-toolbar" role="group" aria-label={params.t("toolCall.diff.viewMode.ariaLabel")}>
<span class="tool-call-diff-toolbar-label">{toolbarLabel}</span>
<div class="tool-call-diff-toggle">
<div class="file-viewer-toolbar">
<button
type="button"
class={`tool-call-diff-mode-button${diffMode() === "split" ? " active" : ""}`}
aria-pressed={diffMode() === "split"}
onClick={() => handleModeChange("split")}
class="file-viewer-toolbar-icon-button"
onClick={() => void copyToClipboard(payload.diffText)}
aria-label={copyPatchTitle()}
title={copyPatchTitle()}
>
{params.t("toolCall.diff.viewMode.split")}
<Copy class="h-4 w-4" aria-hidden="true" />
</button>
<button
type="button"
class={`tool-call-diff-mode-button${diffMode() === "unified" ? " active" : ""}`}
aria-pressed={diffMode() === "unified"}
onClick={() => handleModeChange("unified")}
class="file-viewer-toolbar-icon-button"
onClick={() => handleModeChange(nextViewMode())}
aria-label={viewModeTitle()}
title={viewModeTitle()}
>
{params.t("toolCall.diff.viewMode.unified")}
{nextViewMode() === "split" ? <Split class="h-4 w-4" aria-hidden="true" /> : <AlignJustify class="h-4 w-4" aria-hidden="true" />}
</button>
<button
type="button"
class={`file-viewer-toolbar-icon-button${wordWrapEnabled() ? " active" : ""}`}
onClick={() => setWordWrapEnabled((enabled) => !enabled)}
aria-label={wordWrapTitle()}
title={wordWrapTitle()}
>
<WrapText class="h-4 w-4" aria-hidden="true" />
</button>
</div>
</div>
{cachedHtml ? (
<CachedDiffMarkup html={cachedHtml} onRendered={handleDiffRendered} />
{cachedHtml() ? (
<CachedDiffMarkup html={cachedHtml()!} onRendered={handleDiffRendered} />
) : (
<Suspense fallback={<pre class="tool-call-diff-fallback">{payload.diffText}</pre>}>
<LazyToolCallDiffViewer
diffText={payload.diffText}
filePath={payload.filePath}
theme={themeKey}
mode={diffMode()}
mode={currentMode()}
wrap={currentWrap()}
cacheEntryParams={cacheEntryParams as any}
onRendered={handleDiffRendered}
/>

View File

@@ -1,6 +1,107 @@
import type { ToolRenderer } from "../types"
import { Show, createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk/v2"
import type { ToolRenderer, ToolScrollHelpers } from "../types"
import { ensureMarkdownContent, formatUnknown, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, readToolStatePayload } from "../utils"
import { tGlobal } from "../../../lib/i18n"
import { createStableAnsiStreamUpdater } from "../ansi-render"
import { ansiToHtml, hasAnsi } from "../../../lib/ansi"
function RunningBashOutput(props: {
content: Accessor<string>
scrollHelpers?: ToolScrollHelpers
}) {
let preRef: HTMLPreElement | undefined
const updater = createStableAnsiStreamUpdater()
createEffect(() => {
const element = preRef
if (!element) return
updater.update(element, props.content())
})
onCleanup(() => {
preRef = undefined
updater.reset()
})
return (
<div
class="message-text tool-call-markdown"
ref={props.scrollHelpers?.registerContainer}
onScroll={props.scrollHelpers ? (event) => props.scrollHelpers!.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined}
>
<pre ref={preRef} class="tool-call-content tool-call-ansi" dir="auto" />
{props.scrollHelpers?.renderSentinel?.()}
</div>
)
}
function BashToolBody(props: {
toolState: Accessor<ToolState | undefined>
renderMarkdown: (options: { content: string }) => ReturnType<ToolRenderer["renderBody"]>
scrollHelpers?: ToolScrollHelpers
}) {
const state = createMemo(() => props.toolState())
const joinedContent = createMemo(() => {
const current = state()
if (!current || current.status === "pending") return ""
const { input, metadata } = readToolStatePayload(current)
const command = typeof input.command === "string" && input.command.length > 0 ? `$ ${input.command}` : ""
const outputResult = formatUnknown(
isToolStateCompleted(current)
? current.output
: (isToolStateRunning(current) || isToolStateError(current)) && metadata.output
? metadata.output
: undefined,
)
return [command, outputResult?.text].filter(Boolean).join("\n")
})
const finalMarkdown = createMemo(() => {
const current = state()
const content = joinedContent()
if (!current || current.status === "pending" || current.status === "running" || content.length === 0) {
return null
}
if (hasAnsi(content)) {
return null
}
return ensureMarkdownContent(content, "bash", true)
})
const finalAnsiHtml = createMemo(() => {
const current = state()
const content = joinedContent()
if (!current || current.status === "pending" || current.status === "running" || content.length === 0) {
return null
}
if (!hasAnsi(content)) {
return null
}
return ansiToHtml(content)
})
return (
<Show when={state() && joinedContent().length > 0}>
<Show
when={state()?.status === "running"}
fallback={
<Show when={finalAnsiHtml()} fallback={finalMarkdown() ? props.renderMarkdown({ content: finalMarkdown()! as string }) : null}>
{(html) => (
<div class="message-text tool-call-markdown" ref={props.scrollHelpers?.registerContainer}>
<pre class="tool-call-content tool-call-ansi" dir="auto" innerHTML={html()} />
</div>
)}
</Show>
}
>
<RunningBashOutput content={joinedContent} scrollHelpers={props.scrollHelpers} />
</Show>
</Show>
)
}
export const bashRenderer: ToolRenderer = {
tools: ["bash"],
@@ -21,35 +122,7 @@ export const bashRenderer: ToolRenderer = {
const timeoutLabel = `${timeout}ms`
return `${baseTitle} · ${tGlobal("toolCall.renderer.bash.title.timeout", { timeout: timeoutLabel })}`
},
renderBody({ toolState, renderMarkdown, renderAnsi }) {
const state = toolState()
if (!state || state.status === "pending") return null
const { input, metadata } = readToolStatePayload(state)
const command = typeof input.command === "string" && input.command.length > 0 ? `$ ${input.command}` : ""
const outputResult = formatUnknown(
isToolStateCompleted(state)
? state.output
: (isToolStateRunning(state) || isToolStateError(state)) && metadata.output
? metadata.output
: undefined,
)
const parts = [command, outputResult?.text].filter(Boolean)
if (parts.length === 0) return null
const joined = parts.join("\n")
if (state.status === "running") {
return renderAnsi({ content: joined, variant: "running" })
}
const ansiBody = renderAnsi({ content: joined, requireAnsi: true, variant: "final" })
if (ansiBody) {
return ansiBody
}
const content = ensureMarkdownContent(joined, "bash", true)
if (!content) return null
return renderMarkdown({ content })
renderBody({ toolState, renderMarkdown, scrollHelpers }) {
return <BashToolBody toolState={toolState} renderMarkdown={renderMarkdown as any} scrollHelpers={scrollHelpers} />
},
}

View File

@@ -1,4 +1,4 @@
import { For, Show, createEffect, createMemo, createSignal, untrack } from "solid-js"
import { For, Index, Show, createEffect, createMemo, createSignal, untrack } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk/v2"
import type { ToolRenderer } from "../types"
import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
@@ -145,7 +145,7 @@ export const taskRenderer: ToolRenderer = {
const { input } = readToolStatePayload(state)
return describeTaskTitle(input)
},
renderBody({ toolState, instanceId, renderToolCall, messageVersion, partVersion, scrollHelpers, renderMarkdown, t }) {
renderBody({ toolState, instanceId, renderToolCall, messageVersion, partVersion, scrollHelpers, renderMarkdown, t, onContentRendered }) {
const store = messageStoreBus.getOrCreate(instanceId)
const [requestedChildLoad, setRequestedChildLoad] = createSignal(false)
@@ -360,6 +360,14 @@ export const taskRenderer: ToolRenderer = {
})
})
createEffect(() => {
const childCount = childToolKeys().length
const legacyCount = legacyItems().length
if (childCount === 0 && legacyCount === 0) return
scrollHelpers?.restoreAfterRender()
onContentRendered?.()
})
return (
<div class="tool-call-task-sections">
<Show when={promptContent()}>
@@ -443,12 +451,12 @@ export const taskRenderer: ToolRenderer = {
}
>
<div class="tool-call-task-summary">
<For each={childToolKeys()}>
<Index each={childToolKeys()}>
{(key) => (
<Show when={renderToolCall}>
{(render) => (
<TaskToolCallRow
toolKey={key}
toolKey={key()}
store={store}
sessionId={childSessionId()}
renderToolCall={render()}
@@ -456,7 +464,7 @@ export const taskRenderer: ToolRenderer = {
)}
</Show>
)}
</For>
</Index>
</div>
{scrollHelpers?.renderSentinel?.()}
</div>

View File

@@ -47,6 +47,7 @@ export interface ToolScrollHelpers {
registerContainer(element: HTMLDivElement | null, options?: { disableTracking?: boolean }): void
handleScroll(event: Event & { currentTarget: HTMLDivElement }): void
renderSentinel(options?: { disableTracking?: boolean }): JSXElement | null
restoreAfterRender(options?: { forceBottom?: boolean }): void
}
export interface ToolRendererContext {
@@ -74,6 +75,7 @@ export interface ToolRendererContext {
forceCollapsed?: boolean
}) => JSXElement | null
scrollHelpers?: ToolScrollHelpers
onContentRendered?: () => void
}
export interface ToolRenderer {

View File

@@ -79,6 +79,7 @@ interface UnifiedPickerProps {
mode?: "mention" | "command"
onSelect: (item: PickerItem, action: PickerSelectAction) => void
onClose: () => void
onSubmitWithoutSelection?: () => void
agents: Agent[]
commands?: SDKCommand[]
instanceClient: OpencodeClient | null
@@ -404,6 +405,8 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
if (selected) {
const action: PickerSelectAction = e.key === "Tab" ? "tab" : e.shiftKey ? "shiftEnter" : "enter"
props.onSelect(selected, action)
} else if (e.key === "Enter" && mode() === "mention") {
props.onSubmitWithoutSelection?.()
}
} else if (e.key === "Escape") {
e.preventDefault()

View File

@@ -10,7 +10,10 @@ import type {
SpeechCapabilitiesResponse,
SpeechSynthesisResponse,
SpeechTranscriptionResponse,
SideCar,
ServerMeta,
RemoteServerProbeRequest,
RemoteServerProbeResponse,
VoiceModeStateResponse,
WorkspaceCreateRequest,
WorkspaceDescriptor,
@@ -191,9 +194,42 @@ export const serverApi = {
body: JSON.stringify(payload),
})
},
fetchSidecars(): Promise<{ sidecars: SideCar[] }> {
return request<{ sidecars: SideCar[] }>("/api/sidecars")
},
createSidecar(payload: {
kind: "port"
name: string
port: number
insecure: boolean
prefixMode: "strip" | "preserve"
}): Promise<SideCar> {
return request<SideCar>("/api/sidecars", {
method: "POST",
body: JSON.stringify(payload),
})
},
updateSidecar(
id: string,
payload: Partial<{ name: string; port: number; insecure: boolean; prefixMode: "strip" | "preserve" }>,
): Promise<SideCar> {
return request<SideCar>(`/api/sidecars/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify(payload),
})
},
deleteSidecar(id: string): Promise<void> {
return request(`/api/sidecars/${encodeURIComponent(id)}`, { method: "DELETE" })
},
fetchServerMeta(): Promise<ServerMeta> {
return request<ServerMeta>("/api/meta")
},
probeRemoteServer(payload: RemoteServerProbeRequest): Promise<RemoteServerProbeResponse> {
return request<RemoteServerProbeResponse>("/api/remote-servers/probe", {
method: "POST",
body: JSON.stringify(payload),
})
},
fetchAuthStatus(): Promise<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }> {
return request<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }>("/api/auth/status")
},
@@ -430,4 +466,4 @@ function buildClientEventsUrl(identity: { clientId: string; connectionId: string
return `${url.pathname}${url.search}`
}
export type { WorkspaceDescriptor, WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType }
export type { WorkspaceDescriptor, WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType, SideCar }

View File

@@ -2,6 +2,7 @@ const HUNK_PATTERN = /(^|\n)@@/m
const FILE_MARKER_PATTERN = /(^|\n)(diff --git |--- |\+\+\+)/
const BEGIN_PATCH_PATTERN = /^\*\*\* (Begin|End) Patch/
const UPDATE_FILE_PATTERN = /^\*\*\* Update File: (.+)$/
const HUNK_HEADER_PATTERN = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
function stripCodeFence(value: string): string {
const trimmed = value.trim()
@@ -48,3 +49,48 @@ export function isRenderableDiffText(raw?: string | null): raw is string {
if (!normalized) return false
return HUNK_PATTERN.test(normalized)
}
export function parsePatchToBeforeAfter(patch: string): { before: string; after: string } {
if (!patch || patch.trim().length === 0) {
return { before: "", after: "" }
}
const lines = patch.replace(/\r\n/g, "\n").split("\n")
const beforeLines: string[] = []
const afterLines: string[] = []
for (const line of lines) {
if (line.startsWith("---") || line.startsWith("+++") || line.startsWith("diff --git")) {
continue
}
if (HUNK_HEADER_PATTERN.test(line)) {
continue
}
if (line.startsWith("-") && !line.startsWith("---")) {
beforeLines.push(line.slice(1))
} else if (line.startsWith("+") && !line.startsWith("+++")) {
afterLines.push(line.slice(1))
} else if (line.startsWith(" ")) {
beforeLines.push(line.slice(1))
afterLines.push(line.slice(1))
} else if (line === "") {
beforeLines.push("")
afterLines.push("")
} else {
beforeLines.push(line)
afterLines.push(line)
}
}
while (beforeLines.length > 0 && beforeLines[beforeLines.length - 1] === "") {
beforeLines.pop()
}
while (afterLines.length > 0 && afterLines[afterLines.length - 1] === "") {
afterLines.pop()
}
return {
before: beforeLines.join("\n"),
after: afterLines.join("\n"),
}
}

View File

@@ -0,0 +1,259 @@
import { createEffect, createSignal, onCleanup, type Accessor, type JSXElement } from "solid-js"
const DEFAULT_SCROLL_INTENT_WINDOW_MS = 600
const DEFAULT_SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
interface FollowScrollOptions {
getScrollTopSnapshot: Accessor<number>
setScrollTopSnapshot: (next: number) => void
sentinelMarginPx: number
sentinelClassName: string
intentWindowMs?: number
intentKeys?: ReadonlySet<string>
}
export interface FollowScrollHelpers {
registerContainer: (element: HTMLDivElement | null | undefined, options?: { disableTracking?: boolean }) => void
handleScroll: (event: Event & { currentTarget: HTMLDivElement }) => void
renderSentinel: (options?: { disableTracking?: boolean }) => JSXElement | null
restoreAfterRender: (options?: { forceBottom?: boolean }) => void
autoScroll: Accessor<boolean>
}
export function createFollowScroll(options: FollowScrollOptions): FollowScrollHelpers {
const [scrollContainer, setScrollContainer] = createSignal<HTMLDivElement | undefined>()
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null)
const [autoScroll, setAutoScroll] = createSignal(true)
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true)
let scrollContainerRef: HTMLDivElement | undefined
let detachScrollIntentListeners: (() => void) | undefined
let pendingScrollFrame: number | null = null
let pendingAnchorScroll: number | null = null
let userScrollIntentUntil = 0
let lastKnownScrollTop = options.getScrollTopSnapshot()
let pointerInteractionActive = false
let suppressNextScrollHandling = false
function restoreScrollPosition(forceBottom = false) {
const container = scrollContainerRef
if (!container) return
suppressNextScrollHandling = true
if (forceBottom) {
container.scrollTop = container.scrollHeight
lastKnownScrollTop = container.scrollTop
options.setScrollTopSnapshot(lastKnownScrollTop)
} else {
container.scrollTop = lastKnownScrollTop
}
}
function persistScrollSnapshot(element?: HTMLElement | null) {
if (!element) return
lastKnownScrollTop = element.scrollTop
options.setScrollTopSnapshot(lastKnownScrollTop)
}
function markUserScrollIntent() {
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
userScrollIntentUntil = now + (options.intentWindowMs ?? DEFAULT_SCROLL_INTENT_WINDOW_MS)
}
function hasUserScrollIntent() {
if (pointerInteractionActive) {
return true
}
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
return now <= userScrollIntentUntil
}
function attachScrollIntentListeners(element: HTMLDivElement) {
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
detachScrollIntentListeners = undefined
}
const intentKeys = options.intentKeys ?? DEFAULT_SCROLL_INTENT_KEYS
const handlePointerIntent = () => {
pointerInteractionActive = true
markUserScrollIntent()
}
const clearPointerIntent = () => {
pointerInteractionActive = false
}
const handleKeyIntent = (event: KeyboardEvent) => {
if (intentKeys.has(event.key)) {
markUserScrollIntent()
}
}
element.addEventListener("wheel", handlePointerIntent, { passive: true })
element.addEventListener("pointerdown", handlePointerIntent)
element.addEventListener("touchstart", handlePointerIntent, { passive: true })
element.addEventListener("keydown", handleKeyIntent)
if (typeof window !== "undefined") {
window.addEventListener("pointerup", clearPointerIntent)
window.addEventListener("pointercancel", clearPointerIntent)
window.addEventListener("mouseup", clearPointerIntent)
window.addEventListener("touchend", clearPointerIntent)
window.addEventListener("touchcancel", clearPointerIntent)
}
detachScrollIntentListeners = () => {
element.removeEventListener("wheel", handlePointerIntent)
element.removeEventListener("pointerdown", handlePointerIntent)
element.removeEventListener("touchstart", handlePointerIntent)
element.removeEventListener("keydown", handleKeyIntent)
if (typeof window !== "undefined") {
window.removeEventListener("pointerup", clearPointerIntent)
window.removeEventListener("pointercancel", clearPointerIntent)
window.removeEventListener("mouseup", clearPointerIntent)
window.removeEventListener("touchend", clearPointerIntent)
window.removeEventListener("touchcancel", clearPointerIntent)
}
pointerInteractionActive = false
}
}
function scheduleAnchorScroll(immediate = false) {
if (!autoScroll()) return
const sentinel = bottomSentinel()
const container = scrollContainerRef
if (!sentinel || !container) return
if (pendingAnchorScroll !== null) {
cancelAnimationFrame(pendingAnchorScroll)
pendingAnchorScroll = null
}
pendingAnchorScroll = requestAnimationFrame(() => {
pendingAnchorScroll = null
const containerRect = container.getBoundingClientRect()
const sentinelRect = sentinel.getBoundingClientRect()
const delta = sentinelRect.bottom - containerRect.bottom + options.sentinelMarginPx
if (Math.abs(delta) > 1) {
suppressNextScrollHandling = true
container.scrollBy({ top: delta, behavior: immediate ? "auto" : "smooth" })
}
lastKnownScrollTop = container.scrollTop
options.setScrollTopSnapshot(lastKnownScrollTop)
})
}
function isAtBottom(container: HTMLDivElement) {
return container.scrollHeight - (container.scrollTop + container.clientHeight) <= options.sentinelMarginPx
}
function updateFollowModeFromScroll(containerOverride?: HTMLDivElement) {
const container = containerOverride ?? scrollContainer()
if (!container) return
if (suppressNextScrollHandling) {
suppressNextScrollHandling = false
return
}
const isUserScroll = hasUserScrollIntent()
const atBottomFromScroll = isAtBottom(container)
const atBottom = atBottomFromScroll || bottomSentinelVisible()
if (isUserScroll || !atBottom) {
if (atBottom) {
if (!autoScroll()) setAutoScroll(true)
} else if (autoScroll()) {
setAutoScroll(false)
}
}
}
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
updateFollowModeFromScroll(event.currentTarget)
persistScrollSnapshot(event.currentTarget)
}
const registerContainer = (element: HTMLDivElement | null | undefined, config?: { disableTracking?: boolean }) => {
const next = element || undefined
if (next === scrollContainerRef) {
return
}
scrollContainerRef = next
setScrollContainer(scrollContainerRef)
if (scrollContainerRef) {
lastKnownScrollTop = options.getScrollTopSnapshot()
restoreScrollPosition(autoScroll())
}
}
const renderSentinel = (config?: { disableTracking?: boolean }) => {
if (config?.disableTracking) return null
return <div ref={setBottomSentinel} aria-hidden="true" class={options.sentinelClassName} style={{ height: "1px" }} />
}
const restoreAfterRender = (config?: { forceBottom?: boolean }) => {
const container = scrollContainerRef
if (container && hasUserScrollIntent() && !isAtBottom(container)) {
if (autoScroll()) {
setAutoScroll(false)
}
requestAnimationFrame(() => {
restoreScrollPosition(false)
})
return
}
const shouldFollow = config?.forceBottom ?? autoScroll()
requestAnimationFrame(() => {
restoreScrollPosition(shouldFollow)
if (shouldFollow) {
scheduleAnchorScroll(true)
}
})
}
createEffect(() => {
const container = scrollContainer()
if (!container) return
attachScrollIntentListeners(container)
onCleanup(() => {
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
detachScrollIntentListeners = undefined
}
})
})
createEffect(() => {
const container = scrollContainer()
const sentinel = bottomSentinel()
if (!container || !sentinel) return
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.target === sentinel) {
setBottomSentinelVisible(entry.isIntersecting)
}
})
},
{ root: container, threshold: 0, rootMargin: `0px 0px ${options.sentinelMarginPx}px 0px` },
)
observer.observe(sentinel)
onCleanup(() => observer.disconnect())
})
onCleanup(() => {
if (pendingScrollFrame !== null) {
cancelAnimationFrame(pendingScrollFrame)
pendingScrollFrame = null
}
if (pendingAnchorScroll !== null) {
cancelAnimationFrame(pendingAnchorScroll)
pendingAnchorScroll = null
}
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
detachScrollIntentListeners = undefined
}
})
return {
registerContainer,
handleScroll,
renderSentinel,
restoreAfterRender,
autoScroll,
}
}

View File

@@ -16,6 +16,7 @@ const log = getLogger("actions")
interface UseAppLifecycleOptions {
setEscapeInDebounce: (value: boolean) => void
handleNewInstanceRequest: () => void
handleCloseActiveTab: () => Promise<void>
handleCloseInstance: (instanceId: string) => Promise<void>
handleNewSession: (instanceId: string) => Promise<void>
handleCloseSession: (instanceId: string, sessionId: string) => Promise<void>
@@ -31,7 +32,7 @@ export function useAppLifecycle(options: UseAppLifecycleOptions) {
setupTabKeyboardShortcuts(
options.handleNewInstanceRequest,
options.handleCloseInstance,
options.handleCloseActiveTab,
options.handleNewSession,
options.handleCloseSession,
() => {

View File

@@ -2,7 +2,8 @@ import { createSignal, onMount } from "solid-js"
import type { Accessor } from "solid-js"
import type { Preferences, ExpansionPreference, ToolInputsVisibilityPreference } from "../../stores/preferences"
import { createCommandRegistry, type Command } from "../commands"
import { instances, activeInstanceId, setActiveInstanceId } from "../../stores/instances"
import { activeInstanceId } from "../../stores/instances"
import { selectNextAppTab, selectPreviousAppTab } from "../../stores/app-tabs"
import type { ClientPart, MessageInfo } from "../../types/message"
import { getSessions, getVisibleSessionIds, setActiveSession, setActiveSessionFromList } from "../../stores/sessions"
import { showAlertDialog } from "../../stores/alerts"
@@ -41,6 +42,7 @@ export interface UseCommandsOptions {
setThinkingBlocksExpansion: (mode: ExpansionPreference) => void
setToolInputsVisibility: (mode: ToolInputsVisibilityPreference) => void
handleNewInstanceRequest: () => void
handleCloseActiveTab: () => Promise<void>
handleCloseInstance: (instanceId: string) => Promise<void>
handleNewSession: (instanceId: string) => Promise<void>
handleCloseSession: (instanceId: string, sessionId: string) => Promise<void>
@@ -90,9 +92,7 @@ export function useCommands(options: UseCommandsOptions) {
keywords: () => splitKeywords("commands.closeInstance.keywords"),
shortcut: { key: "W", meta: true },
action: async () => {
const instance = activeInstance()
if (!instance) return
await options.handleCloseInstance(instance.id)
await options.handleCloseActiveTab()
},
})
@@ -103,13 +103,7 @@ export function useCommands(options: UseCommandsOptions) {
category: "Instance",
keywords: () => splitKeywords("commands.nextInstance.keywords"),
shortcut: { key: "]", meta: true },
action: () => {
const ids = Array.from(instances().keys())
if (ids.length <= 1) return
const current = ids.indexOf(activeInstanceId() || "")
const next = (current + 1) % ids.length
if (ids[next]) setActiveInstanceId(ids[next])
},
action: () => selectNextAppTab(),
})
commandRegistry.register({
@@ -119,13 +113,7 @@ export function useCommands(options: UseCommandsOptions) {
category: "Instance",
keywords: () => splitKeywords("commands.previousInstance.keywords"),
shortcut: { key: "[", meta: true },
action: () => {
const ids = Array.from(instances().keys())
if (ids.length <= 1) return
const current = ids.indexOf(activeInstanceId() || "")
const prev = current <= 0 ? ids.length - 1 : current - 1
if (ids[prev]) setActiveInstanceId(ids[prev])
},
action: () => selectPreviousAppTab(),
})
commandRegistry.register({

View File

@@ -15,17 +15,17 @@ export const commandMessages = {
"commands.newInstance.description": "Open folder picker to create new instance",
"commands.newInstance.keywords": "folder, project, workspace",
"commands.closeInstance.label": "Close Instance",
"commands.closeInstance.description": "Stop current instance's server",
"commands.closeInstance.keywords": "stop, quit, close",
"commands.closeInstance.label": "Close Tab",
"commands.closeInstance.description": "Close the current top-level tab",
"commands.closeInstance.keywords": "stop, quit, close, tab",
"commands.nextInstance.label": "Next Instance",
"commands.nextInstance.description": "Cycle to next instance tab",
"commands.nextInstance.keywords": "switch, navigate",
"commands.nextInstance.label": "Next Tab",
"commands.nextInstance.description": "Cycle to the next top-level tab",
"commands.nextInstance.keywords": "switch, navigate, tab",
"commands.previousInstance.label": "Previous Instance",
"commands.previousInstance.description": "Cycle to previous instance tab",
"commands.previousInstance.keywords": "switch, navigate",
"commands.previousInstance.label": "Previous Tab",
"commands.previousInstance.description": "Cycle to the previous top-level tab",
"commands.previousInstance.keywords": "switch, navigate, tab",
"commands.newSession.label": "New Session",
"commands.newSession.description": "Create a new parent session",

View File

@@ -20,6 +20,9 @@ export const folderSelectionMessages = {
"folderSelection.browse.subtitle": "Select any folder on your computer",
"folderSelection.browse.button": "Browse Folders",
"folderSelection.browse.buttonOpening": "Opening...",
"folderSelection.actions.title": "Open Folder or Connect Server",
"folderSelection.actions.subtitle": "Open local folder or connect to a CodeNomad server",
"folderSelection.actions.connectButton": "Connect CodeNomad Server",
"folderSelection.advancedSettings": "Advanced Settings",
"folderSelection.opencode": "OpenCode",
@@ -39,4 +42,32 @@ export const folderSelectionMessages = {
"folderSelection.dialog.title": "Select Workspace",
"folderSelection.dialog.description": "Select workspace to start coding.",
"folderSelection.tabs.local": "Local Folders",
"folderSelection.tabs.servers": "Servers",
"folderSelection.servers.title": "Saved Servers",
"folderSelection.servers.subtitle": "Open a saved remote CodeNomad server in a new window",
"folderSelection.servers.count": "{count} Servers",
"folderSelection.servers.empty.title": "No Saved Servers",
"folderSelection.servers.empty.description": "Add a remote server to reconnect quickly from this device",
"folderSelection.servers.connectTitle": "Connect to Server",
"folderSelection.servers.connectSubtitle": "Save a remote CodeNomad server and open it in a new window",
"folderSelection.servers.connectButton": "Connect to Server",
"folderSelection.servers.remove": "Remove saved server",
"folderSelection.servers.skipTls": "Self-signed TLS",
"folderSelection.servers.errorTitle": "Remote Connection Failed",
"folderSelection.servers.dialog.title": "Connect to Server",
"folderSelection.servers.dialog.description": "Add a remote CodeNomad server and optionally open it right away.",
"folderSelection.servers.dialog.name": "Server name",
"folderSelection.servers.dialog.namePlaceholder": "Production Server",
"folderSelection.servers.dialog.url": "Server URL",
"folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com",
"folderSelection.servers.dialog.skipTls": "Skip TLS verification for self-signed certificates.",
"folderSelection.servers.dialog.cancel": "Cancel",
"folderSelection.servers.dialog.save": "Save",
"folderSelection.servers.dialog.connect": "Connect",
"folderSelection.servers.dialog.connecting": "Connecting...",
"folderSelection.servers.dialog.errorRequired": "Server name and URL are required.",
"folderSelection.servers.dialog.errorConnect": "Could not connect to the remote server.",
"folderSelection.sidecars.button": "Open SideCar",
} as const

View File

@@ -160,6 +160,8 @@ export const instanceMessages = {
"instanceShell.backgroundProcesses.empty": "No background processes.",
"instanceShell.backgroundProcesses.status": "Status: {status}",
"instanceShell.backgroundProcesses.output": "Output: {sizeKb}KB",
"instanceShell.backgroundProcesses.notify.enabled": "Completion notification enabled",
"instanceShell.backgroundProcesses.notify.disabled": "Completion notification disabled",
"instanceShell.backgroundProcesses.actions.output": "Output",
"instanceShell.backgroundProcesses.actions.stop": "Stop",
"instanceShell.backgroundProcesses.actions.terminate": "Terminate",

View File

@@ -41,6 +41,8 @@ export const remoteAccessMessages = {
"remoteAccess.sections.addresses.help": "Launch or scan from another machine to hand over control.",
"remoteAccess.addresses.loading": "Loading addresses…",
"remoteAccess.addresses.none": "No addresses available yet.",
"remoteAccess.addresses.actions.showOther": "Show {count} other addresses",
"remoteAccess.addresses.actions.hideOther": "Hide other addresses",
"remoteAccess.address.scope.network": "Network",
"remoteAccess.address.scope.loopback": "Loopback",
"remoteAccess.address.scope.internal": "Internal",

View File

@@ -113,6 +113,15 @@ export const settingsMessages = {
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.opencode.runtime.title": "Runtime",
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
"settings.opencode.logLevel.title": "OpenCode Log Level",
"settings.opencode.logLevel.subtitle": "Control the log verbosity used when launching new OpenCode instances.",
"settings.opencode.logLevel.selector.title": "Default log level",
"settings.opencode.logLevel.selector.subtitle": "Choose how verbose new OpenCode instances should be.",
"settings.opencode.logLevel.option.debug": "Debug",
"settings.opencode.logLevel.option.info": "Info",
"settings.opencode.logLevel.option.warn": "Warn",
"settings.opencode.logLevel.option.error": "Error",
"settings.appearance.behavior.title": "Interaction",
"settings.appearance.behavior.subtitle": "Message, diff, and input defaults.",
@@ -186,4 +195,40 @@ export const settingsMessages = {
"settings.speech.save.saved": "Saved",
"settings.speech.save.unsaved": "Unsaved changes",
"settings.speech.save.error": "Save failed",
"settings.nav.sidecars": "SideCars",
"settings.section.sidecars.eyebrow": "Server services",
"settings.section.sidecars.title": "SideCars",
"settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.",
"sidecars.form.name": "Name",
"sidecars.form.validation": "Enter a valid SideCar name and port.",
"sidecars.form.port": "Port",
"sidecars.form.insecure": "Use HTTP",
"sidecars.form.protocol": "Protocol",
"sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.",
"sidecars.form.protocol.https": "HTTPS",
"sidecars.form.protocol.http": "HTTP",
"sidecars.form.prefixMode": "Prefix mode",
"sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.",
"sidecars.form.prefixMode.strip": "Strip prefix",
"sidecars.form.prefixMode.preserve": "Preserve prefix",
"sidecars.form.add": "Add SideCar",
"sidecars.kind.port": "Port",
"sidecars.status.running": "Running",
"sidecars.status.stopped": "Stopped",
"sidecars.basePath": "Base path",
"sidecars.settings.listTitle": "Configured SideCars",
"sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.",
"sidecars.settings.empty": "No SideCars configured yet.",
"sidecars.picker.title": "Open SideCar",
"sidecars.picker.loading": "Loading SideCars...",
"sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.",
"sidecars.picker.empty": "No port-based SideCars are available yet.",
"sidecars.picker.close": "Close",
"sidecars.open.errorTitle": "Unable to open SideCar",
"sidecars.open.notFound": "SideCar not found.",
"sidecars.open.notRunning": "SideCar is not reachable on its configured port.",
"sidecars.back": "Back",
"sidecars.refresh": "Refresh",
"sidecars.path": "Path",
"sidecars.go": "Go",
} as const

View File

@@ -18,6 +18,11 @@ export const toolCallMessages = {
"toolCall.diff.viewMode.ariaLabel": "Diff view mode",
"toolCall.diff.viewMode.split": "Split",
"toolCall.diff.viewMode.unified": "Unified",
"toolCall.diff.switchToSplit": "Switch to split view",
"toolCall.diff.switchToUnified": "Switch to unified view",
"toolCall.diff.enableWordWrap": "Enable word wrap",
"toolCall.diff.disableWordWrap": "Disable word wrap",
"toolCall.diff.copyPatch": "Copy patch",
"toolCall.diagnostics.title": "Diagnostics",
"toolCall.diagnostics.ariaLabel": "Diagnostics",

View File

@@ -15,17 +15,17 @@ export const commandMessages = {
"commands.newInstance.description": "Abrir el selector de carpetas para crear una nueva instancia",
"commands.newInstance.keywords": "carpeta, proyecto, workspace",
"commands.closeInstance.label": "Cerrar instancia",
"commands.closeInstance.description": "Detener el servidor de la instancia actual",
"commands.closeInstance.keywords": "detener, salir, cerrar",
"commands.closeInstance.label": "Cerrar pestaña",
"commands.closeInstance.description": "Cerrar la pestaña superior actual",
"commands.closeInstance.keywords": "detener, salir, cerrar, pestaña",
"commands.nextInstance.label": "Siguiente instancia",
"commands.nextInstance.description": "Cambiar a la siguiente pestaña de instancia",
"commands.nextInstance.keywords": "cambiar, navegar",
"commands.nextInstance.label": "Siguiente pestaña",
"commands.nextInstance.description": "Cambiar a la siguiente pestaña superior",
"commands.nextInstance.keywords": "cambiar, navegar, pestaña",
"commands.previousInstance.label": "Instancia anterior",
"commands.previousInstance.description": "Cambiar a la pestaña de instancia anterior",
"commands.previousInstance.keywords": "cambiar, navegar",
"commands.previousInstance.label": "Pestaña anterior",
"commands.previousInstance.description": "Cambiar a la pestaña superior anterior",
"commands.previousInstance.keywords": "cambiar, navegar, pestaña",
"commands.newSession.label": "Nueva sesión",
"commands.newSession.description": "Crear una nueva sesión principal",

View File

@@ -2,35 +2,38 @@ export const folderSelectionMessages = {
"folderSelection.language.ariaLabel": "Idioma",
"folderSelection.logoAlt": "Logo de CodeNomad",
"folderSelection.tagline": "Selecciona una carpeta para empezar a programar con IA",
"folderSelection.tagline": "Selecciona una carpeta para empezar a programar con AI",
"folderSelection.links.github": "GitHub de CodeNomad",
"folderSelection.links.githubStars": "Estrellas de CodeNomad en GitHub",
"folderSelection.links.githubStars": "Estrellas de GitHub de CodeNomad",
"folderSelection.links.discord": "Discord de CodeNomad",
"folderSelection.empty.title": "No hay carpetas recientes",
"folderSelection.empty.description": "Explora una carpeta para comenzar",
"folderSelection.empty.description": "Busca una carpeta para comenzar",
"folderSelection.recent.title": "Carpetas recientes",
"folderSelection.recent.subtitle.one": "{count} carpeta disponible",
"folderSelection.recent.subtitle.other": "{count} carpetas disponibles",
"folderSelection.recent.remove": "Quitar de recientes",
"folderSelection.recent.remove": "Eliminar de recientes",
"folderSelection.browse.title": "Explorar carpetas",
"folderSelection.browse.title": "Buscar carpeta",
"folderSelection.browse.subtitle": "Selecciona cualquier carpeta en tu ordenador",
"folderSelection.browse.button": "Explorar carpetas",
"folderSelection.browse.button": "Buscar carpetas",
"folderSelection.browse.buttonOpening": "Abriendo...",
"folderSelection.actions.title": "Abrir carpeta o conectar servidor",
"folderSelection.actions.subtitle": "Abre una carpeta local o conéctate a un servidor de CodeNomad",
"folderSelection.actions.connectButton": "Conectar servidor CodeNomad",
"folderSelection.advancedSettings": "Configuración avanzada",
"folderSelection.opencode": "OpenCode",
"folderSelection.hints.navigate": "Navegar",
"folderSelection.hints.select": "Seleccionar",
"folderSelection.hints.remove": "Quitar",
"folderSelection.hints.browse": "Explorar",
"folderSelection.hints.remove": "Eliminar",
"folderSelection.hints.browse": "Buscar",
"folderSelection.loading.title": "Iniciando instancia...",
"folderSelection.loading.subtitle": "Espera un momento mientras preparamos tu workspace.",
"folderSelection.loading.subtitle": "Espera mientras preparamos tu espacio de trabajo.",
"folderSelection.drop.title": "Suelta una carpeta para abrirla",
"folderSelection.drop.subtitle": "Inicia una nueva instancia en la carpeta soltada.",
@@ -39,4 +42,32 @@ export const folderSelectionMessages = {
"folderSelection.dialog.title": "Seleccionar workspace",
"folderSelection.dialog.description": "Selecciona un workspace para empezar a programar.",
"folderSelection.tabs.local": "Carpetas locales",
"folderSelection.tabs.servers": "Servidores",
"folderSelection.servers.title": "Servidores guardados",
"folderSelection.servers.subtitle": "Abre un servidor remoto de CodeNomad guardado en una ventana nueva",
"folderSelection.servers.count": "{count} servidores",
"folderSelection.servers.empty.title": "No hay servidores guardados",
"folderSelection.servers.empty.description": "Añade un servidor remoto para volver a conectarte rápidamente desde este dispositivo",
"folderSelection.servers.connectTitle": "Conectar a un servidor",
"folderSelection.servers.connectSubtitle": "Guarda un servidor remoto de CodeNomad y ábrelo en una ventana nueva",
"folderSelection.servers.connectButton": "Conectar a un servidor",
"folderSelection.servers.remove": "Eliminar servidor guardado",
"folderSelection.servers.skipTls": "TLS autofirmado",
"folderSelection.servers.errorTitle": "Falló la conexión remota",
"folderSelection.servers.dialog.title": "Conectar a un servidor",
"folderSelection.servers.dialog.description": "Añade un servidor remoto de CodeNomad y ábrelo ahora si quieres.",
"folderSelection.servers.dialog.name": "Nombre del servidor",
"folderSelection.servers.dialog.namePlaceholder": "Servidor de producción",
"folderSelection.servers.dialog.url": "URL del servidor",
"folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com",
"folderSelection.servers.dialog.skipTls": "Omitir la verificación TLS para certificados autofirmados.",
"folderSelection.servers.dialog.cancel": "Cancelar",
"folderSelection.servers.dialog.save": "Guardar",
"folderSelection.servers.dialog.connect": "Conectar",
"folderSelection.servers.dialog.connecting": "Conectando...",
"folderSelection.servers.dialog.errorRequired": "El nombre y la URL del servidor son obligatorios.",
"folderSelection.servers.dialog.errorConnect": "No se pudo conectar al servidor remoto.",
"folderSelection.sidecars.button": "Open SideCar",
} as const

View File

@@ -150,6 +150,8 @@ export const instanceMessages = {
"instanceShell.backgroundProcesses.empty": "No hay procesos en segundo plano.",
"instanceShell.backgroundProcesses.status": "Estado: {status}",
"instanceShell.backgroundProcesses.output": "Salida: {sizeKb} KB",
"instanceShell.backgroundProcesses.notify.enabled": "Notificacion de finalizacion activada",
"instanceShell.backgroundProcesses.notify.disabled": "Notificacion de finalizacion desactivada",
"instanceShell.backgroundProcesses.actions.output": "Salida",
"instanceShell.backgroundProcesses.actions.stop": "Detener",
"instanceShell.backgroundProcesses.actions.terminate": "Terminar",

View File

@@ -41,6 +41,8 @@ export const remoteAccessMessages = {
"remoteAccess.sections.addresses.help": "Abre o escanea desde otra máquina para transferir el control.",
"remoteAccess.addresses.loading": "Cargando direcciones…",
"remoteAccess.addresses.none": "Aún no hay direcciones disponibles.",
"remoteAccess.addresses.actions.showOther": "Mostrar {count} direcciones más",
"remoteAccess.addresses.actions.hideOther": "Ocultar otras direcciones",
"remoteAccess.address.scope.network": "Red",
"remoteAccess.address.scope.loopback": "Loopback",
"remoteAccess.address.scope.internal": "Interna",

View File

@@ -113,6 +113,14 @@ export const settingsMessages = {
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.opencode.runtime.title": "Runtime",
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
"settings.opencode.logLevel.title": "Nivel de logs de OpenCode",
"settings.opencode.logLevel.subtitle": "Define el nivel de logs usado al iniciar nuevas instancias de OpenCode.",
"settings.opencode.logLevel.selector.title": "Verbosidad de logs",
"settings.opencode.logLevel.selector.subtitle": "Elige cuanta informacion deben registrar las nuevas instancias de OpenCode.",
"settings.opencode.logLevel.option.debug": "Depuracion",
"settings.opencode.logLevel.option.info": "Informacion",
"settings.opencode.logLevel.option.warn": "Advertencia",
"settings.opencode.logLevel.option.error": "Error",
"settings.appearance.behavior.title": "Interaccion",
"settings.appearance.behavior.subtitle": "Valores predeterminados de mensajes, diffs y entrada.",
@@ -186,4 +194,40 @@ export const settingsMessages = {
"settings.speech.save.saved": "Guardado",
"settings.speech.save.unsaved": "Cambios sin guardar",
"settings.speech.save.error": "Error al guardar",
"settings.nav.sidecars": "SideCars",
"settings.section.sidecars.eyebrow": "Server services",
"settings.section.sidecars.title": "SideCars",
"settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.",
"sidecars.form.name": "Name",
"sidecars.form.validation": "Enter a valid SideCar name and port.",
"sidecars.form.port": "Port",
"sidecars.form.insecure": "Use HTTP",
"sidecars.form.protocol": "Protocol",
"sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.",
"sidecars.form.protocol.https": "HTTPS",
"sidecars.form.protocol.http": "HTTP",
"sidecars.form.prefixMode": "Prefix mode",
"sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.",
"sidecars.form.prefixMode.strip": "Strip prefix",
"sidecars.form.prefixMode.preserve": "Preserve prefix",
"sidecars.form.add": "Add SideCar",
"sidecars.kind.port": "Port",
"sidecars.status.running": "Running",
"sidecars.status.stopped": "Stopped",
"sidecars.basePath": "Base path",
"sidecars.settings.listTitle": "Configured SideCars",
"sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.",
"sidecars.settings.empty": "No SideCars configured yet.",
"sidecars.picker.title": "Open SideCar",
"sidecars.picker.loading": "Loading SideCars...",
"sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.",
"sidecars.picker.empty": "No port-based SideCars are available yet.",
"sidecars.picker.close": "Close",
"sidecars.open.errorTitle": "Unable to open SideCar",
"sidecars.open.notFound": "SideCar not found.",
"sidecars.open.notRunning": "SideCar is not reachable on its configured port.",
"sidecars.back": "Back",
"sidecars.refresh": "Refresh",
"sidecars.path": "Path",
"sidecars.go": "Go",
} as const

View File

@@ -18,6 +18,11 @@ export const toolCallMessages = {
"toolCall.diff.viewMode.ariaLabel": "Modo de vista de diff",
"toolCall.diff.viewMode.split": "Dividida",
"toolCall.diff.viewMode.unified": "Unificada",
"toolCall.diff.switchToSplit": "Cambiar a vista dividida",
"toolCall.diff.switchToUnified": "Cambiar a vista unificada",
"toolCall.diff.enableWordWrap": "Activar ajuste de línea",
"toolCall.diff.disableWordWrap": "Desactivar ajuste de línea",
"toolCall.diff.copyPatch": "Copiar patch",
"toolCall.diagnostics.title": "Diagnósticos",
"toolCall.diagnostics.ariaLabel": "Diagnósticos",

View File

@@ -15,17 +15,17 @@ export const commandMessages = {
"commands.newInstance.description": "Ouvrir le sélecteur de dossiers pour créer une nouvelle instance",
"commands.newInstance.keywords": "dossier, projet, espace de travail",
"commands.closeInstance.label": "Fermer l'instance",
"commands.closeInstance.description": "Arrêter le serveur de l'instance actuelle",
"commands.closeInstance.keywords": "arrêter, quitter, fermer",
"commands.closeInstance.label": "Fermer l'onglet",
"commands.closeInstance.description": "Fermer l'onglet de premier niveau actuel",
"commands.closeInstance.keywords": "arrêter, quitter, fermer, onglet",
"commands.nextInstance.label": "Instance suivante",
"commands.nextInstance.description": "Passer à l'onglet d'instance suivant",
"commands.nextInstance.keywords": "changer, naviguer, suivant",
"commands.nextInstance.label": "Onglet suivant",
"commands.nextInstance.description": "Passer à l'onglet de premier niveau suivant",
"commands.nextInstance.keywords": "changer, naviguer, suivant, onglet",
"commands.previousInstance.label": "Instance précédente",
"commands.previousInstance.description": "Passer à l'onglet d'instance précédent",
"commands.previousInstance.keywords": "changer, naviguer, précédent",
"commands.previousInstance.label": "Onglet précédent",
"commands.previousInstance.description": "Passer à l'onglet de premier niveau précédent",
"commands.previousInstance.keywords": "changer, naviguer, précédent, onglet",
"commands.newSession.label": "Nouvelle session",
"commands.newSession.description": "Créer une nouvelle session parente",

View File

@@ -5,7 +5,7 @@ export const folderSelectionMessages = {
"folderSelection.tagline": "Sélectionnez un dossier pour commencer à coder avec l'IA",
"folderSelection.links.github": "GitHub de CodeNomad",
"folderSelection.links.githubStars": "Stars GitHub de CodeNomad",
"folderSelection.links.githubStars": "Étoiles GitHub de CodeNomad",
"folderSelection.links.discord": "Discord de CodeNomad",
"folderSelection.empty.title": "Aucun dossier récent",
@@ -16,10 +16,13 @@ export const folderSelectionMessages = {
"folderSelection.recent.subtitle.other": "{count} dossiers disponibles",
"folderSelection.recent.remove": "Retirer des récents",
"folderSelection.browse.title": "Parcourir les dossiers",
"folderSelection.browse.title": "Parcourir un dossier",
"folderSelection.browse.subtitle": "Sélectionnez n'importe quel dossier sur votre ordinateur",
"folderSelection.browse.button": "Parcourir les dossiers",
"folderSelection.browse.buttonOpening": "Ouverture...",
"folderSelection.actions.title": "Ouvrir un dossier ou se connecter à un serveur",
"folderSelection.actions.subtitle": "Ouvrez un dossier local ou connectez-vous à un serveur CodeNomad",
"folderSelection.actions.connectButton": "Se connecter au serveur CodeNomad",
"folderSelection.advancedSettings": "Paramètres avancés",
"folderSelection.opencode": "OpenCode",
@@ -39,4 +42,32 @@ export const folderSelectionMessages = {
"folderSelection.dialog.title": "Sélectionner l'espace de travail",
"folderSelection.dialog.description": "Sélectionnez un espace de travail pour commencer à coder.",
"folderSelection.tabs.local": "Dossiers locaux",
"folderSelection.tabs.servers": "Serveurs",
"folderSelection.servers.title": "Serveurs enregistrés",
"folderSelection.servers.subtitle": "Ouvrez un serveur CodeNomad distant enregistré dans une nouvelle fenêtre",
"folderSelection.servers.count": "{count} serveurs",
"folderSelection.servers.empty.title": "Aucun serveur enregistré",
"folderSelection.servers.empty.description": "Ajoutez un serveur distant pour vous reconnecter rapidement depuis cet appareil",
"folderSelection.servers.connectTitle": "Se connecter à un serveur",
"folderSelection.servers.connectSubtitle": "Enregistrez un serveur CodeNomad distant et ouvrez-le dans une nouvelle fenêtre",
"folderSelection.servers.connectButton": "Se connecter à un serveur",
"folderSelection.servers.remove": "Supprimer le serveur enregistré",
"folderSelection.servers.skipTls": "TLS auto-signé",
"folderSelection.servers.errorTitle": "Échec de la connexion distante",
"folderSelection.servers.dialog.title": "Se connecter à un serveur",
"folderSelection.servers.dialog.description": "Ajoutez un serveur CodeNomad distant et ouvrez-le immédiatement si vous le souhaitez.",
"folderSelection.servers.dialog.name": "Nom du serveur",
"folderSelection.servers.dialog.namePlaceholder": "Serveur de production",
"folderSelection.servers.dialog.url": "URL du serveur",
"folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com",
"folderSelection.servers.dialog.skipTls": "Ignorer la vérification TLS pour les certificats auto-signés.",
"folderSelection.servers.dialog.cancel": "Annuler",
"folderSelection.servers.dialog.save": "Enregistrer",
"folderSelection.servers.dialog.connect": "Se connecter",
"folderSelection.servers.dialog.connecting": "Connexion...",
"folderSelection.servers.dialog.errorRequired": "Le nom du serveur et l'URL sont requis.",
"folderSelection.servers.dialog.errorConnect": "Impossible de se connecter au serveur distant.",
"folderSelection.sidecars.button": "Open SideCar",
} as const

View File

@@ -150,6 +150,8 @@ export const instanceMessages = {
"instanceShell.backgroundProcesses.empty": "Aucun processus en arrière-plan.",
"instanceShell.backgroundProcesses.status": "Statut : {status}",
"instanceShell.backgroundProcesses.output": "Sortie : {sizeKb}KB",
"instanceShell.backgroundProcesses.notify.enabled": "Notification de fin activee",
"instanceShell.backgroundProcesses.notify.disabled": "Notification de fin desactivee",
"instanceShell.backgroundProcesses.actions.output": "Sortie",
"instanceShell.backgroundProcesses.actions.stop": "Arrêter",
"instanceShell.backgroundProcesses.actions.terminate": "Terminer",

View File

@@ -41,6 +41,8 @@ export const remoteAccessMessages = {
"remoteAccess.sections.addresses.help": "Lancez ou scannez depuis une autre machine pour passer le contrôle.",
"remoteAccess.addresses.loading": "Chargement des adresses…",
"remoteAccess.addresses.none": "Aucune adresse disponible pour le moment.",
"remoteAccess.addresses.actions.showOther": "Afficher {count} autres adresses",
"remoteAccess.addresses.actions.hideOther": "Masquer les autres adresses",
"remoteAccess.address.scope.network": "Réseau",
"remoteAccess.address.scope.loopback": "Boucle locale",
"remoteAccess.address.scope.internal": "Interne",

View File

@@ -113,6 +113,14 @@ export const settingsMessages = {
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.opencode.runtime.title": "Runtime",
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
"settings.opencode.logLevel.title": "Niveau de logs OpenCode",
"settings.opencode.logLevel.subtitle": "Definir le niveau de logs utilise au lancement des nouvelles instances OpenCode.",
"settings.opencode.logLevel.selector.title": "Verbosite des logs",
"settings.opencode.logLevel.selector.subtitle": "Choisir la quantite de journaux emise par les nouvelles instances OpenCode.",
"settings.opencode.logLevel.option.debug": "Debogage",
"settings.opencode.logLevel.option.info": "Info",
"settings.opencode.logLevel.option.warn": "Avertissement",
"settings.opencode.logLevel.option.error": "Erreur",
"settings.appearance.behavior.title": "Interaction",
"settings.appearance.behavior.subtitle": "Parametres par defaut pour les messages, les diffs et la saisie.",
@@ -186,4 +194,40 @@ export const settingsMessages = {
"settings.speech.save.saved": "Enregistré",
"settings.speech.save.unsaved": "Modifications non enregistrées",
"settings.speech.save.error": "Échec de l'enregistrement",
"settings.nav.sidecars": "SideCars",
"settings.section.sidecars.eyebrow": "Server services",
"settings.section.sidecars.title": "SideCars",
"settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.",
"sidecars.form.name": "Name",
"sidecars.form.validation": "Enter a valid SideCar name and port.",
"sidecars.form.port": "Port",
"sidecars.form.insecure": "Use HTTP",
"sidecars.form.protocol": "Protocol",
"sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.",
"sidecars.form.protocol.https": "HTTPS",
"sidecars.form.protocol.http": "HTTP",
"sidecars.form.prefixMode": "Prefix mode",
"sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.",
"sidecars.form.prefixMode.strip": "Strip prefix",
"sidecars.form.prefixMode.preserve": "Preserve prefix",
"sidecars.form.add": "Add SideCar",
"sidecars.kind.port": "Port",
"sidecars.status.running": "Running",
"sidecars.status.stopped": "Stopped",
"sidecars.basePath": "Base path",
"sidecars.settings.listTitle": "Configured SideCars",
"sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.",
"sidecars.settings.empty": "No SideCars configured yet.",
"sidecars.picker.title": "Open SideCar",
"sidecars.picker.loading": "Loading SideCars...",
"sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.",
"sidecars.picker.empty": "No port-based SideCars are available yet.",
"sidecars.picker.close": "Close",
"sidecars.open.errorTitle": "Unable to open SideCar",
"sidecars.open.notFound": "SideCar not found.",
"sidecars.open.notRunning": "SideCar is not reachable on its configured port.",
"sidecars.back": "Back",
"sidecars.refresh": "Refresh",
"sidecars.path": "Path",
"sidecars.go": "Go",
} as const

View File

@@ -18,6 +18,11 @@ export const toolCallMessages = {
"toolCall.diff.viewMode.ariaLabel": "Mode d'affichage du diff",
"toolCall.diff.viewMode.split": "Côte à côte",
"toolCall.diff.viewMode.unified": "Unifié",
"toolCall.diff.switchToSplit": "Passer à la vue côte à côte",
"toolCall.diff.switchToUnified": "Passer à la vue unifiée",
"toolCall.diff.enableWordWrap": "Activer le retour à la ligne",
"toolCall.diff.disableWordWrap": "Désactiver le retour à la ligne",
"toolCall.diff.copyPatch": "Copier le patch",
"toolCall.diagnostics.title": "Diagnostics",
"toolCall.diagnostics.ariaLabel": "Diagnostics",

View File

@@ -15,17 +15,17 @@ export const commandMessages = {
"commands.newInstance.description": "פתח בורר תיקיות ליצירת מופע חדש",
"commands.newInstance.keywords": "תיקייה, פרויקט, סביבת עבודה",
"commands.closeInstance.label": "סגור מופע",
"commands.closeInstance.description": "עצור את השרת של המופע הנוכחי",
"commands.closeInstance.keywords": "עצור, סגור",
"commands.closeInstance.label": "סגור לשונית",
"commands.closeInstance.description": "סגור את הלשונית העליונה הנוכחית",
"commands.closeInstance.keywords": "עצור, סגור, לשונית",
"commands.nextInstance.label": "מופע הבא",
"commands.nextInstance.description": "עבור למופע הבא",
"commands.nextInstance.keywords": "החלף, נווט",
"commands.nextInstance.label": "הלשונית הבאה",
"commands.nextInstance.description": "עבור ללשונית העליונה הבאה",
"commands.nextInstance.keywords": "החלף, נווט, לשונית",
"commands.previousInstance.label": "מופע קודם",
"commands.previousInstance.description": "עבור למופע הקודם",
"commands.previousInstance.keywords": "החלף, נווט",
"commands.previousInstance.label": "הלשונית הקודמת",
"commands.previousInstance.description": "עבור ללשונית העליונה הקודמת",
"commands.previousInstance.keywords": "החלף, נווט, לשונית",
"commands.newSession.label": "סשן חדש",
"commands.newSession.description": "צור סשן הורה חדש",

View File

@@ -20,6 +20,9 @@ export const folderSelectionMessages = {
"folderSelection.browse.subtitle": "בחר כל תיקייה במחשב שלך",
"folderSelection.browse.button": "עיון בתיקיות",
"folderSelection.browse.buttonOpening": "פותח...",
"folderSelection.actions.title": "פתח תיקייה או התחבר לשרת",
"folderSelection.actions.subtitle": "פתח תיקייה מקומית או התחבר לשרת CodeNomad",
"folderSelection.actions.connectButton": "התחבר לשרת CodeNomad",
"folderSelection.advancedSettings": "הגדרות מתקדמות",
"folderSelection.opencode": "OpenCode",
@@ -39,4 +42,32 @@ export const folderSelectionMessages = {
"folderSelection.dialog.title": "בחר סביבת עבודה",
"folderSelection.dialog.description": "בחר סביבת עבודה כדי להתחיל לתכנת.",
"folderSelection.tabs.local": "תיקיות מקומיות",
"folderSelection.tabs.servers": "שרתים",
"folderSelection.servers.title": "שרתים שמורים",
"folderSelection.servers.subtitle": "פתח שרת CodeNomad מרוחק שמור בחלון חדש",
"folderSelection.servers.count": "{count} שרתים",
"folderSelection.servers.empty.title": "אין שרתים שמורים",
"folderSelection.servers.empty.description": "הוסף שרת מרוחק כדי להתחבר אליו במהירות מהמכשיר הזה",
"folderSelection.servers.connectTitle": "התחבר לשרת",
"folderSelection.servers.connectSubtitle": "שמור שרת CodeNomad מרוחק ופתח אותו בחלון חדש",
"folderSelection.servers.connectButton": "התחבר לשרת",
"folderSelection.servers.remove": "הסר שרת שמור",
"folderSelection.servers.skipTls": "TLS בחתימה עצמית",
"folderSelection.servers.errorTitle": "החיבור המרוחק נכשל",
"folderSelection.servers.dialog.title": "התחבר לשרת",
"folderSelection.servers.dialog.description": "הוסף שרת CodeNomad מרוחק ופתח אותו מיד אם תרצה.",
"folderSelection.servers.dialog.name": "שם השרת",
"folderSelection.servers.dialog.namePlaceholder": "שרת ייצור",
"folderSelection.servers.dialog.url": "כתובת השרת",
"folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com",
"folderSelection.servers.dialog.skipTls": "דלג על אימות TLS עבור תעודות בחתימה עצמית.",
"folderSelection.servers.dialog.cancel": "ביטול",
"folderSelection.servers.dialog.save": "שמור",
"folderSelection.servers.dialog.connect": "התחבר",
"folderSelection.servers.dialog.connecting": "מתחבר...",
"folderSelection.servers.dialog.errorRequired": "שם השרת והכתובת הם שדות חובה.",
"folderSelection.servers.dialog.errorConnect": "לא ניתן היה להתחבר לשרת המרוחק.",
"folderSelection.sidecars.button": "Open SideCar",
} as const

View File

@@ -158,6 +158,8 @@ export const instanceMessages = {
"instanceShell.backgroundProcesses.empty": "אין תהליכי רקע.",
"instanceShell.backgroundProcesses.status": "סטטוס: {status}",
"instanceShell.backgroundProcesses.output": "פלט: {sizeKb}KB",
"instanceShell.backgroundProcesses.notify.enabled": "התראת סיום פעילה",
"instanceShell.backgroundProcesses.notify.disabled": "התראת סיום כבויה",
"instanceShell.backgroundProcesses.actions.output": "פלט",
"instanceShell.backgroundProcesses.actions.stop": "עצור",
"instanceShell.backgroundProcesses.actions.terminate": "סיים",

View File

@@ -41,6 +41,8 @@ export const remoteAccessMessages = {
"remoteAccess.sections.addresses.help": "הפעל או סרוק ממכונה אחרת להעברת שליטה.",
"remoteAccess.addresses.loading": "טוען כתובות…",
"remoteAccess.addresses.none": "אין כתובות זמינות עדיין.",
"remoteAccess.addresses.actions.showOther": "הצג עוד {count} כתובות",
"remoteAccess.addresses.actions.hideOther": "הסתר כתובות נוספות",
"remoteAccess.address.scope.network": "רשת",
"remoteAccess.address.scope.loopback": "לולאה מקומית",
"remoteAccess.address.scope.internal": "פנימי",

View File

@@ -112,6 +112,14 @@ export const settingsMessages = {
"settings.section.opencode.subtitle": "בחר את הקובץ הבינארי של OpenCode והסביבה לשימוש במופעים חדשים.",
"settings.opencode.runtime.title": "סביבת ריצה",
"settings.opencode.runtime.subtitle": "הגדר עם איזה קובץ בינארי של OpenCode מופעים חדשים יופעלו.",
"settings.opencode.logLevel.title": "רמת הלוגים של OpenCode",
"settings.opencode.logLevel.subtitle": "הגדר את רמת הלוגים שבה ייעשה שימוש בעת הפעלת מופעי OpenCode חדשים.",
"settings.opencode.logLevel.selector.title": "פירוט לוגים",
"settings.opencode.logLevel.selector.subtitle": "בחר כמה לוגים מופעי OpenCode חדשים צריכים להפיק.",
"settings.opencode.logLevel.option.debug": "ניפוי שגיאות",
"settings.opencode.logLevel.option.info": "מידע",
"settings.opencode.logLevel.option.warn": "אזהרה",
"settings.opencode.logLevel.option.error": "שגיאה",
"settings.appearance.behavior.title": "אינטראקציה",
"settings.appearance.behavior.subtitle": "ברירות מחדל להודעות, diff וקלט.",
@@ -185,4 +193,40 @@ export const settingsMessages = {
"settings.speech.save.saved": "נשמר",
"settings.speech.save.unsaved": "יש שינויים שלא נשמרו",
"settings.speech.save.error": "השמירה נכשלה",
"settings.nav.sidecars": "SideCars",
"settings.section.sidecars.eyebrow": "Server services",
"settings.section.sidecars.title": "SideCars",
"settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.",
"sidecars.form.name": "Name",
"sidecars.form.validation": "Enter a valid SideCar name and port.",
"sidecars.form.port": "Port",
"sidecars.form.insecure": "Use HTTP",
"sidecars.form.protocol": "Protocol",
"sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.",
"sidecars.form.protocol.https": "HTTPS",
"sidecars.form.protocol.http": "HTTP",
"sidecars.form.prefixMode": "Prefix mode",
"sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.",
"sidecars.form.prefixMode.strip": "Strip prefix",
"sidecars.form.prefixMode.preserve": "Preserve prefix",
"sidecars.form.add": "Add SideCar",
"sidecars.kind.port": "Port",
"sidecars.status.running": "Running",
"sidecars.status.stopped": "Stopped",
"sidecars.basePath": "Base path",
"sidecars.settings.listTitle": "Configured SideCars",
"sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.",
"sidecars.settings.empty": "No SideCars configured yet.",
"sidecars.picker.title": "Open SideCar",
"sidecars.picker.loading": "Loading SideCars...",
"sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.",
"sidecars.picker.empty": "No port-based SideCars are available yet.",
"sidecars.picker.close": "Close",
"sidecars.open.errorTitle": "Unable to open SideCar",
"sidecars.open.notFound": "SideCar not found.",
"sidecars.open.notRunning": "SideCar is not reachable on its configured port.",
"sidecars.back": "Back",
"sidecars.refresh": "Refresh",
"sidecars.path": "Path",
"sidecars.go": "Go",
} as const

View File

@@ -18,6 +18,11 @@ export const toolCallMessages = {
"toolCall.diff.viewMode.ariaLabel": "מצב תצוגת diff",
"toolCall.diff.viewMode.split": "מפוצל",
"toolCall.diff.viewMode.unified": "מאוחד",
"toolCall.diff.switchToSplit": "עבור לתצוגה מפוצלת",
"toolCall.diff.switchToUnified": "עבור לתצוגה מאוחדת",
"toolCall.diff.enableWordWrap": "הפעל גלישת מילים",
"toolCall.diff.disableWordWrap": "כבה גלישת מילים",
"toolCall.diff.copyPatch": "העתק patch",
"toolCall.diagnostics.title": "אבחון",
"toolCall.diagnostics.ariaLabel": "אבחון",

View File

@@ -15,17 +15,17 @@ export const commandMessages = {
"commands.newInstance.description": "フォルダ選択を開いて新しいインスタンスを作成",
"commands.newInstance.keywords": "フォルダ, プロジェクト, ワークスペース, folder, project, workspace",
"commands.closeInstance.label": "インスタンスを閉じる",
"commands.closeInstance.description": "現在のインスタンスのサーバーを停止",
"commands.closeInstance.keywords": "停止, 終了, 閉じる, stop, quit, close",
"commands.closeInstance.label": "タブを閉じる",
"commands.closeInstance.description": "現在のトップレベルタブを閉じる",
"commands.closeInstance.keywords": "閉じる, タブ, stop, quit, close",
"commands.nextInstance.label": "次のインスタンス",
"commands.nextInstance.description": "次のインスタンスタブへ切り替え",
"commands.nextInstance.keywords": "切り替え, 移動, switch, navigate",
"commands.nextInstance.label": "次のタブ",
"commands.nextInstance.description": "次のトップレベルタブへ切り替え",
"commands.nextInstance.keywords": "切り替え, 移動, タブ, switch, navigate",
"commands.previousInstance.label": "前のインスタンス",
"commands.previousInstance.description": "前のインスタンスタブへ切り替え",
"commands.previousInstance.keywords": "切り替え, 移動, switch, navigate",
"commands.previousInstance.label": "前のタブ",
"commands.previousInstance.description": "前のトップレベルタブへ切り替え",
"commands.previousInstance.keywords": "切り替え, 移動, タブ, switch, navigate",
"commands.newSession.label": "新しいセッション",
"commands.newSession.description": "新しい親セッションを作成",

View File

@@ -20,6 +20,9 @@ export const folderSelectionMessages = {
"folderSelection.browse.subtitle": "コンピュータ上の任意のフォルダを選択",
"folderSelection.browse.button": "フォルダを参照",
"folderSelection.browse.buttonOpening": "開いています...",
"folderSelection.actions.title": "フォルダを開くかサーバーに接続",
"folderSelection.actions.subtitle": "ローカルフォルダを開くか CodeNomad サーバーに接続します",
"folderSelection.actions.connectButton": "CodeNomad サーバーに接続",
"folderSelection.advancedSettings": "詳細設定",
"folderSelection.opencode": "OpenCode",
@@ -39,4 +42,32 @@ export const folderSelectionMessages = {
"folderSelection.dialog.title": "ワークスペースを選択",
"folderSelection.dialog.description": "コーディングを開始するワークスペースを選択してください。",
"folderSelection.tabs.local": "ローカルフォルダ",
"folderSelection.tabs.servers": "サーバー",
"folderSelection.servers.title": "保存済みサーバー",
"folderSelection.servers.subtitle": "保存したリモート CodeNomad サーバーを新しいウィンドウで開きます",
"folderSelection.servers.count": "{count} サーバー",
"folderSelection.servers.empty.title": "保存済みサーバーはありません",
"folderSelection.servers.empty.description": "この端末からすばやく再接続できるように、リモートサーバーを追加してください",
"folderSelection.servers.connectTitle": "サーバーに接続",
"folderSelection.servers.connectSubtitle": "リモート CodeNomad サーバーを保存して新しいウィンドウで開きます",
"folderSelection.servers.connectButton": "サーバーに接続",
"folderSelection.servers.remove": "保存したサーバーを削除",
"folderSelection.servers.skipTls": "自己署名 TLS",
"folderSelection.servers.errorTitle": "リモート接続に失敗しました",
"folderSelection.servers.dialog.title": "サーバーに接続",
"folderSelection.servers.dialog.description": "リモート CodeNomad サーバーを追加し、必要に応じてすぐに開きます。",
"folderSelection.servers.dialog.name": "サーバー名",
"folderSelection.servers.dialog.namePlaceholder": "本番サーバー",
"folderSelection.servers.dialog.url": "サーバー URL",
"folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com",
"folderSelection.servers.dialog.skipTls": "自己署名証明書の TLS 検証をスキップします。",
"folderSelection.servers.dialog.cancel": "キャンセル",
"folderSelection.servers.dialog.save": "保存",
"folderSelection.servers.dialog.connect": "接続",
"folderSelection.servers.dialog.connecting": "接続中...",
"folderSelection.servers.dialog.errorRequired": "サーバー名と URL は必須です。",
"folderSelection.servers.dialog.errorConnect": "リモートサーバーに接続できませんでした。",
"folderSelection.sidecars.button": "Open SideCar",
} as const

View File

@@ -150,6 +150,8 @@ export const instanceMessages = {
"instanceShell.backgroundProcesses.empty": "バックグラウンドプロセスはありません。",
"instanceShell.backgroundProcesses.status": "状態: {status}",
"instanceShell.backgroundProcesses.output": "出力: {sizeKb}KB",
"instanceShell.backgroundProcesses.notify.enabled": "完了通知が有効",
"instanceShell.backgroundProcesses.notify.disabled": "完了通知が無効",
"instanceShell.backgroundProcesses.actions.output": "出力",
"instanceShell.backgroundProcesses.actions.stop": "停止",
"instanceShell.backgroundProcesses.actions.terminate": "終了",

View File

@@ -41,6 +41,8 @@ export const remoteAccessMessages = {
"remoteAccess.sections.addresses.help": "別の端末から起動またはスキャンして操作を引き継ぎます。",
"remoteAccess.addresses.loading": "アドレスを読み込み中…",
"remoteAccess.addresses.none": "まだ利用可能なアドレスがありません。",
"remoteAccess.addresses.actions.showOther": "他の {count} 件のアドレスを表示",
"remoteAccess.addresses.actions.hideOther": "他のアドレスを隠す",
"remoteAccess.address.scope.network": "ネットワーク",
"remoteAccess.address.scope.loopback": "ループバック",
"remoteAccess.address.scope.internal": "内部",

View File

@@ -113,6 +113,14 @@ export const settingsMessages = {
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.opencode.runtime.title": "Runtime",
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
"settings.opencode.logLevel.title": "OpenCode のログレベル",
"settings.opencode.logLevel.subtitle": "新しい OpenCode インスタンスの起動時に使うログレベルを設定します。",
"settings.opencode.logLevel.selector.title": "ログ出力の詳細度",
"settings.opencode.logLevel.selector.subtitle": "新しい OpenCode インスタンスがどの程度ログを出力するかを選択します。",
"settings.opencode.logLevel.option.debug": "デバッグ",
"settings.opencode.logLevel.option.info": "情報",
"settings.opencode.logLevel.option.warn": "警告",
"settings.opencode.logLevel.option.error": "エラー",
"settings.appearance.behavior.title": "操作",
"settings.appearance.behavior.subtitle": "メッセージ、差分、入力の既定値。",
@@ -186,4 +194,40 @@ export const settingsMessages = {
"settings.speech.save.saved": "保存済み",
"settings.speech.save.unsaved": "未保存の変更",
"settings.speech.save.error": "保存に失敗しました",
"settings.nav.sidecars": "SideCars",
"settings.section.sidecars.eyebrow": "Server services",
"settings.section.sidecars.title": "SideCars",
"settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.",
"sidecars.form.name": "Name",
"sidecars.form.validation": "Enter a valid SideCar name and port.",
"sidecars.form.port": "Port",
"sidecars.form.insecure": "Use HTTP",
"sidecars.form.protocol": "Protocol",
"sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.",
"sidecars.form.protocol.https": "HTTPS",
"sidecars.form.protocol.http": "HTTP",
"sidecars.form.prefixMode": "Prefix mode",
"sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.",
"sidecars.form.prefixMode.strip": "Strip prefix",
"sidecars.form.prefixMode.preserve": "Preserve prefix",
"sidecars.form.add": "Add SideCar",
"sidecars.kind.port": "Port",
"sidecars.status.running": "Running",
"sidecars.status.stopped": "Stopped",
"sidecars.basePath": "Base path",
"sidecars.settings.listTitle": "Configured SideCars",
"sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.",
"sidecars.settings.empty": "No SideCars configured yet.",
"sidecars.picker.title": "Open SideCar",
"sidecars.picker.loading": "Loading SideCars...",
"sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.",
"sidecars.picker.empty": "No port-based SideCars are available yet.",
"sidecars.picker.close": "Close",
"sidecars.open.errorTitle": "Unable to open SideCar",
"sidecars.open.notFound": "SideCar not found.",
"sidecars.open.notRunning": "SideCar is not reachable on its configured port.",
"sidecars.back": "Back",
"sidecars.refresh": "Refresh",
"sidecars.path": "Path",
"sidecars.go": "Go",
} as const

View File

@@ -18,6 +18,11 @@ export const toolCallMessages = {
"toolCall.diff.viewMode.ariaLabel": "diff 表示モード",
"toolCall.diff.viewMode.split": "分割",
"toolCall.diff.viewMode.unified": "ユニファイド",
"toolCall.diff.switchToSplit": "分割表示に切り替え",
"toolCall.diff.switchToUnified": "ユニファイド表示に切り替え",
"toolCall.diff.enableWordWrap": "折り返しを有効化",
"toolCall.diff.disableWordWrap": "折り返しを無効化",
"toolCall.diff.copyPatch": "パッチをコピー",
"toolCall.diagnostics.title": "診断",
"toolCall.diagnostics.ariaLabel": "診断",

View File

@@ -15,17 +15,17 @@ export const commandMessages = {
"commands.newInstance.description": "Открыть выбор папки для создания нового экземпляра",
"commands.newInstance.keywords": "папка, проект, рабочее пространство",
"commands.closeInstance.label": "Закрыть экземпляр",
"commands.closeInstance.description": "Остановить сервер текущего экземпляра",
"commands.closeInstance.keywords": "остановить, выйти, закрыть",
"commands.closeInstance.label": "Закрыть вкладку",
"commands.closeInstance.description": "Закрыть текущую верхнеуровневую вкладку",
"commands.closeInstance.keywords": "остановить, выйти, закрыть, вкладка",
"commands.nextInstance.label": "Следующий экземпляр",
"commands.nextInstance.description": "Переключиться на следующую вкладку экземпляра",
"commands.nextInstance.keywords": "переключить, навигация",
"commands.nextInstance.label": "Следующая вкладка",
"commands.nextInstance.description": "Переключиться на следующую верхнеуровневую вкладку",
"commands.nextInstance.keywords": "переключить, навигация, вкладка",
"commands.previousInstance.label": "Предыдущий экземпляр",
"commands.previousInstance.description": "Переключиться на предыдущую вкладку экземпляра",
"commands.previousInstance.keywords": "переключить, навигация",
"commands.previousInstance.label": "Предыдущая вкладка",
"commands.previousInstance.description": "Переключиться на предыдущую верхнеуровневую вкладку",
"commands.previousInstance.keywords": "переключить, навигация, вкладка",
"commands.newSession.label": "Новая сессия",
"commands.newSession.description": "Создать новую родительскую сессию",

View File

@@ -20,6 +20,9 @@ export const folderSelectionMessages = {
"folderSelection.browse.subtitle": "Выберите любую папку на компьютере",
"folderSelection.browse.button": "Обзор папок",
"folderSelection.browse.buttonOpening": "Открытие…",
"folderSelection.actions.title": "Открыть папку или подключить сервер",
"folderSelection.actions.subtitle": "Откройте локальную папку или подключитесь к серверу CodeNomad",
"folderSelection.actions.connectButton": "Подключить сервер CodeNomad",
"folderSelection.advancedSettings": "Расширенные настройки",
"folderSelection.opencode": "OpenCode",
@@ -39,4 +42,32 @@ export const folderSelectionMessages = {
"folderSelection.dialog.title": "Выберите рабочее пространство",
"folderSelection.dialog.description": "Выберите рабочее пространство, чтобы начать писать код.",
"folderSelection.tabs.local": "Локальные папки",
"folderSelection.tabs.servers": "Серверы",
"folderSelection.servers.title": "Сохраненные серверы",
"folderSelection.servers.subtitle": "Откройте сохраненный удаленный сервер CodeNomad в новом окне",
"folderSelection.servers.count": "{count} серверов",
"folderSelection.servers.empty.title": "Нет сохраненных серверов",
"folderSelection.servers.empty.description": "Добавьте удаленный сервер, чтобы быстро подключаться к нему с этого устройства",
"folderSelection.servers.connectTitle": "Подключиться к серверу",
"folderSelection.servers.connectSubtitle": "Сохраните удаленный сервер CodeNomad и откройте его в новом окне",
"folderSelection.servers.connectButton": "Подключиться к серверу",
"folderSelection.servers.remove": "Удалить сохраненный сервер",
"folderSelection.servers.skipTls": "Самоподписанный TLS",
"folderSelection.servers.errorTitle": "Ошибка удаленного подключения",
"folderSelection.servers.dialog.title": "Подключиться к серверу",
"folderSelection.servers.dialog.description": "Добавьте удаленный сервер CodeNomad и при желании сразу откройте его.",
"folderSelection.servers.dialog.name": "Имя сервера",
"folderSelection.servers.dialog.namePlaceholder": "Продакшн сервер",
"folderSelection.servers.dialog.url": "URL сервера",
"folderSelection.servers.dialog.urlPlaceholder": "https://server.example.com",
"folderSelection.servers.dialog.skipTls": "Пропустить проверку TLS для самоподписанных сертификатов.",
"folderSelection.servers.dialog.cancel": "Отмена",
"folderSelection.servers.dialog.save": "Сохранить",
"folderSelection.servers.dialog.connect": "Подключиться",
"folderSelection.servers.dialog.connecting": "Подключение...",
"folderSelection.servers.dialog.errorRequired": "Имя сервера и URL обязательны.",
"folderSelection.servers.dialog.errorConnect": "Не удалось подключиться к удаленному серверу.",
"folderSelection.sidecars.button": "Open SideCar",
} as const

View File

@@ -150,6 +150,8 @@ export const instanceMessages = {
"instanceShell.backgroundProcesses.empty": "Нет фоновых процессов.",
"instanceShell.backgroundProcesses.status": "Статус: {status}",
"instanceShell.backgroundProcesses.output": "Вывод: {sizeKb}KB",
"instanceShell.backgroundProcesses.notify.enabled": "Уведомление о завершении включено",
"instanceShell.backgroundProcesses.notify.disabled": "Уведомление о завершении выключено",
"instanceShell.backgroundProcesses.actions.output": "Вывод",
"instanceShell.backgroundProcesses.actions.stop": "Остановить",
"instanceShell.backgroundProcesses.actions.terminate": "Завершить",

View File

@@ -41,6 +41,8 @@ export const remoteAccessMessages = {
"remoteAccess.sections.addresses.help": "Откройте или отсканируйте с другой машины, чтобы передать управление.",
"remoteAccess.addresses.loading": "Загрузка адресов…",
"remoteAccess.addresses.none": "Пока нет доступных адресов.",
"remoteAccess.addresses.actions.showOther": "Показать еще {count} адресов",
"remoteAccess.addresses.actions.hideOther": "Скрыть остальные адреса",
"remoteAccess.address.scope.network": "Сеть",
"remoteAccess.address.scope.loopback": "Loopback",
"remoteAccess.address.scope.internal": "Внутренний",

View File

@@ -113,6 +113,14 @@ export const settingsMessages = {
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.opencode.runtime.title": "Runtime",
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
"settings.opencode.logLevel.title": "Уровень логирования OpenCode",
"settings.opencode.logLevel.subtitle": "Задайте уровень логирования, используемый при запуске новых экземпляров OpenCode.",
"settings.opencode.logLevel.selector.title": "Подробность логов",
"settings.opencode.logLevel.selector.subtitle": "Выберите, сколько логов должны выводить новые экземпляры OpenCode.",
"settings.opencode.logLevel.option.debug": "Отладка",
"settings.opencode.logLevel.option.info": "Информация",
"settings.opencode.logLevel.option.warn": "Предупреждение",
"settings.opencode.logLevel.option.error": "Ошибка",
"settings.appearance.behavior.title": "Взаимодействие",
"settings.appearance.behavior.subtitle": "Значения по умолчанию для сообщений, диффов и ввода.",
@@ -186,4 +194,40 @@ export const settingsMessages = {
"settings.speech.save.saved": "Сохранено",
"settings.speech.save.unsaved": "Есть несохранённые изменения",
"settings.speech.save.error": "Не удалось сохранить",
"settings.nav.sidecars": "SideCars",
"settings.section.sidecars.eyebrow": "Server services",
"settings.section.sidecars.title": "SideCars",
"settings.section.sidecars.subtitle": "Configure local services listening on fixed ports that the server can proxy into tabs.",
"sidecars.form.name": "Name",
"sidecars.form.validation": "Enter a valid SideCar name and port.",
"sidecars.form.port": "Port",
"sidecars.form.insecure": "Use HTTP",
"sidecars.form.protocol": "Protocol",
"sidecars.form.protocol.help": "Choose how the proxy should connect to the local service.",
"sidecars.form.protocol.https": "HTTPS",
"sidecars.form.protocol.http": "HTTP",
"sidecars.form.prefixMode": "Prefix mode",
"sidecars.form.prefixMode.help": "Choose whether the SideCar receives the public /sidecars path prefix.",
"sidecars.form.prefixMode.strip": "Strip prefix",
"sidecars.form.prefixMode.preserve": "Preserve prefix",
"sidecars.form.add": "Add SideCar",
"sidecars.kind.port": "Port",
"sidecars.status.running": "Running",
"sidecars.status.stopped": "Stopped",
"sidecars.basePath": "Base path",
"sidecars.settings.listTitle": "Configured SideCars",
"sidecars.settings.listSubtitle": "Review the port-based SideCars available in the picker.",
"sidecars.settings.empty": "No SideCars configured yet.",
"sidecars.picker.title": "Open SideCar",
"sidecars.picker.loading": "Loading SideCars...",
"sidecars.picker.subtitle": "Choose an available SideCar to open in a new tab.",
"sidecars.picker.empty": "No port-based SideCars are available yet.",
"sidecars.picker.close": "Close",
"sidecars.open.errorTitle": "Unable to open SideCar",
"sidecars.open.notFound": "SideCar not found.",
"sidecars.open.notRunning": "SideCar is not reachable on its configured port.",
"sidecars.back": "Back",
"sidecars.refresh": "Refresh",
"sidecars.path": "Path",
"sidecars.go": "Go",
} as const

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