Compare commits

..

18 Commits

Author SHA1 Message Date
Shantur Rathore
cd2bd3c636 Don't try to publish and increment version number 2025-11-14 23:42:03 +00:00
Shantur Rathore
6e7003c57c Puff-up README 2025-11-14 23:26:13 +00:00
Shantur Rathore
adee1e0383 scope custom commands 2025-11-14 23:11:52 +00:00
Shantur Rathore
efe7af6f77 Introduce ConfigProvider to stabilize preference saves
- move config state into a dedicated context provider that eagerly hydrates disk state before any write
- update App, folder selection, message rendering, and advanced settings to consume the context instead of globals
- wrap the renderer entry in ConfigProvider so every view shares the same initialized config data
2025-11-14 20:42:13 +00:00
Shantur Rathore
6fa41d51be stabilize message auto scroll 2025-11-14 16:26:14 +00:00
Shantur Rathore
8431b9f8a2 surface launch failures with guided advanced settings 2025-11-14 16:04:04 +00:00
Shantur Rathore
541027c93e Change folder to codenomad for config 2025-11-14 14:25:04 +00:00
Shantur Rathore
9f2edbb9db Advanced Settings 2025-11-14 14:18:30 +00:00
Shantur Rathore
eced9b8124 Build not installers 2025-11-14 14:17:23 +00:00
Shantur Rathore
68b6793bf3 Add minimal README 2025-11-14 14:12:32 +00:00
Shantur Rathore
d3b194c306 filter release assets by extension 2025-11-14 14:03:44 +00:00
Shantur Rathore
467cbf4b28 limit release uploads to binaries 2025-11-14 13:38:07 +00:00
Shantur Rathore
756f3d68cb fix windows release upload 2025-11-14 13:36:21 +00:00
Shantur Rathore
7354f08abe export gh token for build jobs 2025-11-14 13:27:04 +00:00
Shantur Rathore
db5bd9984e make build script work on windows 2025-11-14 13:25:42 +00:00
Shantur Rathore
6fdd4947f9 Fix borders and user message timestamp 2025-11-14 13:13:24 +00:00
Shantur Rathore
b438702092 Add correct autor details 2025-11-14 13:12:25 +00:00
Shantur Rathore
5faa06601a Fix scrolling buttopn and autoscrolling 2025-11-14 13:10:47 +00:00
31 changed files with 624 additions and 431 deletions

View File

@@ -66,6 +66,8 @@ jobs:
build-macos:
needs: prepare-release
runs-on: macos-13
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -87,11 +89,25 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.prepare-release.outputs.tag }}
run: |
gh release upload "$TAG" release/* --clobber
set -euo pipefail
shopt -s nullglob
for file in release/*; do
[ -f "$file" ] || continue
case "$file" in
*.dmg|*.zip)
gh release upload "$TAG" "$file" --clobber
;;
*)
echo "Skipping non-installer asset: $file"
;;
esac
done
build-windows:
needs: prepare-release
runs-on: windows-latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -109,16 +125,22 @@ jobs:
run: npm run build:win
- name: Upload release assets
shell: bash
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.prepare-release.outputs.tag }}
run: |
gh release upload "$TAG" release/* --clobber
Get-ChildItem -Path "release" -File | Where-Object {
$_.Name -match '\.(exe|zip)$'
} | ForEach-Object {
gh release upload $env:TAG $_.FullName --clobber
}
build-linux:
needs: prepare-release
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -140,4 +162,16 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.prepare-release.outputs.tag }}
run: |
gh release upload "$TAG" release/* --clobber
set -euo pipefail
shopt -s nullglob
for file in release/*; do
[ -f "$file" ] || continue
case "$file" in
*.AppImage|*.deb|*.tar.gz)
gh release upload "$TAG" "$file" --clobber
;;
*)
echo "Skipping non-installer asset: $file"
;;
esac
done

226
README.md
View File

@@ -1,219 +1,41 @@
# CodeNomad
A cross-platform desktop application for interacting with OpenCode servers, built with Electron and SolidJS.
> A fast, multi-instance desktop client for running OpenCode sessions the way long-haul builders actually work.
## Overview
## What is CodeNomad?
CodeNomad provides a multi-instance, multi-session interface for working with AI-powered coding assistants. It manages OpenCode server processes, handles real-time message streaming, and provides an intuitive UI for coding with AI.
CodeNomad is built for people who live inside OpenCode for hours on end and need a cockpit, not a kiosk. When terminals get unwieldy and web clients feel laggy, CodeNomad delivers a desktop-native workspace that favors speed, clarity, and direct control. It runs on macOS, Windows, and Linux using Electron + SolidJS, with prebuilt binaries so you can get started immediately.
**🎯 MVP Focus:** This project prioritizes functionality over performance. Performance optimization is intentionally deferred to post-MVP phases. See [docs/MVP-PRINCIPLES.md](docs/MVP-PRINCIPLES.md) for details.
![Multi-instance workspace](docs/screenshots/newSession.png)
## Features
![Command palette overlay](docs/screenshots/command-palette.png)
### Core Capabilities
## Highlights
- **Multi-Instance Management**: Work on multiple projects simultaneously
- **Session Persistence**: Resume conversations across app restarts
- **Real-time Streaming**: Live message updates via Server-Sent Events
- **Tool Execution Visibility**: See bash commands, file edits, and other tool calls
- **Agent & Model Switching**: Easily switch between different AI agents and models
- **Markdown Rendering**: Beautiful code highlighting and formatting
- **Long-session native** scroll through massive transcripts without hitches and keep full context visible.
- **Multiple instances, one window** juggle several OpenCode instances side-by-side with per-instance tabs.
- **Deep task awareness** jump into sub/child sessions (Tasks tool) instantly, monitor their status, and answer directly.
- **Keyboard first** the full UI is optimized for shortcuts so you can stay mouse-free when you want to.
- **Command palette superpowers** summon a single, global palette to jump tabs, launch tools, tweak preferences, or fire shortcuts faster than the UI can animate.
- **Direct model messaging** keep an eye on child sessions and send targeted replies without losing your flow.
- **Developer-friendly rendering** syntax highlighting, inline diffs, and thoughtful presentation keep the signal high.
### Advanced Features (Planned)
## Command Palette
- Virtual scrolling for large conversations
- Full-text search across sessions
- Workspace management
- Custom themes
- Plugin system
The palette is the nerve center of CodeNomad: hit the shortcut once and you can search commands, switch instances, start a new task, open attachments, or tweak settings without taking your hands off the keyboard. Every action is categorized, fuzzy searchable, and previewed so you can chain moves together in seconds. It is the single interface for command execution, which keeps the workflow predictable and fast whether you are juggling one session or ten.
## Architecture
## Requirements
See [docs/architecture.md](docs/architecture.md) for detailed architecture documentation.
- [OpenCode CLI](https://opencode.ai) installed and available in your `PATH`, or point CodeNomad to a local binary through Advanced Settings.
### High-Level Overview
## Downloads
```
Electron App
├── Main Process (Node.js)
│ ├── Window management
│ ├── OpenCode server spawning
│ └── IPC communication
├── Renderer Process (SolidJS)
│ ├── UI components
│ ├── State management (stores)
│ └── SDK client communication
└── Multiple OpenCode Servers
└── One per instance/project folder
```
Grab the latest build for macOS, Windows, and Linux from the [GitHub Releases page](https://github.com/shantur/CodeNomad/releases).
## Prerequisites
## Quick Start
- Node.js 18+
- Bun package manager
- OpenCode CLI installed and in PATH
1. Install the OpenCode CLI and confirm it is reachable via your terminal.
2. Download the CodeNomad build for your platform and launch the app.
3. Connect to one or more OpenCode instances, set keyboard shortcuts in preferences, and start a session.
4. Use tabs to swap between instances, the task sidebar to dive into child sessions, and the prompt input to keep shipping.
## Installation
```bash
# Install dependencies
bun install
# Run in development mode
bun run dev
# Build for production
bun run build
# Build distributable binaries
bun run build:mac # macOS (Universal)
bun run build:win # Windows (x64)
bun run build:linux # Linux (x64)
bun run build:all # All platforms
# See BUILD.md for more build options
```
## Development
### Project Structure
```
packages/opencode-client/
├── docs/ # Documentation
├── tasks/ # Task management
│ ├── todo/ # Pending tasks
│ └── done/ # Completed tasks
├── electron/ # Electron main process
│ ├── main/ # Main process code
│ ├── preload/ # Preload scripts
│ └── resources/ # App icons, etc.
└── src/ # Renderer (UI) code
├── components/ # UI components
├── stores/ # State management
├── lib/ # Utilities
├── hooks/ # SolidJS hooks
└── types/ # TypeScript types
```
### Tech Stack
- **Electron** - Desktop wrapper
- **SolidJS** - Reactive UI framework
- **TypeScript** - Type safety
- **Vite** - Build tool
- **TailwindCSS** - Styling
- **Kobalte** - Accessible UI primitives
- **OpenCode SDK** - API client
### Scripts
```bash
bun run dev # Start dev server with hot reload
bun run build # Build for production
bun run typecheck # Run TypeScript type checking
bun run preview # Preview production build
```
## Usage
### Starting an Instance
1. Launch the app
2. Click "Select Folder" or press Cmd/Ctrl+N
3. Choose a project folder
4. Wait for OpenCode server to start
5. Select an existing session or create new one
### Working with Sessions
- **Switch sessions**: Click session tab at bottom
- **Create session**: Click "+" button or Cmd/Ctrl+T
- **Change agent**: Use agent dropdown
- **Change model**: Use model dropdown
### Sending Messages
- Type in the input box at bottom
- Press Enter for new line (Cmd+Enter on macOS, Ctrl+Enter on Windows/Linux)
- Use `/` for commands
- Use `@` to mention files
## Documentation
- [Architecture](docs/architecture.md) - System design and structure
- [User Interface](docs/user-interface.md) - UI specifications
- [Technical Implementation](docs/technical-implementation.md) - Implementation details
- [Build Roadmap](docs/build-roadmap.md) - Development plan and phases
- [Tasks](tasks/README.md) - Task breakdown and tracking
## Build Phases
The project is built in phases:
1. **Phase 1**: Foundation (Tasks 001-005)
2. **Phase 2**: Core Chat (Tasks 006-010)
3. **Phase 3**: Essential Features (Tasks 011-015)
4. **Phase 4**: Multi-Instance (Tasks 016-020)
5. **Phase 5**: Advanced Input (Tasks 021-025)
6. **Phase 6**: Polish & UX (Tasks 026-030)
7. **Phase 7**: System Integration (Tasks 031-035)
8. **Phase 8**: Advanced Features (Tasks 036-040)
See [docs/build-roadmap.md](docs/build-roadmap.md) for detailed phase information.
## Contributing
### Getting Started
1. Read the documentation in `docs/`
2. Check `tasks/todo/` for available tasks
3. Pick a task and create a feature branch
4. Follow the task steps
5. Submit PR when complete
### Code Style
- Use TypeScript for all code
- Follow existing patterns and conventions
- Write clear, descriptive commit messages
- Add comments for complex logic
- Keep components small and focused
### Testing
- Test manually at minimum window size (800x600)
- Test on multiple platforms (macOS, Windows, Linux)
- Verify keyboard navigation works
- Check accessibility with screen readers
## Troubleshooting
### Server Won't Start
- Verify `opencode` is in PATH: `which opencode`
- Check folder permissions
- Review server logs in Logs tab
- Try restarting the instance
### Connection Issues
- Check if server is running: `ps aux | grep opencode`
- Verify port is correct in instance metadata
- Check for firewall blocking localhost
- Try killing and restarting server
### Performance Issues
- Check number of messages in session
- Monitor memory usage in Activity Monitor
- Consider enabling virtual scrolling (Phase 8)
- Close unused instances
## License
[License TBD]
## Credits
Built with ❤️ for the OpenCode project.

Binary file not shown.

After

Width:  |  Height:  |  Size: 835 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 966 KiB

View File

@@ -3,7 +3,7 @@ import { join } from "path"
import { readFile, writeFile, mkdir, unlink, stat } from "fs/promises"
import { existsSync } from "fs"
const CONFIG_DIR = join(app.getPath("home"), ".config", "opencode-client")
const CONFIG_DIR = join(app.getPath("home"), ".config", "codenomad")
const CONFIG_FILE = join(CONFIG_DIR, "config.json")
const INSTANCES_DIR = join(CONFIG_DIR, "instances")

View File

@@ -1,8 +1,11 @@
{
"name": "@opencode-ai/client",
"version": "0.1.0",
"description": "CodeNomad desktop client - multi-instance, multi-session AI coding interface",
"author": "OpenCode Team",
"name": "@shantur/codenomad",
"version": "0.1.1",
"description": "CodeNomad - AI coding assistant",
"author": {
"name": "Shantur Rathore",
"email": "codenomad@shantur.com"
},
"type": "module",
"main": "dist/main/main.js",
"scripts": {

View File

@@ -52,6 +52,7 @@ function run(command, args, options = {}) {
const spawnOptions = {
cwd: appDir,
stdio: "inherit",
shell: process.platform === "win32",
...options,
env: { ...process.env, NODE_PATH: nodeModulesPath, ...(options.env || {}) },
}
@@ -97,7 +98,7 @@ async function build(platform) {
throw new Error("dist/ directory not found. Build failed.")
}
await run(npxCmd, ["electron-builder", ...config.args])
await run(npxCmd, ["electron-builder", "--publish=never", ...config.args])
console.log("\n✅ Build complete!")
console.log(`📁 Binaries available in: ${join(appDir, "release")}\n`)

View File

@@ -1,9 +1,10 @@
import { Component, onMount, onCleanup, Show, createMemo, createEffect, createSignal } from "solid-js"
import { Dialog } from "@kobalte/core/dialog"
import { Toaster } from "solid-toast"
import type { Session } from "./types/session"
import type { Attachment } from "./types/attachment"
import type { SDKPart, ClientPart } from "./types/message"
import type { Permission } from "@opencode-ai/sdk"
import type { Permission, Command as SDKCommand } from "@opencode-ai/sdk"
import FolderSelectionView from "./components/folder-selection-view"
import InstanceWelcomeView from "./components/instance-welcome-view"
import CommandPalette from "./components/command-palette"
@@ -28,7 +29,7 @@ import {
showFolderSelection,
setShowFolderSelection,
} from "./stores/ui"
import { toggleShowThinkingBlocks, preferences, addRecentFolder, setDiffViewMode } from "./stores/preferences"
import { useConfig } from "./stores/preferences"
import {
createInstance,
instances,
@@ -64,10 +65,12 @@ import {
getSessionInfo,
isSessionMessagesLoading,
fetchSessions,
executeCustomCommand,
} from "./stores/sessions"
import { isSessionBusy } from "./stores/session-status"
import { setupTabKeyboardShortcuts } from "./lib/keyboard"
import { isOpen as isCommandPaletteOpen, showCommandPalette, hideCommandPalette } from "./stores/command-palette"
import { getCommands as getInstanceCommands } from "./stores/commands"
import { registerNavigationShortcuts } from "./lib/shortcuts/navigation"
import { registerInputShortcuts } from "./lib/shortcuts/input"
import { registerAgentShortcuts } from "./lib/shortcuts/agent"
@@ -349,9 +352,33 @@ const ContextUsagePanel: Component<{ instanceId: string; sessionId: string }> =
const App: Component = () => {
const { isDark } = useTheme()
const { preferences, addRecentFolder, toggleShowThinkingBlocks, setDiffViewMode } = useConfig()
const commandRegistry = createCommandRegistry()
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
const [paletteCommands, setPaletteCommands] = createSignal<Command[]>([])
const [launchErrorBinary, setLaunchErrorBinary] = createSignal<string | null>(null)
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
const launchErrorPath = () => {
const value = launchErrorBinary()
if (!value) return "opencode"
return value.trim() || "opencode"
}
const isMissingBinaryError = (error: unknown): boolean => {
if (!error) return false
const message = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
const normalized = message.toLowerCase()
return (
normalized.includes("opencode binary not found") ||
normalized.includes("binary not found") ||
normalized.includes("no such file or directory") ||
normalized.includes("binary is not executable") ||
normalized.includes("enoent")
)
}
const clearLaunchError = () => setLaunchErrorBinary(null)
const refreshCommandPalette = () => {
setPaletteCommands(commandRegistry.getAll())
@@ -361,15 +388,12 @@ const App: Component = () => {
void initMarkdown(isDark()).catch(console.error)
})
const activeInstance = createMemo(() => getActiveInstance())
const activeSessions = createMemo(() => {
const instance = activeInstance()
if (!instance) return new Map()
const instanceId = instance.id
const parentId = activeParentSessionId().get(instanceId)
if (!parentId) return new Map()
@@ -383,6 +407,7 @@ const App: Component = () => {
return activeSessionId().get(instance.id) || null
})
const activeSessionForInstance = createMemo(() => {
const sessionId = activeSessionIdForInstance()
if (!sessionId || sessionId === "info") return null
@@ -408,6 +433,7 @@ const App: Component = () => {
async function handleSelectFolder(folderPath?: string, binaryPath?: string) {
setIsSelectingFolder(true)
const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode"
try {
let folder: string | null | undefined = folderPath
@@ -418,19 +444,38 @@ const App: Component = () => {
}
}
if (!folder) {
return
}
addRecentFolder(folder)
const instanceId = await createInstance(folder, binaryPath)
clearLaunchError()
const instanceId = await createInstance(folder, selectedBinary)
setHasInstances(true)
setShowFolderSelection(false)
setIsAdvancedSettingsOpen(false)
console.log("Created instance:", instanceId, "Port:", instances().get(instanceId)?.port)
} catch (error) {
clearLaunchError()
if (isMissingBinaryError(error)) {
setLaunchErrorBinary(selectedBinary)
}
console.error("Failed to create instance:", error)
} finally {
setIsSelectingFolder(false)
}
}
function handleLaunchErrorClose() {
clearLaunchError()
}
function handleLaunchErrorAdvanced() {
clearLaunchError()
setIsAdvancedSettingsOpen(true)
}
function handleNewInstanceRequest() {
if (hasInstances()) {
setShowFolderSelection(true)
@@ -802,43 +847,6 @@ const App: Component = () => {
},
})
commandRegistry.register({
id: "init",
label: "Initialize AGENTS.md",
description: "Create or update AGENTS.md file",
category: "Agent & Model",
keywords: ["/init", "agents", "initialize"],
action: async () => {
const instance = activeInstance()
const sessionId = activeSessionIdForInstance()
if (!instance || !instance.client || !sessionId || sessionId === "info") return
const sessions = getSessions(instance.id)
const session = sessions.find((s) => s.id === sessionId)
if (!session) return
try {
// Generate ID similar to server format: timestamp in hex + random chars
const timestamp = Date.now()
const timePart = (timestamp * 0x1000).toString(16).padStart(12, "0")
const randomPart = Math.random().toString(16).substring(2, 16)
const messageID = `msg_${timePart}${randomPart}`
await instance.client.session.init({
path: { id: sessionId },
body: {
messageID,
providerID: session.model.providerId,
modelID: session.model.modelId,
},
})
console.log("Initializing AGENTS.md...")
} catch (error) {
console.error("Failed to initialize AGENTS.md:", error)
}
},
})
commandRegistry.register({
id: "clear-input",
label: "Clear Input",
@@ -893,8 +901,17 @@ const App: Component = () => {
refreshCommandPalette()
}
function handleExecuteCommand(commandId: string) {
commandRegistry.execute(commandId)
function handleExecuteCommand(command: Command) {
try {
const result = command.action?.()
if (result instanceof Promise) {
void result.catch((error) => {
console.error("Command execution failed:", error)
})
}
} catch (error) {
console.error("Command execution failed:", error)
}
}
@@ -909,7 +926,12 @@ const App: Component = () => {
handleCloseInstance,
handleNewSession,
handleCloseSession,
showCommandPalette,
() => {
const instance = activeInstance()
if (instance) {
showCommandPalette(instance.id)
}
},
)
registerNavigationShortcuts()
@@ -996,7 +1018,7 @@ const App: Component = () => {
const active = document.activeElement as HTMLElement
active?.blur()
},
hideCommandPalette,
() => hideCommandPalette(),
)
const handleKeyDown = (e: KeyboardEvent) => {
@@ -1055,6 +1077,41 @@ const App: Component = () => {
reason={disconnectedInstance()?.reason}
onClose={handleDisconnectedInstanceClose}
/>
<Dialog open={Boolean(launchErrorBinary())} modal>
<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-md p-6 flex flex-col gap-6">
<div>
<Dialog.Title class="text-xl font-semibold text-primary">Unable to launch OpenCode</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-2">
Install the OpenCode CLI and make sure it is available in your PATH, or pick a custom binary from
Advanced Settings.
</Dialog.Description>
</div>
<div class="rounded-lg border border-base bg-surface-secondary p-4">
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Binary path</p>
<p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p>
</div>
<div class="flex justify-end gap-2">
<button
type="button"
class="selector-button selector-button-secondary"
onClick={handleLaunchErrorAdvanced}
>
Open Advanced Settings
</button>
<button type="button" class="selector-button selector-button-primary" onClick={handleLaunchErrorClose}>
Close
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
<div class="h-screen w-screen flex flex-col">
<Show
when={!hasInstances()}
@@ -1069,123 +1126,145 @@ const App: Component = () => {
/>
<Show when={activeInstance()} keyed>
{(instance) => (
<>
<Show when={activeSessions().size > 0} fallback={<InstanceWelcomeView instance={instance} />}>
<div class="flex flex-1 min-h-0">
{/* Session Sidebar */}
<div
class="session-sidebar flex flex-col bg-surface-secondary"
style={{ width: `${sessionSidebarWidth()}px` }}
>
<SessionList
instanceId={instance.id}
sessions={activeSessions()}
activeSessionId={activeSessionIdForInstance()}
onSelect={(id) => setActiveSession(instance.id, id)}
onClose={(id) => handleCloseSession(instance.id, id)}
onNew={() => handleNewSession(instance.id)}
showHeader
showFooter={false}
headerContent={
<div class="session-sidebar-header">
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">Sessions</span>
<div class="session-sidebar-shortcuts">
{(() => {
const shortcuts = [
keyboardRegistry.get("session-prev"),
keyboardRegistry.get("session-next"),
].filter((shortcut): shortcut is KeyboardShortcut => Boolean(shortcut))
return shortcuts.length ? (
<KeyboardHint shortcuts={shortcuts} separator=" " showDescription={false} />
) : null
})()}
</div>
</div>
}
{(instance) => {
const customCommands = createMemo(() =>
buildCustomCommandEntries(instance.id, getInstanceCommands(instance.id)),
)
const instancePaletteCommands = createMemo(() => [
...paletteCommands(),
...customCommands(),
])
const paletteOpen = createMemo(() => isCommandPaletteOpen(instance.id))
onWidthChange={setSessionSidebarWidth}
/>
<div class="session-sidebar-separator border-t border-base" />
<Show when={activeSessionForInstance()}>
{(activeSession) => (
<>
<ContextUsagePanel instanceId={instance.id} sessionId={activeSession().id} />
<div class="session-sidebar-controls px-3 py-3 border-r border-base flex flex-col gap-3">
<AgentSelector
instanceId={instance.id}
sessionId={activeSession().id}
currentAgent={activeSession().agent}
onAgentChange={handleSidebarAgentChange}
/>
<ModelSelector
instanceId={instance.id}
sessionId={activeSession().id}
currentModel={activeSession().model}
onModelChange={handleSidebarModelChange}
/>
</div>
</>
)}
</Show>
</div>
{/* Main Content Area */}
<div class="content-area flex-1 min-h-0 overflow-hidden flex flex-col">
<Show
when={activeSessionIdForInstance() === "info"}
fallback={
<Show
when={activeSessionIdForInstance()}
keyed
fallback={
<div class="flex items-center justify-center h-full">
<div class="text-center text-gray-500 dark:text-gray-400">
<p class="mb-2">No session selected</p>
<p class="text-sm">Select a session to view messages</p>
</div>
</div>
}
>
{(sessionId) => (
<SessionView
sessionId={sessionId}
activeSessions={activeSessions()}
instanceId={instance.id}
instanceFolder={instance.folder}
escapeInDebounce={escapeInDebounce()}
/>
)}
</Show>
}
return (
<>
<Show when={activeSessions().size > 0} fallback={<InstanceWelcomeView instance={instance} />}>
<div class="flex flex-1 min-h-0">
{/* Session Sidebar */}
<div
class="session-sidebar flex flex-col bg-surface-secondary"
style={{ width: `${sessionSidebarWidth()}px` }}
>
<InfoView instanceId={instance.id} />
</Show>
<SessionList
instanceId={instance.id}
sessions={activeSessions()}
activeSessionId={activeSessionIdForInstance()}
onSelect={(id) => setActiveSession(instance.id, id)}
onClose={(id) => handleCloseSession(instance.id, id)}
onNew={() => handleNewSession(instance.id)}
showHeader
showFooter={false}
headerContent={
<div class="session-sidebar-header">
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">Sessions</span>
<div class="session-sidebar-shortcuts">
{(() => {
const shortcuts = [
keyboardRegistry.get("session-prev"),
keyboardRegistry.get("session-next"),
].filter((shortcut): shortcut is KeyboardShortcut => Boolean(shortcut))
return shortcuts.length ? (
<KeyboardHint shortcuts={shortcuts} separator=" " showDescription={false} />
) : null
})()}
</div>
</div>
}
onWidthChange={setSessionSidebarWidth}
/>
<div class="session-sidebar-separator border-t border-base" />
<Show when={activeSessionForInstance()}>
{(activeSession) => (
<>
<ContextUsagePanel instanceId={instance.id} sessionId={activeSession().id} />
<div class="session-sidebar-controls px-3 py-3 border-r border-base flex flex-col gap-3">
<AgentSelector
instanceId={instance.id}
sessionId={activeSession().id}
currentAgent={activeSession().agent}
onAgentChange={handleSidebarAgentChange}
/>
<ModelSelector
instanceId={instance.id}
sessionId={activeSession().id}
currentModel={activeSession().model}
onModelChange={handleSidebarModelChange}
/>
</div>
</>
)}
</Show>
</div>
{/* Main Content Area */}
<div class="content-area flex-1 min-h-0 overflow-hidden flex flex-col">
<Show
when={activeSessionIdForInstance() === "info"}
fallback={
<Show
when={activeSessionIdForInstance()}
keyed
fallback={
<div class="flex items-center justify-center h-full">
<div class="text-center text-gray-500 dark:text-gray-400">
<p class="mb-2">No session selected</p>
<p class="text-sm">Select a session to view messages</p>
</div>
</div>
}
>
{(sessionId) => (
<SessionView
sessionId={sessionId}
activeSessions={activeSessions()}
instanceId={instance.id}
instanceFolder={instance.folder}
escapeInDebounce={escapeInDebounce()}
/>
)}
</Show>
}
>
<InfoView instanceId={instance.id} />
</Show>
</div>
</div>
</div>
</Show>
</>
)}
</Show>
<CommandPalette
open={paletteOpen()}
onClose={() => hideCommandPalette(instance.id)}
commands={instancePaletteCommands()}
onExecute={handleExecuteCommand}
/>
</>
)
}}
</Show>
</>
}
>
<FolderSelectionView onSelectFolder={handleSelectFolder} isLoading={isSelectingFolder()} />
<FolderSelectionView
onSelectFolder={handleSelectFolder}
isLoading={isSelectingFolder()}
advancedSettingsOpen={isAdvancedSettingsOpen()}
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
/>
</Show>
<CommandPalette
open={isCommandPaletteOpen()}
onClose={hideCommandPalette}
commands={paletteCommands()}
onExecute={handleExecuteCommand}
/>
<Show when={showFolderSelection()}>
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
<div class="w-full h-full relative">
<button
onClick={() => setShowFolderSelection(false)}
onClick={() => {
setShowFolderSelection(false)
setIsAdvancedSettingsOpen(false)
clearLaunchError()
}}
class="absolute top-4 right-4 z-10 p-2 bg-white dark:bg-gray-800 rounded-lg shadow-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Close (Esc)"
>
@@ -1198,7 +1277,13 @@ const App: Component = () => {
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<FolderSelectionView onSelectFolder={handleSelectFolder} isLoading={isSelectingFolder()} />
<FolderSelectionView
onSelectFolder={handleSelectFolder}
isLoading={isSelectingFolder()}
advancedSettingsOpen={isAdvancedSettingsOpen()}
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
/>
</div>
</div>
</Show>
@@ -1216,4 +1301,52 @@ const App: Component = () => {
)
}
function commandRequiresArguments(template?: string) {
if (!template) return false
return /\$(?:\d+|ARGUMENTS)/.test(template)
}
function promptForCommandArguments(command: SDKCommand.Info) {
if (!commandRequiresArguments(command.template)) {
return ""
}
const input = window.prompt(`Arguments for /${command.name}`, "")
if (input === null) {
return null
}
return input
}
function formatCommandLabel(name: string) {
if (!name) return ""
return name.charAt(0).toUpperCase() + name.slice(1)
}
function buildCustomCommandEntries(instanceId: string, commands: SDKCommand.Info[]): Command[] {
return commands.map((cmd) => ({
id: `custom:${instanceId}:${cmd.name}`,
label: formatCommandLabel(cmd.name),
description: cmd.description ?? "Custom command",
category: "Custom Commands",
keywords: [cmd.name, ...(cmd.description ? cmd.description.split(/\s+/).filter(Boolean) : [])],
action: async () => {
const sessionId = activeSessionId().get(instanceId)
if (!sessionId || sessionId === "info") {
alert("Select a session before running a custom command.")
return
}
const args = promptForCommandArguments(cmd)
if (args === null) {
return
}
try {
await executeCustomCommand(instanceId, sessionId, cmd.name, args)
} catch (error) {
console.error("Failed to run custom command:", error)
alert("Failed to run custom command. Check the console for details.")
}
},
}))
}
export default App

View File

@@ -7,7 +7,7 @@ interface CommandPaletteProps {
open: boolean
onClose: () => void
commands: Command[]
onExecute: (commandId: string) => void
onExecute: (command: Command) => void
}
function buildShortcutString(shortcut: Command["shortcut"]): string {
@@ -30,7 +30,7 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
let inputRef: HTMLInputElement | undefined
let listRef: HTMLDivElement | undefined
const categoryOrder = ["Instance", "Session", "Agent & Model", "Input & Focus", "System", "Other"] as const
const categoryOrder = ["Custom Commands", "Instance", "Session", "Agent & Model", "Input & Focus", "System", "Other"] as const
type CommandGroup = { category: string; commands: Command[]; startIndex: number }
type ProcessedCommands = { groups: CommandGroup[]; ordered: Command[] }
@@ -167,13 +167,15 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
e.stopPropagation()
const index = selectedIndex()
if (index < 0 || index >= ordered.length) return
props.onExecute(ordered[index].id)
const command = ordered[index]
if (!command) return
props.onExecute(command)
props.onClose()
}
}
function handleCommandClick(commandId: string) {
props.onExecute(commandId)
function handleCommandClick(command: Command) {
props.onExecute(command)
props.onClose()
}
@@ -241,7 +243,7 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
<button
type="button"
data-command-index={commandIndex}
onClick={() => handleCommandClick(command.id)}
onClick={() => handleCommandClick(command)}
class={`modal-item ${selectedCommandId() === command.id ? "modal-item-highlight" : ""}`}
onPointerMove={(event) => {
if (event.movementX === 0 && event.movementY === 0) return

View File

@@ -1,17 +1,18 @@
import { Component, createSignal, For, Show } from "solid-js"
import { Plus, Trash2, Key, Globe } from "lucide-solid"
import {
preferences,
addEnvironmentVariable,
removeEnvironmentVariable,
updateEnvironmentVariables,
} from "../stores/preferences"
import { useConfig } from "../stores/preferences"
interface EnvironmentVariablesEditorProps {
disabled?: boolean
}
const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (props) => {
const {
preferences,
addEnvironmentVariable,
removeEnvironmentVariable,
updateEnvironmentVariables,
} = useConfig()
const [envVars, setEnvVars] = createSignal<Record<string, string>>(preferences().environmentVariables || {})
const [newKey, setNewKey] = createSignal("")
const [newValue, setNewValue] = createSignal("")

View File

@@ -1,6 +1,6 @@
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight } from "lucide-solid"
import { recentFolders, removeRecentFolder, preferences, updateLastUsedBinary } from "../stores/preferences"
import { useConfig } from "../stores/preferences"
import AdvancedSettingsModal from "./advanced-settings-modal"
import Kbd from "./kbd"
@@ -9,12 +9,15 @@ const codeNomadLogo = new URL("../../images/CodeNomad-Icon.png", import.meta.url
interface FolderSelectionViewProps {
onSelectFolder: (folder?: string, binaryPath?: string) => void
isLoading?: boolean
advancedSettingsOpen?: boolean
onAdvancedSettingsOpen?: () => void
onAdvancedSettingsClose?: () => void
}
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const { recentFolders, removeRecentFolder, preferences, updateLastUsedBinary } = useConfig()
const [selectedIndex, setSelectedIndex] = createSignal(0)
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
const [isAdvancedModalOpen, setIsAdvancedModalOpen] = createSignal(false)
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
let recentListRef: HTMLDivElement | undefined
@@ -320,7 +323,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
{/* Advanced settings section */}
<div class="panel-section w-full">
<button
onClick={() => setIsAdvancedModalOpen(true)}
onClick={() => props.onAdvancedSettingsOpen?.()}
class="panel-section-header w-full justify-between"
>
<div class="flex items-center gap-2">
@@ -369,8 +372,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</div>
<AdvancedSettingsModal
open={isAdvancedModalOpen()}
onClose={() => setIsAdvancedModalOpen(false)}
open={Boolean(props.advancedSettingsOpen)}
onClose={() => props.onAdvancedSettingsClose?.()}
selectedBinary={selectedBinary()}
onBinaryChange={handleBinaryChange}
isLoading={props.isLoading}

View File

@@ -99,7 +99,6 @@ export default function MessageItem(props: MessageItemProps) {
</Show>
</div>
<div class="flex items-center gap-2">
<span class="text-[11px] text-[var(--text-muted)]">{timestamp()}</span>
<Show when={isUser() && props.onRevert}>
<button
class="bg-transparent border border-[var(--border-base)] text-[var(--text-muted)] cursor-pointer px-3 py-0.5 rounded text-xs font-semibold leading-none transition-all duration-200 flex items-center justify-center h-6 hover:bg-[var(--surface-hover)] hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)] active:scale-95"
@@ -120,9 +119,10 @@ export default function MessageItem(props: MessageItemProps) {
Fork
</button>
</Show>
<span class="text-[11px] text-[var(--text-muted)]">{timestamp()}</span>
</div>
</div>
<div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]">
<Show when={props.isQueued && isUser()}>

View File

@@ -3,7 +3,7 @@ import ToolCall from "./tool-call"
import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state"
import { Markdown } from "./markdown"
import { useTheme } from "../lib/theme"
import { preferences } from "../stores/preferences"
import { useConfig } from "../stores/preferences"
import { partHasRenderableText, SDKPart, TextPart, ClientPart } from "../types/message"
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
@@ -14,6 +14,7 @@ interface MessagePartProps {
}
export default function MessagePart(props: MessagePartProps) {
const { isDark } = useTheme()
const { preferences } = useConfig()
const partType = () => props.part?.type || ""
const reasoningId = () => `reasoning-${props.part?.id || ""}`
const isReasoningExpanded = () => isItemExpanded(reasoningId())

View File

@@ -30,12 +30,13 @@ import MessageItem from "./message-item"
import ToolCall from "./tool-call"
import { sseManager } from "../lib/sse-manager"
import Kbd from "./kbd"
import { preferences } from "../stores/preferences"
import { useConfig } from "../stores/preferences"
import { getSessionInfo, computeDisplayParts, sessions, setActiveSession, setActiveParentSession } from "../stores/sessions"
import { setActiveInstanceId } from "../stores/instances"
const codeNomadLogo = new URL("../../images/CodeNomad-Icon.png", import.meta.url).href
const SCROLL_OFFSET = 64
const SCROLL_DIRECTION_THRESHOLD = 10
interface TaskSessionLocation {
sessionId: string
@@ -169,6 +170,7 @@ function getSessionCache(instanceId: string, sessionId: string): SessionCache {
}
export default function MessageStream(props: MessageStreamProps) {
const { preferences } = useConfig()
let containerRef: HTMLDivElement | undefined
const [autoScroll, setAutoScroll] = createSignal(true)
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
@@ -265,13 +267,13 @@ export default function MessageStream(props: MessageStreamProps) {
if (!containerRef) return
const currentScrollTop = containerRef.scrollTop
const movingUp = currentScrollTop < lastKnownScrollTop - 1
const movingUp = currentScrollTop < lastKnownScrollTop - SCROLL_DIRECTION_THRESHOLD
lastKnownScrollTop = currentScrollTop
const atBottom = isNearBottom(containerRef)
if (isUserScroll) {
if ((movingUp || !atBottom) && autoScroll()) {
if (movingUp && !atBottom && autoScroll()) {
setAutoScroll(false)
} else if (!movingUp && atBottom && !autoScroll()) {
setAutoScroll(true)
@@ -380,7 +382,9 @@ export default function MessageStream(props: MessageStreamProps) {
const toolSignature = createToolSignature(message, toolPart, originalIndex, messageInfo)
const contentKey = createToolContentKey(toolPart, messageInfo)
tokenSegments.push(`tool:${toolKey}:${partVersion}`)
const toolEntry = toolItemCache.get(toolKey)
if (toolEntry && toolEntry.signature === toolSignature) {
if (toolEntry.contentKey !== contentKey) {
const updatedItem: ToolDisplayItem = {

View File

@@ -1,12 +1,6 @@
import { Component, For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { FolderOpen, Trash2, Check, AlertCircle, Loader2, Plus } from "lucide-solid"
import {
opencodeBinaries,
addOpenCodeBinary,
removeOpenCodeBinary,
preferences,
updatePreferences,
} from "../stores/preferences"
import { useConfig } from "../stores/preferences"
interface BinaryOption {
path: string
@@ -23,6 +17,13 @@ interface OpenCodeBinarySelectorProps {
}
const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) => {
const {
opencodeBinaries,
addOpenCodeBinary,
removeOpenCodeBinary,
preferences,
updatePreferences,
} = useConfig()
const [customPath, setCustomPath] = createSignal("")
const [validating, setValidating] = createSignal(false)
const [validationError, setValidationError] = createSignal<string | null>(null)

View File

@@ -6,7 +6,8 @@ import { useTheme } from "../lib/theme"
import { getLanguageFromPath } from "../lib/markdown"
import { isRenderableDiffText } from "../lib/diff-utils"
import { getToolRenderCache, setToolRenderCache } from "../lib/tool-render-cache"
import { preferences, setDiffViewMode, type DiffViewMode } from "../stores/preferences"
import { useConfig } from "../stores/preferences"
import type { DiffViewMode } from "../stores/preferences"
import type { TextPart, SDKPart, ClientPart } from "../types/message"
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
@@ -177,6 +178,7 @@ function extractDiffPayload(toolName: string, state: ToolState): DiffPayload | n
}
export default function ToolCall(props: ToolCallProps) {
const { preferences, setDiffViewMode } = useConfig()
const { isDark } = useTheme()
const toolCallId = () => props.toolCallId || props.toolCall?.id || ""
const expanded = () => isToolCallExpanded(toolCallId())

View File

@@ -1,6 +1,7 @@
import { render } from "solid-js/web"
import App from "./App"
import { ThemeProvider } from "./lib/theme"
import { ConfigProvider } from "./stores/preferences"
import "./index.css"
import "@git-diff-view/solid/styles/diff-view-pure.css"
@@ -12,9 +13,11 @@ if (!root) {
render(
() => (
<ThemeProvider>
<App />
</ThemeProvider>
<ConfigProvider>
<ThemeProvider>
<App />
</ThemeProvider>
</ConfigProvider>
),
root,
)

View File

@@ -1,17 +1,36 @@
import { createSignal } from "solid-js"
const [isOpen, setIsOpen] = createSignal(false)
const [openStates, setOpenStates] = createSignal<Map<string, boolean>>(new Map())
export function showCommandPalette() {
setIsOpen(true)
function updateState(instanceId: string, open: boolean) {
setOpenStates((prev) => {
const next = new Map(prev)
next.set(instanceId, open)
return next
})
}
export function hideCommandPalette() {
setIsOpen(false)
export function showCommandPalette(instanceId: string) {
if (!instanceId) return
updateState(instanceId, true)
}
export function toggleCommandPalette() {
setIsOpen(!isOpen())
export function hideCommandPalette(instanceId?: string) {
if (!instanceId) {
setOpenStates(new Map())
return
}
updateState(instanceId, false)
}
export { isOpen }
export function toggleCommandPalette(instanceId: string) {
if (!instanceId) return
const current = openStates().get(instanceId) ?? false
updateState(instanceId, !current)
}
export function isOpen(instanceId: string): boolean {
return openStates().get(instanceId) ?? false
}
export { openStates }

30
src/stores/commands.ts Normal file
View File

@@ -0,0 +1,30 @@
import { createSignal } from "solid-js"
import type { Command as SDKCommand } from "@opencode-ai/sdk"
import type { OpencodeClient } from "@opencode-ai/sdk/client"
const [commandMap, setCommandMap] = createSignal<Map<string, SDKCommand.Info[]>>(new Map())
export async function fetchCommands(instanceId: string, client: OpencodeClient): Promise<void> {
const response = await client.command.list()
const commands = response.data ?? []
setCommandMap((prev) => {
const next = new Map(prev)
next.set(instanceId, commands)
return next
})
}
export function getCommands(instanceId: string): SDKCommand.Info[] {
return commandMap().get(instanceId) ?? []
}
export function clearCommands(instanceId: string): void {
setCommandMap((prev) => {
if (!prev.has(instanceId)) return prev
const next = new Map(prev)
next.delete(instanceId)
return next
})
}
export { commandMap as commands }

View File

@@ -10,6 +10,7 @@ import {
removeSessionIndexes,
clearInstanceDraftPrompts,
} from "./sessions"
import { fetchCommands, clearCommands } from "./commands"
import { preferences, updateLastUsedBinary } from "./preferences"
import { setHasInstances } from "./ui"
@@ -139,6 +140,7 @@ function removeInstance(id: string) {
})
removeLogContainer(id)
clearCommands(id)
if (activeInstanceId() === id) {
setActiveInstanceId(nextActiveId)
@@ -194,6 +196,7 @@ async function createInstance(folder: string, binaryPath?: string): Promise<stri
await fetchSessions(id)
await fetchAgents(id)
await fetchProviders(id)
await fetchCommands(id, client)
} catch (error) {
console.error("Failed to fetch initial data:", error)
}

View File

@@ -1,4 +1,5 @@
import { createSignal, onMount } from "solid-js"
import { createContext, createSignal, onMount, useContext } from "solid-js"
import type { Accessor, ParentComponent } from "solid-js"
import { storage, type ConfigData } from "../lib/storage"
export interface ModelPreference {
@@ -32,7 +33,7 @@ export interface RecentFolder {
lastAccessed: number
}
const MAX_RECENT_FOLDERS = 10
const MAX_RECENT_FOLDERS = 20
const MAX_RECENT_MODELS = 5
const defaultPreferences: Preferences = {
@@ -45,11 +46,13 @@ const defaultPreferences: Preferences = {
const [preferences, setPreferences] = createSignal<Preferences>(defaultPreferences)
const [recentFolders, setRecentFolders] = createSignal<RecentFolder[]>([])
const [opencodeBinaries, setOpenCodeBinaries] = createSignal<OpenCodeBinary[]>([])
const [isConfigLoaded, setIsConfigLoaded] = createSignal(false)
let cachedConfig: ConfigData = {
preferences: defaultPreferences,
recentFolders: [],
opencodeBinaries: [],
}
let loadPromise: Promise<void> | null = null
async function loadConfig(): Promise<void> {
try {
@@ -60,16 +63,25 @@ async function loadConfig(): Promise<void> {
recentFolders: config.recentFolders || [],
opencodeBinaries: config.opencodeBinaries || [],
}
setPreferences(cachedConfig.preferences)
setRecentFolders(cachedConfig.recentFolders)
setOpenCodeBinaries(cachedConfig.opencodeBinaries)
} catch (error) {
console.error("Failed to load config:", error)
cachedConfig = {
...cachedConfig,
preferences: { ...defaultPreferences },
recentFolders: [],
opencodeBinaries: [],
}
}
setPreferences(cachedConfig.preferences)
setRecentFolders(cachedConfig.recentFolders)
setOpenCodeBinaries(cachedConfig.opencodeBinaries)
setIsConfigLoaded(true)
}
async function saveConfig(): Promise<void> {
try {
await ensureConfigLoaded()
const config: ConfigData = {
...cachedConfig,
preferences: preferences(),
@@ -83,6 +95,17 @@ async function saveConfig(): Promise<void> {
}
}
async function ensureConfigLoaded(): Promise<void> {
if (isConfigLoaded()) return
if (!loadPromise) {
loadPromise = loadConfig().finally(() => {
loadPromise = null
})
}
await loadPromise
}
function updatePreferences(updates: Partial<Preferences>): void {
const updated = { ...preferences(), ...updates }
setPreferences(updated)
@@ -196,20 +219,85 @@ function getAgentModelPreference(instanceId: string, agent: string): ModelPrefer
return preferences().agentModelSelections?.[instanceId]?.[agent]
}
// Load config on mount and listen for changes from other instances
onMount(() => {
loadConfig()
// Reload config when changed by another instance
const unsubscribe = storage.onConfigChanged(() => {
loadConfig()
})
// Cleanup on unmount
return unsubscribe
void ensureConfigLoaded().catch((error) => {
console.error("Failed to initialize config:", error)
})
interface ConfigContextValue {
isLoaded: Accessor<boolean>
preferences: typeof preferences
recentFolders: typeof recentFolders
opencodeBinaries: typeof opencodeBinaries
toggleShowThinkingBlocks: typeof toggleShowThinkingBlocks
setDiffViewMode: typeof setDiffViewMode
addRecentFolder: typeof addRecentFolder
removeRecentFolder: typeof removeRecentFolder
addOpenCodeBinary: typeof addOpenCodeBinary
removeOpenCodeBinary: typeof removeOpenCodeBinary
updateLastUsedBinary: typeof updateLastUsedBinary
updatePreferences: typeof updatePreferences
updateEnvironmentVariables: typeof updateEnvironmentVariables
addEnvironmentVariable: typeof addEnvironmentVariable
removeEnvironmentVariable: typeof removeEnvironmentVariable
addRecentModelPreference: typeof addRecentModelPreference
setAgentModelPreference: typeof setAgentModelPreference
getAgentModelPreference: typeof getAgentModelPreference
}
const ConfigContext = createContext<ConfigContextValue>()
const configContextValue: ConfigContextValue = {
isLoaded: isConfigLoaded,
preferences,
recentFolders,
opencodeBinaries,
toggleShowThinkingBlocks,
setDiffViewMode,
addRecentFolder,
removeRecentFolder,
addOpenCodeBinary,
removeOpenCodeBinary,
updateLastUsedBinary,
updatePreferences,
updateEnvironmentVariables,
addEnvironmentVariable,
removeEnvironmentVariable,
addRecentModelPreference,
setAgentModelPreference,
getAgentModelPreference,
}
const ConfigProvider: ParentComponent = (props) => {
onMount(() => {
ensureConfigLoaded().catch((error) => {
console.error("Failed to initialize config:", error)
})
const unsubscribe = storage.onConfigChanged(() => {
loadConfig().catch((error) => {
console.error("Failed to refresh config:", error)
})
})
return () => {
unsubscribe()
}
})
return <ConfigContext.Provider value={configContextValue}>{props.children}</ConfigContext.Provider>
}
function useConfig(): ConfigContextValue {
const context = useContext(ConfigContext)
if (!context) {
throw new Error("useConfig must be used within ConfigProvider")
}
return context
}
export {
ConfigProvider,
useConfig,
preferences,
updatePreferences,
toggleShowThinkingBlocks,

View File

@@ -2029,6 +2029,48 @@ async function sendMessage(
}
}
async function executeCustomCommand(
instanceId: string,
sessionId: string,
commandName: string,
args: string,
): Promise<void> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
const session = sessions().get(instanceId)?.get(sessionId)
if (!session) {
throw new Error("Session not found")
}
const body: {
command: string
arguments: string
messageID: string
agent?: string
model?: string
} = {
command: commandName,
arguments: args,
messageID: createId("msg"),
}
if (session.agent) {
body.agent = session.agent
}
if (session.model.providerId && session.model.modelId) {
body.model = `${session.model.providerId}/${session.model.modelId}`
}
await instance.client.session.command({
path: { id: sessionId },
body,
})
}
async function abortSession(instanceId: string, sessionId: string): Promise<void> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
@@ -2266,4 +2308,5 @@ export {
setSessionDraftPrompt,
clearSessionDraftPrompt,
clearInstanceDraftPrompts,
executeCustomCommand,
}

View File

@@ -696,7 +696,7 @@ button.button-primary:disabled {
height: 2.75rem;
border-radius: 9999px;
border: 1px solid var(--border-base);
background-color: var(--surface-secondary);
background-color: transparent;
color: var(--text-primary);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.08);
transition: background-color 0.2s ease, color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
@@ -846,7 +846,7 @@ button.button-primary:disabled {
/* Tool call component */
.tool-call {
@apply border rounded-md overflow-hidden;
@apply border overflow-hidden;
border-color: var(--border-base);
color: inherit;
--tool-call-line-unit: 1.4em;
@@ -867,6 +867,7 @@ button.button-primary:disabled {
@apply flex items-center gap-2 p-2 w-full bg-transparent border-none cursor-pointer text-left;
font-family: var(--font-family-mono);
font-size: 13px;
border-radius: 0;
}
.tool-call-header:hover {
@@ -1084,7 +1085,7 @@ button.button-primary:disabled {
margin: 0;
padding: 8px;
background-color: var(--surface-base);
border-radius: 4px;
border-radius: 0px;
overflow-x: auto;
max-height: var(--tool-call-max-height-compact, calc(25 * 1.4em));
overflow-y: scroll;
@@ -1104,12 +1105,12 @@ button.button-primary:disabled {
.tool-call-section pre::-webkit-scrollbar-track {
background: var(--surface-secondary);
border-radius: 4px;
border-radius: 0px;
}
.tool-call-section pre::-webkit-scrollbar-thumb {
background: var(--border-base);
border-radius: 4px;
border-radius: 0px;
}
.tool-call-section pre::-webkit-scrollbar-thumb:hover {
@@ -1151,7 +1152,7 @@ button.button-primary:disabled {
.tool-call-content {
background-color: var(--surface-secondary);
border: 1px solid var(--border-base);
border-radius: 4px;
border-radius: 0;
padding: 8px 12px;
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);

View File

@@ -189,8 +189,8 @@
padding: 4px 8px;
background-color: var(--surface-secondary);
border-bottom: 1px solid var(--border-base);
border-top-left-radius: 6px;
border-top-right-radius: 6px;
/* border-top-left-radius: 6px;
border-top-right-radius: 6px; */
}
.code-block-language {
@@ -236,8 +236,7 @@
padding: 12px !important;
overflow-x: auto;
background-color: transparent !important;
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
border-radius: 0;
}
.markdown-code-block code {