Compare commits

..

39 Commits

Author SHA1 Message Date
Shantur Rathore
b46110937b Improve PATH-based binary execution 2025-11-17 01:38:53 +00:00
Shantur Rathore
28aa5da16d Add tool output visibility preferences 2025-11-17 01:38:53 +00:00
Shantur Rathore
03237f6f79 We always show 1 session 2025-11-17 01:38:53 +00:00
Shantur Rathore
492c6064f9 shorten tool call viewports 2025-11-17 01:38:53 +00:00
Shantur Rathore
fa8eacde53 Improve diagnostics accordion 2025-11-17 01:38:53 +00:00
Shantur Rathore
742c2d2c29 surface lsp status in instance info 2025-11-17 01:38:53 +00:00
Shantur Rathore
eb279cf251 enable prompt shell mode 2025-11-17 01:38:53 +00:00
Shantur Rathore
6658c0b15a add retry loop for local sse reconnection 2025-11-17 01:38:53 +00:00
Shantur Rathore
12044988d6 Increment version to 0.1.2 2025-11-17 01:38:53 +00:00
Shantur Rathore
c4e76aaac4 Inline permission approvals in tool calls 2025-11-17 01:38:53 +00:00
Shantur Rathore
2b6597ad00 prune duplicate messaging styles now sourced from scoped files 2025-11-17 01:38:53 +00:00
Shantur Rathore
cce5d1aba8 modularize app shell into context hooks 2025-11-17 01:38:53 +00:00
Shantur Rathore
04db4fcf94 persist pasted text in history and align sdk command types 2025-11-17 01:38:53 +00:00
Shantur Rathore
cb161e57a4 preserve prompt draft when browsing history 2025-11-17 01:38:53 +00:00
Shantur Rathore
b92fbd93a8 Coding principles 2025-11-17 01:38:53 +00:00
Shantur Rathore
1a0ccac634 modularize session store into focused modules 2025-11-17 01:38:53 +00:00
Shantur Rathore
cd9d7c2a39 Styling guidelines 2025-11-17 01:38:53 +00:00
Shantur Rathore
941052acc8 modularize app styles 2025-11-17 01:38:53 +00:00
Shantur Rathore
5f67a01864 modularize ui styles into tokenized bundles 2025-11-17 01:38:53 +00:00
Shantur Rathore
b80e332021 Merge pull request #2 from alexispurslane/patch-1
remove some duplication in README
2025-11-16 00:22:00 +00:00
Alexis Purslane
df625e0fe7 remove some duplication in README
Refined descriptions for clarity and conciseness in the README.
2025-11-15 04:05:12 -05:00
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
80 changed files with 8458 additions and 6053 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

16
AGENTS.md Normal file
View File

@@ -0,0 +1,16 @@
# AGENT NOTES
## Styling Guidelines
- Reuse the existing token & utility layers before introducing new CSS variables or custom properties. Extend `src/styles/tokens.css` / `src/styles/utilities.css` if a shared pattern is needed.
- Keep aggregate entry files (e.g., `src/styles/controls.css`, `messaging.css`, `panels.css`) lean—they should only `@import` feature-specific subfiles located inside `src/styles/{components|messaging|panels}`.
- When adding new component styles, place them beside their peers in the scoped subdirectory (e.g., `src/styles/messaging/new-part.css`) and import them from the corresponding aggregator file.
- Prefer smaller, focused style files (≈150 lines or less) over large monoliths. Split by component or feature area if a file grows beyond that size.
- Co-locate reusable UI patterns (buttons, selectors, dropdowns, etc.) under `src/styles/components/` and avoid redefining the same utility classes elsewhere.
- Document any new styling conventions or directory additions in this file so future changes remain consistent.
## Coding Principles
- Favor KISS by keeping modules narrowly scoped and limiting public APIs to what callers actually need.
- Uphold DRY: share helpers via dedicated modules before copy/pasting logic across stores, components, or scripts.
- Enforce single responsibility; split large files when concerns diverge (state, actions, API, events, etc.).
- Prefer composable primitives (signals, hooks, utilities) over deep inheritance or implicit global state.
- When adding platform integrations (SSE, IPC, SDK), isolate them in thin adapters that surface typed events/actions.

226
README.md
View File

@@ -1,219 +1,35 @@
# CodeNomad
## A fast, multi-instance desktop client for running OpenCode sessions the way long-haul builders actually work.
A cross-platform desktop application for interacting with OpenCode servers, built with Electron and SolidJS.
## What is CodeNomad?
## Overview
CodeNomad is built for people who live inside OpenCode for hours on end and need a cockpit, not a kiosk. When terminals get unwieldy and web clients feel laggy, CodeNomad delivers a desktop-native workspace that favors speed, clarity, and direct control. It runs on macOS, Windows, and Linux using Electron + SolidJS, with prebuilt binaries so you can get started immediately.
CodeNomad 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.
![Multi-instance workspace](docs/screenshots/newSession.png)
**🎯 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.
![Command palette overlay](docs/screenshots/command-palette.png)
## Features
## Highlights
### Core Capabilities
- **Long-session native** scroll through massive transcripts without hitches and keep full context visible.
- **Multiple instances, one window** juggle several OpenCode instances side-by-side with per-instance tabs.
- **Deep task awareness** jump into sub/child sessions (Tasks tool) instantly, monitor their status, and answer directly without losing your flow.
- **Keyboard first** the full UI is optimized for shortcuts so you can stay mouse-free when you want to.
- **Command palette superpowers** summon a single, global palette to jump tabs, launch tools, tweak preferences, or fire shortcuts. Every action is categorized, fuzzy searchable, and previewed so you can chain moves together in seconds. It keeps your workflow predictable and fast whether you are juggling one session or ten.
- **Developer-friendly rendering** syntax highlighting, inline diffs, and thoughtful presentation keep the signal high.
- **Multi-Instance 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
## Requirements
### Advanced Features (Planned)
- [OpenCode CLI](https://opencode.ai) installed and available in your `PATH`, or point CodeNomad to a local binary through Advanced Settings.
- Virtual scrolling for large conversations
- Full-text search across sessions
- Workspace management
- Custom themes
- Plugin system
## Downloads
## Architecture
Grab the latest build for macOS, Windows, and Linux from the [GitHub Releases page](https://github.com/shantur/CodeNomad/releases).
See [docs/architecture.md](docs/architecture.md) for detailed architecture documentation.
## Quick Start
### High-Level Overview
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.
```
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
- Node.js 18+
- Bun package manager
- OpenCode CLI installed and in PATH
## 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

@@ -1,7 +1,7 @@
import { spawn, ChildProcess } from "child_process"
import { spawn, execSync, ChildProcess } from "child_process"
import { app, BrowserWindow } from "electron"
import { existsSync, statSync } from "fs"
import { execSync } from "child_process"
import { buildUserShellCommand, getUserShellEnv, runUserShellCommandSync, supportsUserShell } from "./user-shell"
export interface ProcessInfo {
pid: number
@@ -57,17 +57,13 @@ class ProcessManager {
environmentVariables?: Record<string, string>,
): Promise<ProcessInfo> {
this.validateFolder(folder)
const actualBinaryPath =
binaryPath && binaryPath !== "opencode" ? this.validateCustomBinary(binaryPath) : this.validateOpenCodeBinary()
const useUserShell = supportsUserShell()
const logAttempt = (message: string) => {
console.info(`[ProcessManager] ${message}`)
this.sendLog(instanceId, "debug", message)
}
this.sendLog(
instanceId,
"info",
`Starting OpenCode server for ${folder} using ${binaryPath || "opencode"} (${actualBinaryPath})...`,
)
// Merge environment variables with process environment
const env = { ...process.env }
const env = useUserShell ? getUserShellEnv() : { ...process.env }
if (environmentVariables) {
Object.assign(env, environmentVariables)
this.sendLog(
@@ -82,14 +78,35 @@ class ProcessManager {
}
}
let targetBinary: string
if (!binaryPath || binaryPath === "opencode") {
targetBinary = useUserShell ? "opencode" : this.validateOpenCodeBinary(logAttempt)
} else {
targetBinary = this.validateCustomBinary(binaryPath, logAttempt)
}
const spawnCommand = useUserShell
? this.buildShellServeCommand(targetBinary)
: { command: targetBinary, args: this.buildServeArgs() }
const launchDetail = `${spawnCommand.command} ${spawnCommand.args.join(" ")}`.trim()
this.sendLog(instanceId, "debug", `Launching process with: ${launchDetail}`)
this.sendLog(
instanceId,
"info",
`Starting OpenCode server for ${folder} using ${targetBinary}...`,
)
return new Promise((resolve, reject) => {
const child = spawn(actualBinaryPath, ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"], {
const child = spawn(spawnCommand.command, spawnCommand.args, {
cwd: folder,
stdio: ["ignore", "pipe", "pipe"],
env,
shell: false,
})
const timeout = setTimeout(() => {
child.kill("SIGKILL")
this.sendLog(instanceId, "error", "Server startup timeout (10s exceeded)")
@@ -129,7 +146,7 @@ class ProcessManager {
}
this.processes.set(child.pid!, meta)
resolve({ pid: child.pid!, port, binaryPath: actualBinaryPath })
resolve({ pid: child.pid!, port, binaryPath: targetBinary })
}
const meta = this.processes.get(child.pid!)
@@ -236,20 +253,45 @@ class ProcessManager {
}
}
private validateOpenCodeBinary(): string {
const command = process.platform === "win32" ? "where opencode" : "which opencode"
private validateOpenCodeBinary(logAttempt?: (message: string) => void): string {
const log = logAttempt ?? ((message: string) => console.info(`[ProcessManager] ${message}`))
if (process.platform === "win32") {
log("Checking PATH via 'where opencode'")
return this.resolveBinaryViaLocator("where opencode", log)
}
const shellCheck = buildUserShellCommand("command -v opencode")
const shellPreview = [shellCheck.command, ...shellCheck.args].join(" ")
log(`Checking PATH via shell: ${shellPreview}`)
try {
const output = execSync(command, { stdio: "pipe", encoding: "utf-8" })
const paths = output.trim().split("\n")
return paths[0].trim()
} catch {
throw new Error(
"opencode binary not found in PATH. Please install OpenCode CLI first: npm install -g @opencode/cli",
)
const resolved = runUserShellCommandSync("command -v opencode")
const path = this.pickFirstPath(resolved)
if (path) {
log(`Shell located opencode at ${path}`)
return path
}
throw new Error("Empty result from shell lookup")
} catch (shellError) {
const message = shellError instanceof Error ? shellError.message : String(shellError)
log(`Shell lookup failed: ${message}`)
try {
log("Fallback to 'which opencode'")
return this.resolveBinaryViaLocator("which opencode", log)
} catch (locatorError) {
const locatorMessage = locatorError instanceof Error ? locatorError.message : String(locatorError)
log(`Locator fallback failed: ${locatorMessage}`)
throw new Error(
"opencode binary not found in PATH. Please install OpenCode CLI first: npm install -g @opencode/cli",
)
}
}
}
private validateCustomBinary(binaryPath: string): string {
private validateCustomBinary(binaryPath: string, log?: (message: string) => void): string {
log?.(`Validating custom binary at ${binaryPath}`)
if (!existsSync(binaryPath)) {
throw new Error(`OpenCode binary not found: ${binaryPath}`)
}
@@ -270,6 +312,36 @@ class ProcessManager {
return binaryPath
}
private resolveBinaryViaLocator(command: string, log?: (message: string) => void): string {
log?.(`Running locator command: ${command}`)
const output = execSync(command, { stdio: "pipe", encoding: "utf-8" })
log?.(`Locator output: ${output.trim() || "<empty>"}`)
const path = this.pickFirstPath(output)
if (!path) {
throw new Error("opencode binary not found in PATH")
}
return path
}
private pickFirstPath(output: string): string | null {
const line = output
.split("\n")
.map((entry) => entry.trim())
.find((entry) => entry.length > 0)
return line ?? null
}
private buildServeArgs(): string[] {
return ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"]
}
private buildShellServeCommand(binaryPath: string): { command: string; args: string[] } {
const args = this.buildServeArgs()
.map((arg) => JSON.stringify(arg))
.join(" ")
return buildUserShellCommand(`exec ${JSON.stringify(binaryPath)} ${args}`)
}
}
export const processManager = new ProcessManager()

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")

139
electron/main/user-shell.ts Normal file
View File

@@ -0,0 +1,139 @@
import { spawn, spawnSync } from "child_process"
import path from "path"
interface ShellCommand {
command: string
args: string[]
}
const isWindows = process.platform === "win32"
function getDefaultShellPath(): string {
if (process.env.SHELL && process.env.SHELL.trim().length > 0) {
return process.env.SHELL
}
if (process.platform === "darwin") {
return "/bin/zsh"
}
return "/bin/bash"
}
function wrapCommandForShell(command: string, shellPath: string): string {
const shellName = path.basename(shellPath)
if (shellName.includes("bash")) {
return 'if [ -f ~/.bashrc ]; then source ~/.bashrc >/dev/null 2>&1; fi; ' + command
}
if (shellName.includes("zsh")) {
return 'if [ -f ~/.zshrc ]; then source ~/.zshrc >/dev/null 2>&1; fi; ' + command
}
return command
}
function buildShellArgs(shellPath: string): string[] {
const shellName = path.basename(shellPath)
if (shellName.includes("zsh")) {
return ["-l", "-i", "-c"]
}
return ["-l", "-c"]
}
function sanitizeShellEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const cleaned = { ...env }
delete cleaned.npm_config_prefix
delete cleaned.NPM_CONFIG_PREFIX
return cleaned
}
export function supportsUserShell(): boolean {
return !isWindows
}
export function buildUserShellCommand(userCommand: string): ShellCommand {
if (!supportsUserShell()) {
throw new Error("User shell invocation is only supported on POSIX platforms")
}
const shellPath = getDefaultShellPath()
const script = wrapCommandForShell(userCommand, shellPath)
const args = buildShellArgs(shellPath)
return {
command: shellPath,
args: [...args, script],
}
}
export function getUserShellEnv(): NodeJS.ProcessEnv {
if (!supportsUserShell()) {
throw new Error("User shell invocation is only supported on POSIX platforms")
}
return sanitizeShellEnv(process.env)
}
export function runUserShellCommand(userCommand: string, timeoutMs = 5000): Promise<string> {
if (!supportsUserShell()) {
return Promise.reject(new Error("User shell invocation is only supported on POSIX platforms"))
}
const { command, args } = buildUserShellCommand(userCommand)
const env = getUserShellEnv()
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
stdio: ["ignore", "pipe", "pipe"],
env,
})
let stdout = ""
let stderr = ""
const timeout = setTimeout(() => {
child.kill("SIGTERM")
reject(new Error(`Shell command timed out after ${timeoutMs}ms`))
}, timeoutMs)
child.stdout?.on("data", (data) => {
stdout += data.toString()
})
child.stderr?.on("data", (data) => {
stderr += data.toString()
})
child.on("error", (error) => {
clearTimeout(timeout)
reject(error)
})
child.on("close", (code) => {
clearTimeout(timeout)
if (code === 0) {
resolve(stdout.trim())
} else {
reject(new Error(stderr.trim() || `Shell command exited with code ${code}`))
}
})
})
}
export function runUserShellCommandSync(userCommand: string): string {
if (!supportsUserShell()) {
throw new Error("User shell invocation is only supported on POSIX platforms")
}
const { command, args } = buildUserShellCommand(userCommand)
const env = getUserShellEnv()
const result = spawnSync(command, args, { encoding: "utf-8", env })
if (result.status !== 0) {
const stderr = (result.stderr || "").toString().trim()
throw new Error(stderr || "Shell command failed")
}
return (result.stdout || "").toString().trim()
}

View File

79
package-lock.json generated
View File

@@ -1,16 +1,16 @@
{
"name": "@opencode-ai/client",
"version": "0.1.0",
"name": "@shantur/codenomad",
"version": "0.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@opencode-ai/client",
"version": "0.1.0",
"name": "@shantur/codenomad",
"version": "0.1.2",
"dependencies": {
"@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11",
"@opencode-ai/sdk": "0.15.13",
"@opencode-ai/sdk": "1.0.68",
"@solidjs/router": "^0.13.0",
"github-markdown-css": "^5.8.1",
"ignore": "7.0.5",
@@ -79,6 +79,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -1465,9 +1466,9 @@
}
},
"node_modules/@opencode-ai/sdk": {
"version": "0.15.13",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-0.15.13.tgz",
"integrity": "sha512-SwKCaWTZvGuGm+CLRDp/Ku476SKuMREIKrPHvKqpZM5gfO3HrVct9zk2f+NwjI0MRKCHNMdtOoEcmz/ypc+pWg=="
"version": "1.0.68",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.0.68.tgz",
"integrity": "sha512-QdpLZw2L/nHdPFGCz8z4du2RvlALgZTFgNeKUM+kJuZTtOWC5t425ELGg5xKIpynD0kj83Euvfn6l6uHs99g3w=="
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
@@ -2289,6 +2290,7 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -2464,7 +2466,6 @@
"integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"archiver-utils": "^2.1.0",
"async": "^3.2.4",
@@ -2484,7 +2485,6 @@
"integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"glob": "^7.1.4",
"graceful-fs": "^4.2.0",
@@ -2507,7 +2507,6 @@
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
@@ -2523,8 +2522,7 @@
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/archiver-utils/node_modules/string_decoder": {
"version": "1.1.1",
@@ -2532,7 +2530,6 @@
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"safe-buffer": "~5.1.0"
}
@@ -2751,7 +2748,6 @@
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
@@ -2827,6 +2823,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -3290,7 +3287,6 @@
"integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"buffer-crc32": "^0.2.13",
"crc32-stream": "^4.0.2",
@@ -3397,7 +3393,6 @@
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"crc32": "bin/crc32.njs"
},
@@ -3411,7 +3406,6 @@
"integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"crc-32": "^1.2.0",
"readable-stream": "^3.4.0"
@@ -3644,6 +3638,7 @@
"integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"app-builder-lib": "24.13.3",
"builder-util": "24.13.1",
@@ -3828,7 +3823,6 @@
"integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"app-builder-lib": "24.13.3",
"archiver": "^5.3.1",
@@ -3842,7 +3836,6 @@
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@@ -3858,7 +3851,6 @@
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"universalify": "^2.0.0"
},
@@ -3872,7 +3864,6 @@
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 10.0.0"
}
@@ -4354,8 +4345,7 @@
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/fs-extra": {
"version": "8.1.0",
@@ -5074,8 +5064,7 @@
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/isbinaryfile": {
"version": "5.0.6",
@@ -5137,6 +5126,7 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -5242,7 +5232,6 @@
"integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"readable-stream": "^2.0.5"
},
@@ -5256,7 +5245,6 @@
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
@@ -5272,8 +5260,7 @@
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/lazystream/node_modules/string_decoder": {
"version": "1.1.1",
@@ -5281,7 +5268,6 @@
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"safe-buffer": "~5.1.0"
}
@@ -5318,40 +5304,35 @@
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/lodash.difference": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz",
"integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/lodash.flatten": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
"integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/lodash.union": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz",
"integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/lowercase-keys": {
"version": "2.0.0",
@@ -6046,6 +6027,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -6194,8 +6176,7 @@
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/progress": {
"version": "2.0.3",
@@ -6344,7 +6325,6 @@
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
@@ -6360,7 +6340,6 @@
"integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"minimatch": "^5.1.0"
}
@@ -6578,8 +6557,7 @@
"url": "https://feross.org/support"
}
],
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
@@ -6645,6 +6623,7 @@
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz",
"integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
}
@@ -6772,6 +6751,7 @@
"resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.10.tgz",
"integrity": "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.1.0",
"seroval": "~1.3.0",
@@ -6891,7 +6871,6 @@
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"safe-buffer": "~5.2.0"
}
@@ -7149,7 +7128,6 @@
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
@@ -7533,6 +7511,7 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -8195,7 +8174,6 @@
"integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"archiver-utils": "^3.0.4",
"compress-commons": "^4.1.2",
@@ -8211,7 +8189,6 @@
"integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"glob": "^7.2.3",
"graceful-fs": "^4.2.0",

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.2",
"description": "CodeNomad - AI coding assistant",
"author": {
"name": "Shantur Rathore",
"email": "codenomad@shantur.com"
},
"type": "module",
"main": "dist/main/main.js",
"scripts": {
@@ -27,7 +30,7 @@
"dependencies": {
"@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11",
"@opencode-ai/sdk": "0.15.13",
"@opencode-ai/sdk": "1.0.68",
"@solidjs/router": "^0.13.0",
"github-markdown-css": "^5.8.1",
"ignore": "7.0.5",

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`)

File diff suppressed because it is too large Load Diff

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

@@ -1,6 +1,6 @@
import { Component, Show, For, createSignal, createEffect, onCleanup } from "solid-js"
import type { Instance, RawMcpStatus } from "../types/instance"
import { updateInstance } from "../stores/instances"
import { fetchLspStatus, updateInstance } from "../stores/instances"
interface InstanceInfoProps {
instance: Instance
@@ -52,6 +52,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
const status = metadata()?.mcpStatus
return status ? parseMcpStatus(status) : []
}
const lspServers = () => metadata()?.lspStatus ?? []
createEffect(() => {
const instance = props.instance
@@ -82,9 +83,10 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
void (async () => {
try {
const [projectResult, mcpResult] = await Promise.allSettled([
const [projectResult, mcpResult, lspResult] = await Promise.allSettled([
client.project.current(),
client.mcp.status(),
fetchLspStatus(instanceId),
])
if (cancelled) {
@@ -93,11 +95,13 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
const project = projectResult.status === "fulfilled" ? projectResult.value.data : undefined
const mcpStatus = mcpResult.status === "fulfilled" ? (mcpResult.value.data as RawMcpStatus) : undefined
const lspStatus = lspResult.status === "fulfilled" ? lspResult.value ?? [] : undefined
const nextMetadata = {
...(instance.metadata ?? {}),
...(project ? { project } : {}),
...(mcpStatus ? { mcpStatus } : {}),
...(lspStatus ? { lspStatus } : {}),
}
if (!nextMetadata.version) {
@@ -213,6 +217,34 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
</div>
</Show>
<Show when={!isLoadingMetadata() && lspServers().length > 0}>
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">
LSP Servers
</div>
<div class="space-y-1.5">
<For each={lspServers()}>
{(server) => (
<div class="px-2 py-1.5 rounded border bg-surface-secondary border-base">
<div class="flex items-center justify-between gap-2">
<div class="flex flex-col flex-1 min-w-0">
<span class="text-xs text-primary font-medium truncate">{server.name ?? server.id}</span>
<span class="text-[11px] text-secondary truncate" title={server.root}>
{server.root}
</span>
</div>
<div class="flex items-center gap-1.5 flex-shrink-0 text-xs text-secondary">
<div class={`status-dot ${server.status === "connected" ? "ready animate-pulse" : "error"}`} />
<span>{server.status === "connected" ? "Connected" : "Error"}</span>
</div>
</div>
</div>
)}
</For>
</div>
</div>
</Show>
<Show when={!isLoadingMetadata() && mcpServers().length > 0}>
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">

View File

@@ -0,0 +1,173 @@
import { Show, createMemo, createSignal, type Component } from "solid-js"
import type { Accessor } from "solid-js"
import type { Instance } from "../../types/instance"
import type { Command } from "../../lib/commands"
import { activeParentSessionId, activeSessionId as activeSessionMap, getSessionFamily, setActiveSession } from "../../stores/sessions"
import { keyboardRegistry, type KeyboardShortcut } from "../../lib/keyboard-registry"
import { buildCustomCommandEntries } from "../../lib/command-utils"
import { getCommands as getInstanceCommands } from "../../stores/commands"
import { isOpen as isCommandPaletteOpen, hideCommandPalette } from "../../stores/command-palette"
import SessionList from "../session-list"
import KeyboardHint from "../keyboard-hint"
import InstanceWelcomeView from "../instance-welcome-view"
import InfoView from "../info-view"
import AgentSelector from "../agent-selector"
import ModelSelector from "../model-selector"
import CommandPalette from "../command-palette"
import ContextUsagePanel from "../session/context-usage-panel"
import SessionView from "../session/session-view"
interface InstanceShellProps {
instance: Instance
escapeInDebounce: boolean
paletteCommands: Accessor<Command[]>
onCloseSession: (sessionId: string) => Promise<void> | void
onNewSession: () => Promise<void> | void
handleSidebarAgentChange: (sessionId: string, agent: string) => Promise<void>
handleSidebarModelChange: (sessionId: string, model: { providerId: string; modelId: string }) => Promise<void>
onExecuteCommand: (command: Command) => void
}
const DEFAULT_SESSION_SIDEBAR_WIDTH = 280
const InstanceShell: Component<InstanceShellProps> = (props) => {
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
const activeSessions = createMemo(() => {
const parentId = activeParentSessionId().get(props.instance.id)
if (!parentId) return new Map<string, ReturnType<typeof getSessionFamily>[number]>()
const sessionFamily = getSessionFamily(props.instance.id, parentId)
return new Map(sessionFamily.map((s) => [s.id, s]))
})
const activeSessionIdForInstance = createMemo(() => {
return activeSessionMap().get(props.instance.id) || null
})
const activeSessionForInstance = createMemo(() => {
const sessionId = activeSessionIdForInstance()
if (!sessionId || sessionId === "info") return null
return activeSessions().get(sessionId) ?? null
})
const customCommands = createMemo(() => buildCustomCommandEntries(props.instance.id, getInstanceCommands(props.instance.id)))
const instancePaletteCommands = createMemo(() => [...props.paletteCommands(), ...customCommands()])
const paletteOpen = createMemo(() => isCommandPaletteOpen(props.instance.id))
const keyboardShortcuts = createMemo(() =>
[keyboardRegistry.get("session-prev"), keyboardRegistry.get("session-next")].filter(
(shortcut): shortcut is KeyboardShortcut => Boolean(shortcut),
),
)
const handleSessionSelect = (sessionId: string) => {
setActiveSession(props.instance.id, sessionId)
}
return (
<>
<Show when={activeSessions().size > 0} fallback={<InstanceWelcomeView instance={props.instance} />}>
<div class="flex flex-1 min-h-0">
<div class="session-sidebar flex flex-col bg-surface-secondary" style={{ width: `${sessionSidebarWidth()}px` }}>
<SessionList
instanceId={props.instance.id}
sessions={activeSessions()}
activeSessionId={activeSessionIdForInstance()}
onSelect={handleSessionSelect}
onClose={(id) => {
const result = props.onCloseSession(id)
if (result instanceof Promise) {
void result.catch((error) => console.error("Failed to close session:", error))
}
}}
onNew={() => {
const result = props.onNewSession()
if (result instanceof Promise) {
void result.catch((error) => console.error("Failed to create session:", error))
}
}}
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">
{keyboardShortcuts().length ? (
<KeyboardHint shortcuts={keyboardShortcuts()} separator=" " showDescription={false} />
) : null}
</div>
</div>
}
onWidthChange={setSessionSidebarWidth}
/>
<div class="session-sidebar-separator border-t border-base" />
<Show when={activeSessionForInstance()}>
{(activeSession) => (
<>
<ContextUsagePanel instanceId={props.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={props.instance.id}
sessionId={activeSession().id}
currentAgent={activeSession().agent}
onAgentChange={(agent) => props.handleSidebarAgentChange(activeSession().id, agent)}
/>
<ModelSelector
instanceId={props.instance.id}
sessionId={activeSession().id}
currentModel={activeSession().model}
onModelChange={(model) => props.handleSidebarModelChange(activeSession().id, model)}
/>
</div>
</>
)}
</Show>
</div>
<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={props.instance.id}
instanceFolder={props.instance.folder}
escapeInDebounce={props.escapeInDebounce}
/>
)}
</Show>
}
>
<InfoView instanceId={props.instance.id} />
</Show>
</div>
</div>
</Show>
<CommandPalette
open={paletteOpen()}
onClose={() => hideCommandPalette(props.instance.id)}
commands={instancePaletteCommands()}
onExecute={props.onExecuteCommand}
/>
</>
)
}
export default InstanceShell

View File

@@ -6,6 +6,8 @@ import MessagePart from "./message-part"
interface MessageItemProps {
message: Message
messageInfo?: MessageInfo
instanceId: string
sessionId: string
isQueued?: boolean
parts?: ClientPart[]
onRevert?: (messageId: string) => void
@@ -99,7 +101,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 +121,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()}>
@@ -139,7 +141,14 @@ export default function MessageItem(props: MessageItemProps) {
</div>
</Show>
<For each={messageParts()}>{(part) => <MessagePart part={part} messageType={props.message.type} />}</For>
<For each={messageParts()}>{(part) => (
<MessagePart
part={part}
messageType={props.message.type}
instanceId={props.instanceId}
sessionId={props.sessionId}
/>
)}</For>
</div>
<Show when={props.message.status === "sending"}>

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" }>
@@ -11,9 +11,12 @@ type ToolCallPart = Extract<ClientPart, { type: "tool" }>
interface MessagePartProps {
part: ClientPart
messageType?: "user" | "assistant"
instanceId: string
sessionId: string
}
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())
@@ -70,7 +73,12 @@ export default function MessagePart(props: MessagePartProps) {
</Match>
<Match when={partType() === "tool"}>
<ToolCall toolCall={props.part as ToolCallPart} toolCallId={props.part?.id} />
<ToolCall
toolCall={props.part as ToolCallPart}
toolCallId={props.part?.id}
instanceId={props.instanceId}
sessionId={props.sessionId}
/>
</Match>

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 = {
@@ -611,6 +615,8 @@ export default function MessageStream(props: MessageStreamProps) {
<MessageItem
message={item.message}
messageInfo={item.messageInfo}
instanceId={props.instanceId}
sessionId={props.sessionId}
isQueued={item.isQueued}
parts={item.combinedParts}
onRevert={props.onRevert}
@@ -661,6 +667,8 @@ export default function MessageStream(props: MessageStreamProps) {
messageId={item.messageId}
messageVersion={item.messageVersion}
partVersion={item.partVersion}
instanceId={props.instanceId}
sessionId={props.sessionId}
/>
</div>
)

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

@@ -2,6 +2,7 @@ import { createSignal, Show, onMount, For, onCleanup, createEffect, on, untrack
import UnifiedPicker from "./unified-picker"
import { addToHistory, getHistory } from "../stores/message-history"
import { getAttachments, addAttachment, clearAttachments, removeAttachment } from "../stores/attachments"
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
import { createFileAttachment, createTextAttachment, createAgentAttachment } from "../types/attachment"
import type { Attachment } from "../types/attachment"
import type { Agent } from "../types/session"
@@ -15,6 +16,7 @@ interface PromptInputProps {
instanceFolder: string
sessionId: string
onSend: (prompt: string, attachments: Attachment[]) => Promise<void>
onRunShell?: (command: string) => Promise<void>
disabled?: boolean
escapeInDebounce?: boolean
}
@@ -23,6 +25,7 @@ export default function PromptInput(props: PromptInputProps) {
const [prompt, setPromptInternal] = createSignal("")
const [history, setHistory] = createSignal<string[]>([])
const [historyIndex, setHistoryIndex] = createSignal(-1)
const [historyDraft, setHistoryDraft] = createSignal<string | null>(null)
const [isFocused, setIsFocused] = createSignal(false)
const [showPicker, setShowPicker] = createSignal(false)
const [searchQuery, setSearchQuery] = createSignal("")
@@ -31,6 +34,7 @@ export default function PromptInput(props: PromptInputProps) {
const [ignoredAtPositions, setIgnoredAtPositions] = createSignal<Set<number>>(new Set<number>())
const [pasteCount, setPasteCount] = createSignal(0)
const [imageCount, setImageCount] = createSignal(0)
const [mode, setMode] = createSignal<"normal" | "shell">("normal")
let textareaRef: HTMLTextAreaElement | undefined
let containerRef: HTMLDivElement | undefined
@@ -48,6 +52,8 @@ export default function PromptInput(props: PromptInputProps) {
const clearPrompt = () => {
clearSessionDraftPrompt(props.instanceId, props.sessionId)
setPromptInternal("")
setHistoryDraft(null)
setMode("normal")
}
function syncAttachmentCounters(currentPrompt: string, sessionAttachments: Attachment[]) {
@@ -110,6 +116,7 @@ export default function PromptInput(props: PromptInputProps) {
setPromptInternal(storedPrompt)
setSessionDraftPrompt(instanceId, sessionId, storedPrompt)
setHistoryIndex(-1)
setHistoryDraft(null)
setIgnoredAtPositions(new Set<number>())
setShowPicker(false)
setAtPosition(null)
@@ -291,9 +298,40 @@ export default function PromptInput(props: PromptInputProps) {
return
}
const currentText = prompt()
const cursorAtBufferStart = textarea.selectionStart === 0 && textarea.selectionEnd === 0
const isShellMode = mode() === "shell"
if (!isShellMode && e.key === "!" && cursorAtBufferStart && currentText.length === 0 && !props.disabled) {
e.preventDefault()
setMode("shell")
return
}
if (showPicker() && e.key === "Escape") {
e.preventDefault()
e.stopPropagation()
handlePickerClose()
return
}
if (isShellMode) {
if (e.key === "Escape") {
e.preventDefault()
e.stopPropagation()
setMode("normal")
return
}
if (e.key === "Backspace" && cursorAtBufferStart && currentText.length === 0) {
e.preventDefault()
setMode("normal")
return
}
}
if (e.key === "Backspace" || e.key === "Delete") {
const cursorPos = textarea.selectionStart
const text = prompt()
const text = currentText
const pastePlaceholderRegex = /\[pasted #(\d+)\]/g
let pasteMatch
@@ -433,6 +471,9 @@ export default function PromptInput(props: PromptInputProps) {
if (e.key === "ArrowUp" && !showPicker() && atStart && currentHistory.length > 0) {
e.preventDefault()
if (historyIndex() === -1) {
setHistoryDraft(prompt())
}
const newIndex = historyIndex() === -1 ? 0 : Math.min(historyIndex() + 1, currentHistory.length - 1)
setHistoryIndex(newIndex)
setPrompt(currentHistory[newIndex])
@@ -447,7 +488,9 @@ export default function PromptInput(props: PromptInputProps) {
setPrompt(currentHistory[newIndex])
} else {
setHistoryIndex(-1)
setPrompt("")
const draft = historyDraft()
setPrompt(draft ?? "")
setHistoryDraft(null)
}
}
}
@@ -455,20 +498,32 @@ export default function PromptInput(props: PromptInputProps) {
async function handleSend() {
const text = prompt().trim()
const currentAttachments = attachments()
if (!text || props.disabled) return
if (props.disabled || !text) return
const resolvedPrompt = resolvePastedPlaceholders(text, currentAttachments)
const isShellMode = mode() === "shell"
clearPrompt()
clearAttachments(props.instanceId, props.sessionId)
setIgnoredAtPositions(new Set<number>())
setPasteCount(0)
setImageCount(0)
setHistoryDraft(null)
try {
await addToHistory(props.instanceFolder, text)
await addToHistory(props.instanceFolder, resolvedPrompt)
const updated = await getHistory(props.instanceFolder)
setHistory(updated)
setHistoryIndex(-1)
await props.onSend(text, currentAttachments)
if (isShellMode) {
if (props.onRunShell) {
await props.onRunShell(resolvedPrompt)
} else {
await props.onSend(resolvedPrompt, [])
}
} else {
await props.onSend(text, currentAttachments)
}
} catch (error) {
console.error("Failed to send message:", error)
alert("Failed to send message: " + (error instanceof Error ? error.message : String(error)))
@@ -482,6 +537,7 @@ export default function PromptInput(props: PromptInputProps) {
const value = target.value
setPrompt(value)
setHistoryIndex(-1)
setHistoryDraft(null)
const cursorPos = target.selectionStart
const textBeforeCursor = value.substring(0, cursorPos)
@@ -654,7 +710,14 @@ export default function PromptInput(props: PromptInputProps) {
textareaRef?.focus()
}
const canSend = () => (prompt().trim().length > 0 || attachments().length > 0) && !props.disabled
const canSend = () => {
if (props.disabled) return false
const hasText = prompt().trim().length > 0
if (mode() === "shell") return hasText
return hasText || attachments().length > 0
}
const shellHint = () => (mode() === "shell" ? { key: "Esc", text: "to exit shell mode" } : { key: "!", text: "for shell mode" })
const instance = () => getActiveInstance()
@@ -663,7 +726,11 @@ export default function PromptInput(props: PromptInputProps) {
<div
ref={containerRef}
class={`prompt-input-wrapper relative ${isDragging() ? "border-2" : ""}`}
style={isDragging() ? "border-color: var(--accent-primary); background-color: rgba(0, 102, 255, 0.05);" : ""}
style={
isDragging()
? "border-color: var(--accent-primary); background-color: rgba(0, 102, 255, 0.05);"
: ""
}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
@@ -755,8 +822,12 @@ export default function PromptInput(props: PromptInputProps) {
</Show>
<textarea
ref={textareaRef}
class="prompt-input"
placeholder="Type your message, @file, @agent, or paste images and text..."
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""}`}
placeholder={
mode() === "shell"
? "Run a shell command (Esc to exit)..."
: "Type your message, @file, @agent, or paste images and text..."
}
value={prompt()}
onInput={handleInput}
onKeyDown={handleKeyDown}
@@ -773,22 +844,37 @@ export default function PromptInput(props: PromptInputProps) {
/>
</div>
<button class="send-button" onClick={handleSend} disabled={!canSend()} aria-label="Send message">
<span class="send-icon"></span>
<button
class={`send-button ${mode() === "shell" ? "shell-mode" : ""}`}
onClick={handleSend}
disabled={!canSend()}
aria-label="Send message"
>
<Show
when={mode() === "shell"}
fallback={<span class="send-icon"></span>}
>
<svg class="shell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 8l5 4-5 4" />
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h6" />
</svg>
</Show>
</button>
</div>
<div class="prompt-input-hints">
<div class="flex justify-end">
<div class="flex justify-between w-full gap-4">
<HintRow>
<Show
when={props.escapeInDebounce}
fallback={
<>
<Kbd>Enter</Kbd> for new line <Kbd shortcut="cmd+enter" /> to send <Kbd>@</Kbd> for files/agents {" "}
<Kbd></Kbd> for history
<Kbd>Enter</Kbd> for new line <Kbd shortcut="cmd+enter" /> to send <Kbd>@</Kbd> for files/agents <Kbd></Kbd> for history
<Show when={attachments().length > 0}>
<span class="ml-2 text-xs" style="color: var(--text-muted);"> {attachments().length} file(s) attached</span>
</Show>
<span class="ml-2">
<Kbd>{shellHint().key}</Kbd> {shellHint().text}
</span>
</>
}
>
@@ -797,6 +883,9 @@ export default function PromptInput(props: PromptInputProps) {
</span>
</Show>
</HintRow>
<Show when={mode() === "shell"}>
<HintRow>Shell mode active</HintRow>
</Show>
</div>
</div>
</div>

View File

@@ -208,9 +208,13 @@ const SessionList: Component<SessionListProps> = (props) => {
const title = () => session()?.title || "Untitled"
const status = () => getSessionStatus(props.instanceId, rowProps.sessionId)
const statusLabel = () => formatSessionStatus(status())
const pendingPermission = () => Boolean(session()?.pendingPermission)
const statusClassName = () => (pendingPermission() ? "session-permission" : `session-${status()}`)
const statusText = () => (pendingPermission() ? "Needs Permission" : statusLabel())
return (
<div class="session-list-item group">
<div class="session-list-item group">
<button
class={`session-item-base ${isActive() ? "session-item-active" : "session-item-inactive"}`}
onClick={() => selectSession(rowProps.sessionId)}
@@ -239,9 +243,9 @@ const SessionList: Component<SessionListProps> = (props) => {
</Show>
</div>
<div class="session-item-row session-item-meta">
<span class={`status-indicator session-status session-status-list session-${status()}`}>
<span class={`status-indicator session-status session-status-list ${statusClassName()}`}>
<span class="status-dot" />
{statusLabel()}
{statusText()}
</span>
<div class="session-item-actions">
<span
@@ -348,7 +352,7 @@ const SessionList: Component<SessionListProps> = (props) => {
<Show when={userSessionIds().length > 0}>
<div class="session-section">
<div class="session-section-header px-3 py-2 text-xs font-semibold text-primary/70 uppercase tracking-wide">
User Sessions
User Session
</div>
<For each={userSessionIds()}>{(id) => <SessionRow sessionId={id} canClose />}</For>
</div>

View File

@@ -0,0 +1,63 @@
import { createMemo, type Component } from "solid-js"
import { getSessionInfo } from "../../stores/sessions"
import { formatTokenTotal } from "../../lib/formatters"
interface ContextUsagePanelProps {
instanceId: string
sessionId: string
}
const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
const info = createMemo(
() =>
getSessionInfo(props.instanceId, props.sessionId) ?? {
tokens: 0,
cost: 0,
contextWindow: 0,
isSubscriptionModel: false,
contextUsageTokens: 0,
contextUsagePercent: null,
},
)
const tokens = createMemo(() => info().tokens)
const contextUsageTokens = createMemo(() => info().contextUsageTokens ?? 0)
const contextWindow = createMemo(() => info().contextWindow)
const contextUsagePercent = createMemo(() => info().contextUsagePercent)
const costLabel = createMemo(() => {
if (info().isSubscriptionModel || info().cost <= 0) return "Included in plan"
return `$${info().cost.toFixed(2)} spent`
})
return (
<div class="session-context-panel border-r border-base border-b px-3 py-3">
<div class="flex items-center justify-between gap-4">
<div>
<div class="text-xs font-semibold text-primary/70 uppercase tracking-wide">Tokens (last call)</div>
<div class="text-lg font-semibold text-primary">{formatTokenTotal(tokens())}</div>
</div>
<div class="text-xs text-primary/70 text-right leading-tight">{costLabel()}</div>
</div>
<div class="mt-4">
<div class="flex items-center justify-between mb-1">
<div class="text-xs font-semibold text-primary/70 uppercase tracking-wide">Context window usage</div>
<div class="text-sm font-medium text-primary">{contextUsagePercent() !== null ? `${contextUsagePercent()}%` : "--"}</div>
</div>
<div class="text-sm text-primary/90">
{contextWindow()
? `${formatTokenTotal(contextUsageTokens())} of ${formatTokenTotal(contextWindow())}`
: "Window size unavailable"}
</div>
</div>
<div class="mt-3 h-1.5 rounded-full bg-base relative overflow-hidden">
<div
class="absolute inset-y-0 left-0 rounded-full bg-accent-primary transition-[width]"
style={{ width: contextUsagePercent() === null ? "0%" : `${contextUsagePercent()}%` }}
/>
</div>
</div>
)
}
export default ContextUsagePanel

View File

@@ -0,0 +1,150 @@
import { Show, createMemo, createEffect, onCleanup, type Component } from "solid-js"
import type { Session } from "../../types/session"
import type { Attachment } from "../../types/attachment"
import type { ClientPart } from "../../types/message"
import MessageStream from "../message-stream"
import PromptInput from "../prompt-input"
import { instances } from "../../stores/instances"
import { loadMessages, sendMessage, forkSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand } from "../../stores/sessions"
interface SessionViewProps {
sessionId: string
activeSessions: Map<string, Session>
instanceId: string
instanceFolder: string
escapeInDebounce: boolean
}
export const SessionView: Component<SessionViewProps> = (props) => {
const session = () => props.activeSessions.get(props.sessionId)
const messagesLoading = createMemo(() => isSessionMessagesLoading(props.instanceId, props.sessionId))
createEffect(() => {
const currentSession = session()
if (currentSession) {
loadMessages(props.instanceId, currentSession.id).catch(console.error)
}
})
async function handleSendMessage(prompt: string, attachments: Attachment[]) {
await sendMessage(props.instanceId, props.sessionId, prompt, attachments)
}
async function handleRunShell(command: string) {
await runShellCommand(props.instanceId, props.sessionId, command)
}
function getUserMessageText(messageId: string): string | null {
const currentSession = session()
if (!currentSession) return null
const targetMessage = currentSession.messages.find((m) => m.id === messageId)
const targetInfo = currentSession.messagesInfo.get(messageId)
if (!targetMessage || targetInfo?.role !== "user") {
return null
}
const textParts = targetMessage.parts.filter((p): p is ClientPart & { type: "text"; text: string } => p.type === "text")
if (textParts.length === 0) {
return null
}
return textParts.map((p) => p.text).join("\n")
}
async function handleRevert(messageId: string) {
const instance = instances().get(props.instanceId)
if (!instance || !instance.client) return
try {
await instance.client.session.revert({
path: { id: props.sessionId },
body: { messageID: messageId },
})
const restoredText = getUserMessageText(messageId)
if (restoredText) {
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
if (textarea) {
textarea.value = restoredText
textarea.dispatchEvent(new Event("input", { bubbles: true }))
textarea.focus()
}
}
} catch (error) {
console.error("Failed to revert:", error)
alert("Failed to revert to message")
}
}
async function handleFork(messageId?: string) {
if (!messageId) {
console.warn("Fork requires a user message id")
return
}
const restoredText = getUserMessageText(messageId)
try {
const forkedSession = await forkSession(props.instanceId, props.sessionId, { messageId })
const parentToActivate = forkedSession.parentId ?? forkedSession.id
setActiveParentSession(props.instanceId, parentToActivate)
if (forkedSession.parentId) {
setActiveSession(props.instanceId, forkedSession.id)
}
await loadMessages(props.instanceId, forkedSession.id).catch(console.error)
if (restoredText) {
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
if (textarea) {
textarea.value = restoredText
textarea.dispatchEvent(new Event("input", { bubbles: true }))
textarea.focus()
}
}
} catch (error) {
console.error("Failed to fork session:", error)
alert("Failed to fork session")
}
}
return (
<Show
when={session()}
fallback={
<div class="flex items-center justify-center h-full">
<div class="text-center text-gray-500">Session not found</div>
</div>
}
>
{(s) => (
<div class="session-view">
<MessageStream
instanceId={props.instanceId}
sessionId={s().id}
messages={s().messages || []}
messagesInfo={s().messagesInfo}
revert={s().revert}
loading={messagesLoading()}
onRevert={handleRevert}
onFork={handleFork}
/>
<PromptInput
instanceId={props.instanceId}
instanceFolder={props.instanceFolder}
sessionId={s().id}
onSend={handleSendMessage}
onRunShell={handleRunShell}
escapeInDebounce={props.escapeInDebounce}
/>
</div>
)}
</Show>
)
}
export default SessionView

View File

@@ -1,4 +1,4 @@
import { createSignal, Show, For, createEffect, onCleanup } from "solid-js"
import { createSignal, Show, For, createEffect, createMemo, onCleanup } from "solid-js"
import { isToolCallExpanded, toggleToolCallExpanded, setToolCallExpanded } from "../stores/tool-call-state"
import { Markdown } from "./markdown"
import { ToolCallDiffViewer } from "./diff-viewer"
@@ -6,7 +6,9 @@ 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 { sendPermissionResponse } from "../stores/instances"
import type { TextPart, SDKPart, ClientPart } from "../types/message"
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
@@ -80,6 +82,8 @@ interface ToolCallProps {
messageId?: string
messageVersion?: number
partVersion?: number
instanceId: string
sessionId: string
}
function getToolIcon(tool: string): string {
@@ -137,6 +141,34 @@ function getRelativePath(path: string): string {
const diffCapableTools = new Set(["edit", "patch"])
interface LspRangePosition {
line?: number
character?: number
}
interface LspRange {
start?: LspRangePosition
}
interface LspDiagnostic {
message?: string
severity?: number
range?: LspRange
}
interface DiagnosticEntry {
id: string
severity: number
tone: "error" | "warning" | "info"
label: string
icon: string
message: string
filePath: string
displayPath: string
line: number
column: number
}
interface DiffPayload {
diffText: string
filePath?: string
@@ -176,17 +208,177 @@ function extractDiffPayload(toolName: string, state: ToolState): DiffPayload | n
return { diffText, filePath }
}
function normalizeDiagnosticPath(path: string) {
return path.replace(/\\/g, "/")
}
function determineSeverityTone(severity?: number): DiagnosticEntry["tone"] {
if (severity === 1) return "error"
if (severity === 2) return "warning"
return "info"
}
function getSeverityMeta(tone: DiagnosticEntry["tone"]) {
if (tone === "error") return { label: "ERR", icon: "!", rank: 0 }
if (tone === "warning") return { label: "WARN", icon: "!", rank: 1 }
return { label: "INFO", icon: "i", rank: 2 }
}
function extractDiagnostics(toolName: string, state: ToolState | undefined): DiagnosticEntry[] {
if (!state) return []
const supportsMetadata = isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)
if (!supportsMetadata) return []
const metadata = (state.metadata || {}) as Record<string, unknown>
const input = (state.input || {}) as Record<string, unknown>
const diagnosticsMap = metadata?.diagnostics as Record<string, LspDiagnostic[] | undefined> | undefined
if (!diagnosticsMap) return []
const preferredPath = [
input.filePath,
metadata.filePath,
metadata.filepath,
input.path,
].find((value) => typeof value === "string" && value.length > 0) as string | undefined
const normalizedPreferred = preferredPath ? normalizeDiagnosticPath(preferredPath) : undefined
const candidateEntries = Object.entries(diagnosticsMap).filter(([, items]) => Array.isArray(items) && items.length > 0)
if (candidateEntries.length === 0) return []
const prioritizedEntries = (() => {
if (!normalizedPreferred) return candidateEntries
const matched = candidateEntries.filter(([path]) => {
const normalized = normalizeDiagnosticPath(path)
if (normalized === normalizedPreferred) return true
if (normalized.endsWith(`/${normalizedPreferred}`)) return true
const normalizedBase = normalized.split("/").pop()
const preferredBase = normalizedPreferred.split("/").pop()
return normalizedBase && preferredBase ? normalizedBase === preferredBase : false
})
return matched.length > 0 ? matched : candidateEntries
})()
const entries: DiagnosticEntry[] = []
for (const [pathKey, list] of prioritizedEntries) {
if (!Array.isArray(list)) continue
const normalizedPath = normalizeDiagnosticPath(pathKey)
for (let index = 0; index < list.length; index++) {
const diagnostic = list[index]
if (!diagnostic || typeof diagnostic.message !== "string") continue
const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined)
const severityMeta = getSeverityMeta(tone)
const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0
const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0
entries.push({
id: `${normalizedPath}-${index}-${diagnostic.message}`,
severity: severityMeta.rank,
tone,
label: severityMeta.label,
icon: severityMeta.icon,
message: diagnostic.message,
filePath: normalizedPath,
displayPath: getRelativePath(normalizedPath),
line,
column,
})
}
}
return entries.sort((a, b) => a.severity - b.severity)
}
function diagnosticFileName(entries: DiagnosticEntry[]) {
const first = entries[0]
return first ? first.displayPath : ""
}
function renderDiagnosticsSection(
entries: DiagnosticEntry[],
expanded: boolean,
toggle: () => void,
toolIcon: string,
fileLabel: string,
) {
if (entries.length === 0) return null
return (
<div class="tool-call-diagnostics-wrapper">
<button
type="button"
class="tool-call-diagnostics-heading"
aria-expanded={expanded}
onClick={toggle}
>
<span class="tool-call-icon" aria-hidden="true">
{expanded ? "▼" : "▶"}
</span>
<span class="tool-call-emoji" aria-hidden="true">🛠</span>
<span class="tool-call-summary">Diagnostics</span>
<span class="tool-call-diagnostics-file" title={fileLabel}>{fileLabel}</span>
</button>
<Show when={expanded}>
<div class="tool-call-diagnostics" role="region" aria-label="Diagnostics">
<div class="tool-call-diagnostics-body" role="list">
<For each={entries}>
{(entry) => (
<div class="tool-call-diagnostic-row" role="listitem">
<span class={`tool-call-diagnostic-chip tool-call-diagnostic-${entry.tone}`}>
<span class="tool-call-diagnostic-chip-icon">{entry.icon}</span>
<span>{entry.label}</span>
</span>
<span class="tool-call-diagnostic-path" title={entry.filePath}>
{entry.displayPath}
<span class="tool-call-diagnostic-coords">
:L{entry.line || "-"}:C{entry.column || "-"}
</span>
</span>
<span class="tool-call-diagnostic-message">{entry.message}</span>
</div>
)}
</For>
</div>
</div>
</Show>
</div>
)
}
export default function ToolCall(props: ToolCallProps) {
const { preferences, setDiffViewMode } = useConfig()
const { isDark } = useTheme()
const toolCallId = () => props.toolCallId || props.toolCall?.id || ""
const expanded = () => isToolCallExpanded(toolCallId())
const [initializedId, setInitializedId] = createSignal<string | null>(null)
const toolOutputDefaultExpanded = createMemo(() => (preferences().toolOutputExpansion || "expanded") === "expanded")
const diagnosticsDefaultExpanded = createMemo(() => (preferences().diagnosticsExpansion || "expanded") === "expanded")
const [appliedPreference, setAppliedPreference] = createSignal<boolean | null>(null)
const pendingPermission = createMemo(() => props.toolCall.pendingPermission)
const permissionDetails = createMemo(() => pendingPermission()?.permission)
const isPermissionActive = createMemo(() => pendingPermission()?.active === true)
const activePermissionKey = createMemo(() => {
const permission = permissionDetails()
return permission && isPermissionActive() ? permission.id : ""
})
const [permissionSubmitting, setPermissionSubmitting] = createSignal(false)
const [permissionError, setPermissionError] = createSignal<string | null>(null)
const [diagnosticsExpanded, setDiagnosticsExpanded] = createSignal(diagnosticsDefaultExpanded())
const diagnosticsEntries = createMemo(() => {
const tool = props.toolCall?.tool || ""
const state = props.toolCall?.state
if (!state) return []
return extractDiagnostics(tool, state)
})
createEffect(() => {
const preferred = diagnosticsDefaultExpanded()
setDiagnosticsExpanded((prev) => (prev === preferred ? prev : preferred))
})
let scrollContainerRef: HTMLDivElement | undefined
let toolCallRootRef: HTMLDivElement | undefined
const handleScrollRendered = () => {
const id = toolCallId()
const id = toolCallId()
if (!id || !scrollContainerRef) return
restoreScrollState(id, scrollContainerRef)
}
@@ -210,13 +402,35 @@ export default function ToolCall(props: ToolCallProps) {
createEffect(() => {
const id = toolCallId()
if (!id || initializedId() === id) return
if (!id) return
const toolName = props.toolCall?.tool || ""
const desiredExpansion = toolName === "read" ? false : toolOutputDefaultExpanded()
if (appliedPreference() === desiredExpansion) return
setToolCallExpanded(id, desiredExpansion)
setAppliedPreference(desiredExpansion)
})
const tool = props.toolCall?.tool || ""
const shouldExpand = tool !== "read"
createEffect(() => {
const id = toolCallId()
if (!id) return
setAppliedPreference((prev) => (prev === null ? prev : null))
})
setToolCallExpanded(id, shouldExpand)
setInitializedId(id)
createEffect(() => {
if (!pendingPermission()) return
const id = toolCallId()
if (!id) return
setToolCallExpanded(id, true)
})
createEffect(() => {
const permission = permissionDetails()
if (!permission) {
setPermissionSubmitting(false)
setPermissionError(null)
} else {
setPermissionError(null)
}
})
// Cleanup cache entry when component unmounts or toolCallId changes
@@ -243,6 +457,34 @@ export default function ToolCall(props: ToolCallProps) {
})
})
createEffect(() => {
const activeKey = activePermissionKey()
if (!activeKey) return
requestAnimationFrame(() => {
toolCallRootRef?.scrollIntoView({ block: "center", behavior: "smooth" })
})
})
createEffect(() => {
const activeKey = activePermissionKey()
if (!activeKey) return
const handler = (event: KeyboardEvent) => {
if (event.key === "Enter") {
event.preventDefault()
handlePermissionResponse("once")
} else if (event.key === "a" || event.key === "A") {
event.preventDefault()
handlePermissionResponse("always")
} else if (event.key === "d" || event.key === "D") {
event.preventDefault()
handlePermissionResponse("reject")
}
}
document.addEventListener("keydown", handler)
onCleanup(() => document.removeEventListener("keydown", handler))
})
const statusIcon = () => {
const status = props.toolCall?.state?.status || ""
switch (status) {
@@ -264,6 +506,11 @@ export default function ToolCall(props: ToolCallProps) {
return `tool-call-status-${status}`
}
const combinedStatusClass = () => {
const base = statusClass()
return pendingPermission() ? `${base} tool-call-awaiting-permission` : base
}
function toggle() {
toggleToolCallExpanded(toolCallId())
}
@@ -299,6 +546,24 @@ export default function ToolCall(props: ToolCallProps) {
}
}
async function handlePermissionResponse(response: "once" | "always" | "reject") {
const permission = permissionDetails()
if (!permission || !isPermissionActive()) {
return
}
setPermissionSubmitting(true)
setPermissionError(null)
try {
const sessionId = permission.sessionID || props.sessionId
await sendPermissionResponse(props.instanceId, sessionId, permission.id, response)
} catch (error) {
console.error("Failed to send permission response:", error)
setPermissionError(error instanceof Error ? error.message : "Unable to update permission")
} finally {
setPermissionSubmitting(false)
}
}
const getTodoTitle = () => {
const state = props.toolCall?.state || {}
if (state.status !== "completed") return "Plan"
@@ -422,10 +687,11 @@ export default function ToolCall(props: ToolCallProps) {
return renderMarkdownTool(toolName, state)
}
function renderDiffTool(payload: DiffPayload) {
function renderDiffTool(payload: DiffPayload, options?: { cacheKeySuffix?: string; disableScrollTracking?: boolean; label?: string }) {
const relativePath = payload.filePath ? getRelativePath(payload.filePath) : ""
const toolbarLabel = relativePath ? `Diff · ${relativePath}` : "Diff"
const cacheKey = makeRenderCacheKey(toolCallId(), props.messageId, props.messageVersion, props.partVersion)
const toolbarLabel = options?.label || (relativePath ? `Diff · ${relativePath}` : "Diff")
const cacheKeyBase = makeRenderCacheKey(toolCallId(), props.messageId, props.messageVersion, props.partVersion)
const cacheKey = options?.cacheKeySuffix ? `${cacheKeyBase}${options.cacheKeySuffix}` : cacheKeyBase
const diffMode = () => (preferences().diffViewMode || "split") as DiffViewMode
const themeKey = isDark() ? "dark" : "light"
@@ -451,15 +717,21 @@ export default function ToolCall(props: ToolCallProps) {
// Cache will be updated by the diff viewer component itself
// We'll capture HTML from the rendered component
}
handleScrollRendered()
if (!options?.disableScrollTracking) {
handleScrollRendered()
}
}
return (
<div
class="message-text tool-call-markdown tool-call-markdown-large tool-call-diff-shell"
ref={(element) => initializeScrollContainer(element)}
onScroll={(event) => updateScrollState(toolCallId(), event.currentTarget)}
ref={(element) => {
if (options?.disableScrollTracking) return
initializeScrollContainer(element)
}}
onScroll={options?.disableScrollTracking ? undefined : (event) => updateScrollState(toolCallId(), event.currentTarget)}
>
<div class="tool-call-diff-toolbar" role="group" aria-label="Diff view mode">
<span class="tool-call-diff-toolbar-label">{toolbarLabel}</span>
<div class="tool-call-diff-toggle">
@@ -800,11 +1072,103 @@ export default function ToolCall(props: ToolCallProps) {
return null
}
const renderPermissionBlock = () => {
const permission = permissionDetails()
if (!permission) return null
const active = isPermissionActive()
const metadata = (permission.metadata ?? {}) as Record<string, unknown>
const diffValue = typeof metadata.diff === "string" ? (metadata.diff as string) : null
const diffPathRaw = (() => {
if (typeof metadata.filePath === "string") {
return metadata.filePath as string
}
if (typeof metadata.path === "string") {
return metadata.path as string
}
return undefined
})()
const diffPayload = diffValue && diffValue.trim().length > 0 ? { diffText: diffValue, filePath: diffPathRaw } : null
return (
<div class={`tool-call-permission ${active ? "tool-call-permission-active" : "tool-call-permission-queued"}`}>
<div class="tool-call-permission-header">
<span class="tool-call-permission-label">{active ? "Permission Required" : "Permission Queued"}</span>
<span class="tool-call-permission-type">{permission.type}</span>
</div>
<div class="tool-call-permission-body">
<div class="tool-call-permission-title">
<code>{permission.title}</code>
</div>
<Show when={diffPayload}>
{(payload) => (
<div class="tool-call-permission-diff">
{renderDiffTool(payload(), {
cacheKeySuffix: "::permission",
disableScrollTracking: true,
label: payload().filePath ? `Requested diff · ${getRelativePath(payload().filePath || "")}` : "Requested diff",
})}
</div>
)}
</Show>
<Show
when={active}
fallback={<p class="tool-call-permission-queued-text">Waiting for earlier permission responses.</p>}
>
<div class="tool-call-permission-actions">
<div class="tool-call-permission-buttons">
<button
type="button"
class="tool-call-permission-button"
disabled={permissionSubmitting()}
onClick={() => handlePermissionResponse("once")}
>
Allow Once
</button>
<button
type="button"
class="tool-call-permission-button"
disabled={permissionSubmitting()}
onClick={() => handlePermissionResponse("always")}
>
Always Allow
</button>
<button
type="button"
class="tool-call-permission-button"
disabled={permissionSubmitting()}
onClick={() => handlePermissionResponse("reject")}
>
Deny
</button>
</div>
<div class="tool-call-permission-shortcuts">
<kbd class="kbd">Enter</kbd>
<span>Allow once</span>
<kbd class="kbd">A</kbd>
<span>Always allow</span>
<kbd class="kbd">D</kbd>
<span>Deny</span>
</div>
</div>
<Show when={permissionError()}>
<div class="tool-call-permission-error">{permissionError()}</div>
</Show>
</Show>
</div>
</div>
)
}
const toolName = () => props.toolCall?.tool || ""
const status = () => props.toolCall?.state?.status || ""
return (
<div class={`tool-call ${statusClass()}`}>
<div
ref={(element) => {
toolCallRootRef = element || undefined
}}
class={`tool-call ${combinedStatusClass()}`}
>
<button class="tool-call-header" onClick={toggle} aria-expanded={expanded()}>
<span class="tool-call-icon">{expanded() ? "▼" : "▶"}</span>
<span class="tool-call-emoji">{getToolIcon(toolName())}</span>
@@ -815,9 +1179,12 @@ export default function ToolCall(props: ToolCallProps) {
<Show when={expanded()}>
<div class="tool-call-details">
{renderToolBody()}
{renderError()}
<Show when={status() === "pending"}>
{renderPermissionBlock()}
<Show when={status() === "pending" && !pendingPermission()}>
<div class="tool-call-pending-message">
<span class="spinner-small"></span>
<span>Waiting for permission...</span>
@@ -825,6 +1192,16 @@ export default function ToolCall(props: ToolCallProps) {
</Show>
</div>
</Show>
<Show when={diagnosticsEntries().length}>
{renderDiagnosticsSection(
diagnosticsEntries(),
diagnosticsExpanded(),
() => setDiagnosticsExpanded((prev) => !prev),
getToolIcon(toolName()),
diagnosticFileName(diagnosticsEntries()),
)}
</Show>
</div>
)
}

View File

@@ -1,5 +1,8 @@
@import './styles/tokens.css';
@import './styles/components.css';
@import './styles/utilities.css';
@import './styles/controls.css';
@import './styles/messaging.css';
@import './styles/panels.css';
@import './styles/markdown.css';
@tailwind base;
@tailwind components;

51
src/lib/command-utils.ts Normal file
View File

@@ -0,0 +1,51 @@
import type { Command } from "./commands"
import type { Command as SDKCommand } from "@opencode-ai/sdk"
import { activeSessionId, executeCustomCommand } from "../stores/sessions"
export function commandRequiresArguments(template?: string): boolean {
if (!template) return false
return /\$(?:\d+|ARGUMENTS)/.test(template)
}
export function promptForCommandArguments(command: SDKCommand): string | null {
if (!commandRequiresArguments(command.template)) {
return ""
}
const input = window.prompt(`Arguments for /${command.name}`, "")
if (input === null) {
return null
}
return input
}
function formatCommandLabel(name: string): string {
if (!name) return ""
return name.charAt(0).toUpperCase() + name.slice(1)
}
export function buildCustomCommandEntries(instanceId: string, commands: SDKCommand[]): 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.")
}
},
}))
}

9
src/lib/formatters.ts Normal file
View File

@@ -0,0 +1,9 @@
export function formatTokenTotal(value: number): string {
if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(1)}M`
}
if (value >= 1_000) {
return `${(value / 1_000).toFixed(0)}K`
}
return value.toLocaleString()
}

View File

@@ -0,0 +1,178 @@
import { onMount, onCleanup, type Accessor } from "solid-js"
import { setupTabKeyboardShortcuts } from "../keyboard"
import { registerNavigationShortcuts } from "../shortcuts/navigation"
import { registerInputShortcuts } from "../shortcuts/input"
import { registerAgentShortcuts } from "../shortcuts/agent"
import { registerEscapeShortcut, setEscapeStateChangeHandler } from "../shortcuts/escape"
import { keyboardRegistry } from "../keyboard-registry"
import { abortSession, getSessions, isSessionBusy } from "../../stores/sessions"
import { showCommandPalette, hideCommandPalette } from "../../stores/command-palette"
import { addLog, updateInstance } from "../../stores/instances"
import type { Instance } from "../../types/instance"
interface UseAppLifecycleOptions {
setEscapeInDebounce: (value: boolean) => void
handleNewInstanceRequest: () => void
handleCloseInstance: (instanceId: string) => Promise<void>
handleNewSession: (instanceId: string) => Promise<void>
handleCloseSession: (instanceId: string, sessionId: string) => Promise<void>
showFolderSelection: Accessor<boolean>
setShowFolderSelection: (value: boolean) => void
getActiveInstance: () => Instance | null
getActiveSessionIdForInstance: () => string | null
}
export function useAppLifecycle(options: UseAppLifecycleOptions) {
onMount(() => {
setEscapeStateChangeHandler(options.setEscapeInDebounce)
setupTabKeyboardShortcuts(
options.handleNewInstanceRequest,
options.handleCloseInstance,
options.handleNewSession,
options.handleCloseSession,
() => {
const instance = options.getActiveInstance()
if (instance) {
showCommandPalette(instance.id)
}
},
)
registerNavigationShortcuts()
registerInputShortcuts(
() => {
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
if (textarea) textarea.value = ""
},
() => {
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
textarea?.focus()
},
)
registerAgentShortcuts(
() => {
const modelInput = document.querySelector("[data-model-selector]") as HTMLInputElement
if (modelInput) {
modelInput.focus()
setTimeout(() => {
const event = new KeyboardEvent("keydown", {
key: "ArrowDown",
code: "ArrowDown",
keyCode: 40,
which: 40,
bubbles: true,
cancelable: true,
})
modelInput.dispatchEvent(event)
}, 10)
}
},
() => {
const agentTrigger = document.querySelector("[data-agent-selector]") as HTMLElement
if (agentTrigger) {
agentTrigger.focus()
setTimeout(() => {
const event = new KeyboardEvent("keydown", {
key: "Enter",
code: "Enter",
keyCode: 13,
which: 13,
bubbles: true,
cancelable: true,
})
agentTrigger.dispatchEvent(event)
}, 50)
}
},
)
registerEscapeShortcut(
() => {
if (options.showFolderSelection()) return true
const instance = options.getActiveInstance()
if (!instance) return false
const sessionId = options.getActiveSessionIdForInstance()
if (!sessionId || sessionId === "info") return false
const sessions = getSessions(instance.id)
const session = sessions.find((s) => s.id === sessionId)
if (!session) return false
return isSessionBusy(instance.id, sessionId)
},
async () => {
if (options.showFolderSelection()) {
options.setShowFolderSelection(false)
return
}
const instance = options.getActiveInstance()
const sessionId = options.getActiveSessionIdForInstance()
if (!instance || !sessionId || sessionId === "info") return
try {
await abortSession(instance.id, sessionId)
console.log("Session aborted successfully")
} catch (error) {
console.error("Failed to abort session:", error)
}
},
() => {
const active = document.activeElement as HTMLElement
active?.blur()
},
() => hideCommandPalette(),
)
const handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement
const isInCombobox = target.closest('[role="combobox"]') !== null
const isInListbox = target.closest('[role="listbox"]') !== null
const isInAgentSelect = target.closest('[role="button"][data-agent-selector]') !== null
if (isInCombobox || isInListbox || isInAgentSelect) {
return
}
const shortcut = keyboardRegistry.findMatch(e)
if (shortcut) {
e.preventDefault()
shortcut.handler()
}
}
window.addEventListener("keydown", handleKeyDown)
window.electronAPI.onNewInstance(() => {
options.handleNewInstanceRequest()
})
window.electronAPI.onInstanceStarted(({ id, port, pid, binaryPath }) => {
console.log("Instance started:", { id, port, pid, binaryPath })
updateInstance(id, { port, pid, status: "ready", binaryPath })
})
window.electronAPI.onInstanceError(({ id, error }) => {
console.error("Instance error:", { id, error })
updateInstance(id, { status: "error", error })
})
window.electronAPI.onInstanceStopped(({ id }) => {
console.log("Instance stopped:", id)
updateInstance(id, { status: "stopped" })
})
window.electronAPI.onInstanceLog(({ id, entry }) => {
addLog(id, entry)
})
onCleanup(() => {
window.removeEventListener("keydown", handleKeyDown)
})
})
}

View File

@@ -0,0 +1,450 @@
import { createSignal, onMount } from "solid-js"
import type { Accessor } from "solid-js"
import type { Preferences, ExpansionPreference } from "../../stores/preferences"
import { createCommandRegistry, type Command } from "../commands"
import { instances, activeInstanceId, setActiveInstanceId } from "../../stores/instances"
import {
activeParentSessionId,
activeSessionId as activeSessionMap,
getSessionFamily,
getSessions,
setActiveSession,
} from "../../stores/sessions"
import { setSessionCompactionState } from "../../stores/session-compaction"
import type { Instance } from "../../types/instance"
export interface UseCommandsOptions {
preferences: Accessor<Preferences>
toggleShowThinkingBlocks: () => void
setDiffViewMode: (mode: "split" | "unified") => void
setToolOutputExpansion: (mode: ExpansionPreference) => void
setDiagnosticsExpansion: (mode: ExpansionPreference) => void
handleNewInstanceRequest: () => void
handleCloseInstance: (instanceId: string) => Promise<void>
handleNewSession: (instanceId: string) => Promise<void>
handleCloseSession: (instanceId: string, sessionId: string) => Promise<void>
getActiveInstance: () => Instance | null
getActiveSessionIdForInstance: () => string | null
}
export function useCommands(options: UseCommandsOptions) {
const commandRegistry = createCommandRegistry()
const [commands, setCommands] = createSignal<Command[]>([])
function refreshCommands() {
setCommands(commandRegistry.getAll())
}
function registerCommands() {
const activeInstance = options.getActiveInstance
const activeSessionIdForInstance = options.getActiveSessionIdForInstance
commandRegistry.register({
id: "new-instance",
label: "New Instance",
description: "Open folder picker to create new instance",
category: "Instance",
keywords: ["folder", "project", "workspace"],
shortcut: { key: "N", meta: true },
action: options.handleNewInstanceRequest,
})
commandRegistry.register({
id: "close-instance",
label: "Close Instance",
description: "Stop current instance's server",
category: "Instance",
keywords: ["stop", "quit", "close"],
shortcut: { key: "W", meta: true },
action: async () => {
const instance = activeInstance()
if (!instance) return
await options.handleCloseInstance(instance.id)
},
})
commandRegistry.register({
id: "instance-next",
label: "Next Instance",
description: "Cycle to next instance tab",
category: "Instance",
keywords: ["switch", "navigate"],
shortcut: { key: "]", meta: true },
action: () => {
const ids = Array.from(instances().keys())
if (ids.length <= 1) return
const current = ids.indexOf(activeInstanceId() || "")
const next = (current + 1) % ids.length
if (ids[next]) setActiveInstanceId(ids[next])
},
})
commandRegistry.register({
id: "instance-prev",
label: "Previous Instance",
description: "Cycle to previous instance tab",
category: "Instance",
keywords: ["switch", "navigate"],
shortcut: { key: "[", meta: true },
action: () => {
const ids = Array.from(instances().keys())
if (ids.length <= 1) return
const current = ids.indexOf(activeInstanceId() || "")
const prev = current <= 0 ? ids.length - 1 : current - 1
if (ids[prev]) setActiveInstanceId(ids[prev])
},
})
commandRegistry.register({
id: "new-session",
label: "New Session",
description: "Create a new parent session",
category: "Session",
keywords: ["create", "start"],
shortcut: { key: "N", meta: true, shift: true },
action: async () => {
const instance = activeInstance()
if (!instance) return
await options.handleNewSession(instance.id)
},
})
commandRegistry.register({
id: "close-session",
label: "Close Session",
description: "Close current parent session",
category: "Session",
keywords: ["close", "stop"],
shortcut: { key: "W", meta: true, shift: true },
action: async () => {
const instance = activeInstance()
const sessionId = activeSessionIdForInstance()
if (!instance || !sessionId || sessionId === "info") return
await options.handleCloseSession(instance.id, sessionId)
},
})
commandRegistry.register({
id: "switch-to-info",
label: "Instance Info",
description: "Open the instance overview for logs and status",
category: "Instance",
keywords: ["info", "logs", "console", "output"],
shortcut: { key: "L", meta: true, shift: true },
action: () => {
const instance = activeInstance()
if (instance) setActiveSession(instance.id, "info")
},
})
commandRegistry.register({
id: "session-next",
label: "Next Session",
description: "Cycle to next session tab",
category: "Session",
keywords: ["switch", "navigate"],
shortcut: { key: "]", meta: true, shift: true },
action: () => {
const instanceId = activeInstanceId()
if (!instanceId) return
const parentId = activeParentSessionId().get(instanceId)
if (!parentId) return
const familySessions = getSessionFamily(instanceId, parentId)
const ids = familySessions.map((s) => s.id).concat(["info"])
if (ids.length <= 1) return
const current = ids.indexOf(activeSessionMap().get(instanceId) || "")
const next = (current + 1) % ids.length
if (ids[next]) setActiveSession(instanceId, ids[next])
},
})
commandRegistry.register({
id: "session-prev",
label: "Previous Session",
description: "Cycle to previous session tab",
category: "Session",
keywords: ["switch", "navigate"],
shortcut: { key: "[", meta: true, shift: true },
action: () => {
const instanceId = activeInstanceId()
if (!instanceId) return
const parentId = activeParentSessionId().get(instanceId)
if (!parentId) return
const familySessions = getSessionFamily(instanceId, parentId)
const ids = familySessions.map((s) => s.id).concat(["info"])
if (ids.length <= 1) return
const current = ids.indexOf(activeSessionMap().get(instanceId) || "")
const prev = current <= 0 ? ids.length - 1 : current - 1
if (ids[prev]) setActiveSession(instanceId, ids[prev])
},
})
commandRegistry.register({
id: "compact",
label: "Compact Session",
description: "Summarize and compact the current session",
category: "Session",
keywords: ["/compact", "summarize", "compress"],
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 {
setSessionCompactionState(instance.id, sessionId, true)
await instance.client.session.summarize({
path: { id: sessionId },
body: {
providerID: session.model.providerId,
modelID: session.model.modelId,
},
})
} catch (error: unknown) {
setSessionCompactionState(instance.id, sessionId, false)
console.error("Failed to compact session:", error)
const message = error instanceof Error ? error.message : "Failed to compact session"
alert(`Compact failed: ${message}`)
}
},
})
commandRegistry.register({
id: "undo",
label: "Undo Last Message",
description: "Revert the last message",
category: "Session",
keywords: ["/undo", "revert", "undo"],
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
let after = 0
const revert = session.revert
if (revert?.messageID) {
for (let i = session.messages.length - 1; i >= 0; i--) {
const msg = session.messages[i]
const info = session.messagesInfo.get(msg.id)
if (info?.id === revert.messageID) {
after = info.time?.created || 0
break
}
}
}
let messageID = ""
for (let i = session.messages.length - 1; i >= 0; i--) {
const msg = session.messages[i]
const info = session.messagesInfo.get(msg.id)
if (msg.type === "user" && info?.time?.created) {
if (after > 0 && info.time.created >= after) {
continue
}
messageID = msg.id
break
}
}
if (!messageID) {
alert("Nothing to undo")
return
}
try {
await instance.client.session.revert({
path: { id: sessionId },
body: { messageID },
})
const revertedMessage = session.messages.find((m) => m.id === messageID)
const revertedInfo = session.messagesInfo.get(messageID)
if (revertedMessage && revertedInfo?.role === "user") {
const textParts = revertedMessage.parts.filter((p) => p.type === "text")
if (textParts.length > 0) {
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
if (textarea) {
textarea.value = textParts.map((p: any) => p.text).join("\n")
textarea.dispatchEvent(new Event("input", { bubbles: true }))
textarea.focus()
}
}
}
} catch (error) {
console.error("Failed to revert message:", error)
alert("Failed to revert message")
}
},
})
commandRegistry.register({
id: "open-model-selector",
label: "Open Model Selector",
description: "Choose a different model",
category: "Agent & Model",
keywords: ["model", "llm", "ai"],
shortcut: { key: "M", meta: true, shift: true },
action: () => {
const modelInput = document.querySelector("[data-model-selector]") as HTMLInputElement
if (modelInput) {
modelInput.focus()
setTimeout(() => {
const event = new KeyboardEvent("keydown", {
key: "ArrowDown",
code: "ArrowDown",
keyCode: 40,
which: 40,
bubbles: true,
cancelable: true,
})
modelInput.dispatchEvent(event)
}, 10)
}
},
})
commandRegistry.register({
id: "open-agent-selector",
label: "Open Agent Selector",
description: "Choose a different agent",
category: "Agent & Model",
keywords: ["agent", "mode"],
shortcut: { key: "A", meta: true, shift: true },
action: () => {
const agentTrigger = document.querySelector("[data-agent-selector]") as HTMLElement
if (agentTrigger) {
agentTrigger.focus()
setTimeout(() => {
const event = new KeyboardEvent("keydown", {
key: "Enter",
code: "Enter",
keyCode: 13,
which: 13,
bubbles: true,
cancelable: true,
})
agentTrigger.dispatchEvent(event)
}, 50)
}
},
})
commandRegistry.register({
id: "clear-input",
label: "Clear Input",
description: "Clear the prompt textarea",
category: "Input & Focus",
keywords: ["clear", "reset"],
shortcut: { key: "K", meta: true },
action: () => {
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
if (textarea) textarea.value = ""
},
})
commandRegistry.register({
id: "thinking",
label: () => `${options.preferences().showThinkingBlocks ? "Hide" : "Show"} Thinking Blocks`,
description: "Show/hide AI thinking process",
category: "System",
keywords: ["/thinking", "toggle", "show", "hide"],
action: options.toggleShowThinkingBlocks,
})
commandRegistry.register({
id: "diff-view-split",
label: () => `${(options.preferences().diffViewMode || "split") === "split" ? "✓ " : ""}Use Split Diff View`,
description: "Display tool-call diffs side-by-side",
category: "System",
keywords: ["diff", "split", "view"],
action: () => options.setDiffViewMode("split"),
})
commandRegistry.register({
id: "diff-view-unified",
label: () => `${(options.preferences().diffViewMode || "split") === "unified" ? "✓ " : ""}Use Unified Diff View`,
description: "Display tool-call diffs inline",
category: "System",
keywords: ["diff", "unified", "view"],
action: () => options.setDiffViewMode("unified"),
})
commandRegistry.register({
id: "tool-output-default-visibility",
label: () => {
const mode = options.preferences().toolOutputExpansion || "expanded"
return `Tool Outputs Default · ${mode === "expanded" ? "Expanded" : "Collapsed"}`
},
description: "Toggle default expansion for tool outputs",
category: "System",
keywords: ["tool", "output", "expand", "collapse"],
action: () => {
const mode = options.preferences().toolOutputExpansion || "expanded"
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
options.setToolOutputExpansion(next)
},
})
commandRegistry.register({
id: "diagnostics-default-visibility",
label: () => {
const mode = options.preferences().diagnosticsExpansion || "expanded"
return `Diagnostics Default · ${mode === "expanded" ? "Expanded" : "Collapsed"}`
},
description: "Toggle default expansion for diagnostics output",
category: "System",
keywords: ["diagnostics", "expand", "collapse"],
action: () => {
const mode = options.preferences().diagnosticsExpansion || "expanded"
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
options.setDiagnosticsExpansion(next)
},
})
commandRegistry.register({
id: "help",
label: "Show Help",
description: "Display keyboard shortcuts and help",
category: "System",
keywords: ["/help", "shortcuts", "help"],
action: () => {
console.log("Show help modal (not implemented)")
},
})
}
function executeCommand(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)
}
}
onMount(() => {
registerCommands()
refreshCommands()
})
return {
commands,
commandRegistry,
refreshCommands,
executeCommand,
}
}

View File

@@ -0,0 +1,36 @@
import type { Attachment } from "../types/attachment"
export function resolvePastedPlaceholders(prompt: string, attachments: Attachment[] = []): string {
if (!prompt || !prompt.includes("[pasted #")) {
return prompt
}
if (!attachments || attachments.length === 0) {
return prompt
}
const lookup = new Map<string, string>()
for (const attachment of attachments) {
const source = attachment?.source
if (!source || source.type !== "text") continue
const display = attachment?.display
const value = source.value
if (typeof display !== "string" || typeof value !== "string") continue
const match = display.match(/pasted #(\d+)/)
if (!match) continue
const placeholder = `[pasted #${match[1]}]`
if (!lookup.has(placeholder)) {
lookup.set(placeholder, value)
}
}
if (lookup.size === 0) {
return prompt
}
return prompt.replace(/\[pasted #(\d+)\]/g, (fullMatch) => {
const replacement = lookup.get(fullMatch)
return typeof replacement === "string" ? replacement : fullMatch
})
}

View File

@@ -6,18 +6,22 @@ import {
MessagePartRemovedEvent
} from "../types/message"
import type {
EventSessionUpdated,
EventLspUpdated,
EventPermissionReplied,
EventPermissionUpdated,
EventSessionCompacted,
EventSessionError,
EventSessionIdle,
EventPermissionUpdated,
EventPermissionReplied
EventSessionUpdated,
} from "@opencode-ai/sdk"
interface SSEConnection {
instanceId: string
port: number
eventSource: EventSource
status: "connecting" | "connected" | "disconnected" | "error"
reconnectAttempts: number
reconnectTimer?: ReturnType<typeof setTimeout>
}
interface TuiToastEvent {
@@ -41,6 +45,7 @@ type SSEEvent =
| EventSessionIdle
| EventPermissionUpdated
| EventPermissionReplied
| EventLspUpdated
| TuiToastEvent
| { type: string; properties?: Record<string, unknown> } // Fallback for unknown event types
@@ -50,10 +55,13 @@ const [connectionStatus, setConnectionStatus] = createSignal<
class SSEManager {
private connections = new Map<string, SSEConnection>()
private static readonly MAX_RECONNECT_ATTEMPTS = 3
connect(instanceId: string, port: number): void {
if (this.connections.has(instanceId)) {
this.disconnect(instanceId)
connect(instanceId: string, port: number, reconnectAttempts = 0): void {
const existing = this.connections.get(instanceId)
if (existing) {
this.clearReconnectTimer(existing)
existing.eventSource.close()
}
const url = `http://localhost:${port}/event`
@@ -61,8 +69,10 @@ class SSEManager {
const connection: SSEConnection = {
instanceId,
port,
eventSource,
status: "connecting",
reconnectAttempts,
}
this.connections.set(instanceId, connection)
@@ -70,6 +80,7 @@ class SSEManager {
eventSource.onopen = () => {
connection.status = "connected"
connection.reconnectAttempts = 0
this.updateConnectionStatus(instanceId, "connected")
console.log(`[SSE] Connected to instance ${instanceId}`)
}
@@ -87,13 +98,14 @@ class SSEManager {
connection.status = "error"
this.updateConnectionStatus(instanceId, "error")
console.error(`[SSE] Connection error for instance ${instanceId}`)
this.handleConnectionLost(instanceId, "Connection to instance lost")
this.handleConnectionError(instanceId, "Connection to instance lost")
}
}
disconnect(instanceId: string): void {
const connection = this.connections.get(instanceId)
if (connection) {
this.clearReconnectTimer(connection)
connection.eventSource.close()
this.connections.delete(instanceId)
this.updateConnectionStatus(instanceId, "disconnected")
@@ -138,15 +150,45 @@ class SSEManager {
case "permission.replied":
this.onPermissionReplied?.(instanceId, event as EventPermissionReplied)
break
case "lsp.updated":
this.onLspUpdated?.(instanceId, event as EventLspUpdated)
break
default:
console.warn("[SSE] Unknown event type:", event.type)
}
}
private handleConnectionError(instanceId: string, reason: string): void {
const connection = this.connections.get(instanceId)
if (!connection) return
connection.eventSource.close()
if (connection.reconnectAttempts >= SSEManager.MAX_RECONNECT_ATTEMPTS) {
this.handleConnectionLost(instanceId, reason)
return
}
const nextAttempt = connection.reconnectAttempts + 1
const delay = Math.min(nextAttempt * 1000, 5000)
connection.reconnectAttempts = nextAttempt
connection.status = "connecting"
this.updateConnectionStatus(instanceId, "connecting")
console.warn(`[SSE] Attempting reconnect ${nextAttempt} for instance ${instanceId}`)
connection.reconnectTimer = setTimeout(() => {
connection.reconnectTimer = undefined
this.connect(instanceId, connection.port, nextAttempt)
}, delay)
}
private handleConnectionLost(instanceId: string, reason: string): void {
const connection = this.connections.get(instanceId)
if (!connection) return
this.clearReconnectTimer(connection)
connection.eventSource.close()
this.connections.delete(instanceId)
connection.status = "disconnected"
@@ -154,6 +196,13 @@ class SSEManager {
this.onConnectionLost?.(instanceId, reason)
}
private clearReconnectTimer(connection: SSEConnection): void {
if (connection.reconnectTimer) {
clearTimeout(connection.reconnectTimer)
connection.reconnectTimer = undefined
}
}
private updateConnectionStatus(instanceId: string, status: SSEConnection["status"]): void {
setConnectionStatus((prev) => {
const next = new Map(prev)
@@ -173,6 +222,7 @@ class SSEManager {
onSessionIdle?: (instanceId: string, event: EventSessionIdle) => void
onPermissionUpdated?: (instanceId: string, event: EventPermissionUpdated) => void
onPermissionReplied?: (instanceId: string, event: EventPermissionReplied) => void
onLspUpdated?: (instanceId: string, event: EventLspUpdated) => void
onConnectionLost?: (instanceId: string, reason: string) => void | Promise<void>
getStatus(instanceId: string): "connecting" | "connected" | "disconnected" | "error" | null {

View File

@@ -83,6 +83,8 @@ export class FileStorage {
modelRecents: [],
agentModelSelections: {},
diffViewMode: "split",
toolOutputExpansion: "expanded",
diagnosticsExpansion: "expanded",
},
recentFolders: [],
opencodeBinaries: [],

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[]>>(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[] {
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

@@ -1,6 +1,7 @@
import { createSignal } from "solid-js"
import type { Instance, LogEntry } from "../types/instance"
import type { Permission } from "@opencode-ai/sdk"
import type { LspStatus, Permission } from "@opencode-ai/sdk"
import type { ClientPart, Message } from "../types/message"
import { sdkManager } from "../lib/sdk-manager"
import { sseManager } from "../lib/sse-manager"
import {
@@ -10,7 +11,10 @@ import {
removeSessionIndexes,
clearInstanceDraftPrompts,
} from "./sessions"
import { fetchCommands, clearCommands } from "./commands"
import { preferences, updateLastUsedBinary } from "./preferences"
import { computeDisplayParts } from "./session-messages"
import { withSession, setSessionPendingPermission } from "./session-state"
import { setHasInstances } from "./ui"
const [instances, setInstances] = createSignal<Map<string, Instance>>(new Map())
@@ -21,6 +25,7 @@ const [logStreamingState, setLogStreamingState] = createSignal<Map<string, boole
// Permission queue management per instance
const [permissionQueues, setPermissionQueues] = createSignal<Map<string, Permission[]>>(new Map())
const [activePermissionId, setActivePermissionId] = createSignal<Map<string, string | null>>(new Map())
const permissionSessionCounts = new Map<string, Map<string, number>>()
interface DisconnectedInstanceInfo {
id: string
folder: string
@@ -139,6 +144,8 @@ function removeInstance(id: string) {
})
removeLogContainer(id)
clearCommands(id)
clearPermissionQueue(id)
if (activeInstanceId() === id) {
setActiveInstanceId(nextActiveId)
@@ -194,6 +201,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)
}
@@ -225,6 +233,26 @@ async function stopInstance(id: string) {
removeInstance(id)
}
async function fetchLspStatus(instanceId: string): Promise<LspStatus[] | undefined> {
const instance = instances().get(instanceId)
if (!instance) {
console.warn(`[LSP] Skipping fetch; instance ${instanceId} not found`)
return undefined
}
if (!instance.client) {
console.warn(`[LSP] Skipping fetch; instance ${instanceId} client not ready`)
return undefined
}
const lsp = instance.client.lsp
if (!lsp?.status) {
console.warn(`[LSP] Skipping fetch; lsp.status API unavailable for instance ${instanceId}`)
return undefined
}
console.log(`[HTTP] GET /lsp.status for instance ${instanceId}`)
const response = await lsp.status()
return response.data ?? []
}
function getActiveInstance(): Instance | null {
const id = activeInstanceId()
return id ? instances().get(id) || null : null
@@ -257,30 +285,73 @@ function clearLogs(id: string) {
// Permission management functions
function getPermissionQueue(instanceId: string): Permission[] {
return permissionQueues().get(instanceId) ?? []
const queue = permissionQueues().get(instanceId)
if (!queue) {
return []
}
return queue
}
function getPermissionQueueLength(instanceId: string): number {
return getPermissionQueue(instanceId).length
}
function incrementSessionPendingCount(instanceId: string, sessionId: string): void {
let sessionCounts = permissionSessionCounts.get(instanceId)
if (!sessionCounts) {
sessionCounts = new Map()
permissionSessionCounts.set(instanceId, sessionCounts)
}
const current = sessionCounts.get(sessionId) ?? 0
sessionCounts.set(sessionId, current + 1)
}
function decrementSessionPendingCount(instanceId: string, sessionId: string): number {
const sessionCounts = permissionSessionCounts.get(instanceId)
if (!sessionCounts) return 0
const current = sessionCounts.get(sessionId) ?? 0
if (current <= 1) {
sessionCounts.delete(sessionId)
if (sessionCounts.size === 0) {
permissionSessionCounts.delete(instanceId)
}
return 0
}
const nextValue = current - 1
sessionCounts.set(sessionId, nextValue)
return nextValue
}
function clearSessionPendingCounts(instanceId: string): void {
const sessionCounts = permissionSessionCounts.get(instanceId)
if (!sessionCounts) return
for (const sessionId of sessionCounts.keys()) {
setSessionPendingPermission(instanceId, sessionId, false)
}
permissionSessionCounts.delete(instanceId)
}
function addPermissionToQueue(instanceId: string, permission: Permission): void {
let inserted = false
setPermissionQueues((prev) => {
const next = new Map(prev)
const queue = next.get(instanceId) ?? []
// Check if permission already exists
if (queue.some(p => p.id === permission.id)) {
return next // Don't add duplicate
if (queue.some((p) => p.id === permission.id)) {
return next
}
// Add to queue and sort by creation time to maintain order
const updatedQueue = [...queue, permission].sort((a, b) => a.time.created - b.time.created)
next.set(instanceId, updatedQueue)
inserted = true
return next
})
// Set as active if no active permission
if (!inserted) {
return
}
setActivePermissionId((prev) => {
const next = new Map(prev)
if (!next.get(instanceId)) {
@@ -288,6 +359,13 @@ function addPermissionToQueue(instanceId: string, permission: Permission): void
}
return next
})
const sessionId = getPermissionSessionId(permission)
incrementSessionPendingCount(instanceId, sessionId)
setSessionPendingPermission(instanceId, sessionId, true)
const isActive = getActivePermission(instanceId)?.id === permission.id
attachPermissionToToolPart(instanceId, permission, isActive)
}
function getActivePermission(instanceId: string): Permission | null {
@@ -299,30 +377,53 @@ function getActivePermission(instanceId: string): Permission | null {
}
function removePermissionFromQueue(instanceId: string, permissionId: string): void {
let updatedQueue: Permission[] = []
let removedPermission: Permission | null = null
setPermissionQueues((prev) => {
const next = new Map(prev)
const queue = next.get(instanceId) ?? []
updatedQueue = queue.filter(p => p.id !== permissionId)
if (updatedQueue.length > 0) {
next.set(instanceId, updatedQueue)
const filtered: Permission[] = []
for (const item of queue) {
if (item.id === permissionId) {
removedPermission = item
continue
}
filtered.push(item)
}
if (filtered.length > 0) {
next.set(instanceId, filtered)
} else {
next.delete(instanceId)
}
return next
})
const updatedQueue = getPermissionQueue(instanceId)
setActivePermissionId((prev) => {
const next = new Map(prev)
const activeId = next.get(instanceId)
if (activeId === permissionId) {
// Set the next permission in queue as active, or null if queue is empty
const nextPermission = updatedQueue.length > 0 ? updatedQueue[0] : null
const nextPermission = updatedQueue.length > 0 ? (updatedQueue[0] as Permission) : null
next.set(instanceId, nextPermission?.id ?? null)
}
return next
})
const removed = removedPermission
if (removed) {
clearPermissionFromToolPart(instanceId, removed)
const removedSessionId = getPermissionSessionId(removed)
const remaining = decrementSessionPendingCount(instanceId, removedSessionId)
setSessionPendingPermission(instanceId, removedSessionId, remaining > 0)
}
const nextActivePermission = getActivePermission(instanceId)
if (nextActivePermission) {
attachPermissionToToolPart(instanceId, nextActivePermission, true)
}
}
function clearPermissionQueue(instanceId: string): void {
@@ -336,6 +437,95 @@ function clearPermissionQueue(instanceId: string): void {
next.delete(instanceId)
return next
})
clearSessionPendingCounts(instanceId)
}
function getPermissionSessionId(permission: Permission): string {
return (permission as any).sessionID
}
function findToolPartForPermission(message: Message, permission: Permission): ClientPart | null {
const expectedCallId = permission.callID
for (const part of message.parts) {
if (part.type !== "tool") continue
const toolCallId = (part as any).callID
if (expectedCallId) {
if (toolCallId === expectedCallId) {
return part as ClientPart
}
if (!toolCallId && (part.id === expectedCallId || part.messageID === permission.messageID)) {
return part as ClientPart
}
continue
}
if ((toolCallId && toolCallId === permission.id) || part.id === permission.id || part.messageID === permission.messageID) {
return part as ClientPart
}
}
return null
}
function mutateToolPartPermission(
instanceId: string,
permission: Permission,
mutator: (part: ClientPart, message: Message) => boolean,
): void {
const permissionSessionId = getPermissionSessionId(permission)
withSession(instanceId, permissionSessionId, (session) => {
const message = session.messages.find((msg) => msg.id === permission.messageID)
if (!message) return
const targetPart = findToolPartForPermission(message, permission)
if (!targetPart) return
const changed = mutator(targetPart, message)
if (!changed) return
const nextPartVersion = typeof targetPart.version === "number" ? targetPart.version + 1 : 1
targetPart.version = nextPartVersion
message.version = (message.version ?? 0) + 1
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
})
}
function attachPermissionToToolPart(instanceId: string, permission: Permission, active: boolean): void {
mutateToolPartPermission(instanceId, permission, (part) => {
const existing = part.pendingPermission
if (existing && existing.permission.id === permission.id && existing.active === active) {
return false
}
part.pendingPermission = { permission, active }
return true
})
}
function clearPermissionFromToolPart(instanceId: string, permission: Permission): void {
mutateToolPartPermission(instanceId, permission, (part) => {
if (!part.pendingPermission || part.pendingPermission.permission.id !== permission.id) {
return false
}
delete part.pendingPermission
return true
})
}
function refreshPermissionsForSession(instanceId: string, sessionId: string): void {
const queue = getPermissionQueue(instanceId)
if (queue.length === 0) {
setSessionPendingPermission(instanceId, sessionId, false)
return
}
const activeId = activePermissionId().get(instanceId)
for (const permission of queue) {
if (getPermissionSessionId(permission) !== sessionId) continue
const isActive = permission.id === activeId
attachPermissionToToolPart(instanceId, permission, isActive)
}
const pendingCount = permissionSessionCounts.get(instanceId)?.get(sessionId) ?? 0
setSessionPendingPermission(instanceId, sessionId, pendingCount > 0)
}
async function sendPermissionResponse(
@@ -376,6 +566,29 @@ sseManager.onConnectionLost = (instanceId, reason) => {
})
}
sseManager.onLspUpdated = async (instanceId) => {
console.log(`[LSP] Received lsp.updated event for instance ${instanceId}`)
try {
const lspStatus = await fetchLspStatus(instanceId)
if (!lspStatus) {
return
}
const instance = instances().get(instanceId)
if (!instance) {
console.warn(`[LSP] Instance ${instanceId} disappeared before metadata update`)
return
}
updateInstance(instanceId, {
metadata: {
...(instance.metadata ?? {}),
lspStatus,
},
})
} catch (error) {
console.error("Failed to refresh LSP status:", error)
}
}
async function acknowledgeDisconnectedInstance(): Promise<void> {
const pending = disconnectedInstance()
if (!pending) {
@@ -419,7 +632,9 @@ export {
getActivePermission,
removePermissionFromQueue,
clearPermissionQueue,
refreshPermissionsForSession,
sendPermissionResponse,
disconnectedInstance,
acknowledgeDisconnectedInstance,
fetchLspStatus,
}

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 {
@@ -11,6 +12,7 @@ export interface AgentModelSelections {
}
export type DiffViewMode = "split" | "unified"
export type ExpansionPreference = "expanded" | "collapsed"
export interface Preferences {
showThinkingBlocks: boolean
@@ -19,6 +21,8 @@ export interface Preferences {
modelRecents?: ModelPreference[]
agentModelSelections?: AgentModelSelections
diffViewMode?: DiffViewMode
toolOutputExpansion?: ExpansionPreference
diagnosticsExpansion?: ExpansionPreference
}
export interface OpenCodeBinary {
@@ -32,7 +36,7 @@ export interface RecentFolder {
lastAccessed: number
}
const MAX_RECENT_FOLDERS = 10
const MAX_RECENT_FOLDERS = 20
const MAX_RECENT_MODELS = 5
const defaultPreferences: Preferences = {
@@ -40,16 +44,20 @@ const defaultPreferences: Preferences = {
modelRecents: [],
agentModelSelections: {},
diffViewMode: "split",
toolOutputExpansion: "expanded",
diagnosticsExpansion: "expanded",
}
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 +68,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 +100,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)
@@ -94,6 +122,16 @@ function setDiffViewMode(mode: DiffViewMode): void {
updatePreferences({ diffViewMode: mode })
}
function setToolOutputExpansion(mode: ExpansionPreference): void {
if (preferences().toolOutputExpansion === mode) return
updatePreferences({ toolOutputExpansion: mode })
}
function setDiagnosticsExpansion(mode: ExpansionPreference): void {
if (preferences().diagnosticsExpansion === mode) return
updatePreferences({ diagnosticsExpansion: mode })
}
function toggleShowThinkingBlocks(): void {
updatePreferences({ showThinkingBlocks: !preferences().showThinkingBlocks })
}
@@ -196,20 +234,89 @@ 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
setToolOutputExpansion: typeof setToolOutputExpansion
setDiagnosticsExpansion: typeof setDiagnosticsExpansion
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,
setToolOutputExpansion,
setDiagnosticsExpansion,
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,
@@ -227,4 +334,6 @@ export {
setAgentModelPreference,
getAgentModelPreference,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
}

View File

@@ -0,0 +1,352 @@
import type { Message } from "../types/message"
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
import { instances } from "./instances"
import {
addRecentModelPreference,
preferences,
setAgentModelPreference,
} from "./preferences"
import { sessions, withSession } from "./session-state"
import { getDefaultModel, isModelValid } from "./session-models"
import {
computeDisplayParts,
getSessionIndex,
initializePartVersion,
updateSessionInfo,
} from "./session-messages"
const ID_LENGTH = 26
const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
let lastTimestamp = 0
let localCounter = 0
function randomBase62(length: number): string {
let result = ""
const cryptoObj = (globalThis as unknown as { crypto?: Crypto }).crypto
if (cryptoObj && typeof cryptoObj.getRandomValues === "function") {
const bytes = new Uint8Array(length)
cryptoObj.getRandomValues(bytes)
for (let i = 0; i < length; i++) {
result += BASE62_CHARS[bytes[i] % BASE62_CHARS.length]
}
} else {
for (let i = 0; i < length; i++) {
const idx = Math.floor(Math.random() * BASE62_CHARS.length)
result += BASE62_CHARS[idx]
}
}
return result
}
function createId(prefix: string): string {
const timestamp = Date.now()
if (timestamp !== lastTimestamp) {
lastTimestamp = timestamp
localCounter = 0
}
localCounter++
const value = (BigInt(timestamp) << BigInt(12)) + BigInt(localCounter)
const bytes = new Array<number>(6)
for (let i = 0; i < 6; i++) {
const shift = BigInt(8 * (5 - i))
bytes[i] = Number((value >> shift) & BigInt(0xff))
}
const hex = bytes.map((b) => b.toString(16).padStart(2, "0")).join("")
const random = randomBase62(ID_LENGTH - 12)
return `${prefix}_${hex}${random}`
}
async function sendMessage(
instanceId: string,
sessionId: string,
prompt: string,
attachments: any[] = [],
): Promise<void> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
const instanceSessions = sessions().get(instanceId)
const session = instanceSessions?.get(sessionId)
if (!session) {
throw new Error("Session not found")
}
const messageId = createId("msg")
const textPartId = createId("part")
const resolvedPrompt = resolvePastedPlaceholders(prompt, attachments)
const optimisticParts: any[] = [
{
id: textPartId,
type: "text" as const,
text: resolvedPrompt,
synthetic: true,
renderCache: undefined,
},
]
const optimisticMessage: Message = {
id: messageId,
sessionId,
type: "user",
parts: optimisticParts,
timestamp: Date.now(),
status: "sending",
version: 0,
}
optimisticParts.forEach((part: any) => initializePartVersion(part))
optimisticMessage.displayParts = computeDisplayParts(optimisticMessage, preferences().showThinkingBlocks)
withSession(instanceId, sessionId, (session) => {
session.messages.push(optimisticMessage)
const index = getSessionIndex(instanceId, sessionId)
index.messageIndex.set(optimisticMessage.id, session.messages.length - 1)
})
const requestParts: any[] = [
{
id: textPartId,
type: "text" as const,
text: resolvedPrompt,
},
]
if (attachments.length > 0) {
for (const att of attachments) {
const source = att.source
if (source.type === "file") {
const partId = createId("part")
requestParts.push({
id: partId,
type: "file" as const,
url: att.url,
mime: source.mime,
filename: att.filename,
})
optimisticParts.push({
id: partId,
type: "file" as const,
url: att.url,
mime: source.mime,
filename: att.filename,
synthetic: true,
})
} else if (source.type === "text") {
const display: string | undefined = att.display
const value: unknown = source.value
const isPastedPlaceholder = typeof display === "string" && /^pasted #\d+/.test(display)
if (isPastedPlaceholder || typeof value !== "string") {
continue
}
const partId = createId("part")
requestParts.push({
id: partId,
type: "text" as const,
text: value,
})
optimisticParts.push({
id: partId,
type: "text" as const,
text: value,
synthetic: true,
renderCache: undefined,
})
}
}
}
const requestBody = {
messageID: messageId,
parts: requestParts,
...(session.agent && { agent: session.agent }),
...(session.model.providerId &&
session.model.modelId && {
model: {
providerID: session.model.providerId,
modelID: session.model.modelId,
},
}),
}
console.log("[sendMessage] Sending prompt:", {
sessionId,
requestBody,
})
try {
console.log(`[HTTP] POST /session.prompt for instance ${instanceId}`, { sessionId, requestBody })
const response = await instance.client.session.prompt({
path: { id: sessionId },
body: requestBody,
})
console.log("[sendMessage] Response:", response)
if (response.error) {
console.error("[sendMessage] Server returned error:", response.error)
throw new Error(JSON.stringify(response.error) || "Failed to send message")
}
} catch (error) {
console.error("[sendMessage] Failed to send prompt:", error)
throw error
}
}
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 runShellCommand(instanceId: string, sessionId: string, command: 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 agent = session.agent || "build"
await instance.client.session.shell({
path: { id: sessionId },
body: {
agent,
command,
},
})
}
async function abortSession(instanceId: string, sessionId: string): Promise<void> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
console.log("[abortSession] Aborting session:", { instanceId, sessionId })
try {
console.log(`[HTTP] POST /session.abort for instance ${instanceId}`, { sessionId })
await instance.client.session.abort({
path: { id: sessionId },
})
console.log("[abortSession] Session aborted successfully")
} catch (error) {
console.error("[abortSession] Failed to abort session:", error)
throw error
}
}
async function updateSessionAgent(instanceId: string, sessionId: string, agent: string): Promise<void> {
const instanceSessions = sessions().get(instanceId)
const session = instanceSessions?.get(sessionId)
if (!session) {
throw new Error("Session not found")
}
const nextModel = await getDefaultModel(instanceId, agent)
const shouldApplyModel = isModelValid(instanceId, nextModel)
withSession(instanceId, sessionId, (current) => {
current.agent = agent
if (shouldApplyModel) {
current.model = nextModel
}
})
if (agent && shouldApplyModel) {
setAgentModelPreference(instanceId, agent, nextModel)
}
if (shouldApplyModel) {
updateSessionInfo(instanceId, sessionId)
}
}
async function updateSessionModel(
instanceId: string,
sessionId: string,
model: { providerId: string; modelId: string },
): Promise<void> {
const instanceSessions = sessions().get(instanceId)
const session = instanceSessions?.get(sessionId)
if (!session) {
throw new Error("Session not found")
}
if (!isModelValid(instanceId, model)) {
console.warn("Invalid model selection", model)
return
}
withSession(instanceId, sessionId, (current) => {
current.model = model
})
if (session.agent) {
setAgentModelPreference(instanceId, session.agent, model)
}
addRecentModelPreference(model)
updateSessionInfo(instanceId, sessionId)
}
export {
abortSession,
executeCustomCommand,
runShellCommand,
sendMessage,
updateSessionAgent,
updateSessionModel,
}

625
src/stores/session-api.ts Normal file
View File

@@ -0,0 +1,625 @@
import type { Session } from "../types/session"
import type { Message } from "../types/message"
import { instances, refreshPermissionsForSession } from "./instances"
import { preferences, setAgentModelPreference } from "./preferences"
import { setSessionCompactionState } from "./session-compaction"
import {
activeSessionId,
agents,
clearSessionDraftPrompt,
messagesLoaded,
providers,
pruneDraftPrompts,
setActiveSessionId,
setAgents,
setMessagesLoaded,
setProviders,
setSessionInfoByInstance,
setSessions,
sessions,
loading,
setLoading,
} from "./session-state"
import { getDefaultModel, isModelValid } from "./session-models"
import {
computeDisplayParts,
clearSessionIndex,
getSessionIndex,
initializePartVersion,
normalizeMessagePart,
rebuildSessionIndex,
updateSessionInfo,
} from "./session-messages"
interface SessionForkResponse {
id: string
title?: string
parentID?: string | null
agent?: string
model?: {
providerID?: string
modelID?: string
}
time?: {
created?: number
updated?: number
}
revert?: {
messageID?: string
partID?: string
snapshot?: string
diff?: string
}
}
async function fetchSessions(instanceId: string): Promise<void> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
setLoading((prev) => {
const next = { ...prev }
next.fetchingSessions.set(instanceId, true)
return next
})
try {
console.log(`[HTTP] GET /session.list for instance ${instanceId}`)
const response = await instance.client.session.list()
const sessionMap = new Map<string, Session>()
if (!response.data || !Array.isArray(response.data)) {
return
}
const existingSessions = sessions().get(instanceId)
for (const apiSession of response.data) {
const existingSession = existingSessions?.get(apiSession.id)
sessionMap.set(apiSession.id, {
id: apiSession.id,
instanceId,
title: apiSession.title || "Untitled",
parentId: apiSession.parentID || null,
agent: existingSession?.agent ?? "",
model: existingSession?.model ?? { providerId: "", modelId: "" },
version: apiSession.version,
time: {
...apiSession.time,
},
revert: apiSession.revert
? {
messageID: apiSession.revert.messageID,
partID: apiSession.revert.partID,
snapshot: apiSession.revert.snapshot,
diff: apiSession.revert.diff,
}
: undefined,
messages: existingSession?.messages ?? [],
messagesInfo: existingSession?.messagesInfo ?? new Map(),
})
}
const validSessionIds = new Set(sessionMap.keys())
setSessions((prev) => {
const next = new Map(prev)
next.set(instanceId, sessionMap)
return next
})
setMessagesLoaded((prev) => {
const next = new Map(prev)
const loadedSet = next.get(instanceId)
if (loadedSet) {
const filtered = new Set<string>()
for (const id of loadedSet) {
if (validSessionIds.has(id)) {
filtered.add(id)
}
}
next.set(instanceId, filtered)
}
return next
})
for (const session of sessionMap.values()) {
const flag = (session.time as (Session["time"] & { compacting?: number | boolean }) | undefined)?.compacting
const active = typeof flag === "number" ? flag > 0 : Boolean(flag)
setSessionCompactionState(instanceId, session.id, active)
}
pruneDraftPrompts(instanceId, new Set(sessionMap.keys()))
} catch (error) {
console.error("Failed to fetch sessions:", error)
throw error
} finally {
setLoading((prev) => {
const next = { ...prev }
next.fetchingSessions.set(instanceId, false)
return next
})
}
}
async function createSession(instanceId: string, agent?: string): Promise<Session> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
const instanceAgents = agents().get(instanceId) || []
const nonSubagents = instanceAgents.filter((a) => a.mode !== "subagent")
const selectedAgent = agent || (nonSubagents.length > 0 ? nonSubagents[0].name : "")
const defaultModel = await getDefaultModel(instanceId, selectedAgent)
if (selectedAgent && isModelValid(instanceId, defaultModel)) {
setAgentModelPreference(instanceId, selectedAgent, defaultModel)
}
setLoading((prev) => {
const next = { ...prev }
next.creatingSession.set(instanceId, true)
return next
})
try {
console.log(`[HTTP] POST /session.create for instance ${instanceId}`)
const response = await instance.client.session.create()
if (!response.data) {
throw new Error("Failed to create session: No data returned")
}
const session: Session = {
id: response.data.id,
instanceId,
title: response.data.title || "New Session",
parentId: null,
agent: selectedAgent,
model: defaultModel,
version: response.data.version,
time: {
...response.data.time,
},
revert: response.data.revert
? {
messageID: response.data.revert.messageID,
partID: response.data.revert.partID,
snapshot: response.data.revert.snapshot,
diff: response.data.revert.diff,
}
: undefined,
messages: [],
messagesInfo: new Map(),
}
setSessions((prev) => {
const next = new Map(prev)
const instanceSessions = next.get(instanceId) || new Map()
instanceSessions.set(session.id, session)
next.set(instanceId, instanceSessions)
return next
})
const instanceProviders = providers().get(instanceId) || []
const initialProvider = instanceProviders.find((p) => p.id === session.model.providerId)
const initialModel = initialProvider?.models.find((m) => m.id === session.model.modelId)
const initialContextWindow = initialModel?.limit?.context ?? 0
const initialSubscriptionModel = initialModel?.cost?.input === 0 && initialModel?.cost?.output === 0
const initialContextPercent = initialContextWindow > 0 ? 0 : null
setSessionInfoByInstance((prev) => {
const next = new Map(prev)
const instanceInfo = new Map(prev.get(instanceId))
instanceInfo.set(session.id, {
tokens: 0,
cost: 0,
contextWindow: initialContextWindow,
isSubscriptionModel: Boolean(initialSubscriptionModel),
contextUsageTokens: 0,
contextUsagePercent: initialContextPercent,
})
next.set(instanceId, instanceInfo)
return next
})
getSessionIndex(instanceId, session.id)
return session
} catch (error) {
console.error("Failed to create session:", error)
throw error
} finally {
setLoading((prev) => {
const next = { ...prev }
next.creatingSession.set(instanceId, false)
return next
})
}
}
async function forkSession(
instanceId: string,
sourceSessionId: string,
options?: { messageId?: string },
): Promise<Session> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
const request: {
path: { id: string }
body?: { messageID: string }
} = {
path: { id: sourceSessionId },
}
if (options?.messageId) {
request.body = { messageID: options.messageId }
}
console.log(`[HTTP] POST /session.fork for instance ${instanceId}`, request)
const response = await instance.client.session.fork(request)
if (!response.data) {
throw new Error("Failed to fork session: No data returned")
}
const info = response.data as SessionForkResponse
const forkedSession = {
id: info.id,
instanceId,
title: info.title || "Forked Session",
parentId: info.parentID || null,
agent: info.agent || "",
model: {
providerId: info.model?.providerID || "",
modelId: info.model?.modelID || "",
},
version: "0",
time: info.time ? { ...info.time } : { created: Date.now(), updated: Date.now() },
revert: info.revert
? {
messageID: info.revert.messageID,
partID: info.revert.partID,
snapshot: info.revert.snapshot,
diff: info.revert.diff,
}
: undefined,
messages: [],
messagesInfo: new Map(),
} as unknown as Session
setSessions((prev) => {
const next = new Map(prev)
const instanceSessions = next.get(instanceId) || new Map()
instanceSessions.set(forkedSession.id, forkedSession)
next.set(instanceId, instanceSessions)
return next
})
const instanceProviders = providers().get(instanceId) || []
const forkProvider = instanceProviders.find((p) => p.id === forkedSession.model.providerId)
const forkModel = forkProvider?.models.find((m) => m.id === forkedSession.model.modelId)
const forkContextWindow = forkModel?.limit?.context ?? 0
const forkSubscriptionModel = forkModel?.cost?.input === 0 && forkModel?.cost?.output === 0
const forkContextPercent = forkContextWindow > 0 ? 0 : null
setSessionInfoByInstance((prev) => {
const next = new Map(prev)
const instanceInfo = new Map(prev.get(instanceId))
instanceInfo.set(forkedSession.id, {
tokens: 0,
cost: 0,
contextWindow: forkContextWindow,
isSubscriptionModel: Boolean(forkSubscriptionModel),
contextUsageTokens: 0,
contextUsagePercent: forkContextPercent,
})
next.set(instanceId, instanceInfo)
return next
})
getSessionIndex(instanceId, forkedSession.id)
return forkedSession
}
async function deleteSession(instanceId: string, sessionId: string): Promise<void> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
setLoading((prev) => {
const next = { ...prev }
const deleting = next.deletingSession.get(instanceId) || new Set()
deleting.add(sessionId)
next.deletingSession.set(instanceId, deleting)
return next
})
try {
console.log(`[HTTP] DELETE /session.delete for instance ${instanceId}`, { sessionId })
await instance.client.session.delete({ path: { id: sessionId } })
setSessions((prev) => {
const next = new Map(prev)
const instanceSessions = next.get(instanceId)
if (instanceSessions) {
instanceSessions.delete(sessionId)
}
return next
})
setSessionCompactionState(instanceId, sessionId, false)
clearSessionDraftPrompt(instanceId, sessionId)
setSessionInfoByInstance((prev) => {
const next = new Map(prev)
const instanceInfo = next.get(instanceId)
if (instanceInfo) {
const updatedInstanceInfo = new Map(instanceInfo)
updatedInstanceInfo.delete(sessionId)
if (updatedInstanceInfo.size === 0) {
next.delete(instanceId)
} else {
next.set(instanceId, updatedInstanceInfo)
}
}
return next
})
clearSessionIndex(instanceId, sessionId)
if (activeSessionId().get(instanceId) === sessionId) {
setActiveSessionId((prev) => {
const next = new Map(prev)
next.delete(instanceId)
return next
})
}
} catch (error) {
console.error("Failed to delete session:", error)
throw error
} finally {
setLoading((prev) => {
const next = { ...prev }
const deleting = next.deletingSession.get(instanceId)
if (deleting) {
deleting.delete(sessionId)
}
return next
})
}
}
async function fetchAgents(instanceId: string): Promise<void> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
try {
console.log(`[HTTP] GET /app.agents for instance ${instanceId}`)
const response = await instance.client.app.agents()
const agentList = (response.data ?? []).map((agent) => ({
name: agent.name,
description: agent.description || "",
mode: agent.mode,
model: agent.model?.modelID
? {
providerId: agent.model.providerID || "",
modelId: agent.model.modelID,
}
: undefined,
}))
setAgents((prev) => {
const next = new Map(prev)
next.set(instanceId, agentList)
return next
})
} catch (error) {
console.error("Failed to fetch agents:", error)
}
}
async function fetchProviders(instanceId: string): Promise<void> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
try {
console.log(`[HTTP] GET /config.providers for instance ${instanceId}`)
const response = await instance.client.config.providers()
if (!response.data) return
const providerList = response.data.providers.map((provider) => ({
id: provider.id,
name: provider.name,
defaultModelId: response.data?.default?.[provider.id],
models: Object.entries(provider.models).map(([id, model]) => ({
id,
name: model.name,
providerId: provider.id,
limit: model.limit,
cost: model.cost,
})),
}))
setProviders((prev) => {
const next = new Map(prev)
next.set(instanceId, providerList)
return next
})
} catch (error) {
console.error("Failed to fetch providers:", error)
}
}
async function loadMessages(instanceId: string, sessionId: string, force = false): Promise<void> {
if (force) {
setMessagesLoaded((prev) => {
const next = new Map(prev)
const loadedSet = next.get(instanceId)
if (loadedSet) {
loadedSet.delete(sessionId)
}
return next
})
}
const alreadyLoaded = messagesLoaded().get(instanceId)?.has(sessionId)
if (alreadyLoaded && !force) {
return
}
const isLoading = loading().loadingMessages.get(instanceId)?.has(sessionId)
if (isLoading) {
return
}
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
const instanceSessions = sessions().get(instanceId)
const session = instanceSessions?.get(sessionId)
if (!session) {
throw new Error("Session not found")
}
setLoading((prev) => {
const next = { ...prev }
const loadingSet = next.loadingMessages.get(instanceId) || new Set()
loadingSet.add(sessionId)
next.loadingMessages.set(instanceId, loadingSet)
return next
})
try {
console.log(`[HTTP] GET /session.messages for instance ${instanceId}`, { sessionId })
const response = await instance.client.session.messages({ path: { id: sessionId } })
if (!response.data || !Array.isArray(response.data)) {
return
}
const messagesInfo = new Map<string, any>()
const messages: Message[] = response.data.map((apiMessage: any) => {
const info = apiMessage.info || apiMessage
const role = info.role || "assistant"
const messageId = info.id || String(Date.now())
messagesInfo.set(messageId, info)
const parts: any[] = (apiMessage.parts || []).map((part: any) => normalizeMessagePart(part))
const message: Message = {
id: messageId,
sessionId,
type: role === "user" ? "user" : "assistant",
parts,
timestamp: info.time?.created || Date.now(),
status: "complete" as const,
version: 0,
}
parts.forEach((part: any) => initializePartVersion(part))
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
return message
})
let agentName = ""
let providerID = ""
let modelID = ""
for (let i = response.data.length - 1; i >= 0; i--) {
const apiMessage = response.data[i]
const info = apiMessage.info || apiMessage
if (info.role === "assistant") {
agentName = (info as any).mode || (info as any).agent || ""
providerID = (info as any).providerID || ""
modelID = (info as any).modelID || ""
if (agentName && providerID && modelID) break
}
}
if (!agentName && !providerID && !modelID) {
const defaultModel = await getDefaultModel(instanceId, session.agent)
agentName = session.agent
providerID = defaultModel.providerId
modelID = defaultModel.modelId
}
setSessions((prev) => {
const next = new Map(prev)
const nextInstanceSessions = next.get(instanceId)
if (nextInstanceSessions) {
const existingSession = nextInstanceSessions.get(sessionId)
if (existingSession) {
const updatedSession = {
...existingSession,
messages,
messagesInfo,
agent: agentName || existingSession.agent,
model: providerID && modelID ? { providerId: providerID, modelId: modelID } : existingSession.model,
}
const updatedInstanceSessions = new Map(nextInstanceSessions)
updatedInstanceSessions.set(sessionId, updatedSession)
next.set(instanceId, updatedInstanceSessions)
}
}
return next
})
rebuildSessionIndex(instanceId, sessionId, messages)
setMessagesLoaded((prev) => {
const next = new Map(prev)
const loadedSet = next.get(instanceId) || new Set()
loadedSet.add(sessionId)
next.set(instanceId, loadedSet)
return next
})
} catch (error) {
console.error("Failed to load messages:", error)
throw error
} finally {
setLoading((prev) => {
const next = { ...prev }
const loadingSet = next.loadingMessages.get(instanceId)
if (loadingSet) {
loadingSet.delete(sessionId)
}
return next
})
}
updateSessionInfo(instanceId, sessionId)
refreshPermissionsForSession(instanceId, sessionId)
}
export {
createSession,
deleteSession,
fetchAgents,
fetchProviders,
fetchSessions,
forkSession,
loadMessages,
}

View File

@@ -0,0 +1,507 @@
import type {
MessagePartRemovedEvent,
MessagePartUpdatedEvent,
MessageRemovedEvent,
MessageUpdateEvent,
} from "../types/message"
import type {
EventPermissionReplied,
EventPermissionUpdated,
EventSessionCompacted,
EventSessionError,
EventSessionIdle,
EventSessionUpdated,
} from "@opencode-ai/sdk"
import { showToastNotification, ToastVariant } from "../lib/notifications"
import { preferences } from "./preferences"
import { instances, addPermissionToQueue, removePermissionFromQueue, refreshPermissionsForSession } from "./instances"
import {
sessions,
setSessions,
withSession,
} from "./session-state"
import {
bumpPartVersion,
computeDisplayParts,
getSessionIndex,
initializePartVersion,
normalizeMessagePart,
rebuildSessionIndex,
updateSessionInfo,
} from "./session-messages"
import { loadMessages } from "./session-api"
import { setSessionCompactionState } from "./session-compaction"
interface TuiToastEvent {
type: "tui.toast.show"
properties: {
title?: string
message: string
variant: "info" | "success" | "warning" | "error"
duration?: number
}
}
const ALLOWED_TOAST_VARIANTS = new Set<ToastVariant>(["info", "success", "warning", "error"])
function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | MessagePartUpdatedEvent): void {
const instanceSessions = sessions().get(instanceId)
if (!instanceSessions) return
if (event.type === "message.part.updated") {
const rawPart = event.properties?.part
if (!rawPart) return
const part = normalizeMessagePart(rawPart)
const session = instanceSessions.get(part.sessionID)
if (!session) return
const index = getSessionIndex(instanceId, part.sessionID)
let messageIndex = index.messageIndex.get(part.messageID)
let replacedTemp = false
if (messageIndex === undefined) {
for (let i = 0; i < session.messages.length; i++) {
const msg = session.messages[i]
if (msg.sessionId === part.sessionID && msg.status === "sending") {
messageIndex = i
replacedTemp = true
break
}
}
}
if (messageIndex === undefined) {
const newMessage: any = {
id: part.messageID,
sessionId: part.sessionID,
type: "assistant" as const,
parts: [part],
timestamp: Date.now(),
status: "streaming" as const,
version: 0,
}
initializePartVersion(part)
newMessage.displayParts = computeDisplayParts(newMessage, preferences().showThinkingBlocks)
let insertIndex = session.messages.length
for (let i = session.messages.length - 1; i >= 0; i--) {
if (session.messages[i].id < newMessage.id) {
insertIndex = i + 1
break
}
}
session.messages.splice(insertIndex, 0, newMessage)
rebuildSessionIndex(instanceId, part.sessionID, session.messages)
} else {
const message = session.messages[messageIndex]
if (typeof message.version !== "number") {
message.version = 0
}
let filteredSynthetics = false
if (message.parts.some((partItem: any) => partItem.synthetic === true)) {
message.parts = message.parts.filter((partItem: any) => partItem.synthetic !== true)
filteredSynthetics = true
message.parts.forEach((partItem: any) => {
if (partItem.type === "text") {
partItem.renderCache = undefined
}
})
}
let baseParts: any[]
if (replacedTemp) {
baseParts = message.parts.filter((partItem: any) => partItem.type !== "text")
message.parts = baseParts
baseParts.forEach((partItem: any) => {
if (partItem.type === "text") {
partItem.renderCache = undefined
}
})
} else {
baseParts = message.parts
}
let partMap = index.partIndex.get(message.id)
if (!partMap) {
partMap = new Map()
index.partIndex.set(message.id, partMap)
}
let shouldIncrementVersion = filteredSynthetics || replacedTemp
const partIndex = partMap.get(part.id)
if (partIndex === undefined) {
initializePartVersion(part)
baseParts.push(part)
if (part.id && typeof part.id === "string") {
partMap.set(part.id, baseParts.length - 1)
}
shouldIncrementVersion = true
if (part.type === "text") {
part.renderCache = undefined
}
} else {
const previousPart = baseParts[partIndex]
const textUnchanged =
!filteredSynthetics &&
!replacedTemp &&
part.type === "text" &&
previousPart?.type === "text" &&
previousPart.text === part.text
if (textUnchanged) {
return
}
bumpPartVersion(previousPart, part)
baseParts[partIndex] = part
if (part.type !== "text" || !previousPart || previousPart.text !== part.text) {
shouldIncrementVersion = true
if (part.type === "text") {
part.renderCache = undefined
}
}
}
const oldId = message.id
message.id = replacedTemp ? part.messageID : message.id
message.status = message.status === "sending" ? "streaming" : message.status
message.parts = baseParts
if (shouldIncrementVersion) {
message.version += 1
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
} else if (
!message.displayParts ||
message.displayParts.showThinking !== preferences().showThinkingBlocks ||
message.displayParts.version !== message.version
) {
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
}
if (oldId !== message.id) {
index.messageIndex.delete(oldId)
index.messageIndex.set(message.id, messageIndex)
const existingPartMap = index.partIndex.get(oldId)
if (existingPartMap) {
index.partIndex.delete(oldId)
index.partIndex.set(message.id, existingPartMap)
}
}
if (filteredSynthetics || replacedTemp) {
const refreshed = new Map<string, number>()
message.parts.forEach((partItem, idx) => {
if (partItem.id && typeof partItem.id === "string") {
refreshed.set(partItem.id, idx)
}
})
index.partIndex.set(message.id, refreshed)
}
}
withSession(instanceId, part.sessionID, () => {
/* mutations already applied above */
})
updateSessionInfo(instanceId, part.sessionID)
refreshPermissionsForSession(instanceId, part.sessionID)
} else if (event.type === "message.updated") {
const info = event.properties?.info
if (!info) return
const session = instanceSessions.get(info.sessionID)
if (!session) return
const index = getSessionIndex(instanceId, info.sessionID)
let messageIndex = index.messageIndex.get(info.id)
if (messageIndex === undefined) {
let tempMessageIndex = -1
for (let i = 0; i < session.messages.length; i++) {
const msg = session.messages[i]
if (
msg.sessionId === info.sessionID &&
msg.type === (info.role === "user" ? "user" : "assistant") &&
msg.status === "sending"
) {
tempMessageIndex = i
break
}
}
if (tempMessageIndex === -1) {
for (let i = 0; i < session.messages.length; i++) {
const msg = session.messages[i]
if (msg.sessionId === info.sessionID && msg.status === "sending") {
tempMessageIndex = i
break
}
}
}
if (tempMessageIndex > -1) {
const message = session.messages[tempMessageIndex]
if (typeof message.version !== "number") {
message.version = 0
}
const oldId = message.id
message.id = info.id
message.type = (info.role === "user" ? "user" : "assistant") as "user" | "assistant"
message.timestamp = info.time?.created || Date.now()
message.status = "complete" as const
message.version += 1
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
if (oldId !== message.id) {
index.messageIndex.delete(oldId)
index.messageIndex.set(message.id, tempMessageIndex)
const existingPartMap = index.partIndex.get(oldId)
if (existingPartMap) {
index.partIndex.delete(oldId)
index.partIndex.set(message.id, existingPartMap)
}
}
} else {
const newMessage: any = {
id: info.id,
sessionId: info.sessionID,
type: (info.role === "user" ? "user" : "assistant") as "user" | "assistant",
parts: [],
timestamp: info.time?.created || Date.now(),
status: "complete" as const,
version: 0,
}
newMessage.displayParts = computeDisplayParts(newMessage, preferences().showThinkingBlocks)
let insertIndex = session.messages.length
for (let i = session.messages.length - 1; i >= 0; i--) {
if (session.messages[i].id < newMessage.id) {
insertIndex = i + 1
break
}
}
session.messages.splice(insertIndex, 0, newMessage)
rebuildSessionIndex(instanceId, info.sessionID, session.messages)
}
} else {
const message = session.messages[messageIndex]
if (typeof message.version !== "number") {
message.version = 0
}
message.status = "complete" as const
message.version += 1
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
}
session.messagesInfo.set(info.id, info)
withSession(instanceId, info.sessionID, () => {
/* ensure reactivity */
})
updateSessionInfo(instanceId, info.sessionID)
refreshPermissionsForSession(instanceId, info.sessionID)
}
}
function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): void {
const info = event.properties?.info
if (!info) return
const compactingFlag = info.time?.compacting
const isCompacting = typeof compactingFlag === "number" ? compactingFlag > 0 : Boolean(compactingFlag)
setSessionCompactionState(instanceId, info.id, isCompacting)
const instanceSessions = sessions().get(instanceId)
if (!instanceSessions) return
const existingSession = instanceSessions.get(info.id)
if (!existingSession) {
const newSession = {
id: info.id,
instanceId,
title: info.title || "Untitled",
parentId: info.parentID || null,
agent: "",
model: {
providerId: "",
modelId: "",
},
version: info.version || "0",
time: info.time
? { ...info.time }
: {
created: Date.now(),
updated: Date.now(),
},
messages: [],
messagesInfo: new Map(),
} as any
setSessions((prev) => {
const next = new Map(prev)
const updated = new Map(prev.get(instanceId))
updated.set(newSession.id, newSession)
next.set(instanceId, updated)
return next
})
console.log(`[SSE] New session created: ${info.id}`, newSession)
} else {
const mergedTime = {
...existingSession.time,
...(info.time ?? {}),
}
if (!info.time?.updated) {
mergedTime.updated = Date.now()
}
const updatedSession = {
...existingSession,
title: info.title || existingSession.title,
time: mergedTime,
revert: info.revert
? {
messageID: info.revert.messageID,
partID: info.revert.partID,
snapshot: info.revert.snapshot,
diff: info.revert.diff,
}
: existingSession.revert,
}
setSessions((prev) => {
const next = new Map(prev)
const updated = new Map(prev.get(instanceId))
updated.set(existingSession.id, updatedSession)
next.set(instanceId, updated)
return next
})
}
}
function handleSessionIdle(_instanceId: string, event: EventSessionIdle): void {
const sessionId = event.properties?.sessionID
if (!sessionId) return
console.log(`[SSE] Session idle: ${sessionId}`)
}
function handleSessionCompacted(instanceId: string, event: EventSessionCompacted): void {
const sessionID = event.properties?.sessionID
if (!sessionID) return
console.log(`[SSE] Session compacted: ${sessionID}`)
setSessionCompactionState(instanceId, sessionID, false)
withSession(instanceId, sessionID, (session) => {
const time = { ...(session.time ?? {}) }
time.compacting = 0
session.time = time
})
loadMessages(instanceId, sessionID, true).catch(console.error)
const instanceSessions = sessions().get(instanceId)
const session = instanceSessions?.get(sessionID)
const label = session?.title?.trim() ? session.title : sessionID
const instanceFolder = instances().get(instanceId)?.folder ?? instanceId
const instanceName = instanceFolder.split(/[\\/]/).filter(Boolean).pop() ?? instanceFolder
showToastNotification({
title: instanceName,
message: `Session ${label ? `"${label}"` : sessionID} was compacted`,
variant: "info",
duration: 10000,
})
}
function handleSessionError(_instanceId: string, event: EventSessionError): void {
const error = event.properties?.error
console.error(`[SSE] Session error:`, error)
let message = "Unknown error"
if (error) {
if ("data" in error && error.data && typeof error.data === "object" && "message" in error.data) {
message = error.data.message as string
} else if ("message" in error && typeof error.message === "string") {
message = error.message
}
}
alert(`Error: ${message}`)
}
function handleMessageRemoved(instanceId: string, event: MessageRemovedEvent): void {
const sessionID = event.properties?.sessionID
if (!sessionID) return
console.log(`[SSE] Message removed from session ${sessionID}, reloading messages`)
loadMessages(instanceId, sessionID, true).catch(console.error)
}
function handleMessagePartRemoved(instanceId: string, event: MessagePartRemovedEvent): void {
const sessionID = event.properties?.sessionID
if (!sessionID) return
console.log(`[SSE] Message part removed from session ${sessionID}, reloading messages`)
loadMessages(instanceId, sessionID, true).catch(console.error)
}
function handleTuiToast(_instanceId: string, event: TuiToastEvent): void {
const payload = event?.properties
if (!payload || typeof payload.message !== "string" || typeof payload.variant !== "string") return
if (!payload.message.trim()) return
const variant: ToastVariant = ALLOWED_TOAST_VARIANTS.has(payload.variant as ToastVariant)
? (payload.variant as ToastVariant)
: "info"
showToastNotification({
title: typeof payload.title === "string" ? payload.title : undefined,
message: payload.message,
variant,
duration: typeof payload.duration === "number" ? payload.duration : undefined,
})
}
function handlePermissionUpdated(instanceId: string, event: EventPermissionUpdated): void {
const permission = event.properties
if (!permission) return
console.log(`[SSE] Permission updated: ${permission.id} (${permission.type})`)
addPermissionToQueue(instanceId, permission)
}
function handlePermissionReplied(instanceId: string, event: EventPermissionReplied): void {
const { permissionID } = event.properties
if (!permissionID) return
console.log(`[SSE] Permission replied: ${permissionID}`)
removePermissionFromQueue(instanceId, permissionID)
}
export {
handleMessagePartRemoved,
handleMessageRemoved,
handleMessageUpdate,
handlePermissionReplied,
handlePermissionUpdated,
handleSessionCompacted,
handleSessionError,
handleSessionIdle,
handleSessionUpdate,
handleTuiToast,
}

View File

@@ -0,0 +1,295 @@
import type { Message, MessageDisplayParts } from "../types/message"
import { partHasRenderableText } from "../types/message"
import type { Provider } from "../types/session"
import { decodeHtmlEntities } from "../lib/markdown"
import { providers, sessions, setSessionInfoByInstance } from "./session-state"
import { DEFAULT_MODEL_OUTPUT_LIMIT } from "./session-models"
interface SessionIndexCache {
messageIndex: Map<string, number>
partIndex: Map<string, Map<string, number>>
}
const sessionIndexes = new Map<string, Map<string, SessionIndexCache>>()
function decodeTextSegment(segment: any): any {
if (typeof segment === "string") {
return decodeHtmlEntities(segment)
}
if (segment && typeof segment === "object") {
const updated: Record<string, any> = { ...segment }
if (typeof updated.text === "string") {
updated.text = decodeHtmlEntities(updated.text)
}
if (typeof updated.value === "string") {
updated.value = decodeHtmlEntities(updated.value)
}
if (Array.isArray(updated.content)) {
updated.content = updated.content.map((item: any) => decodeTextSegment(item))
}
return updated
}
return segment
}
function normalizeMessagePart(part: any): any {
if (!part || typeof part !== "object") {
return part
}
if (part.type !== "text") {
return part
}
const normalized: Record<string, any> = { ...part, renderCache: undefined }
if (typeof normalized.text === "string") {
normalized.text = decodeHtmlEntities(normalized.text)
} else if (normalized.text && typeof normalized.text === "object") {
const textObject: Record<string, any> = { ...normalized.text }
if (typeof textObject.value === "string") {
textObject.value = decodeHtmlEntities(textObject.value)
}
if (Array.isArray(textObject.content)) {
textObject.content = textObject.content.map((item: any) => decodeTextSegment(item))
}
if (typeof textObject.text === "string") {
textObject.text = decodeHtmlEntities(textObject.text)
}
normalized.text = textObject
}
if (Array.isArray(normalized.content)) {
normalized.content = normalized.content.map((item: any) => decodeTextSegment(item))
}
if (normalized.thinking && typeof normalized.thinking === "object") {
const thinking: Record<string, any> = { ...normalized.thinking }
if (Array.isArray(thinking.content)) {
thinking.content = thinking.content.map((item: any) => decodeTextSegment(item))
}
normalized.thinking = thinking
}
return normalized
}
function computeDisplayParts(message: Message, showThinking: boolean): MessageDisplayParts {
const text: any[] = []
const tool: any[] = []
const reasoning: any[] = []
for (const part of message.parts) {
if (part.type === "text" && !part.synthetic && partHasRenderableText(part)) {
text.push(part)
} else if (part.type === "tool") {
tool.push(part)
} else if (part.type === "reasoning" && showThinking && partHasRenderableText(part)) {
reasoning.push(part)
}
}
const combined = reasoning.length > 0 ? [...text, ...reasoning] : [...text]
const version = typeof message.version === "number" ? message.version : 0
return { text, tool, reasoning, combined, showThinking, version }
}
function initializePartVersion(part: any, version = 0) {
if (!part || typeof part !== "object") return
const partAny = part as any
if (typeof partAny.version !== "number") {
partAny.version = version
}
}
function bumpPartVersion(previousPart: any, nextPart: any): number {
const prevVersion = typeof previousPart?.version === "number" ? previousPart.version : -1
const nextVersion = prevVersion + 1
nextPart.version = nextVersion
return nextVersion
}
function getSessionIndex(instanceId: string, sessionId: string) {
let instanceMap = sessionIndexes.get(instanceId)
if (!instanceMap) {
instanceMap = new Map()
sessionIndexes.set(instanceId, instanceMap)
}
let sessionMap = instanceMap.get(sessionId)
if (!sessionMap) {
sessionMap = { messageIndex: new Map(), partIndex: new Map() }
instanceMap.set(sessionId, sessionMap)
}
return sessionMap
}
function rebuildSessionIndex(instanceId: string, sessionId: string, messages: Message[]) {
const index = getSessionIndex(instanceId, sessionId)
index.messageIndex.clear()
index.partIndex.clear()
messages.forEach((message, messageIdx) => {
index.messageIndex.set(message.id, messageIdx)
const partMap = new Map<string, number>()
message.parts.forEach((part, partIdx) => {
if (part.id && typeof part.id === "string") {
partMap.set(part.id, partIdx)
}
})
index.partIndex.set(message.id, partMap)
})
}
function clearSessionIndex(instanceId: string, sessionId: string) {
const instanceMap = sessionIndexes.get(instanceId)
if (instanceMap) {
instanceMap.delete(sessionId)
if (instanceMap.size === 0) {
sessionIndexes.delete(instanceId)
}
}
}
function removeSessionIndexes(instanceId: string) {
sessionIndexes.delete(instanceId)
}
function updateSessionInfo(instanceId: string, sessionId: string) {
const instanceSessions = sessions().get(instanceId)
if (!instanceSessions) return
const session = instanceSessions.get(sessionId)
if (!session) return
let tokens = 0
let cost = 0
let contextWindow = 0
let isSubscriptionModel = false
let modelID = ""
let providerID = ""
let actualUsageTokens = 0
let contextUsagePercent: number | null = null
let hasContextUsage = false
if (session.messagesInfo.size > 0) {
const messageArray = Array.from(session.messagesInfo.values()).reverse()
for (const info of messageArray) {
if (info.role === "assistant" && info.tokens) {
const usage = info.tokens
if (usage.output > 0) {
const inputTokens = usage.input || 0
const reasoningTokens = usage.reasoning || 0
const cacheReadTokens = usage.cache?.read || 0
const cacheWriteTokens = usage.cache?.write || 0
const outputTokens = usage.output || 0
if (info.summary) {
tokens = outputTokens
} else {
tokens = inputTokens + cacheReadTokens + cacheWriteTokens + outputTokens + reasoningTokens
}
cost = info.cost || 0
actualUsageTokens = tokens
hasContextUsage = inputTokens + cacheReadTokens + cacheWriteTokens > 0
modelID = info.modelID || ""
providerID = info.providerID || ""
isSubscriptionModel = cost === 0
break
}
}
}
}
const instanceProviders = providers().get(instanceId) || []
const sessionModel = session.model
let selectedModel: Provider["models"][number] | undefined
if (sessionModel?.providerId && sessionModel?.modelId) {
const provider = instanceProviders.find((p) => p.id === sessionModel.providerId)
selectedModel = provider?.models.find((m) => m.id === sessionModel.modelId)
}
if (!selectedModel && modelID && providerID) {
const provider = instanceProviders.find((p) => p.id === providerID)
selectedModel = provider?.models.find((m) => m.id === modelID)
}
let modelOutputLimit = DEFAULT_MODEL_OUTPUT_LIMIT
if (selectedModel) {
if (selectedModel.limit?.context) {
contextWindow = selectedModel.limit.context
}
if (selectedModel.limit?.output && selectedModel.limit.output > 0) {
modelOutputLimit = selectedModel.limit.output
}
if (selectedModel.cost?.input === 0 && selectedModel.cost?.output === 0) {
isSubscriptionModel = true
}
}
const outputBudget = Math.min(modelOutputLimit, DEFAULT_MODEL_OUTPUT_LIMIT)
let contextUsageTokens = 0
if (hasContextUsage && actualUsageTokens > 0) {
contextUsageTokens = actualUsageTokens + outputBudget
if (contextWindow > 0) {
const percent = Math.round((contextUsageTokens / contextWindow) * 100)
contextUsagePercent = Math.min(100, Math.max(0, percent))
} else {
contextUsagePercent = null
}
} else {
contextUsagePercent = contextWindow > 0 ? 0 : null
}
setSessionInfoByInstance((prev) => {
const next = new Map(prev)
const instanceInfo = new Map(prev.get(instanceId))
instanceInfo.set(sessionId, {
tokens,
cost,
contextWindow,
isSubscriptionModel,
contextUsageTokens,
contextUsagePercent,
})
next.set(instanceId, instanceInfo)
return next
})
}
export {
bumpPartVersion,
clearSessionIndex,
computeDisplayParts,
getSessionIndex,
initializePartVersion,
normalizeMessagePart,
rebuildSessionIndex,
removeSessionIndexes,
updateSessionInfo,
}

View File

@@ -0,0 +1,83 @@
import { agents, providers } from "./session-state"
import { preferences, getAgentModelPreference } from "./preferences"
const DEFAULT_MODEL_OUTPUT_LIMIT = 32_000
function isModelValid(
instanceId: string,
model?: { providerId: string; modelId: string } | null,
): model is { providerId: string; modelId: string } {
if (!model?.providerId || !model.modelId) return false
const instanceProviders = providers().get(instanceId) || []
const provider = instanceProviders.find((p) => p.id === model.providerId)
if (!provider) return false
return provider.models.some((item) => item.id === model.modelId)
}
function getRecentModelPreferenceForInstance(
instanceId: string,
): { providerId: string; modelId: string } | undefined {
const recents = preferences().modelRecents ?? []
for (const item of recents) {
if (isModelValid(instanceId, item)) {
return item
}
}
}
async function getDefaultModel(
instanceId: string,
agentName?: string,
): Promise<{ providerId: string; modelId: string }> {
const instanceProviders = providers().get(instanceId) || []
const instanceAgents = agents().get(instanceId) || []
if (agentName) {
const stored = getAgentModelPreference(instanceId, agentName)
if (isModelValid(instanceId, stored)) {
return stored
}
}
if (agentName) {
const agent = instanceAgents.find((a) => a.name === agentName)
if (agent && agent.model && isModelValid(instanceId, agent.model)) {
return {
providerId: agent.model.providerId,
modelId: agent.model.modelId,
}
}
}
const recent = getRecentModelPreferenceForInstance(instanceId)
if (recent) {
return recent
}
for (const provider of instanceProviders) {
if (provider.defaultModelId) {
const model = provider.models.find((m) => m.id === provider.defaultModelId)
if (model) {
return {
providerId: provider.id,
modelId: model.id,
}
}
}
}
if (instanceProviders.length > 0) {
const firstProvider = instanceProviders[0]
const firstModel = firstProvider.models[0]
if (firstModel) {
return {
providerId: firstProvider.id,
modelId: firstModel.id,
}
}
}
return { providerId: "", modelId: "" }
}
export { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, getRecentModelPreferenceForInstance, isModelValid }

261
src/stores/session-state.ts Normal file
View File

@@ -0,0 +1,261 @@
import { createSignal } from "solid-js"
import type { Session, Agent, Provider } from "../types/session"
export interface SessionInfo {
tokens: number
cost: number
contextWindow: number
isSubscriptionModel: boolean
contextUsageTokens: number
contextUsagePercent: number | null
}
const [sessions, setSessions] = createSignal<Map<string, Map<string, Session>>>(new Map())
const [activeSessionId, setActiveSessionId] = createSignal<Map<string, string>>(new Map())
const [activeParentSessionId, setActiveParentSessionId] = createSignal<Map<string, string>>(new Map())
const [agents, setAgents] = createSignal<Map<string, Agent[]>>(new Map())
const [providers, setProviders] = createSignal<Map<string, Provider[]>>(new Map())
const [sessionDraftPrompts, setSessionDraftPrompts] = createSignal<Map<string, string>>(new Map())
const [loading, setLoading] = createSignal({
fetchingSessions: new Map<string, boolean>(),
creatingSession: new Map<string, boolean>(),
deletingSession: new Map<string, Set<string>>(),
loadingMessages: new Map<string, Set<string>>(),
})
const [messagesLoaded, setMessagesLoaded] = createSignal<Map<string, Set<string>>>(new Map())
const [sessionInfoByInstance, setSessionInfoByInstance] = createSignal<Map<string, Map<string, SessionInfo>>>(new Map())
function getDraftKey(instanceId: string, sessionId: string): string {
return `${instanceId}:${sessionId}`
}
function getSessionDraftPrompt(instanceId: string, sessionId: string): string {
if (!instanceId || !sessionId) return ""
const key = getDraftKey(instanceId, sessionId)
return sessionDraftPrompts().get(key) ?? ""
}
function setSessionDraftPrompt(instanceId: string, sessionId: string, value: string) {
const key = getDraftKey(instanceId, sessionId)
setSessionDraftPrompts((prev) => {
const next = new Map(prev)
if (!value) {
next.delete(key)
} else {
next.set(key, value)
}
return next
})
}
function clearSessionDraftPrompt(instanceId: string, sessionId: string) {
const key = getDraftKey(instanceId, sessionId)
setSessionDraftPrompts((prev) => {
if (!prev.has(key)) return prev
const next = new Map(prev)
next.delete(key)
return next
})
}
function clearInstanceDraftPrompts(instanceId: string) {
if (!instanceId) return
setSessionDraftPrompts((prev) => {
let changed = false
const next = new Map(prev)
const prefix = `${instanceId}:`
for (const key of Array.from(next.keys())) {
if (key.startsWith(prefix)) {
next.delete(key)
changed = true
}
}
return changed ? next : prev
})
}
function pruneDraftPrompts(instanceId: string, validSessionIds: Set<string>) {
setSessionDraftPrompts((prev) => {
let changed = false
const next = new Map(prev)
const prefix = `${instanceId}:`
for (const key of Array.from(next.keys())) {
if (key.startsWith(prefix)) {
const sessionId = key.slice(prefix.length)
if (!validSessionIds.has(sessionId)) {
next.delete(key)
changed = true
}
}
}
return changed ? next : prev
})
}
function withSession(instanceId: string, sessionId: string, updater: (session: Session) => void) {
const instanceSessions = sessions().get(instanceId)
if (!instanceSessions) return
const session = instanceSessions.get(sessionId)
if (!session) return
updater(session)
const updatedSession = {
...session,
messages: [...session.messages],
messagesInfo: new Map(session.messagesInfo),
}
setSessions((prev) => {
const next = new Map(prev)
const newInstanceSessions = new Map(instanceSessions)
newInstanceSessions.set(sessionId, updatedSession)
next.set(instanceId, newInstanceSessions)
return next
})
}
function setSessionCompactionState(instanceId: string, sessionId: string, isCompacting: boolean): void {
withSession(instanceId, sessionId, (session) => {
const time = { ...(session.time ?? {}) }
time.compacting = isCompacting ? Date.now() : 0
session.time = time
})
}
function setSessionPendingPermission(instanceId: string, sessionId: string, pending: boolean): void {
withSession(instanceId, sessionId, (session) => {
if (session.pendingPermission === pending) return
session.pendingPermission = pending
})
}
function setActiveSession(instanceId: string, sessionId: string): void {
setActiveSessionId((prev) => {
const next = new Map(prev)
next.set(instanceId, sessionId)
return next
})
}
function setActiveParentSession(instanceId: string, parentSessionId: string): void {
setActiveParentSessionId((prev) => {
const next = new Map(prev)
next.set(instanceId, parentSessionId)
return next
})
setActiveSession(instanceId, parentSessionId)
}
function clearActiveParentSession(instanceId: string): void {
setActiveParentSessionId((prev) => {
const next = new Map(prev)
next.delete(instanceId)
return next
})
setActiveSessionId((prev) => {
const next = new Map(prev)
next.delete(instanceId)
return next
})
}
function getActiveParentSession(instanceId: string): Session | null {
const parentId = activeParentSessionId().get(instanceId)
if (!parentId) return null
const instanceSessions = sessions().get(instanceId)
return instanceSessions?.get(parentId) || null
}
function getActiveSession(instanceId: string): Session | null {
const sessionId = activeSessionId().get(instanceId)
if (!sessionId) return null
const instanceSessions = sessions().get(instanceId)
return instanceSessions?.get(sessionId) || null
}
function getSessions(instanceId: string): Session[] {
const instanceSessions = sessions().get(instanceId)
return instanceSessions ? Array.from(instanceSessions.values()) : []
}
function getParentSessions(instanceId: string): Session[] {
const allSessions = getSessions(instanceId)
return allSessions.filter((s) => s.parentId === null)
}
function getChildSessions(instanceId: string, parentId: string): Session[] {
const allSessions = getSessions(instanceId)
return allSessions.filter((s) => s.parentId === parentId)
}
function getSessionFamily(instanceId: string, parentId: string): Session[] {
const parent = sessions().get(instanceId)?.get(parentId)
if (!parent) return []
const children = getChildSessions(instanceId, parentId)
return [parent, ...children]
}
function isSessionBusy(instanceId: string, sessionId: string): boolean {
const instanceSessions = sessions().get(instanceId)
if (!instanceSessions) return false
if (!instanceSessions.has(sessionId)) return false
return true
}
function isSessionMessagesLoading(instanceId: string, sessionId: string): boolean {
return Boolean(loading().loadingMessages.get(instanceId)?.has(sessionId))
}
function getSessionInfo(instanceId: string, sessionId: string): SessionInfo | undefined {
return sessionInfoByInstance().get(instanceId)?.get(sessionId)
}
export {
sessions,
setSessions,
activeSessionId,
setActiveSessionId,
activeParentSessionId,
setActiveParentSessionId,
agents,
setAgents,
providers,
setProviders,
loading,
setLoading,
messagesLoaded,
setMessagesLoaded,
sessionInfoByInstance,
setSessionInfoByInstance,
getSessionDraftPrompt,
setSessionDraftPrompt,
clearSessionDraftPrompt,
clearInstanceDraftPrompts,
pruneDraftPrompts,
withSession,
setSessionCompactionState,
setSessionPendingPermission,
setActiveSession,
setActiveParentSession,
clearActiveParentSession,
getActiveSession,
getActiveParentSession,
getSessions,
getParentSessions,
getChildSessions,
getSessionFamily,
isSessionBusy,
isSessionMessagesLoading,
getSessionInfo,
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,46 @@
/* Badge + status utilities */
.neutral-badge {
@apply inline-flex items-center rounded px-1.5 py-0.5 text-xs font-normal;
background-color: var(--badge-neutral-bg);
color: var(--badge-neutral-text);
}
.status-badge {
@apply inline-flex items-center gap-1.5 rounded px-2 py-1 text-xs font-medium;
}
.status-badge.ready {
background-color: var(--status-ready-bg);
color: var(--status-ready-fg);
}
.status-badge.starting {
background-color: var(--status-starting-bg);
color: var(--status-starting-fg);
}
.status-badge.error {
background-color: var(--status-error-bg);
color: var(--status-error-fg);
}
.status-badge.stopped {
background-color: var(--status-stopped-bg);
color: var(--status-stopped-fg);
}
.status-dot.ready {
background-color: var(--status-ready-fg);
}
.status-dot.starting {
background-color: var(--status-starting-fg);
}
.status-dot.error {
background-color: var(--status-error-fg);
}
.status-dot.stopped {
background-color: var(--status-stopped-fg);
}

View File

@@ -0,0 +1,56 @@
/* Button component styles */
.button-primary,
button.button-primary {
@apply px-6 py-3 text-base rounded-lg;
background-color: var(--button-primary-bg);
color: var(--button-primary-text);
border-color: var(--button-primary-bg);
}
.button-primary:hover:not(:disabled),
button.button-primary:hover:not(:disabled) {
background-color: var(--button-primary-hover-bg);
border-color: var(--button-primary-hover-bg);
}
.button-primary:focus-visible,
button.button-primary:focus-visible {
box-shadow: 0 0 0 2px var(--focus-ring-offset), 0 0 0 4px var(--focus-ring-color);
}
.button-secondary,
button.button-secondary {
@apply px-6 py-3 text-base rounded-lg;
background-color: var(--surface-secondary);
color: var(--text-primary);
border-color: var(--border-base);
}
.button-secondary:hover:not(:disabled),
button.button-secondary:hover:not(:disabled) {
background-color: var(--surface-hover);
}
.button-secondary:focus-visible,
button.button-secondary:focus-visible {
box-shadow: 0 0 0 2px var(--focus-ring-offset), 0 0 0 4px var(--focus-ring-color);
}
.button-tertiary,
button.button-tertiary {
@apply px-4 py-2 text-sm rounded-lg;
background-color: transparent;
color: var(--text-secondary);
border-color: var(--border-base);
}
.button-tertiary:hover:not(:disabled),
button.button-tertiary:hover:not(:disabled) {
color: var(--text-primary);
background-color: var(--surface-hover);
}
.button-tertiary:focus-visible,
button.button-tertiary:focus-visible {
box-shadow: 0 0 0 2px var(--focus-ring-offset), 0 0 0 4px var(--focus-ring-color);
}

View File

@@ -0,0 +1,72 @@
/* Dropdown utilities */
.dropdown-surface {
@apply absolute w-full rounded-md shadow-lg z-50;
background-color: var(--surface-base);
border: 1px solid var(--border-base);
}
.dropdown-header {
@apply px-3 py-2 border-b;
border-color: var(--border-base);
background-color: var(--surface-secondary);
}
.dropdown-header-title {
@apply text-xs font-medium;
color: var(--text-muted);
}
.dropdown-section-header {
@apply px-3 py-1.5 text-xs font-semibold;
color: var(--text-muted);
background-color: var(--surface-secondary);
}
.dropdown-content {
@apply overflow-y-auto;
}
.dropdown-item {
@apply cursor-pointer px-3 py-2 transition-colors;
color: var(--text-primary);
}
.dropdown-item:hover {
background-color: var(--surface-hover);
}
.dropdown-item-highlight {
background-color: var(--dropdown-highlight-bg);
color: var(--dropdown-highlight-text);
}
.dropdown-empty {
@apply px-3 py-4 text-center text-sm;
color: var(--text-muted);
}
.dropdown-loading {
@apply p-4 text-center text-sm;
color: var(--text-muted);
}
.dropdown-footer {
@apply border-t px-3 py-2 text-xs;
border-color: var(--border-base);
color: var(--text-muted);
}
.dropdown-badge {
@apply rounded px-1.5 py-0.5 text-xs font-normal;
background-color: var(--accent-primary);
color: var(--text-inverted);
}
.dropdown-icon {
@apply flex-shrink-0;
color: var(--text-muted);
}
.dropdown-icon-accent {
color: var(--accent-primary);
}

View File

@@ -0,0 +1,29 @@
/* Environment variables display */
.env-vars-container {
@apply px-4 py-3 border-b;
background-color: var(--env-vars-bg);
border-color: var(--env-vars-border);
}
.env-vars-title {
@apply text-xs font-medium mb-2;
color: var(--env-vars-text);
}
.env-var-item {
@apply flex items-center gap-2 text-xs;
}
.env-var-key {
@apply font-mono font-medium min-w-0 flex-1;
color: var(--env-vars-text);
}
.env-var-separator {
color: var(--env-vars-text);
}
.env-var-value {
@apply font-mono min-w-0 flex-1;
color: var(--env-vars-text);
}

View File

@@ -0,0 +1,32 @@
/* Folder loading overlay */
.folder-loading-overlay {
position: absolute;
inset: 0;
background-color: var(--folder-overlay-bg);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
backdrop-filter: blur(2px);
}
.folder-loading-indicator {
@apply flex flex-col items-center gap-3 text-center;
padding: 24px 32px;
border-radius: var(--folder-card-radius);
background-color: var(--surface-base);
border: 1px solid var(--border-base);
box-shadow: var(--folder-card-shadow);
min-width: 260px;
}
.folder-loading-text {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.folder-loading-subtext {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}

View File

@@ -0,0 +1,252 @@
/* Selector component utilities */
.selector-trigger {
@apply inline-flex items-center justify-between gap-2 px-2 py-1 border rounded outline-none transition-colors text-xs min-w-[180px];
background-color: var(--surface-base);
border-color: var(--border-base);
color: var(--text-primary);
}
.selector-trigger:hover {
background-color: var(--surface-hover);
}
.selector-trigger:focus {
@apply ring-2;
ring-color: var(--accent-primary);
}
.selector-trigger-disabled {
@apply opacity-50 cursor-not-allowed;
}
.selector-trigger-label {
@apply flex flex-col min-w-0;
}
.selector-trigger-label--stacked {
@apply items-start;
}
.selector-trigger-primary {
@apply text-sm font-medium truncate;
color: var(--text-primary);
}
.selector-trigger-primary--align-left {
@apply text-left w-full;
}
.selector-trigger-secondary {
@apply text-xs text-left truncate;
color: var(--text-muted);
}
.selector-trigger-icon {
@apply flex-shrink-0;
color: var(--text-muted);
}
.selector-popover {
@apply rounded-md shadow-lg overflow-hidden z-50 min-w-[300px];
background-color: var(--surface-base);
border: 1px solid var(--border-base);
}
.selector-search-container {
@apply p-2 border-b;
border-color: var(--border-base);
}
.selector-search-input {
@apply w-full px-3 py-1.5 text-xs border rounded outline-none transition-colors;
background-color: var(--surface-base);
border-color: var(--border-base);
color: var(--text-primary);
}
.selector-search-input:focus {
@apply ring-2;
ring-color: var(--accent-primary);
}
.selector-search-input::placeholder {
color: var(--text-muted);
}
.selector-listbox {
@apply max-h-64 overflow-auto p-1;
background-color: var(--surface-base);
}
.selector-option {
@apply px-3 py-2 cursor-pointer rounded outline-none transition-colors flex items-start gap-2 w-full;
color: var(--text-primary);
}
.selector-option:hover {
background-color: var(--surface-hover);
}
.selector-option[data-highlighted],
.selector-option[data-focused] {
background-color: var(--selection-highlight-bg);
}
.selector-option[data-selected],
.selector-option-selected {
background-color: var(--selection-highlight-strong-bg);
}
.selector-option-content {
@apply flex flex-col flex-1 min-w-0;
}
.selector-option-label {
@apply font-medium text-sm;
color: var(--text-primary);
}
.selector-option-description {
@apply text-xs;
color: var(--text-muted);
}
.selector-option .remove-binary-button {
opacity: 0;
}
.selector-option:hover .remove-binary-button {
opacity: 1;
}
.remove-binary-button {
@apply p-1 rounded transition-all;
color: var(--text-muted);
}
.remove-binary-button:hover {
background-color: var(--danger-soft-bg);
color: var(--status-error);
}
.selector-option-indicator {
@apply flex-shrink-0 mt-0.5;
color: var(--accent-primary);
}
.selector-section {
@apply px-3 py-2 border-b;
border-color: var(--border-base);
background-color: var(--surface-secondary);
}
.selector-section-title {
@apply text-xs font-medium;
color: var(--text-muted);
}
.selector-badge {
@apply rounded px-1.5 py-0.5 text-xs font-normal;
background-color: var(--accent-primary);
color: var(--text-inverted);
}
.selector-badge-version {
@apply text-xs;
color: var(--text-muted);
}
.selector-badge-time {
@apply text-xs;
color: var(--text-muted);
}
.selector-validation-error {
@apply p-2 rounded border;
background-color: var(--message-error-bg);
border-color: var(--status-error);
color: var(--status-error);
}
.selector-validation-error-content {
@apply flex items-start gap-2;
}
.selector-validation-error-icon {
@apply w-4 h-4 mt-0.5 flex-shrink-0;
color: var(--status-error);
}
.selector-validation-error-text {
@apply text-xs;
}
.selector-input-group {
@apply flex gap-2;
}
.selector-input {
@apply flex-1 px-2 py-1.5 text-sm border rounded outline-none transition-colors;
background-color: var(--surface-base);
border-color: var(--border-base);
color: var(--text-primary);
}
.selector-input:focus {
@apply ring-1;
ring-color: var(--accent-primary);
}
.selector-input::placeholder {
color: var(--text-muted);
}
.selector-button {
@apply px-3 py-1.5 text-sm rounded transition-colors cursor-pointer w-full inline-flex items-center justify-center font-medium;
background-color: var(--surface-secondary);
color: var(--text-primary);
border: 1px solid var(--border-base);
}
.selector-button-primary {
background-color: var(--accent-primary) !important;
color: var(--text-inverted) !important;
border: 1px solid var(--accent-primary) !important;
}
.selector-button-primary:hover:not(:disabled) {
background-color: var(--accent-hover);
}
.selector-button-primary:disabled {
@apply opacity-50 cursor-not-allowed;
background-color: var(--surface-muted);
}
.selector-button-secondary {
background-color: var(--surface-secondary);
color: var(--text-primary);
}
.selector-button-secondary:hover:not(:disabled) {
background-color: var(--surface-hover);
}
.selector-button-secondary:disabled {
@apply opacity-50 cursor-not-allowed;
}
.selector-empty-state {
@apply p-4 text-center text-sm;
color: var(--text-muted);
}
.selector-loading {
@apply flex items-center gap-2;
color: var(--text-muted);
}
.selector-loading-spinner {
@apply w-4 h-4 animate-spin;
color: var(--accent-primary);
}

6
src/styles/controls.css Normal file
View File

@@ -0,0 +1,6 @@
@import "./components/buttons.css";
@import "./components/badges.css";
@import "./components/folder-loading.css";
@import "./components/dropdown.css";
@import "./components/selector.css";
@import "./components/env-vars.css";

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 {

166
src/styles/messaging.css Normal file
View File

@@ -0,0 +1,166 @@
@import "./messaging/message-base.css";
@import "./messaging/prompt-input.css";
@import "./messaging/message-stream.css";
@import "./messaging/tool-call.css";
@import "./messaging/log-view.css";
/* Message item base styles */
.message-item-base {
@apply flex flex-col gap-2 p-3 w-full;
}
.assistant-message {
/* gap: 0.25rem; */
padding: 0.6rem 0.65rem;
}
/* Message state badges */
.message-queued-badge {
@apply inline-block font-bold px-3 py-1 rounded mb-3 text-xs tracking-wide;
background-color: var(--accent-primary);
color: var(--text-inverted);
}
/* Message error block */
.message-error-block {
@apply text-sm p-3 rounded border-l-[3px] my-2;
color: var(--status-error);
background-color: var(--message-error-bg);
border-color: var(--status-error);
}
/* Message state indicators */
.message-generating {
@apply text-sm italic py-2;
color: var(--text-muted);
}
.message-sending {
@apply text-xs italic mt-1;
color: var(--text-muted);
}
.message-error {
@apply text-xs mt-1;
color: var(--status-error);
}
.generating-spinner {
@apply inline-block;
animation: pulse 1.5s ease-in-out infinite;
}
/* Message stream component utilities */
.message-stream-container {
@apply relative flex-1 min-h-0 flex flex-col overflow-hidden;
}
.connection-status {
@apply grid items-center px-4 py-2 gap-4;
grid-template-columns: 1fr auto 1fr;
background-color: var(--surface-secondary);
border-bottom: 1px solid var(--border-base);
}
.message-stream {
@apply flex-1 min-h-0 overflow-y-auto flex flex-col gap-1;
background-color: var(--surface-base);
color: inherit;
}
.message-scroll-button-wrapper {
position: absolute;
right: 1rem;
bottom: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: flex-end;
}
.message-scroll-button {
@apply inline-flex items-center justify-center;
width: 2.75rem;
height: 2.75rem;
border-radius: 9999px;
border: 1px solid var(--border-base);
background-color: transparent;
color: var(--text-primary);
box-shadow: var(--scroll-elevation-shadow);
transition: background-color 0.2s ease, color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
}
.message-scroll-button:hover {
background-color: var(--surface-hover);
transform: translateY(-1px);
}
.message-scroll-button:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--surface-base), 0 0 0 4px var(--accent-primary);
}
.message-scroll-icon {
font-size: var(--font-size-lg);
color: var(--accent-primary);
}
.message-text {
font-size: var(--font-size-base);
line-height: var(--line-height-normal);
word-wrap: break-word;
overflow-wrap: break-word;
color: inherit;
}
.message-text-assistant {
white-space: normal;
}
.message-text pre {
overflow-x: auto;
padding: 8px;
background-color: var(--surface-code);
border-radius: 4px;
}
/* Message error part */
.message-error-part {
color: var(--status-error);
font-size: var(--font-size-base);
padding: 8px;
background-color: var(--message-error-bg);
border-radius: 4px;
}
/* Message reasoning */
.message-reasoning {
@apply my-2 border rounded;
border-color: var(--border-base);
background-color: var(--surface-secondary);
color: inherit;
}
.reasoning-container {
@apply p-2;
}
.reasoning-header {
@apply flex items-center gap-1.5 text-xs cursor-pointer select-none;
color: var(--text-muted);
}
.reasoning-header:hover {
color: var(--accent-primary);
}
.reasoning-icon {
@apply text-xs;
transition: transform 150ms ease;
}
.reasoning-label {
font-weight: var(--font-weight-medium);
}

View File

@@ -0,0 +1,74 @@
/* Log view utilities */
.log-container {
@apply flex flex-col h-full;
background-color: var(--surface-base);
}
.log-header {
@apply flex items-center justify-between px-4 py-3 border-b;
border-color: var(--border-base);
background-color: var(--surface-secondary);
}
.log-content {
@apply flex-1 overflow-y-auto p-4 font-mono text-xs leading-relaxed;
background-color: var(--surface-secondary);
color: var(--text-primary);
}
.log-entry {
@apply flex gap-3 py-0.5 px-2 -mx-2 rounded transition-colors;
}
.log-entry:hover {
background-color: var(--surface-hover);
}
.log-timestamp {
@apply select-none shrink-0;
color: var(--text-muted);
}
.log-message {
@apply break-all;
}
.log-level-error {
color: var(--log-level-error);
}
.log-level-warn {
color: var(--log-level-warn);
}
.log-level-debug {
color: var(--log-level-debug);
}
.log-level-default {
color: var(--log-level-default);
}
.log-empty-state {
@apply text-center py-8;
color: var(--text-muted);
}
.log-paused-state {
@apply flex flex-col items-center justify-center gap-3 text-center py-10 px-6;
border: 1px dashed var(--border-base);
border-radius: 12px;
background-color: var(--surface-base);
}
.log-paused-title {
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
}
.log-paused-description {
font-size: var(--font-size-sm);
color: var(--text-secondary);
max-width: 320px;
}

View File

@@ -0,0 +1,98 @@
/* Message item base styles */
.message-item-base {
@apply flex flex-col gap-2 p-3 w-full;
}
.assistant-message {
/* gap: 0.25rem; */
padding: 0.6rem 0.65rem;
}
.message-queued-badge {
@apply inline-block font-bold px-3 py-1 rounded mb-3 text-xs tracking-wide;
background-color: var(--accent-primary);
color: var(--text-inverted);
}
.message-error-block {
@apply text-sm p-3 rounded border-l-[3px] my-2;
color: var(--status-error);
background-color: var(--message-error-bg);
border-color: var(--status-error);
}
.message-generating {
@apply text-sm italic py-2;
color: var(--text-muted);
}
.message-sending {
@apply text-xs italic mt-1;
color: var(--text-muted);
}
.message-error {
@apply text-xs mt-1;
color: var(--status-error);
}
.generating-spinner {
@apply inline-block;
animation: pulse 1.5s ease-in-out infinite;
}
.message-text {
font-size: var(--font-size-base);
line-height: var(--line-height-normal);
word-wrap: break-word;
overflow-wrap: break-word;
color: inherit;
}
.message-text-assistant {
white-space: normal;
}
.message-text pre {
overflow-x: auto;
padding: 8px;
background-color: var(--surface-code);
border-radius: 4px;
}
.message-error-part {
color: var(--status-error);
font-size: var(--font-size-base);
padding: 8px;
background-color: var(--message-error-bg);
border-radius: 4px;
}
.message-reasoning {
@apply my-2 border rounded;
border-color: var(--border-base);
background-color: var(--surface-secondary);
color: inherit;
}
.reasoning-container {
@apply p-2;
}
.reasoning-header {
@apply flex items-center gap-1.5 text-xs cursor-pointer select-none;
color: var(--text-muted);
}
.reasoning-header:hover {
color: var(--accent-primary);
}
.reasoning-icon {
@apply text-xs;
transition: transform 150ms ease;
}
.reasoning-label {
font-weight: var(--font-weight-medium);
}

View File

@@ -0,0 +1,71 @@
/* Message stream container + status */
.message-stream-container {
@apply relative flex-1 min-h-0 flex flex-col overflow-hidden;
}
.connection-status {
@apply grid items-center px-4 py-2 gap-4;
grid-template-columns: 1fr auto 1fr;
background-color: var(--surface-secondary);
border-bottom: 1px solid var(--border-base);
}
.connection-status-info {
justify-self: start;
}
.connection-status-shortcut {
justify-self: center;
text-align: center;
}
.connection-status-meta {
justify-self: end;
}
.connection-status-text {
color: var(--text-muted);
}
.message-stream {
@apply flex-1 min-h-0 overflow-y-auto flex flex-col gap-1;
background-color: var(--surface-base);
color: inherit;
}
.message-scroll-button-wrapper {
position: absolute;
right: 1rem;
bottom: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: flex-end;
}
.message-scroll-button {
@apply inline-flex items-center justify-center;
width: 2.75rem;
height: 2.75rem;
border-radius: 9999px;
border: 1px solid var(--border-base);
background-color: transparent;
color: var(--text-primary);
box-shadow: var(--scroll-elevation-shadow);
transition: background-color 0.2s ease, color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
}
.message-scroll-button:hover {
background-color: var(--surface-hover);
transform: translateY(-1px);
}
.message-scroll-button:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--surface-base), 0 0 0 4px var(--accent-primary);
}
.message-scroll-icon {
font-size: var(--font-size-lg);
color: var(--accent-primary);
}

View File

@@ -0,0 +1,112 @@
/* Prompt input & attachment styles */
.prompt-input-container {
@apply flex flex-col border-t;
border-color: var(--border-base);
background-color: var(--surface-base);
}
.prompt-input-wrapper {
@apply flex items-end gap-2 p-3;
}
.prompt-input {
@apply flex-1 min-h-[96px] max-h-[200px] p-2.5 border rounded-md text-sm resize-none outline-none transition-colors;
font-family: inherit;
background-color: var(--surface-base);
color: inherit;
border-color: var(--border-base);
line-height: var(--line-height-normal);
}
.prompt-input.shell-mode {
border-color: var(--status-success);
box-shadow: inset 0 0 0 1px rgba(76, 175, 80, 0.4);
}
.prompt-input:focus {
border-color: var(--accent-primary);
}
.prompt-input.shell-mode:focus {
border-color: var(--status-success);
box-shadow: inset 0 0 0 1px rgba(76, 175, 80, 0.4);
}
.prompt-input:disabled {
@apply opacity-60 cursor-not-allowed;
}
.prompt-input::placeholder {
color: var(--text-muted);
}
.send-button {
@apply w-10 h-10 rounded-md border-none cursor-pointer flex items-center justify-center transition-all flex-shrink-0;
background-color: var(--accent-primary);
color: var(--text-inverted);
}
.send-button.shell-mode {
background-color: var(--status-success);
}
.send-button.shell-mode:hover:not(:disabled) {
filter: brightness(1.05);
}
.send-button.shell-mode:active:not(:disabled) {
filter: brightness(0.95);
}
.send-button:hover:not(:disabled) {
@apply opacity-90 scale-105;
}
.send-button:active:not(:disabled) {
@apply scale-95;
}
.send-button:disabled {
@apply opacity-40 cursor-not-allowed;
}
.send-icon {
@apply text-base;
}
.shell-icon {
width: 1rem;
height: 1rem;
}
.prompt-input-hints {
@apply px-4 pb-2 flex justify-between items-center;
}
.hint {
@apply text-xs;
color: var(--text-muted);
}
.hint kbd {
@apply inline-block px-1.5 py-0.5 text-xs font-mono rounded mx-0.5;
background-color: var(--surface-secondary);
border: 1px solid var(--border-base);
border-radius: 3px;
}
.attachment-chip {
@apply px-2.5 py-1 text-xs font-medium ring-1 ring-inset;
background-color: var(--attachment-chip-bg);
color: var(--attachment-chip-text);
ring-color: var(--attachment-chip-ring);
}
.attachment-remove {
@apply ml-0.5 flex h-4 w-4 items-center justify-center rounded transition-colors;
}
.attachment-remove:hover {
background-color: var(--attachment-chip-ring);
}

View File

@@ -0,0 +1,789 @@
/* Tool call rendering */
.tool-call-message {
@apply flex flex-col gap-2 p-3 w-full;
background-color: var(--message-tool-bg);
border-left: 4px solid var(--message-tool-border);
color: inherit;
}
.tool-call-header-label {
@apply flex items-center justify-between gap-2 font-semibold text-sm;
color: var(--message-tool-border);
margin-bottom: 1px;
}
.tool-call-header-meta {
@apply flex items-center gap-2;
}
.tool-call-header-button {
background-color: transparent;
border: 1px solid var(--border-base);
color: var(--message-tool-border);
padding: 0.15rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: var(--font-weight-semibold);
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
height: 1.5rem;
transition: all 0.2s ease;
}
.tool-call-header-button:hover:not(:disabled) {
background-color: var(--surface-hover);
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.tool-call-header-button:active:not(:disabled) {
transform: scale(0.95);
}
.tool-call-header-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.tool-call-header-label .tool-call-icon {
@apply text-base;
}
.tool-call-header-label .tool-name {
font-family: var(--font-family-mono);
color: inherit;
background-color: var(--surface-secondary);
border: 1px solid var(--border-base);
padding: 2px 6px;
border-radius: 3px;
font-size: 13px;
}
.tool-call {
@apply border overflow-hidden;
border-color: var(--border-base);
color: inherit;
--tool-call-line-unit: 1.4em;
--tool-call-lines-compact: 15;
--tool-call-lines-large: 30;
--tool-call-max-height-compact: calc(var(--tool-call-lines-compact) * var(--tool-call-line-unit));
--tool-call-max-height-large: calc(var(--tool-call-lines-large) * var(--tool-call-line-unit));
}
.tool-call-message .tool-call {
border: none;
border-radius: 0;
margin: 0;
}
.tool-call-header {
@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 {
background-color: var(--surface-hover);
}
.tool-call-icon {
@apply text-xs;
}
.tool-call-summary {
@apply flex-1 text-left;
}
.tool-call-status {
@apply text-sm;
}
.tool-call-status-success {
border-left: 3px solid var(--status-success);
}
.tool-call-status-error {
border-left: 3px solid var(--status-error);
}
.tool-call-status-running {
border-left: 3px solid var(--status-warning);
}
.tool-call-status-running .tool-call-status {
animation: pulse 1.5s ease-in-out infinite;
}
.tool-call-status-pending {
border-left: 3px solid var(--accent-primary);
}
.tool-call-status-pending .tool-call-summary {
animation: shimmer 2s ease-in-out infinite;
}
.tool-call-preview {
@apply p-2 flex flex-col gap-1.5;
background-color: var(--surface-code);
border-top: 1px solid var(--border-base);
}
.tool-call-preview-label {
@apply text-xs font-semibold uppercase tracking-wide;
color: var(--text-muted);
letter-spacing: 0.5px;
}
.tool-call-preview-text {
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
line-height: var(--line-height-tight);
color: var(--text-muted);
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
max-height: var(--tool-call-max-height-compact, calc(25 * 1.4em));
overflow-y: scroll;
}
.tool-call-details {
@apply flex flex-col;
background-color: var(--surface-code);
font-size: var(--font-size-xs);
}
.tool-call-markdown {
background-color: var(--surface-code);
border: none;
border-radius: 0;
padding: 0;
font-size: var(--font-size-xs);
line-height: var(--line-height-tight);
max-height: var(--tool-call-max-height-compact, calc(25 * 1.4em));
overflow-y: scroll;
scrollbar-width: thin;
scrollbar-color: var(--border-base) transparent;
scrollbar-gutter: stable both-edges;
position: relative;
}
.tool-call-markdown-large {
max-height: var(--tool-call-max-height-large, calc(48 * 1.4em));
}
.tool-call-diff-shell {
padding: 0;
}
.tool-call-diff-viewer {
max-height: var(--tool-call-max-height-large, calc(48 * 1.4em));
overflow: auto;
background-color: var(--surface-code);
}
.tool-call-diff-toolbar {
@apply flex items-center justify-between gap-3 px-3 py-2;
background-color: var(--surface-secondary);
border-bottom: 1px solid var(--border-base);
}
.tool-call-diff-toolbar-label {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.tool-call-diff-toggle {
@apply inline-flex items-center gap-1;
}
.tool-call-diff-mode-button {
@apply border text-xs font-semibold px-3 py-1 rounded transition-all duration-150;
border-color: var(--border-base);
background-color: transparent;
color: var(--text-muted);
}
.tool-call-diff-mode-button:hover {
background-color: var(--surface-hover);
color: var(--text-primary);
}
.tool-call-diff-mode-button.active {
background-color: var(--accent-primary);
border-color: var(--accent-primary);
color: var(--text-inverted);
}
.tool-call-diff-viewer .diff-tailwindcss-wrapper {
background-color: transparent;
color: inherit;
}
.tool-call-diff-viewer .diff-view-wrapper {
font-family: var(--font-family-mono);
}
.tool-call-diff-fallback {
margin: 0;
padding: 0.75rem;
background-color: var(--surface-code);
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
line-height: var(--line-height-tight);
}
.tool-call-awaiting-permission {
border-left-color: var(--status-warning);
}
.tool-call-permission {
@apply flex flex-col gap-3;
border: 2px solid var(--status-warning);
border-radius: 0;
margin: 0;
padding: 1rem 1.25rem;
background-color: var(--message-tool-bg);
}
.tool-call-permission-header {
@apply flex items-center justify-between gap-3;
}
.tool-call-permission-label {
@apply font-semibold text-sm;
color: var(--text-primary);
}
.tool-call-permission-type {
font-family: var(--font-family-mono);
font-size: 12px;
padding: 2px 6px;
border-radius: 0.375rem;
border: 1px solid var(--border-base);
background-color: var(--surface-code);
}
.tool-call-permission-title code {
display: block;
font-size: 13px;
color: var(--text-primary);
background-color: var(--surface-code);
border: 1px solid var(--border-base);
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
word-break: break-word;
}
.tool-call-permission-actions {
@apply flex items-center justify-between gap-3 flex-wrap;
margin-top: 0.75rem;
}
.tool-call-permission-buttons {
@apply flex flex-wrap gap-2;
}
.tool-call-permission-button {
background-color: var(--surface-base);
border: 1px solid var(--status-warning);
color: var(--text-secondary);
padding: 0.4rem 1.05rem;
border-radius: 0.5rem;
font-size: 0.8rem;
font-weight: var(--font-weight-medium);
line-height: 1.15;
transition: transform 0.15s ease, color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 1.75rem;
}
.tool-call-permission-button:hover:not(:disabled) {
background-color: var(--surface-hover);
border-color: var(--status-warning);
color: var(--status-warning);
}
.tool-call-permission-button:active:not(:disabled) {
transform: scale(0.97);
}
.tool-call-permission-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.tool-call-permission-shortcuts {
@apply flex items-center gap-2 text-xs text-muted;
}
.tool-call-permission-shortcuts .kbd {
margin-right: 0.25rem;
}
.tool-call-permission-queued-text {
@apply text-sm text-muted;
}
.tool-call-permission-error {
@apply text-sm;
color: var(--status-error);
margin-top: 0.5rem;
}
.tool-call-permission-diff {
margin-top: 1rem;
margin-bottom: 0.5rem;
}
.tool-call-permission-diff .tool-call-diff-shell {
margin: 0;
}
.tool-call-diff-viewer .diff-line-old-num,
.tool-call-diff-viewer .diff-line-new-num,
.tool-call-diff-viewer .diff-line-num {
color: var(--text-muted);
font-size: var(--font-size-xs);
}
.tool-call-markdown .markdown-code-block {
margin: 0;
border-radius: 0;
}
.tool-call-markdown .markdown-code-block {
margin: 0;
border: none;
background-color: transparent;
}
.tool-call-markdown .code-block-header {
position: sticky;
top: 0;
z-index: auto;
box-shadow: 0 1px 0 var(--border-base);
}
.tool-call-markdown .markdown-code-block pre {
margin: 0 !important;
min-height: auto;
max-height: none;
overflow-y: visible;
}
.tool-call-markdown::-webkit-scrollbar {
width: 8px;
}
.tool-call-markdown::-webkit-scrollbar-track {
background: transparent;
}
.tool-call-markdown::-webkit-scrollbar-thumb {
background-color: var(--border-base);
border-radius: 4px;
border: 2px solid transparent;
background-clip: padding-box;
}
.tool-call-section h4 {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
margin-bottom: 4px;
color: var(--text-muted);
}
.tool-call-diagnostics {
display: flex;
flex-direction: column;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-md);
border-top: 1px solid var(--border-base);
background-color: var(--surface-base);
}
.tool-call-diagnostics-wrapper {
border-top: 1px solid var(--border-base);
background-color: var(--surface-base);
margin-top: var(--space-md);
}
.tool-call-diagnostics-heading {
@apply flex items-center gap-2 p-2 w-full border-none cursor-pointer text-left;
font-family: var(--font-family-mono);
font-size: 13px;
color: var(--message-tool-border);
background-color: var(--surface-code);
}
.tool-call-diagnostics-heading:hover {
background-color: var(--surface-hover);
}
.tool-call-diagnostics-file {
@apply inline-flex items-center;
flex: 1;
font-size: 12px;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
justify-content: flex-end;
}
.tool-call-diagnostics-heading {
@apply flex items-center gap-2 p-2 w-full border-none cursor-pointer text-left;
font-family: var(--font-family-mono);
font-size: 13px;
color: var(--message-tool-border);
background-color: var(--surface-code);
}
.tool-call-diagnostics-heading:hover {
background-color: var(--surface-hover);
}
.tool-call-diagnostics-title {
font-size: 13px;
}
.tool-call-diagnostics-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: var(--radius-sm);
border: 1px solid var(--border-base);
font-size: 12px;
}
.tool-call-diagnostics-file {
@apply inline-flex items-center;
flex: 1;
font-size: 12px;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tool-call-diagnostics-spacer {
flex: 1;
}
.tool-call-diagnostics-caret {
font-size: 12px;
color: var(--text-muted);
}
.tool-call-diagnostics {
display: flex;
flex-direction: column;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-md) var(--space-sm) var(--space-md);
background-color: var(--surface-base);
}
.tool-call-diagnostics-body {
display: flex;
flex-direction: column;
gap: var(--space-xs);
max-height: calc(4 * var(--tool-call-line-unit, 1.4em));
overflow-y: scroll;
padding-right: 0;
margin-right: 0;
scrollbar-gutter: stable both-edges;
scrollbar-width: thin;
}
.tool-call-diagnostic-row {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--space-sm);
font-size: var(--font-size-xs);
color: var(--text-primary);
}
.tool-call-diagnostic-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0 var(--space-sm);
min-height: 20px;
border-radius: var(--pill-radius);
font-size: 11px;
font-weight: var(--font-weight-medium);
letter-spacing: 0.02em;
}
.tool-call-diagnostic-error {
background-color: var(--status-error-bg);
color: var(--status-error);
}
.tool-call-diagnostic-warning {
background-color: var(--status-starting-bg);
color: var(--status-warning);
}
.tool-call-diagnostic-info {
background-color: var(--badge-neutral-bg);
color: var(--badge-neutral-text);
}
.tool-call-diagnostic-path {
font-family: var(--font-family-mono);
color: var(--text-secondary);
display: inline-flex;
align-items: baseline;
gap: var(--space-2xs);
}
.tool-call-diagnostic-coords {
color: var(--text-muted);
}
.tool-call-diagnostic-message {
flex: 1;
min-width: 200px;
color: var(--text-primary);
}
.tool-call-section pre {
margin: 0;
padding: 8px;
background-color: var(--surface-base);
border-radius: 0px;
overflow-x: auto;
max-height: var(--tool-call-max-height-compact, calc(25 * 1.4em));
overflow-y: scroll;
}
.tool-call-section code {
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
line-height: var(--line-height-tight);
}
.tool-call-section pre::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.tool-call-section pre::-webkit-scrollbar-track {
background: var(--surface-secondary);
border-radius: 0px;
}
.tool-call-section pre::-webkit-scrollbar-thumb {
background: var(--border-base);
border-radius: 0px;
}
.tool-call-section pre::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
.tool-call-pending-message {
@apply flex items-center gap-2 p-3 text-xs italic;
color: var(--text-muted);
}
.tool-call-emoji {
@apply text-base mr-1;
}
.tool-call-action-button {
@apply border text-xs font-semibold px-3 py-1 rounded transition-colors h-8 flex items-center;
border-color: var(--border-base);
color: var(--text-muted);
background-color: transparent;
}
.tool-call-action-button:hover:not(:disabled) {
background-color: var(--surface-hover);
color: var(--text-primary);
}
.tool-call-action-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.tool-call-bash,
.tool-call-diff {
@apply my-2;
}
.tool-call-content {
background-color: var(--surface-secondary);
border: 1px solid var(--border-base);
border-radius: 0;
padding: 8px 12px;
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
line-height: var(--line-height-tight);
overflow-x: auto;
margin: 0;
}
.tool-call-content code {
font-family: inherit;
background: none;
padding: 0;
font-size: inherit;
}
.tool-call-todos {
@apply my-2 flex flex-col gap-2;
list-style: none;
padding: 4px 0;
}
.tool-call-todo-item {
@apply flex items-start gap-3;
border: 1px solid var(--border-base);
border-radius: 8px;
padding: 10px 12px;
background-color: var(--surface-secondary);
}
.tool-call-todo-item-completed {
background-color: var(--surface-code);
}
.tool-call-todo-item-active {
border-color: var(--accent-primary);
background-color: var(--surface-hover);
}
.tool-call-todo-item-cancelled {
opacity: 0.75;
}
.tool-call-todo-checkbox {
width: 1.1rem;
height: 1.1rem;
border-radius: 9999px;
border: 2px solid var(--border-base);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: var(--font-weight-semibold);
color: var(--text-muted);
background-color: transparent;
}
.tool-call-todo-checkbox::after {
content: "";
line-height: 1;
}
.tool-call-todo-checkbox[data-status="completed"] {
background-color: var(--accent-primary);
border-color: var(--accent-primary);
color: var(--text-inverted);
}
.tool-call-todo-checkbox[data-status="completed"]::after {
content: "✓";
}
.tool-call-todo-checkbox[data-status="in_progress"]::after {
content: "…";
color: var(--accent-primary);
}
.tool-call-todo-checkbox[data-status="cancelled"]::after {
content: "×";
color: var(--status-error);
}
.tool-call-todo-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.tool-call-todo-text {
font-size: var(--font-size-sm);
line-height: var(--line-height-tight);
color: var(--text-primary);
}
.tool-call-todo-item-cancelled .tool-call-todo-text {
text-decoration: line-through;
color: var(--text-muted);
}
.tool-call-todo-tag {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
border-radius: 9999px;
padding: 2px 8px;
background-color: var(--surface-hover);
color: var(--text-muted);
}
.tool-call-todo-item-active .tool-call-todo-tag {
background-color: var(--accent-primary);
color: var(--text-inverted);
}
.tool-call-task-container {
padding: 12px;
}
.tool-call-task-summary {
@apply my-2 flex flex-col gap-1.5;
}
.tool-call-task-item {
font-size: var(--font-size-xs);
line-height: var(--line-height-normal);
padding-left: 8px;
border-left: 2px solid var(--border-base);
}
.tool-call-task-item::before {
content: "∟ ";
color: var(--text-muted);
}
.tool-call-error-content {
background-color: var(--message-error-bg);
border-left: 3px solid var(--status-error);
padding: 12px;
margin: 8px 0;
border-radius: 4px;
color: var(--status-error);
font-size: var(--font-size-xs);
}
.tool-call-error-content strong {
font-weight: var(--font-weight-semibold);
}
.dropdown-diff-added {
@apply text-xs;
color: var(--status-success);
}
.dropdown-diff-removed {
@apply text-xs;
color: var(--status-error);
}

676
src/styles/panels.css Normal file
View File

@@ -0,0 +1,676 @@
@import "./panels/tabs.css";
@import "./panels/empty-loading.css";
@import "./panels/modal.css";
@import "./panels/panel-shell.css";
@import "./panels/session-layout.css";
.tab-bar-instance {
background-color: var(--surface-secondary);
}
.tab-bar-session {
background-color: var(--surface-base);
}
.tab-container {
@apply flex items-center justify-between gap-1 px-2 py-1 overflow-x-auto;
}
.tab-base {
@apply inline-flex items-center gap-2 px-3 py-2 rounded-t-md max-w-[200px] transition-colors text-sm font-medium;
font-family: var(--font-family-sans);
outline: none;
}
.tab-base:focus-visible {
@apply ring-2 ring-offset-1;
ring-color: var(--accent-primary);
ring-offset-color: var(--surface-base);
}
.tab-active {
background-color: var(--tab-active-bg);
color: var(--tab-active-text);
}
.tab-inactive {
background-color: var(--tab-inactive-bg);
color: var(--tab-inactive-text);
}
.tab-inactive:hover {
background-color: var(--tab-inactive-hover-bg);
}
.tab-active:hover {
background-color: var(--tab-active-hover-bg);
}
.tab-label {
@apply truncate;
}
.tab-close {
@apply opacity-0 group-hover:opacity-100 hover:bg-red-500 hover:text-white rounded p-0.5 transition-all cursor-pointer;
}
.tab-close:focus-visible {
@apply ring-2 ring-offset-1;
ring-color: var(--accent-primary);
ring-offset-color: inherit;
}
.new-tab-button {
@apply inline-flex items-center justify-center w-8 h-8 rounded-md transition-colors;
background-color: var(--new-tab-bg);
color: var(--new-tab-text);
}
.new-tab-button:hover {
background-color: var(--new-tab-hover-bg);
}
.new-tab-button:focus-visible {
@apply ring-2 ring-offset-1;
ring-color: var(--accent-primary);
ring-offset-color: var(--surface-base);
}
/* Session tab specific styles */
.session-tab-base {
@apply inline-flex items-center gap-2 px-3 py-1.5 rounded-t-md max-w-[150px] transition-colors text-sm;
font-family: var(--font-family-sans);
outline: none;
border-bottom: 2px solid transparent;
}
.session-tab-base:focus-visible {
@apply ring-2 ring-offset-1;
ring-color: var(--accent-primary);
ring-offset-color: var(--surface-base);
}
.session-tab-active {
background-color: var(--session-tab-active-bg);
border-bottom-color: var(--accent-primary);
color: var(--session-tab-active-text);
font-weight: var(--font-weight-medium);
}
.session-tab-inactive {
color: var(--session-tab-inactive-text);
}
.session-tab-inactive:hover {
background-color: var(--session-tab-hover-bg);
}
.session-tab-special {
color: var(--session-tab-inactive-text);
}
.connection-status-info {
justify-self: start;
}
.connection-status-shortcut {
justify-self: center;
text-align: center;
}
.connection-status-meta {
justify-self: end;
}
.connection-status-text {
color: var(--text-muted);
}
.sidebar-selector-hint {
@apply flex justify-center text-xs w-full;
color: var(--text-muted);
}
.session-header-hints {
@apply flex-shrink-0;
}
.session-sidebar-controls .selector-trigger,
.session-sidebar-controls [data-model-selector-control],
.session-sidebar-controls .selector-trigger-label,
.session-sidebar-controls .selector-trigger-primary {
@apply w-full;
}
.sidebar-selector {
@apply flex flex-col gap-1 w-full;
}
.status-indicator {
@apply flex items-center gap-1.5 text-xs;
color: var(--text-muted);
}
.status-indicator .status-dot {
@apply w-2 h-2 rounded-full;
}
.status-indicator.connected .status-dot {
background-color: var(--status-success);
}
.status-indicator.connecting .status-dot {
background-color: var(--status-warning);
animation: pulse 1.5s ease-in-out infinite;
}
.status-indicator.disconnected .status-dot {
background-color: var(--status-error);
}
.status-indicator.session-status {
--session-status-dot: var(--text-muted);
}
.status-indicator.session-status.session-working,
.status-indicator.session-status.session-compacting,
.status-indicator.session-status.session-idle {
font-weight: var(--font-weight-medium);
}
.status-indicator.session-status.session-working {
color: var(--session-status-working-fg);
--session-status-dot: var(--session-status-working-fg);
}
.status-indicator.session-status.session-compacting {
color: var(--session-status-compacting-fg);
--session-status-dot: var(--session-status-compacting-fg);
}
.status-indicator.session-status.session-idle {
color: var(--session-status-idle-fg);
--session-status-dot: var(--session-status-idle-fg);
}
.status-indicator.session-status.session-permission {
color: var(--session-status-permission-fg);
--session-status-dot: var(--session-status-permission-fg);
}
.status-indicator.session-status .status-dot {
background-color: var(--session-status-dot);
}
.status-indicator.session-status.session-working .status-dot,
.status-indicator.session-status.session-compacting .status-dot {
animation: pulse 1.5s ease-in-out infinite;
}
.status-indicator.session-status.session-working.session-status-list {
background-color: var(--session-status-working-bg);
}
.status-indicator.session-status.session-compacting.session-status-list {
background-color: var(--session-status-compacting-bg);
}
.status-indicator.session-status.session-idle.session-status-list {
background-color: var(--session-status-idle-bg);
}
.status-indicator.session-status.session-permission.session-status-list {
background-color: var(--session-status-permission-bg);
}
.status-indicator.session-status-list {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: var(--font-weight-medium);
color: inherit;
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
border: 1px solid transparent;
}
/* Empty state */
.empty-state {
@apply flex-1 flex items-center justify-center p-12;
}
.empty-state-content {
@apply text-center max-w-sm;
}
.empty-state-content h3 {
font-size: var(--font-size-xl);
margin-bottom: 12px;
}
.empty-state-content p {
font-size: var(--font-size-base);
color: var(--text-muted);
margin-bottom: 16px;
}
.empty-state-content ul {
list-style: none;
padding: 0;
@apply flex flex-col gap-2;
}
.empty-state-content li {
font-size: var(--font-size-base);
color: var(--text-muted);
}
.empty-state-content code {
background-color: var(--surface-code);
padding: 2px 6px;
border-radius: 3px;
font-family: var(--font-family-mono);
font-size: 13px;
}
/* Loading state */
.loading-state {
@apply flex-1 flex flex-col items-center justify-center gap-4 p-12;
}
.spinner {
@apply w-8 h-8 border-2 border-t-transparent rounded-full;
border-color: var(--border-base);
border-top-color: var(--accent-primary);
animation: spin 1s linear infinite;
}
/* Modal utilities */
.modal-overlay {
@apply fixed inset-0 z-50;
background-color: var(--overlay-scrim);
}
.modal-surface {
@apply rounded-lg shadow-2xl flex flex-col;
background-color: var(--surface-base);
color: var(--text-primary);
}
.modal-search-container {
@apply p-4 border-b;
border-color: var(--border-base);
}
.modal-search-input {
@apply flex-1 bg-transparent outline-none;
color: var(--text-primary);
}
.modal-search-input::placeholder {
color: var(--text-muted);
}
.modal-search-icon {
color: var(--text-muted);
}
.modal-list-container {
@apply flex-1 overflow-y-auto;
}
.modal-section-header {
@apply px-4 py-2 text-xs font-semibold uppercase tracking-wide;
color: var(--text-muted);
}
.modal-item {
@apply w-full px-4 py-3 flex items-start gap-3 transition-colors cursor-pointer border-none text-left;
color: var(--text-primary);
}
.modal-list-container[data-pointer-mode="pointer"] .modal-item:hover {
background-color: var(--surface-hover);
}
.modal-list-container[data-pointer-mode="keyboard"] .modal-item:hover:not(.modal-item-highlight) {
background-color: inherit;
}
.modal-item-highlight {
background-color: var(--selection-highlight-bg);
}
.modal-item-label {
@apply font-medium;
color: var(--text-primary);
}
.modal-item-description {
@apply text-sm mt-0.5;
color: var(--text-secondary);
}
.modal-empty-state {
@apply p-8 text-center;
color: var(--text-muted);
}
/* Panel component utilities */
.panel {
@apply rounded-lg shadow-sm border overflow-hidden;
background-color: var(--surface-base);
border-color: var(--border-base);
color: var(--text-primary);
}
.panel-footer-hints {
@apply flex items-center justify-center flex-wrap gap-3 text-xs;
color: var(--text-muted);
}
.panel-header {
@apply px-4 py-3 border-b;
border-color: var(--border-base);
background-color: var(--surface-secondary);
}
.panel-title {
@apply text-base font-semibold;
color: var(--text-primary);
}
.panel-subtitle {
@apply text-xs mt-0.5;
color: var(--text-muted);
}
.panel-body {
@apply p-4;
background-color: var(--surface-base);
}
.panel-section {
@apply border-t;
border-color: var(--border-base);
}
.panel-section-header {
@apply w-full px-4 py-3 flex items-center justify-center transition-colors cursor-pointer gap-2;
background-color: var(--surface-secondary);
}
.panel-section-header:hover {
background-color: var(--surface-hover);
}
.panel-section-content {
@apply px-4 py-3 border-t overflow-visible space-y-4 w-full;
border-color: var(--border-base);
background-color: var(--surface-secondary);
}
.panel-list {
@apply max-h-[400px] overflow-y-auto;
}
.panel-list--fill {
max-height: none;
height: 100%;
}
.panel-list-item {
@apply border-b last:border-b-0 transition-colors w-full;
border-color: var(--border-base);
}
.panel-list-item:hover {
background-color: var(--surface-hover);
}
.panel-list-item-highlight {
background-color: var(--list-item-highlight-bg) !important;
box-shadow: inset 0 0 0 1px var(--list-item-highlight-border);
}
.panel-list-item-content {
@apply flex-1 text-left px-4 py-3 flex items-center justify-between gap-3 outline-none transition-colors w-full;
}
.panel-list-item-content:hover {
background-color: transparent;
}
.panel-list-item-content:disabled {
opacity: 0.6;
}
.panel-list-item button:disabled {
cursor: not-allowed;
}
.panel-list-item-disabled {
opacity: 0.6;
}
.panel-empty-state {
@apply p-6 text-center;
}
.panel-empty-state-icon {
@apply text-gray-400 dark:text-gray-600 mb-2;
color: var(--text-muted);
}
.panel-empty-state-title {
@apply font-medium text-sm mb-1;
color: var(--text-secondary);
}
.panel-empty-state-description {
@apply text-xs;
color: var(--text-muted);
}
.panel-footer {
@apply px-4 py-3 border-t;
border-color: var(--border-base);
background-color: var(--surface-secondary);
}
/* Session view utility */
.session-view {
@apply flex flex-1 min-h-0 flex-col;
background-color: var(--surface-base);
color: inherit;
}
/* Session list component */
.session-list-container {
@apply flex flex-col flex-1 min-h-0 relative;
background-color: var(--surface-secondary);
min-width: 200px;
max-width: 500px;
}
.session-sidebar {
@apply flex flex-col min-h-0;
background-color: var(--surface-secondary);
}
.session-sidebar-header {
@apply flex flex-col gap-2 w-full;
}
.session-sidebar-title {
color: var(--text-primary);
}
.session-sidebar-shortcuts {
@apply flex flex-col gap-1;
}
.session-sidebar-new {
@apply w-full;
}
.session-sidebar-controls {
@apply flex flex-col gap-3;
background-color: var(--surface-secondary);
}
.session-sidebar-controls > * {
@apply w-full;
}
.session-sidebar-separator {
background-color: var(--border-base);
height: 1px;
width: 100%;
}
.session-resize-handle {
@apply absolute top-0 right-0 w-1 h-full cursor-col-resize bg-transparent transition-colors;
z-index: 10;
}
.session-resize-handle:hover {
background-color: var(--accent-primary);
}
.session-resize-handle::before {
content: "";
@apply absolute top-0 left-0 w-2 h-full -translate-x-1/2;
}
.session-list-header {
@apply border-b relative;
border-color: var(--border-base);
}
.session-list-header h3 {
color: var(--text-primary);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
}
.session-list {
@apply flex-1;
}
.session-list-item {
@apply border-b last:border-b-0;
border-color: var(--border-base);
}
.session-item-base {
@apply w-full flex flex-col gap-1 px-3 py-2.5 text-left transition-colors outline-none;
font-family: var(--font-family-sans);
font-size: var(--font-size-sm);
}
.session-item-base:focus-visible {
@apply ring-2 ring-offset-1;
ring-color: var(--accent-primary);
ring-offset-color: var(--surface-secondary);
}
.session-item-row {
@apply flex items-center gap-2 w-full;
}
.session-item-header {
@apply justify-between;
}
.session-item-title-row {
@apply flex items-center gap-2 min-w-0 flex-1;
}
.session-item-meta {
@apply justify-between items-center;
font-size: var(--font-size-xs);
color: var(--text-secondary);
margin-top: 0.125rem;
}
.session-item-active .session-item-meta {
color: var(--text-secondary);
opacity: 1;
}
.session-item-actions {
@apply flex items-center gap-1;
}
.session-item-active {
background-color: var(--list-item-highlight-bg);
color: var(--text-primary);
font-weight: var(--font-weight-medium);
box-shadow: inset 0 0 0 1px var(--list-item-highlight-border);
}
.session-item-inactive {
color: var(--text-secondary);
}
.session-item-inactive:hover {
background-color: var(--surface-hover);
color: var(--text-primary);
}
.session-item-active .session-item-close:hover {
background-color: var(--surface-hover);
color: var(--text-primary);
}
.session-item-title {
@apply flex-1 min-w-0;
font-weight: inherit;
}
.session-item-close {
@apply flex-shrink-0 p-0.5 rounded transition-all;
}
.session-item-close:focus-visible {
@apply ring-2 ring-offset-1;
ring-color: var(--accent-primary);
ring-offset-color: inherit;
}
.session-list-footer {
@apply border-t;
border-color: var(--border-base);
}
.session-new-button {
background-color: var(--surface-base);
color: var(--text-primary);
border: 1px solid var(--border-base);
}
.session-new-button:hover {
background-color: var(--surface-hover);
}
.session-new-button:focus-visible {
@apply ring-2 ring-offset-1;
ring-color: var(--accent-primary);
ring-offset-color: var(--surface-secondary);
}
/* Responsive behavior for session list */
@media (max-width: 768px) {
.session-list-container {
min-width: 200px;
}
.session-item-base {
@apply px-2 py-2;
}
}

View File

@@ -0,0 +1,49 @@
/* Empty + loading panels */
.empty-state {
@apply flex-1 flex items-center justify-center p-12;
}
.empty-state-content {
@apply text-center max-w-sm;
}
.empty-state-content h3 {
font-size: var(--font-size-xl);
margin-bottom: 12px;
}
.empty-state-content p {
font-size: var(--font-size-base);
color: var(--text-muted);
margin-bottom: 16px;
}
.empty-state-content ul {
list-style: none;
padding: 0;
@apply flex flex-col gap-2;
}
.empty-state-content li {
font-size: var(--font-size-base);
color: var(--text-muted);
}
.empty-state-content code {
background-color: var(--surface-code);
padding: 2px 6px;
border-radius: 3px;
font-family: var(--font-family-mono);
font-size: 13px;
}
.loading-state {
@apply flex-1 flex flex-col items-center justify-center gap-4 p-12;
}
.spinner {
@apply w-8 h-8 border-2 border-t-transparent rounded-full;
border-color: var(--border-base);
border-top-color: var(--accent-primary);
animation: spin 1s linear infinite;
}

View File

@@ -0,0 +1,70 @@
/* Modal utilities */
.modal-overlay {
@apply fixed inset-0 z-50;
background-color: var(--overlay-scrim);
}
.modal-surface {
@apply rounded-lg shadow-2xl flex flex-col;
background-color: var(--surface-base);
color: var(--text-primary);
}
.modal-search-container {
@apply p-4 border-b;
border-color: var(--border-base);
}
.modal-search-input {
@apply flex-1 bg-transparent outline-none;
color: var(--text-primary);
}
.modal-search-input::placeholder {
color: var(--text-muted);
}
.modal-search-icon {
color: var(--text-muted);
}
.modal-list-container {
@apply flex-1 overflow-y-auto;
}
.modal-section-header {
@apply px-4 py-2 text-xs font-semibold uppercase tracking-wide;
color: var(--text-muted);
}
.modal-item {
@apply w-full px-4 py-3 flex items-start gap-3 transition-colors cursor-pointer border-none text-left;
color: var(--text-primary);
}
.modal-list-container[data-pointer-mode="pointer"] .modal-item:hover {
background-color: var(--surface-hover);
}
.modal-list-container[data-pointer-mode="keyboard"] .modal-item:hover:not(.modal-item-highlight) {
background-color: inherit;
}
.modal-item-highlight {
background-color: var(--selection-highlight-bg);
}
.modal-item-label {
@apply font-medium;
color: var(--text-primary);
}
.modal-item-description {
@apply text-sm mt-0.5;
color: var(--text-secondary);
}
.modal-empty-state {
@apply p-8 text-center;
color: var(--text-muted);
}

View File

@@ -0,0 +1,121 @@
/* Panel component shells */
.panel {
@apply rounded-lg shadow-sm border overflow-hidden;
background-color: var(--surface-base);
border-color: var(--border-base);
color: var(--text-primary);
}
.panel-footer-hints {
@apply flex items-center justify-center flex-wrap gap-3 text-xs;
color: var(--text-muted);
}
.panel-header {
@apply px-4 py-3 border-b;
border-color: var(--border-base);
background-color: var(--surface-secondary);
}
.panel-title {
@apply text-base font-semibold;
color: var(--text-primary);
}
.panel-subtitle {
@apply text-xs mt-0.5;
color: var(--text-muted);
}
.panel-body {
@apply p-4;
background-color: var(--surface-base);
}
.panel-section {
@apply border-t;
border-color: var(--border-base);
}
.panel-section-header {
@apply w-full px-4 py-3 flex items-center justify-center transition-colors cursor-pointer gap-2;
background-color: var(--surface-secondary);
}
.panel-section-header:hover {
background-color: var(--surface-hover);
}
.panel-section-content {
@apply px-4 py-3 border-t overflow-visible space-y-4 w-full;
border-color: var(--border-base);
background-color: var(--surface-secondary);
}
.panel-list {
@apply max-h-[400px] overflow-y-auto;
}
.panel-list--fill {
max-height: none;
height: 100%;
}
.panel-list-item {
@apply border-b last:border-b-0 transition-colors w-full;
border-color: var(--border-base);
}
.panel-list-item:hover {
background-color: var(--surface-hover);
}
.panel-list-item-highlight {
background-color: var(--list-item-highlight-bg) !important;
box-shadow: inset 0 0 0 1px var(--list-item-highlight-border);
}
.panel-list-item-content {
@apply flex-1 text-left px-4 py-3 flex items-center justify-between gap-3 outline-none transition-colors w-full;
}
.panel-list-item-content:hover {
background-color: transparent;
}
.panel-list-item-content:disabled {
opacity: 0.6;
}
.panel-list-item button:disabled {
cursor: not-allowed;
}
.panel-list-item-disabled {
opacity: 0.6;
}
.panel-empty-state {
@apply p-6 text-center;
}
.panel-empty-state-icon {
@apply text-gray-400 dark:text-gray-600 mb-2;
color: var(--text-muted);
}
.panel-empty-state-title {
@apply font-medium text-sm mb-1;
color: var(--text-secondary);
}
.panel-empty-state-description {
@apply text-xs;
color: var(--text-muted);
}
.panel-footer {
@apply px-4 py-3 border-t;
border-color: var(--border-base);
background-color: var(--surface-secondary);
}

View File

@@ -0,0 +1,301 @@
/* Session view + sidebar */
.session-view {
@apply flex flex-1 min-h-0 flex-col;
background-color: var(--surface-base);
color: inherit;
}
.session-list-container {
@apply flex flex-col flex-1 min-h-0 relative;
background-color: var(--surface-secondary);
min-width: 200px;
max-width: 500px;
}
.session-sidebar {
@apply flex flex-col min-h-0;
background-color: var(--surface-secondary);
}
.session-sidebar-header {
@apply flex flex-col gap-2 w-full;
}
.session-sidebar-title {
color: var(--text-primary);
}
.session-sidebar-shortcuts {
@apply flex flex-col gap-1;
}
.session-sidebar-new {
@apply w-full;
}
.session-sidebar-controls {
@apply flex flex-col gap-3;
background-color: var(--surface-secondary);
}
.session-sidebar-controls > * {
@apply w-full;
}
.session-sidebar-controls .selector-trigger,
.session-sidebar-controls [data-model-selector-control],
.session-sidebar-controls .selector-trigger-label,
session-sidebar-controls .selector-trigger-primary {
@apply w-full;
}
.sidebar-selector {
@apply flex flex-col gap-1 w-full;
}
.sidebar-selector-hint {
@apply flex justify-center text-xs w-full;
color: var(--text-muted);
}
.session-header-hints {
@apply flex-shrink-0;
}
.session-sidebar-separator {
background-color: var(--border-base);
height: 1px;
width: 100%;
}
.session-resize-handle {
@apply absolute top-0 right-0 w-1 h-full cursor-col-resize bg-transparent transition-colors;
z-index: 10;
}
.session-resize-handle:hover {
background-color: var(--accent-primary);
}
.session-resize-handle::before {
content: "";
@apply absolute top-0 left-0 w-2 h-full -translate-x-1/2;
}
.session-list-header {
@apply border-b relative;
border-color: var(--border-base);
}
.session-list-header h3 {
color: var(--text-primary);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
}
.session-list {
@apply flex-1;
}
.session-list-item {
@apply border-b last:border-b-0;
border-color: var(--border-base);
}
.session-item-base {
@apply w-full flex flex-col gap-1 px-3 py-2.5 text-left transition-colors outline-none;
font-family: var(--font-family-sans);
font-size: var(--font-size-sm);
}
.session-item-base:focus-visible {
@apply ring-2 ring-offset-1;
ring-color: var(--accent-primary);
ring-offset-color: var(--surface-secondary);
}
.session-item-row {
@apply flex items-center gap-2 w-full;
}
.session-item-header {
@apply justify-between;
}
.session-item-title-row {
@apply flex items-center gap-2 min-w-0 flex-1;
}
.session-item-meta {
@apply justify-between items-center;
font-size: var(--font-size-xs);
color: var(--text-secondary);
margin-top: 0.125rem;
}
.session-item-active .session-item-meta {
color: var(--text-secondary);
opacity: 1;
}
.session-item-actions {
@apply flex items-center gap-1;
}
.session-item-active {
background-color: var(--list-item-highlight-bg);
color: var(--text-primary);
font-weight: var(--font-weight-medium);
box-shadow: inset 0 0 0 1px var(--list-item-highlight-border);
}
.session-item-inactive {
color: var(--text-secondary);
}
.session-item-inactive:hover {
background-color: var(--surface-hover);
color: var(--text-primary);
}
.session-item-active .session-item-close:hover {
background-color: var(--surface-hover);
color: var(--text-primary);
}
.session-item-title {
@apply flex-1 min-w-0;
font-weight: inherit;
}
.session-item-close {
@apply flex-shrink-0 p-0.5 rounded transition-all;
}
.session-item-close:focus-visible {
@apply ring-2 ring-offset-1;
ring-color: var(--accent-primary);
ring-offset-color: inherit;
}
.session-list-footer {
@apply border-t;
border-color: var(--border-base);
}
.session-new-button {
background-color: var(--surface-base);
color: var(--text-primary);
border: 1px solid var(--border-base);
}
.session-new-button:hover {
background-color: var(--surface-hover);
}
.session-new-button:focus-visible {
@apply ring-2 ring-offset-1;
ring-color: var(--accent-primary);
ring-offset-color: var(--surface-secondary);
}
.status-indicator {
@apply flex items-center gap-1.5 text-xs;
color: var(--text-muted);
}
.status-indicator .status-dot {
@apply w-2 h-2 rounded-full;
}
.status-indicator.connected .status-dot {
background-color: var(--status-success);
}
.status-indicator.connecting .status-dot {
background-color: var(--status-warning);
animation: pulse 1.5s ease-in-out infinite;
}
.status-indicator.disconnected .status-dot {
background-color: var(--status-error);
}
.status-indicator.session-status {
--session-status-dot: var(--text-muted);
}
.status-indicator.session-status.session-working,
.status-indicator.session-status.session-compacting,
.status-indicator.session-status.session-idle {
font-weight: var(--font-weight-medium);
}
.status-indicator.session-status.session-working {
color: var(--session-status-working-fg);
--session-status-dot: var(--session-status-working-fg);
}
.status-indicator.session-status.session-compacting {
color: var(--session-status-compacting-fg);
--session-status-dot: var(--session-status-compacting-fg);
}
.status-indicator.session-status.session-idle {
color: var(--session-status-idle-fg);
--session-status-dot: var(--session-status-idle-fg);
}
.status-indicator.session-status.session-permission {
color: var(--session-status-permission-fg);
--session-status-dot: var(--session-status-permission-fg);
}
.status-indicator.session-status .status-dot {
background-color: var(--session-status-dot);
}
.status-indicator.session-status.session-working .status-dot,
.status-indicator.session-status.session-compacting .status-dot {
animation: pulse 1.5s ease-in-out infinite;
}
.status-indicator.session-status.session-working.session-status-list {
background-color: var(--session-status-working-bg);
}
.status-indicator.session-status.session-compacting.session-status-list {
background-color: var(--session-status-compacting-bg);
}
.status-indicator.session-status.session-idle.session-status-list {
background-color: var(--session-status-idle-bg);
}
.status-indicator.session-status.session-permission.session-status-list {
background-color: var(--session-status-permission-bg);
}
.status-indicator.session-status-list {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: var(--font-weight-medium);
color: inherit;
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
border: 1px solid transparent;
}
@media (max-width: 768px) {
.session-list-container {
min-width: 200px;
}
.session-item-base {
@apply px-2 py-2;
}
}

110
src/styles/panels/tabs.css Normal file
View File

@@ -0,0 +1,110 @@
/* Primary tab strip */
.tab-bar {
@apply border-b;
border-color: var(--border-base);
}
.tab-bar-instance {
background-color: var(--surface-secondary);
}
.tab-bar-session {
background-color: var(--surface-base);
}
.tab-container {
@apply flex items-center justify-between gap-1 px-2 py-1 overflow-x-auto;
}
.tab-base {
@apply inline-flex items-center gap-2 px-3 py-2 rounded-t-md max-w-[200px] transition-colors text-sm font-medium;
font-family: var(--font-family-sans);
outline: none;
}
.tab-base:focus-visible {
@apply ring-2 ring-offset-1;
ring-color: var(--accent-primary);
ring-offset-color: var(--surface-base);
}
.tab-active {
background-color: var(--tab-active-bg);
color: var(--tab-active-text);
}
.tab-inactive {
background-color: var(--tab-inactive-bg);
color: var(--tab-inactive-text);
}
.tab-inactive:hover {
background-color: var(--tab-inactive-hover-bg);
}
.tab-active:hover {
background-color: var(--tab-active-hover-bg);
}
.tab-label {
@apply truncate;
}
.tab-close {
@apply opacity-0 group-hover:opacity-100 hover:bg-red-500 hover:text-white rounded p-0.5 transition-all cursor-pointer;
}
.tab-close:focus-visible {
@apply ring-2 ring-offset-1;
ring-color: var(--accent-primary);
ring-offset-color: inherit;
}
.new-tab-button {
@apply inline-flex items-center justify-center w-8 h-8 rounded-md transition-colors;
background-color: var(--new-tab-bg);
color: var(--new-tab-text);
}
.new-tab-button:hover {
background-color: var(--new-tab-hover-bg);
}
.new-tab-button:focus-visible {
@apply ring-2 ring-offset-1;
ring-color: var(--accent-primary);
ring-offset-color: var(--surface-base);
}
/* Session tabs */
.session-tab-base {
@apply inline-flex items-center gap-2 px-3 py-1.5 rounded-t-md max-w-[150px] transition-colors text-sm;
font-family: var(--font-family-sans);
outline: none;
border-bottom: 2px solid transparent;
}
.session-tab-base:focus-visible {
@apply ring-2 ring-offset-1;
ring-color: var(--accent-primary);
ring-offset-color: var(--surface-base);
}
.session-tab-active {
background-color: var(--session-tab-active-bg);
border-bottom-color: var(--accent-primary);
color: var(--session-tab-active-text);
font-weight: var(--font-weight-medium);
}
.session-tab-inactive {
color: var(--session-tab-inactive-text);
}
.session-tab-inactive:hover {
background-color: var(--session-tab-hover-bg);
}
.session-tab-special {
color: var(--session-tab-inactive-text);
}

View File

@@ -34,6 +34,92 @@
--message-tool-bg: #f8f9fa;
--message-tool-border: #6c757d;
/* Semantic component colors */
--session-status-working-fg: #b45309;
--session-status-working-bg: rgba(245, 158, 11, 0.16);
--session-status-compacting-fg: #6d28d9;
--session-status-compacting-bg: rgba(109, 40, 217, 0.18);
--session-status-idle-fg: #15803d;
--session-status-idle-bg: rgba(22, 163, 74, 0.16);
--session-status-permission-fg: #c2410c;
--session-status-permission-bg: rgba(251, 191, 36, 0.25);
--list-item-highlight-bg: rgba(0, 102, 255, 0.1);
--list-item-highlight-border: rgba(0, 102, 255, 0.25);
--attachment-chip-bg: rgba(0, 102, 255, 0.1);
--attachment-chip-text: #0066ff;
--attachment-chip-ring: rgba(0, 102, 255, 0.1);
--badge-neutral-bg: rgba(0, 102, 255, 0.05);
--badge-neutral-text: #0066ff;
--status-ready-fg: #16a34a;
--status-ready-bg: rgba(34, 197, 94, 0.1);
--status-starting-fg: #ca8a04;
--status-starting-bg: rgba(250, 204, 21, 0.1);
--status-error-fg: #dc2626;
--status-error-bg: rgba(239, 68, 68, 0.1);
--status-stopped-fg: #6b7280;
--status-stopped-bg: rgba(107, 114, 128, 0.1);
--env-vars-bg: rgba(0, 102, 255, 0.1);
--env-vars-border: rgba(0, 102, 255, 0.2);
--env-vars-text: #0066ff;
--folder-overlay-bg: rgba(0, 0, 0, 0.35);
--folder-card-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
--folder-card-radius: 16px;
--dropdown-highlight-bg: rgba(0, 102, 255, 0.1);
--dropdown-highlight-text: var(--text-inverted);
--selection-highlight-bg: rgba(0, 102, 255, 0.12);
--selection-highlight-strong-bg: rgba(0, 102, 255, 0.18);
--overlay-scrim: rgba(0, 0, 0, 0.5);
--scroll-elevation-shadow: 0 10px 25px rgba(0, 0, 0, 0.08);
--message-error-bg: rgba(244, 67, 54, 0.1);
--message-error-bg-strong: rgba(244, 67, 54, 0.15);
--danger-soft-bg: rgba(239, 68, 68, 0.1);
--danger-soft-bg-strong: rgba(244, 67, 54, 0.15);
--log-level-error: var(--status-error);
--log-level-warn: var(--status-warning);
--log-level-debug: var(--text-muted);
--log-level-default: var(--text-primary);
--focus-ring-color: var(--accent-primary);
--focus-ring-offset: var(--surface-base);
--kbd-bg: var(--surface-secondary);
--kbd-border: var(--border-base);
--kbd-text: var(--text-primary);
--button-primary-bg: var(--accent-primary);
--button-primary-hover-bg: var(--accent-hover);
--button-primary-text: var(--text-inverted);
--tab-active-bg: var(--accent-primary);
--tab-active-hover-bg: var(--accent-hover);
--tab-active-text: var(--text-inverted);
--tab-inactive-bg: var(--surface-muted);
--tab-inactive-hover-bg: var(--surface-hover);
--tab-inactive-text: var(--text-secondary);
--new-tab-bg: var(--surface-secondary);
--new-tab-hover-bg: var(--surface-hover);
--new-tab-text: var(--text-muted);
--session-tab-active-bg: var(--surface-base);
--session-tab-active-text: var(--text-primary);
--session-tab-inactive-text: var(--text-muted);
--session-tab-hover-bg: var(--surface-hover);
/* Layout & spacing tokens */
--space-2xs: 2px;
--space-xs: 4px;
--space-sm: 8px;
--space-md: 12px;
--space-lg: 16px;
--space-xl: 24px;
--radius-xs: 3px;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-2xl: 16px;
--radius-full: 9999px;
--button-padding-y: 0.75rem;
--button-padding-x: 1.25rem;
--button-radius: 0.5rem;
--chip-radius: 0.375rem;
--pill-radius: 9999px;
/* Typography tokens */
--font-family-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
@@ -96,17 +182,106 @@
--message-tool-bg: #212529;
--message-tool-border: #adb5bd;
/* Semantic component colors */
--session-status-working-fg: #facc15;
--session-status-working-bg: rgba(250, 204, 21, 0.25);
--session-status-compacting-fg: #c084fc;
--session-status-compacting-bg: rgba(192, 132, 252, 0.28);
--session-status-idle-fg: #4ade80;
--session-status-idle-bg: rgba(74, 222, 128, 0.22);
--session-status-permission-fg: #fbbf24;
--session-status-permission-bg: rgba(251, 191, 36, 0.35);
--list-item-highlight-bg: rgba(0, 128, 255, 0.2);
--list-item-highlight-border: rgba(0, 128, 255, 0.4);
--attachment-chip-bg: rgba(0, 128, 255, 0.1);
--attachment-chip-text: #0080ff;
--attachment-chip-ring: rgba(0, 128, 255, 0.2);
--badge-neutral-bg: rgba(0, 128, 255, 0.15);
--badge-neutral-text: #0080ff;
--status-ready-fg: #22c55e;
--status-ready-bg: rgba(34, 197, 94, 0.2);
--status-starting-fg: #facc15;
--status-starting-bg: rgba(250, 204, 21, 0.2);
--status-error-fg: #ef4444;
--status-error-bg: rgba(239, 68, 68, 0.2);
--status-stopped-fg: #9ca3af;
--status-stopped-bg: rgba(107, 114, 128, 0.2);
--env-vars-bg: rgba(0, 128, 255, 0.2);
--env-vars-border: rgba(0, 128, 255, 0.3);
--env-vars-text: #0080ff;
--folder-overlay-bg: rgba(0, 0, 0, 0.45);
--folder-card-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
--folder-card-radius: 16px;
--dropdown-highlight-bg: rgba(0, 128, 255, 0.2);
--dropdown-highlight-text: var(--text-primary);
--kbd-bg: var(--surface-secondary);
--kbd-border: var(--border-base);
--kbd-text: var(--text-primary);
--button-primary-bg: #3f3f46;
--button-primary-hover-bg: #52525b;
--button-primary-text: #f5f6f8;
--tab-active-bg: #3f3f46;
--tab-active-hover-bg: #52525b;
--tab-active-text: #f5f6f8;
--tab-inactive-bg: #2a2a31;
--tab-inactive-hover-bg: #3f3f46;
--tab-inactive-text: #d4d4d8;
--new-tab-bg: #3f3f46;
--new-tab-hover-bg: #52525b;
--new-tab-text: #f5f6f8;
--session-tab-active-bg: var(--surface-muted);
--session-tab-active-text: var(--text-primary);
--session-tab-inactive-text: var(--text-muted);
--session-tab-hover-bg: #3f3f46;
--button-primary-bg: #3f3f46;
--button-primary-hover-bg: #52525b;
--button-primary-text: #f5f6f8;
--tab-active-bg: #3f3f46;
--tab-active-hover-bg: #52525b;
--tab-active-text: #f5f6f8;
--tab-inactive-bg: #2f2f36;
--tab-inactive-hover-bg: #3d3d45;
--tab-inactive-text: #d4d4d8;
--new-tab-bg: #3f3f46;
--new-tab-hover-bg: #52525b;
--new-tab-text: #f5f6f8;
--session-tab-active-bg: var(--surface-muted);
--session-tab-active-text: var(--text-primary);
--session-tab-inactive-text: var(--text-muted);
--session-tab-hover-bg: #3f3f46;
/* Layout & spacing tokens */
--space-2xs: 2px;
--space-xs: 4px;
--space-sm: 8px;
--space-md: 12px;
--space-lg: 16px;
--space-xl: 24px;
--radius-xs: 3px;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-2xl: 16px;
--radius-full: 9999px;
--button-padding-y: 0.75rem;
--button-padding-x: 1.25rem;
--button-radius: 0.5rem;
--chip-radius: 0.375rem;
--pill-radius: 9999px;
/* Typography tokens (same as light theme) */
--font-family-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--font-family-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
/* Font weights */
--font-weight-regular: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
/* Font sizes */
--font-size-xs: 11px;
--font-size-sm: 12px;
@@ -119,6 +294,7 @@
--line-height-tight: 1.25;
--line-height-normal: 1.5;
--line-height-relaxed: 1.6;
}
}
@@ -158,17 +334,87 @@
--message-tool-bg: #212529;
--message-tool-border: #adb5bd;
/* Semantic component colors */
--session-status-working-fg: #facc15;
--session-status-working-bg: rgba(250, 204, 21, 0.25);
--session-status-compacting-fg: #c084fc;
--session-status-compacting-bg: rgba(192, 132, 252, 0.28);
--session-status-idle-fg: #4ade80;
--session-status-idle-bg: rgba(74, 222, 128, 0.22);
--session-status-permission-fg: #fbbf24;
--session-status-permission-bg: rgba(251, 191, 36, 0.35);
--list-item-highlight-bg: rgba(0, 128, 255, 0.2);
--list-item-highlight-border: rgba(0, 128, 255, 0.4);
--attachment-chip-bg: rgba(0, 128, 255, 0.1);
--attachment-chip-text: #0080ff;
--attachment-chip-ring: rgba(0, 128, 255, 0.2);
--badge-neutral-bg: rgba(0, 128, 255, 0.15);
--badge-neutral-text: #0080ff;
--status-ready-fg: #22c55e;
--status-ready-bg: rgba(34, 197, 94, 0.2);
--status-starting-fg: #facc15;
--status-starting-bg: rgba(250, 204, 21, 0.2);
--status-error-fg: #ef4444;
--status-error-bg: rgba(239, 68, 68, 0.2);
--status-stopped-fg: #9ca3af;
--status-stopped-bg: rgba(107, 114, 128, 0.2);
--env-vars-bg: rgba(0, 128, 255, 0.2);
--env-vars-border: rgba(0, 128, 255, 0.3);
--env-vars-text: #0080ff;
--folder-overlay-bg: rgba(0, 0, 0, 0.45);
--folder-card-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
--folder-card-radius: 16px;
--dropdown-highlight-bg: rgba(0, 128, 255, 0.2);
--dropdown-highlight-text: var(--text-primary);
--selection-highlight-bg: rgba(0, 128, 255, 0.18);
--selection-highlight-strong-bg: rgba(0, 128, 255, 0.28);
--overlay-scrim: rgba(0, 0, 0, 0.6);
--scroll-elevation-shadow: 0 10px 25px rgba(0, 0, 0, 0.35);
--message-error-bg: rgba(244, 67, 54, 0.12);
--message-error-bg-strong: rgba(244, 67, 54, 0.2);
--danger-soft-bg: rgba(244, 67, 54, 0.16);
--danger-soft-bg-strong: rgba(244, 67, 54, 0.28);
--log-level-error: var(--status-error);
--log-level-warn: var(--status-warning);
--log-level-debug: var(--text-secondary);
--log-level-default: var(--text-primary);
--focus-ring-color: var(--accent-primary);
--focus-ring-offset: var(--surface-base);
--kbd-bg: var(--surface-secondary);
--kbd-border: var(--border-base);
--kbd-text: var(--text-primary);
/* Layout & spacing tokens */
--space-2xs: 2px;
--space-xs: 4px;
--space-sm: 8px;
--space-md: 12px;
--space-lg: 16px;
--space-xl: 24px;
--radius-xs: 3px;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-2xl: 16px;
--radius-full: 9999px;
--button-padding-y: 0.75rem;
--button-padding-x: 1.25rem;
--button-radius: 0.5rem;
--chip-radius: 0.375rem;
--pill-radius: 9999px;
/* Typography tokens (same as light theme) */
--font-family-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--font-family-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
/* Font weights */
--font-weight-regular: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
/* Font sizes */
--font-size-xs: 11px;
--font-size-sm: 12px;
@@ -181,4 +427,5 @@
--line-height-tight: 1.25;
--line-height-normal: 1.5;
--line-height-relaxed: 1.6;
}

135
src/styles/utilities.css Normal file
View File

@@ -0,0 +1,135 @@
@import "./tokens.css";
/* Reusable component utilities using tokens */
/* Base token utility helpers */
.text-primary {
color: var(--text-primary);
}
.text-secondary {
color: var(--text-secondary);
}
.text-muted {
color: var(--text-muted);
}
.text-inverted {
color: var(--text-inverted);
}
.text-accent {
color: var(--accent-primary);
}
.bg-surface-base {
background-color: var(--surface-base);
}
.bg-surface-secondary {
background-color: var(--surface-secondary);
}
.bg-surface-muted {
background-color: var(--surface-muted);
}
.border-base {
border-color: var(--border-base);
}
.icon-muted {
color: var(--text-muted);
}
.icon-accent {
color: var(--accent-primary);
}
.icon-danger-hover:hover {
color: var(--status-error);
}
.icon-accent-hover:hover {
color: var(--accent-primary);
}
.ring-accent-inset {
box-shadow: inset 0 0 0 2px var(--accent-primary);
}
/* Shared button + chip helpers */
:is(.button-primary,
button.button-primary,
.button-secondary,
button.button-secondary,
.button-tertiary,
button.button-tertiary) {
@apply inline-flex items-center justify-center gap-2 font-medium transition-colors rounded-md;
border: 1px solid transparent;
}
:is(.button-primary,
button.button-primary,
.button-secondary,
button.button-secondary,
.button-tertiary,
button.button-tertiary):focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--focus-ring-offset), 0 0 0 4px var(--focus-ring-color);
}
:is(.button-primary,
button.button-primary,
.button-secondary,
button.button-secondary,
.button-tertiary,
button.button-tertiary):disabled {
@apply cursor-not-allowed opacity-50;
}
:is(.attachment-chip,
.neutral-badge,
.status-badge) {
@apply inline-flex items-center gap-1;
border-radius: var(--chip-radius);
}
/* Focus helpers */
.focus-ring-accent:focus {
outline: none;
border-color: transparent;
box-shadow: 0 0 0 2px var(--accent-primary);
}
/* Shared animations */
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes shimmer {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Shared layout helpers */
.status-dot {
@apply w-1 h-1 rounded-full;
}
.kbd {
@apply inline-flex items-center gap-0.5 font-mono text-xs px-1.5 py-0.5 rounded;
background-color: var(--surface-secondary);
border: 1px solid var(--border-base);
color: var(--text-primary);
}
.kbd-separator {
@apply opacity-50;
}

View File

@@ -1,5 +1,5 @@
import type { OpencodeClient } from "@opencode-ai/sdk/client"
import type { Project as SDKProject } from "@opencode-ai/sdk"
import type { LspStatus, Project as SDKProject } from "@opencode-ai/sdk"
export interface LogEntry {
timestamp: number
@@ -24,6 +24,7 @@ export type RawMcpStatus = Record<string, {
export interface InstanceMetadata {
project?: ProjectInfo
mcpStatus?: RawMcpStatus
lspStatus?: LspStatus[]
version?: string
}

View File

@@ -5,7 +5,8 @@ import type {
EventMessagePartUpdated as MessagePartUpdatedEvent,
EventMessagePartRemoved as MessagePartRemovedEvent,
Part as SDKPart,
Message as SDKMessage
Message as SDKMessage,
Permission,
} from "@opencode-ai/sdk"
// Re-export for other modules
@@ -25,6 +26,11 @@ export interface RenderCache {
mode?: string
}
export interface PendingPermissionState {
permission: Permission
active: boolean
}
// Client-specific part extensions (using intersection type since SDKPart is a union)
export type ClientPart = SDKPart & {
sessionID?: string
@@ -32,6 +38,7 @@ export type ClientPart = SDKPart & {
synthetic?: boolean
version?: number
renderCache?: RenderCache
pendingPermission?: PendingPermissionState
}
export interface MessageDisplayParts {

View File

@@ -28,6 +28,7 @@ export interface Session extends Omit<import("@opencode-ai/sdk").Session, 'proje
messages: Message[] // Client-specific field
messagesInfo: Map<string, MessageInfo> // Client-specific field
version: string // Include version from SDK Session
pendingPermission?: boolean // Indicates if session is waiting on user permission
}
// Adapter function to convert SDK Session to client Session