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: build-macos:
needs: prepare-release needs: prepare-release
runs-on: macos-13 runs-on: macos-13
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -87,11 +89,25 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.prepare-release.outputs.tag }} TAG: ${{ needs.prepare-release.outputs.tag }}
run: | 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: build-windows:
needs: prepare-release needs: prepare-release
runs-on: windows-latest runs-on: windows-latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -109,16 +125,22 @@ jobs:
run: npm run build:win run: npm run build:win
- name: Upload release assets - name: Upload release assets
shell: bash shell: pwsh
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.prepare-release.outputs.tag }} TAG: ${{ needs.prepare-release.outputs.tag }}
run: | 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: build-linux:
needs: prepare-release needs: prepare-release
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -140,4 +162,16 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.prepare-release.outputs.tag }} TAG: ${{ needs.prepare-release.outputs.tag }}
run: | 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 # 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 - **Long-session native** scroll through massive transcripts without hitches and keep full context visible.
- **Session Persistence**: Resume conversations across app restarts - **Multiple instances, one window** juggle several OpenCode instances side-by-side with per-instance tabs.
- **Real-time Streaming**: Live message updates via Server-Sent Events - **Deep task awareness** jump into sub/child sessions (Tasks tool) instantly, monitor their status, and answer directly.
- **Tool Execution Visibility**: See bash commands, file edits, and other tool calls - **Keyboard first** the full UI is optimized for shortcuts so you can stay mouse-free when you want to.
- **Agent & Model Switching**: Easily switch between different AI agents and models - **Command palette superpowers** summon a single, global palette to jump tabs, launch tools, tweak preferences, or fire shortcuts faster than the UI can animate.
- **Markdown Rendering**: Beautiful code highlighting and formatting - **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 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.
- Full-text search across sessions
- Workspace management
- Custom themes
- Plugin system
## 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
``` Grab the latest build for macOS, Windows, and Linux from the [GitHub Releases page](https://github.com/shantur/CodeNomad/releases).
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
```
## Prerequisites ## Quick Start
- Node.js 18+ 1. Install the OpenCode CLI and confirm it is reachable via your terminal.
- Bun package manager 2. Download the CodeNomad build for your platform and launch the app.
- OpenCode CLI installed and in PATH 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 { readFile, writeFile, mkdir, unlink, stat } from "fs/promises"
import { existsSync } from "fs" 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 CONFIG_FILE = join(CONFIG_DIR, "config.json")
const INSTANCES_DIR = join(CONFIG_DIR, "instances") const INSTANCES_DIR = join(CONFIG_DIR, "instances")

View File

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

View File

@@ -52,6 +52,7 @@ function run(command, args, options = {}) {
const spawnOptions = { const spawnOptions = {
cwd: appDir, cwd: appDir,
stdio: "inherit", stdio: "inherit",
shell: process.platform === "win32",
...options, ...options,
env: { ...process.env, NODE_PATH: nodeModulesPath, ...(options.env || {}) }, 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.") 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("\n✅ Build complete!")
console.log(`📁 Binaries available in: ${join(appDir, "release")}\n`) 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 { Component, onMount, onCleanup, Show, createMemo, createEffect, createSignal } from "solid-js"
import { Dialog } from "@kobalte/core/dialog"
import { Toaster } from "solid-toast" import { Toaster } from "solid-toast"
import type { Session } from "./types/session" import type { Session } from "./types/session"
import type { Attachment } from "./types/attachment" import type { Attachment } from "./types/attachment"
import type { SDKPart, ClientPart } from "./types/message" 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 FolderSelectionView from "./components/folder-selection-view"
import InstanceWelcomeView from "./components/instance-welcome-view" import InstanceWelcomeView from "./components/instance-welcome-view"
import CommandPalette from "./components/command-palette" import CommandPalette from "./components/command-palette"
@@ -28,7 +29,7 @@ import {
showFolderSelection, showFolderSelection,
setShowFolderSelection, setShowFolderSelection,
} from "./stores/ui" } from "./stores/ui"
import { toggleShowThinkingBlocks, preferences, addRecentFolder, setDiffViewMode } from "./stores/preferences" import { useConfig } from "./stores/preferences"
import { import {
createInstance, createInstance,
instances, instances,
@@ -64,10 +65,12 @@ import {
getSessionInfo, getSessionInfo,
isSessionMessagesLoading, isSessionMessagesLoading,
fetchSessions, fetchSessions,
executeCustomCommand,
} from "./stores/sessions" } from "./stores/sessions"
import { isSessionBusy } from "./stores/session-status" import { isSessionBusy } from "./stores/session-status"
import { setupTabKeyboardShortcuts } from "./lib/keyboard" import { setupTabKeyboardShortcuts } from "./lib/keyboard"
import { isOpen as isCommandPaletteOpen, showCommandPalette, hideCommandPalette } from "./stores/command-palette" import { isOpen as isCommandPaletteOpen, showCommandPalette, hideCommandPalette } from "./stores/command-palette"
import { getCommands as getInstanceCommands } from "./stores/commands"
import { registerNavigationShortcuts } from "./lib/shortcuts/navigation" import { registerNavigationShortcuts } from "./lib/shortcuts/navigation"
import { registerInputShortcuts } from "./lib/shortcuts/input" import { registerInputShortcuts } from "./lib/shortcuts/input"
import { registerAgentShortcuts } from "./lib/shortcuts/agent" import { registerAgentShortcuts } from "./lib/shortcuts/agent"
@@ -349,9 +352,33 @@ const ContextUsagePanel: Component<{ instanceId: string; sessionId: string }> =
const App: Component = () => { const App: Component = () => {
const { isDark } = useTheme() const { isDark } = useTheme()
const { preferences, addRecentFolder, toggleShowThinkingBlocks, setDiffViewMode } = useConfig()
const commandRegistry = createCommandRegistry() const commandRegistry = createCommandRegistry()
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false) const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
const [paletteCommands, setPaletteCommands] = createSignal<Command[]>([]) 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 = () => { const refreshCommandPalette = () => {
setPaletteCommands(commandRegistry.getAll()) setPaletteCommands(commandRegistry.getAll())
@@ -361,15 +388,12 @@ const App: Component = () => {
void initMarkdown(isDark()).catch(console.error) void initMarkdown(isDark()).catch(console.error)
}) })
const activeInstance = createMemo(() => getActiveInstance()) const activeInstance = createMemo(() => getActiveInstance())
const activeSessions = createMemo(() => { const activeSessions = createMemo(() => {
const instance = activeInstance() const instance = activeInstance()
if (!instance) return new Map() if (!instance) return new Map()
const instanceId = instance.id const instanceId = instance.id
const parentId = activeParentSessionId().get(instanceId) const parentId = activeParentSessionId().get(instanceId)
if (!parentId) return new Map() if (!parentId) return new Map()
@@ -383,6 +407,7 @@ const App: Component = () => {
return activeSessionId().get(instance.id) || null return activeSessionId().get(instance.id) || null
}) })
const activeSessionForInstance = createMemo(() => { const activeSessionForInstance = createMemo(() => {
const sessionId = activeSessionIdForInstance() const sessionId = activeSessionIdForInstance()
if (!sessionId || sessionId === "info") return null if (!sessionId || sessionId === "info") return null
@@ -408,6 +433,7 @@ const App: Component = () => {
async function handleSelectFolder(folderPath?: string, binaryPath?: string) { async function handleSelectFolder(folderPath?: string, binaryPath?: string) {
setIsSelectingFolder(true) setIsSelectingFolder(true)
const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode"
try { try {
let folder: string | null | undefined = folderPath let folder: string | null | undefined = folderPath
@@ -418,19 +444,38 @@ const App: Component = () => {
} }
} }
if (!folder) {
return
}
addRecentFolder(folder) addRecentFolder(folder)
const instanceId = await createInstance(folder, binaryPath) clearLaunchError()
const instanceId = await createInstance(folder, selectedBinary)
setHasInstances(true) setHasInstances(true)
setShowFolderSelection(false) setShowFolderSelection(false)
setIsAdvancedSettingsOpen(false)
console.log("Created instance:", instanceId, "Port:", instances().get(instanceId)?.port) console.log("Created instance:", instanceId, "Port:", instances().get(instanceId)?.port)
} catch (error) { } catch (error) {
clearLaunchError()
if (isMissingBinaryError(error)) {
setLaunchErrorBinary(selectedBinary)
}
console.error("Failed to create instance:", error) console.error("Failed to create instance:", error)
} finally { } finally {
setIsSelectingFolder(false) setIsSelectingFolder(false)
} }
} }
function handleLaunchErrorClose() {
clearLaunchError()
}
function handleLaunchErrorAdvanced() {
clearLaunchError()
setIsAdvancedSettingsOpen(true)
}
function handleNewInstanceRequest() { function handleNewInstanceRequest() {
if (hasInstances()) { if (hasInstances()) {
setShowFolderSelection(true) 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({ commandRegistry.register({
id: "clear-input", id: "clear-input",
label: "Clear Input", label: "Clear Input",
@@ -893,8 +901,17 @@ const App: Component = () => {
refreshCommandPalette() refreshCommandPalette()
} }
function handleExecuteCommand(commandId: string) { function handleExecuteCommand(command: Command) {
commandRegistry.execute(commandId) 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, handleCloseInstance,
handleNewSession, handleNewSession,
handleCloseSession, handleCloseSession,
showCommandPalette, () => {
const instance = activeInstance()
if (instance) {
showCommandPalette(instance.id)
}
},
) )
registerNavigationShortcuts() registerNavigationShortcuts()
@@ -996,7 +1018,7 @@ const App: Component = () => {
const active = document.activeElement as HTMLElement const active = document.activeElement as HTMLElement
active?.blur() active?.blur()
}, },
hideCommandPalette, () => hideCommandPalette(),
) )
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
@@ -1055,6 +1077,41 @@ const App: Component = () => {
reason={disconnectedInstance()?.reason} reason={disconnectedInstance()?.reason}
onClose={handleDisconnectedInstanceClose} 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"> <div class="h-screen w-screen flex flex-col">
<Show <Show
when={!hasInstances()} when={!hasInstances()}
@@ -1069,123 +1126,145 @@ const App: Component = () => {
/> />
<Show when={activeInstance()} keyed> <Show when={activeInstance()} keyed>
{(instance) => ( {(instance) => {
<> const customCommands = createMemo(() =>
<Show when={activeSessions().size > 0} fallback={<InstanceWelcomeView instance={instance} />}> buildCustomCommandEntries(instance.id, getInstanceCommands(instance.id)),
<div class="flex flex-1 min-h-0"> )
{/* Session Sidebar */} const instancePaletteCommands = createMemo(() => [
<div ...paletteCommands(),
class="session-sidebar flex flex-col bg-surface-secondary" ...customCommands(),
style={{ width: `${sessionSidebarWidth()}px` }} ])
> const paletteOpen = createMemo(() => isCommandPaletteOpen(instance.id))
<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} return (
/> <>
<Show when={activeSessions().size > 0} fallback={<InstanceWelcomeView instance={instance} />}>
<div class="session-sidebar-separator border-t border-base" /> <div class="flex flex-1 min-h-0">
<Show when={activeSessionForInstance()}> {/* Session Sidebar */}
{(activeSession) => ( <div
<> class="session-sidebar flex flex-col bg-surface-secondary"
<ContextUsagePanel instanceId={instance.id} sessionId={activeSession().id} /> style={{ width: `${sessionSidebarWidth()}px` }}
<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} /> <SessionList
</Show> 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>
</div> </Show>
</Show>
</> <CommandPalette
)} open={paletteOpen()}
onClose={() => hideCommandPalette(instance.id)}
commands={instancePaletteCommands()}
onExecute={handleExecuteCommand}
/>
</>
)
}}
</Show> </Show>
</> </>
} }
> >
<FolderSelectionView onSelectFolder={handleSelectFolder} isLoading={isSelectingFolder()} /> <FolderSelectionView
onSelectFolder={handleSelectFolder}
isLoading={isSelectingFolder()}
advancedSettingsOpen={isAdvancedSettingsOpen()}
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
/>
</Show> </Show>
<CommandPalette
open={isCommandPaletteOpen()}
onClose={hideCommandPalette}
commands={paletteCommands()}
onExecute={handleExecuteCommand}
/>
<Show when={showFolderSelection()}> <Show when={showFolderSelection()}>
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center"> <div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
<div class="w-full h-full relative"> <div class="w-full h-full relative">
<button <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" 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)" 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" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
<FolderSelectionView onSelectFolder={handleSelectFolder} isLoading={isSelectingFolder()} /> <FolderSelectionView
onSelectFolder={handleSelectFolder}
isLoading={isSelectingFolder()}
advancedSettingsOpen={isAdvancedSettingsOpen()}
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
/>
</div> </div>
</div> </div>
</Show> </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 export default App

View File

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

View File

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

View File

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

View File

@@ -99,7 +99,6 @@ export default function MessageItem(props: MessageItemProps) {
</Show> </Show>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-[11px] text-[var(--text-muted)]">{timestamp()}</span>
<Show when={isUser() && props.onRevert}> <Show when={isUser() && props.onRevert}>
<button <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" 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,6 +119,7 @@ export default function MessageItem(props: MessageItemProps) {
Fork Fork
</button> </button>
</Show> </Show>
<span class="text-[11px] text-[var(--text-muted)]">{timestamp()}</span>
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,36 @@
import { createSignal } from "solid-js" import { createSignal } from "solid-js"
const [isOpen, setIsOpen] = createSignal(false) const [openStates, setOpenStates] = createSignal<Map<string, boolean>>(new Map())
export function showCommandPalette() { function updateState(instanceId: string, open: boolean) {
setIsOpen(true) setOpenStates((prev) => {
const next = new Map(prev)
next.set(instanceId, open)
return next
})
} }
export function hideCommandPalette() { export function showCommandPalette(instanceId: string) {
setIsOpen(false) if (!instanceId) return
updateState(instanceId, true)
} }
export function toggleCommandPalette() { export function hideCommandPalette(instanceId?: string) {
setIsOpen(!isOpen()) 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, removeSessionIndexes,
clearInstanceDraftPrompts, clearInstanceDraftPrompts,
} from "./sessions" } from "./sessions"
import { fetchCommands, clearCommands } from "./commands"
import { preferences, updateLastUsedBinary } from "./preferences" import { preferences, updateLastUsedBinary } from "./preferences"
import { setHasInstances } from "./ui" import { setHasInstances } from "./ui"
@@ -139,6 +140,7 @@ function removeInstance(id: string) {
}) })
removeLogContainer(id) removeLogContainer(id)
clearCommands(id)
if (activeInstanceId() === id) { if (activeInstanceId() === id) {
setActiveInstanceId(nextActiveId) setActiveInstanceId(nextActiveId)
@@ -194,6 +196,7 @@ async function createInstance(folder: string, binaryPath?: string): Promise<stri
await fetchSessions(id) await fetchSessions(id)
await fetchAgents(id) await fetchAgents(id)
await fetchProviders(id) await fetchProviders(id)
await fetchCommands(id, client)
} catch (error) { } catch (error) {
console.error("Failed to fetch initial data:", 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" import { storage, type ConfigData } from "../lib/storage"
export interface ModelPreference { export interface ModelPreference {
@@ -32,7 +33,7 @@ export interface RecentFolder {
lastAccessed: number lastAccessed: number
} }
const MAX_RECENT_FOLDERS = 10 const MAX_RECENT_FOLDERS = 20
const MAX_RECENT_MODELS = 5 const MAX_RECENT_MODELS = 5
const defaultPreferences: Preferences = { const defaultPreferences: Preferences = {
@@ -45,11 +46,13 @@ const defaultPreferences: Preferences = {
const [preferences, setPreferences] = createSignal<Preferences>(defaultPreferences) const [preferences, setPreferences] = createSignal<Preferences>(defaultPreferences)
const [recentFolders, setRecentFolders] = createSignal<RecentFolder[]>([]) const [recentFolders, setRecentFolders] = createSignal<RecentFolder[]>([])
const [opencodeBinaries, setOpenCodeBinaries] = createSignal<OpenCodeBinary[]>([]) const [opencodeBinaries, setOpenCodeBinaries] = createSignal<OpenCodeBinary[]>([])
const [isConfigLoaded, setIsConfigLoaded] = createSignal(false)
let cachedConfig: ConfigData = { let cachedConfig: ConfigData = {
preferences: defaultPreferences, preferences: defaultPreferences,
recentFolders: [], recentFolders: [],
opencodeBinaries: [], opencodeBinaries: [],
} }
let loadPromise: Promise<void> | null = null
async function loadConfig(): Promise<void> { async function loadConfig(): Promise<void> {
try { try {
@@ -60,16 +63,25 @@ async function loadConfig(): Promise<void> {
recentFolders: config.recentFolders || [], recentFolders: config.recentFolders || [],
opencodeBinaries: config.opencodeBinaries || [], opencodeBinaries: config.opencodeBinaries || [],
} }
setPreferences(cachedConfig.preferences)
setRecentFolders(cachedConfig.recentFolders)
setOpenCodeBinaries(cachedConfig.opencodeBinaries)
} catch (error) { } catch (error) {
console.error("Failed to load config:", 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> { async function saveConfig(): Promise<void> {
try { try {
await ensureConfigLoaded()
const config: ConfigData = { const config: ConfigData = {
...cachedConfig, ...cachedConfig,
preferences: preferences(), 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 { function updatePreferences(updates: Partial<Preferences>): void {
const updated = { ...preferences(), ...updates } const updated = { ...preferences(), ...updates }
setPreferences(updated) setPreferences(updated)
@@ -196,20 +219,85 @@ function getAgentModelPreference(instanceId: string, agent: string): ModelPrefer
return preferences().agentModelSelections?.[instanceId]?.[agent] return preferences().agentModelSelections?.[instanceId]?.[agent]
} }
// Load config on mount and listen for changes from other instances void ensureConfigLoaded().catch((error) => {
onMount(() => { console.error("Failed to initialize config:", error)
loadConfig()
// Reload config when changed by another instance
const unsubscribe = storage.onConfigChanged(() => {
loadConfig()
})
// Cleanup on unmount
return unsubscribe
}) })
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 { export {
ConfigProvider,
useConfig,
preferences, preferences,
updatePreferences, updatePreferences,
toggleShowThinkingBlocks, 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> { async function abortSession(instanceId: string, sessionId: string): Promise<void> {
const instance = instances().get(instanceId) const instance = instances().get(instanceId)
if (!instance || !instance.client) { if (!instance || !instance.client) {
@@ -2266,4 +2308,5 @@ export {
setSessionDraftPrompt, setSessionDraftPrompt,
clearSessionDraftPrompt, clearSessionDraftPrompt,
clearInstanceDraftPrompts, clearInstanceDraftPrompts,
executeCustomCommand,
} }

View File

@@ -696,7 +696,7 @@ button.button-primary:disabled {
height: 2.75rem; height: 2.75rem;
border-radius: 9999px; border-radius: 9999px;
border: 1px solid var(--border-base); border: 1px solid var(--border-base);
background-color: var(--surface-secondary); background-color: transparent;
color: var(--text-primary); color: var(--text-primary);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.08); 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; 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 component */
.tool-call { .tool-call {
@apply border rounded-md overflow-hidden; @apply border overflow-hidden;
border-color: var(--border-base); border-color: var(--border-base);
color: inherit; color: inherit;
--tool-call-line-unit: 1.4em; --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; @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-family: var(--font-family-mono);
font-size: 13px; font-size: 13px;
border-radius: 0;
} }
.tool-call-header:hover { .tool-call-header:hover {
@@ -1084,7 +1085,7 @@ button.button-primary:disabled {
margin: 0; margin: 0;
padding: 8px; padding: 8px;
background-color: var(--surface-base); background-color: var(--surface-base);
border-radius: 4px; border-radius: 0px;
overflow-x: auto; overflow-x: auto;
max-height: var(--tool-call-max-height-compact, calc(25 * 1.4em)); max-height: var(--tool-call-max-height-compact, calc(25 * 1.4em));
overflow-y: scroll; overflow-y: scroll;
@@ -1104,12 +1105,12 @@ button.button-primary:disabled {
.tool-call-section pre::-webkit-scrollbar-track { .tool-call-section pre::-webkit-scrollbar-track {
background: var(--surface-secondary); background: var(--surface-secondary);
border-radius: 4px; border-radius: 0px;
} }
.tool-call-section pre::-webkit-scrollbar-thumb { .tool-call-section pre::-webkit-scrollbar-thumb {
background: var(--border-base); background: var(--border-base);
border-radius: 4px; border-radius: 0px;
} }
.tool-call-section pre::-webkit-scrollbar-thumb:hover { .tool-call-section pre::-webkit-scrollbar-thumb:hover {
@@ -1151,7 +1152,7 @@ button.button-primary:disabled {
.tool-call-content { .tool-call-content {
background-color: var(--surface-secondary); background-color: var(--surface-secondary);
border: 1px solid var(--border-base); border: 1px solid var(--border-base);
border-radius: 4px; border-radius: 0;
padding: 8px 12px; padding: 8px 12px;
font-family: var(--font-family-mono); font-family: var(--font-family-mono);
font-size: var(--font-size-xs); font-size: var(--font-size-xs);

View File

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