diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cb3f81..8c8bbc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.7.4] - 2026-06-30 + +### Added + +- ๐Ÿ–ฅ๏ธ **Live command sessions in chat.** Running commands now appear in the chat status menu, where you can open live output, keep an eye on long jobs, and terminate a stuck command without leaving the conversation. +- ๐Ÿ“Š **More useful chat status.** Chats now have a compact status bar with the conversation title, context usage, queued messages, Chat ID, and active command count in one place. + +### Fixed + +- โŒจ๏ธ **More reliable command control.** Long-running commands now keep accepting input and can be stopped from either the chat tools or the live session view. +- ๐Ÿงน **Cleaner activity details.** Chat activity stays aligned and easier to scan while command output remains available where it is useful. + ## [0.7.3] - 2026-06-30 ### Fixed @@ -188,12 +200,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- ๐Ÿ“ **File browser drag-and-drop completely reworked.** You can now freely drag and drop files and folders anywhere โ€” into folders, onto breadcrumbs, or between nested directories. Multi-select drag, auto-expand on hover, and full expanded-area highlighting are all supported. +- ๐Ÿ“ **File browser drag-and-drop completely reworked.** You can now freely drag and drop files and folders anywhere: into folders, onto breadcrumbs, or between nested directories. Multi-select drag, auto-expand on hover, and full expanded-area highlighting are all supported. ### Fixed - ๐Ÿ“‹ **Dropdown menus no longer get cut off by the keyboard on mobile.** Menus that open upward (like the model picker) now anchor from the bottom of the screen instead of the top, so they stay fully visible even when the on-screen keyboard is open. -- ๐Ÿ”„ **Renamed files no longer break git operations.** Renaming or moving a file could cause errors when staging, discarding, or viewing changes โ€” the file list would show garbled entries like "R100 old-name.txt" instead of the actual file. This is now handled correctly. +- ๐Ÿ”„ **Renamed files no longer break git operations.** Renaming or moving a file could cause errors when staging, discarding, or viewing changes, and the file list would show garbled entries like "R100 old-name.txt" instead of the actual file. This is now handled correctly. ## [0.4.9] - 2026-06-15 @@ -238,7 +250,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - ๐Ÿง  **Reasoning models no longer lose chain of thought across parallel tool calls.** Multiple tool calls in the same turn are now grouped into a single assistant message with shared reasoning items, instead of being split into separate messages that broke reasoning model round-tripping (e.g. o3, o4-mini). - ๐Ÿ›ก๏ธ **Orphaned tool calls no longer break chat history.** If a crash or data corruption left a function call without a matching result, the Responses API conversion now skips the orphaned call instead of sending invalid input that caused permanent 400 errors. -- ๐Ÿ”„ **Tool output ordering prevents corrupted history on crash.** Tool call outputs are now appended to the output list *before* marking the call as "completed", so a crash between the two steps can no longer produce a "completed" call with no output โ€” which would corrupt the message history on reload. +- ๐Ÿ”„ **Tool output ordering prevents corrupted history on crash.** Tool call outputs are now appended to the output list *before* marking the call as "completed", so a crash between the two steps can no longer produce a "completed" call with no output and corrupt the message history on reload. - ๐Ÿงน **In-progress tool calls scrubbed on error or cancellation.** When a chat task is cancelled or hits an error, any tool calls still marked "in_progress" are now set to "failed" before persisting, preventing stale in-progress items from lingering in the database. ## [0.4.4] - 2026-06-15 @@ -246,9 +258,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - ๐Ÿง™ **Setup wizard.** A friendly first-run guide walks you through picking a folder and connecting your AI. Pops up automatically after sign-up. -- ๐Ÿ”Œ **Local tool servers (stdio).** You can now connect MCP tool servers that run as local commands โ€” not just over HTTP. Add the command and arguments from the Tool Servers tab. +- ๐Ÿ”Œ **Local tool servers (stdio).** You can now connect MCP tool servers that run as local commands, not just over HTTP. Add the command and arguments from the Tool Servers tab. - ๐Ÿ“„ **Read documents.** The AI can now open and read PDFs, Word docs, Excel spreadsheets, PowerPoint files, and more. -- ๐Ÿ’ฌ **Send input to running commands.** The AI can now type into running processes โ€” answering prompts, interacting with REPLs, or sending Ctrl-C. +- ๐Ÿ’ฌ **Send input to running commands.** The AI can now type into running processes, answer prompts, interact with REPLs, or send Ctrl-C. - โ†ฉ๏ธ **Undo last commit.** Changed your mind? Undo the last commit from the Git history and get your changes back in staging. - ๐Ÿš€ **Publish branches.** Push a new branch for the first time with one click. The button says "Publish" when there's no upstream yet. - ๐Ÿ”— **View on GitHub / GitLab.** A new link in the Git panel takes you straight to your repo on the web. @@ -414,7 +426,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- ๐Ÿ” **Global search modal.** New unified search (โŒ˜K / โŒ˜โ‡งF) that finds chats by title *and* message content, plus files by name โ€” across all workspaces or scoped to the active one. Shows recent chats when the query is empty. Replaces the old QuickOpen modal. +- ๐Ÿ” **Global search modal.** New unified search (โŒ˜K / โŒ˜โ‡งF) that finds chats by title *and* message content, plus files by name across all workspaces or scoped to the active one. Shows recent chats when the query is empty. Replaces the old QuickOpen modal. - ๐ŸŒ **Perplexity web search provider.** Added Perplexity as a first-class search provider (auto-detected between Exa and Tavily when `PERPLEXITY_API_KEY` is set). - ๐ŸŒ **Chat Completions search provider.** Any OpenAI-compatible `/chat/completions` endpoint (e.g. Perplexity Sonar, LiteLLM proxy) can now be used for web search. Configure via Settings or environment variables. - โž• **New Chat button per workspace.** Each workspace row in the sidebar now shows a pencil icon on hover to quickly create a new chat. @@ -487,7 +499,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - ๐Ÿ—„๏ธ **Alembic migration no longer fails on startup.** Renamed migration revision IDs from 3-character (`001`) to 4-character (`0001`) identifiers, fixing Alembic's `ResolutionError` for partial revision matches. -- ๐Ÿ“ฆ **PyPI wheel no longer bundles frontend source code.** Fixed `.gitignore` paths (old `computr/` โ†’ `cptr/`) and added an explicit `exclude` in `pyproject.toml` so only `cptr/frontend/build` ships in the wheel โ€” eliminating ~14,000 unnecessary files including `node_modules`. +- ๐Ÿ“ฆ **PyPI wheel no longer bundles frontend source code.** Fixed `.gitignore` paths (old `computr/` โ†’ `cptr/`) and added an explicit `exclude` in `pyproject.toml` so only `cptr/frontend/build` ships in the wheel, eliminating ~14,000 unnecessary files including `node_modules`. ## [0.1.4] - 2026-06-06 @@ -533,7 +545,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- ๐Ÿ›‘ **File browser no longer refreshes constantly.** Replaced the macOS `PollingObserver` (which generated a feedback loop of phantom filesystem events) with the native `FSEventsObserver`. Background refreshes are now silent โ€” no more loading spinner flash on every update. +- ๐Ÿ›‘ **File browser no longer refreshes constantly.** Replaced the macOS `PollingObserver` (which generated a feedback loop of phantom filesystem events) with the native `FSEventsObserver`. Background refreshes are now silent, so there is no more loading spinner flash on every update. - ๐Ÿ”‡ **Eliminated noisy filesystem watcher events.** The watcher now ignores changes in `.git`, `__pycache__`, `.DS_Store`, and `node_modules` directories, which were triggering unnecessary file browser refreshes. - โšก **Centralized git status store.** All components (GitBar, GitView, FileEditor, layout) now share a single `gitStatusStore` instead of each independently polling `git/status`. On page load, this reduces git status API calls from ~6+ down to 1. - ๐Ÿ” **Reduced git polling frequency.** Removed the 5-second git status polling intervals that were running in multiple components simultaneously. @@ -547,7 +559,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - ๐Ÿ’ฌ **AI chat.** Built-in chat panel with multi-provider LLM support (OpenAI, Anthropic, Ollama, and OpenAI-compatible endpoints), model selector, tool calling, and streaming responses. - ๐Ÿ”ง **Tool system.** Extensible tool framework enabling AI agents to interact with the local filesystem, run commands, search the web (Brave, DuckDuckGo, Exa, Tavily), and read URLs. Streaming JSON parser for improved tool-calling reliability. - ๐Ÿ“Ž **File mentions.** Type `@` in the chat input to mention files with an inline suggestion popup, giving the AI context about your codebase. -- ๐Ÿ”„ **Queued messages.** Queue follow-up messages while the AI is responding โ€” they'll be sent automatically when the current response completes. +- ๐Ÿ”„ **Queued messages.** Queue follow-up messages while the AI is responding. They will be sent automatically when the current response completes. - โœ๏ธ **Output editing.** Review and edit AI-generated file changes before applying them. - ๐Ÿ“ **File browser.** Web-based file explorer with directory navigation, file viewing, file icons by extension, and management capabilities. - โŒจ๏ธ **Keyboard shortcuts.** Customizable keybinding system with a dedicated settings panel, including support for new-tab, quick-open, and other common actions. diff --git a/cptr/frontend/src/lib/apis/terminal.ts b/cptr/frontend/src/lib/apis/terminal.ts index 3fcc16d..6f7afaf 100644 --- a/cptr/frontend/src/lib/apis/terminal.ts +++ b/cptr/frontend/src/lib/apis/terminal.ts @@ -7,6 +7,22 @@ export interface TerminalSession { session_id: string; } +export interface CommandSession { + command_session_id: string; + task_id: string; + workspace: string; + chat_id: string | null; + message_id: string | null; + call_id: string | null; + command: string; + created_at: number; + status: 'running' | 'completed'; + done: boolean; + exit_code: number | null; + total_bytes: number; + output: string; +} + export const listSessions = () => fetchJSON('/api/terminal'); export const createSession = (cwd: string) => @@ -14,3 +30,8 @@ export const createSession = (cwd: string) => export const deleteSession = (sessionId: string) => fetchHandler(`/api/terminal/${sessionId}`, { method: 'DELETE' }).catch(() => {}); + +export const listCommandSessions = (workspace: string, chatId?: string | null) => + fetchJSON( + `/api/terminal/sessions?workspace=${encodeURIComponent(workspace)}${chatId ? `&chat_id=${encodeURIComponent(chatId)}` : ''}` + ); diff --git a/cptr/frontend/src/lib/components/DropdownMenu.svelte b/cptr/frontend/src/lib/components/DropdownMenu.svelte index 63c1b3c..1fcc8de 100644 --- a/cptr/frontend/src/lib/components/DropdownMenu.svelte +++ b/cptr/frontend/src/lib/components/DropdownMenu.svelte @@ -45,6 +45,8 @@ footerDivider?: boolean; /** Optional snippet rendered when items array is empty. */ empty?: Snippet; + /** Optional custom dropdown content. */ + children?: Snippet; /** Additional CSS classes for the menu container. */ className?: string; /** Horizontal alignment relative to anchor: 'start' (left) or 'end' (right). */ @@ -65,6 +67,7 @@ headerDivider = true, footerDivider = true, empty, + children, className = '', align = 'start' }: Props = $props(); @@ -355,7 +358,9 @@ {/if}
- {#if items.length === 0 && empty} + {#if children} + {@render children()} + {:else if items.length === 0 && empty} {@render empty()} {:else} {#each items as item} diff --git a/cptr/frontend/src/lib/components/Modal.svelte b/cptr/frontend/src/lib/components/Modal.svelte index de87202..41232fe 100644 --- a/cptr/frontend/src/lib/components/Modal.svelte +++ b/cptr/frontend/src/lib/components/Modal.svelte @@ -4,10 +4,16 @@ interface Props { onclose: () => void; class?: string; + overlayClass?: string; children: Snippet; } - let { onclose, class: className = '', children }: Props = $props(); + let { + onclose, + class: className = '', + overlayClass = 'bg-black/50 items-center justify-center', + children + }: Props = $props(); function handleKeydown(e: KeyboardEvent) { if (e.key === 'Escape') onclose(); @@ -18,7 +24,7 @@
{}} > diff --git a/cptr/frontend/src/lib/components/Terminal.svelte b/cptr/frontend/src/lib/components/Terminal.svelte index bb41314..83aefe6 100644 --- a/cptr/frontend/src/lib/components/Terminal.svelte +++ b/cptr/frontend/src/lib/components/Terminal.svelte @@ -6,11 +6,14 @@ // โ”€โ”€ Compact binary WebSocket protocol โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Client โ†’ Server: byte 0 = type, rest = payload - // 0x00 + raw input bytes (microtask-batched for throughput) - // 0x02 + uint16 cols + uint16 rows, big-endian (5 bytes total) - // Server โ†’ Client: raw PTY output bytes (no prefix) - const MSG_INPUT = 0; - const MSG_RESIZE = 2; +// 0x00 + raw input bytes (microtask-batched for throughput) +// 0x02 + uint16 cols + uint16 rows, big-endian (5 bytes total) +// 0x03 stop, 0x04 force stop +// Server โ†’ Client: raw PTY output bytes (no prefix) +const MSG_INPUT = 0; +const MSG_RESIZE = 2; +const MSG_STOP = 3; +const MSG_FORCE_STOP = 4; const textEncoder = new TextEncoder(); @@ -35,10 +38,24 @@ } interface Props { - sessionId: string; - } - - let { sessionId }: Props = $props(); + sessionId?: string; + wsPath?: string; + initialOutput?: string; + initialOffset?: number; + stopSignal?: number; + forceStopSignal?: number; + readOnly?: boolean; +} + +let { + sessionId = '', + wsPath = '', + initialOutput = '', + initialOffset = 0, + stopSignal = 0, + forceStopSignal = 0, + readOnly = false +}: Props = $props(); let containerEl: HTMLDivElement | undefined = $state(); let term: Terminal | null = null; @@ -61,7 +78,9 @@ try { if ('wakeLock' in navigator) { wakeLock = await navigator.wakeLock.request('screen'); - wakeLock.addEventListener('release', () => { wakeLock = null; }); + wakeLock.addEventListener('release', () => { + wakeLock = null; + }); } } catch { // Wake lock request can fail if document is hidden or permission denied @@ -111,7 +130,11 @@ function getWsUrl(sid: string): string { const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - return `${proto}//${window.location.host}/api/terminal/${sid}/ws`; + const path = wsPath || `/api/terminal/${sid}/ws`; + const url = new URL(path, `${window.location.protocol}//${window.location.host}`); + if (initialOffset > 0) url.searchParams.set('offset', String(initialOffset)); + url.protocol = proto; + return url.toString(); } function doFit() { @@ -156,6 +179,7 @@ // Writes directly into the pre-allocated buffer. Zero allocation // for the common single-ASCII-keystroke path. function sendInput(data: string) { + if (readOnly) return; if (ws?.readyState !== WebSocket.OPEN) return; if (data.length === 1 && data.charCodeAt(0) < 128) { @@ -187,6 +211,28 @@ ws!.send(resizeBuf); } +let lastStopSignal = 0; +$effect(() => { + if (!stopSignal || stopSignal === lastStopSignal) return; + lastStopSignal = stopSignal; + if (ws?.readyState === WebSocket.OPEN) { + ws.send(new Uint8Array([MSG_STOP])); + } +}); + +let lastForceStopSignal = 0; +$effect(() => { + if (!forceStopSignal || forceStopSignal === lastForceStopSignal) return; + lastForceStopSignal = forceStopSignal; + if (ws?.readyState === WebSocket.OPEN) { + ws.send(new Uint8Array([MSG_FORCE_STOP])); + } +}); + + $effect(() => { + if (term) term.options.disableStdin = readOnly; + }); + onMount(() => { if (!containerEl) return; @@ -209,6 +255,7 @@ lineHeight: 1.3, scrollback: 10000, macOptionClickForceSelection: true, + disableStdin: readOnly, theme: isDarkMode() ? darkTheme : lightTheme }); @@ -258,6 +305,7 @@ fitAddon = new FitAddon(); term.loadAddon(fitAddon); term.open(containerEl); + if (initialOutput) term.write(initialOutput); // // iOS: Move xterm's textarea from inside .xterm-helpers (position:absolute, // // offscreen) to a flex sibling BELOW the terminal. // if ('ontouchstart' in window && term.textarea) { @@ -404,12 +452,13 @@ if (!term || destroyed) return; const url = getWsUrl(sessionId); + const label = sessionId || wsPath; console.log(`[terminal] connecting to ${url}`); ws = new WebSocket(url); ws.binaryType = 'arraybuffer'; ws.onopen = () => { - console.log(`[terminal] WebSocket open for ${sessionId}`); + console.log(`[terminal] WebSocket open for ${label}`); if (term) { // Send ONE resize message with current dimensions. // Do NOT call doFit() here; that would trigger term.onResize @@ -430,9 +479,7 @@ }; ws.onclose = (e) => { - console.log( - `[terminal] WebSocket closed for ${sessionId}, code=${e.code}, reason=${e.reason}` - ); + console.log(`[terminal] WebSocket closed for ${label}, code=${e.code}, reason=${e.reason}`); if (destroyed) return; reconnectTimer = setTimeout(() => { if (!destroyed) connectWebSocket(); @@ -440,7 +487,7 @@ }; ws.onerror = (e) => { - console.error(`[terminal] WebSocket error for ${sessionId}`, e); + console.error(`[terminal] WebSocket error for ${label}`, e); }; // NOTE: term.onData and term.onResize are registered in onMount, diff --git a/cptr/frontend/src/lib/components/chat/ChatInput.svelte b/cptr/frontend/src/lib/components/chat/ChatInput.svelte index 7003ba9..6d0c532 100644 --- a/cptr/frontend/src/lib/components/chat/ChatInput.svelte +++ b/cptr/frontend/src/lib/components/chat/ChatInput.svelte @@ -10,7 +10,11 @@ import { toast } from 'svelte-sonner'; import { createFileMention, extractMentionedFiles, type FileMentionAttrs } from './FileMention'; - import { createSkillMention, extractMentionedSkills, type SkillMentionAttrs } from './SkillMention'; + import { + createSkillMention, + extractMentionedSkills, + type SkillMentionAttrs + } from './SkillMention'; import FileSuggestionPopup from './FileSuggestionPopup.svelte'; import SkillSuggestionPopup from './SkillSuggestionPopup.svelte'; import { searchFiles } from '$lib/apis/files'; @@ -43,7 +47,9 @@ try { if ('wakeLock' in navigator) { voiceWakeLock = await navigator.wakeLock.request('screen'); - voiceWakeLock.addEventListener('release', () => { voiceWakeLock = null; }); + voiceWakeLock.addEventListener('release', () => { + voiceWakeLock = null; + }); } } catch {} } @@ -124,7 +130,9 @@ }; // โ”€โ”€ File Uploads โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - let attachedUploads = $state<{ id: string; name: string; url: string; type: string; loading?: boolean }[]>([]); + let attachedUploads = $state< + { id: string; name: string; url: string; type: string; loading?: boolean }[] + >([]); let isDragging = $state(false); async function processFiles(files: File[]) { @@ -133,21 +141,21 @@ const isImage = file.type.startsWith('image/'); const type = isImage ? 'image' : 'file'; attachedUploads = [...attachedUploads, { id, name: file.name, url: '', type, loading: true }]; - + try { const form = new FormData(); form.append('file', file); const res = await uploadFile(form); if (res && res.id) { - attachedUploads = attachedUploads.map(u => + attachedUploads = attachedUploads.map((u) => u.id === id ? { ...u, id: res.id, url: res.url, loading: false } : u ); } else { - attachedUploads = attachedUploads.filter(u => u.id !== id); + attachedUploads = attachedUploads.filter((u) => u.id !== id); } } catch (err) { - console.error("Upload failed", err); - attachedUploads = attachedUploads.filter(u => u.id !== id); + console.error('Upload failed', err); + attachedUploads = attachedUploads.filter((u) => u.id !== id); } } } @@ -177,7 +185,7 @@ } function removeUpload(id: string) { - attachedUploads = attachedUploads.filter(u => u.id !== id); + attachedUploads = attachedUploads.filter((u) => u.id !== id); } // โ”€โ”€ @file mention suggestion โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -344,7 +352,7 @@ id: s.name, label: s.name, description: s.description, - source: s.source, + source: s.source })); } if (!query) return cachedSkills; @@ -363,7 +371,9 @@ onselect: (i: number) => void ) { if (skillPopupComponent) { - try { unmount(skillPopupComponent); } catch {} + try { + unmount(skillPopupComponent); + } catch {} skillPopupComponent = null; } if (!skillPopupEl) { @@ -433,7 +443,8 @@ return true; } if (event.key === 'ArrowUp') { - selectedIndex = (selectedIndex - 1 + currentItems.length) % Math.max(currentItems.length, 1); + selectedIndex = + (selectedIndex - 1 + currentItems.length) % Math.max(currentItems.length, 1); remount(); return true; } @@ -468,7 +479,9 @@ stopSkillRepositionLoop(); skillActiveClientRectFn = null; if (skillPopupComponent) { - try { unmount(skillPopupComponent); } catch {} + try { + unmount(skillPopupComponent); + } catch {} skillPopupComponent = null; } if (skillPopupEl) { @@ -594,7 +607,7 @@ } export function getFiles(): any[] { - return attachedUploads.filter(u => !u.loading); + return attachedUploads.filter((u) => !u.loading); } export function clearUploads() { @@ -625,7 +638,8 @@ streaming || sending || inputText.trim() - ) return; + ) + return; voiceRearming = true; voiceRestartTimer = window.setTimeout(() => { voiceRestartTimer = 0; @@ -679,7 +693,11 @@ } } - async function stopVoiceCapture(): Promise<{ blob: Blob; filename: string; contentType: string } | null> { + async function stopVoiceCapture(): Promise<{ + blob: Blob; + filename: string; + contentType: string; + } | null> { const recorder = voiceCaptureRecorder; const stream = voiceCaptureStream; const chunks = voiceCaptureChunks; @@ -732,9 +750,11 @@ } catch {} } - async function transcribeVoiceModeCapture( - capture: { blob: Blob; filename: string; contentType: string } - ): Promise { + async function transcribeVoiceModeCapture(capture: { + blob: Blob; + filename: string; + contentType: string; + }): Promise { const form = new FormData(); form.append('file', capture.blob, capture.filename); form.append('workspace', workspace); @@ -937,12 +957,17 @@ const canSend = $derived(!!(inputText.trim() && selectedModel && !sending)); -
{ e.preventDefault(); isDragging = true; }} - ondragleave={() => { isDragging = false; }} + ondragover={(e) => { + e.preventDefault(); + isDragging = true; + }} + ondragleave={() => { + isDragging = false; + }} role="presentation" > @@ -966,7 +991,9 @@
-
+
Commands
{#if slashCommandIds.includes('compact')} @@ -983,7 +1010,9 @@ }} onmouseenter={() => (selectedSlashCommandIndex = slashCommandIds.indexOf('compact'))} > - + + (selectedSlashCommandIndex = slashCommandIds.indexOf('status'))} > - + {#if upload.loading} -
+
{:else if upload.type === 'image'} - {upload.name} + {upload.name} {:else} -
+
-
+
{upload.name}
-
{upload.type === 'file' ? 'File' : upload.type}
+
+ {upload.type === 'file' ? 'File' : upload.type} +
{/if} - +
-
@@ -1139,9 +1192,15 @@
{#if $voiceModeEnabled} -
+
- + {voiceStatusLabel} diff --git a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte index f87cc7e..280a566 100644 --- a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte +++ b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte @@ -53,7 +53,9 @@ import AssistantMessage from './AssistantMessage.svelte'; import ChatHistory from './ChatHistory.svelte'; import StatusModal from './StatusModal.svelte'; + import { listCommandSessions, type CommandSession } from '$lib/apis/terminal'; import Spinner from '../common/Spinner.svelte'; + import Icon from '../Icon.svelte'; import { toast } from 'svelte-sonner'; import { t } from '$lib/i18n'; @@ -77,14 +79,17 @@ let currentMessageId = $state(null); let contextUsage = $state(null); let showStatusModal = $state(false); + let commandSessions = $state([]); + let initialCommandSessionId = $state(null); let previousChats = $state([]); let messagesEl: HTMLDivElement; let chatInputEl: ChatInput; + let statusButtonEl: HTMLButtonElement | undefined = $state(); let sending = $state(false); let autoScroll = $state(true); let cancelledMessageId: string | null = null; let loading = $state(!!initialChatId); - let chatTitle = ''; + let chatTitle = $state(''); let ttsQueue: string[] = []; let ttsBuffer = ''; let ttsInsideCodeFence = false; @@ -97,6 +102,7 @@ let ttsErrorShown = false; let speakingMessageId = $state(null); let ttsStopRequested = false; + let commandSessionsChatId: string | null = null; // This browser memory cache only helps while the current page is open, such as when // someone taps the same speak button twice. The backend cache is the durable source // for cross-session reuse and the workspace data flywheel. @@ -106,6 +112,7 @@ const TTS_AUDIO_CACHE_LIMIT_BYTES = 20 * 1024 * 1024; const TTS_MAX_PREFETCH = 2; let unbindSocketListeners: (() => void) | null = null; + let commandSessionsTimer: ReturnType | null = null; onMount(() => { if (initialChatId || typeof sessionStorage === 'undefined') return; @@ -283,6 +290,12 @@ const streaming = $derived(allMessages.some((m) => m.role === 'assistant' && !m.done)); const isLanding = $derived(allMessages.length === 0 && !chatId); const workspaceDisplayName = $derived(getPathDisplayName(workspace, 'workspace')); + const displayChatTitle = $derived(chatTitle || firstUserMessageTitle() || workspaceDisplayName); + const runningCommandSessions = $derived(commandSessions.filter((session) => !session.done)); + const summaryCount = $derived(runningCommandSessions.length); + const statusButtonClass = + 'text-gray-400 hover:bg-gray-50 hover:text-gray-700 dark:text-gray-600 dark:hover:bg-white/5 dark:hover:text-gray-300'; + const statusTitle = $derived(summaryCount > 0 ? `${summaryCount} active command session` : 'Status'); // Queued messages: user-authored messages waiting behind an active response. const queuedMessages = $derived( @@ -296,6 +309,13 @@ .map((m) => ({ id: m.id, content: m.content })) ); + $effect(() => { + if (chatId === commandSessionsChatId) return; + commandSessionsChatId = chatId; + commandSessions = []; + if (chatId) refreshCommandSessions(); + }); + // โ”€โ”€ Load chat from DB โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ let loadGeneration = 0; @@ -531,6 +551,9 @@ } else { loadPreviousChats(); } + refreshCommandSessions(); + commandSessionsTimer = setInterval(refreshCommandSessions, 5000); + window.addEventListener('cptr:inspect-command-session', handleInspectCommandSession); const offChat = socketStore.on('events:chat', handleSocketEvent); const offConnect = socketStore.on('connect', handleReconnect); @@ -544,6 +567,9 @@ stopTtsPlayback(); unbindSocketListeners?.(); unbindSocketListeners = null; + if (commandSessionsTimer) clearInterval(commandSessionsTimer); + commandSessionsTimer = null; + window.removeEventListener('cptr:inspect-command-session', handleInspectCommandSession); if (landingRefreshTimer) clearTimeout(landingRefreshTimer); // Don't clear streamingChatTabs here -- the global listener in // chat.ts handles cleanup when the "done" event arrives, so the @@ -825,8 +851,40 @@ planMode.set(!get(planMode)); } + function firstUserMessageTitle(): string { + const message = allMessages.find((m) => m.role === 'user' && !isPendingHiddenMessage(m)); + return message ? message.content.replace(/\s+/g, ' ').trim().slice(0, 80) : ''; + } + function handleStatusCommand() { + if (isLanding) return; + initialCommandSessionId = null; showStatusModal = true; + refreshCommandSessions(); + } + + async function refreshCommandSessions() { + const currentChatId = chatId; + if (!currentChatId) { + commandSessions = []; + return; + } + try { + const sessions = await listCommandSessions(workspace, currentChatId); + if (chatId !== currentChatId) return; + commandSessions = sessions; + } catch (err) { + console.error('[chat] command sessions refresh error', err); + if (chatId === currentChatId) commandSessions = []; + } + } + + function handleInspectCommandSession(e: Event) { + const id = (e as CustomEvent<{ commandSessionId?: string }>).detail?.commandSessionId; + if (!id) return; + initialCommandSessionId = id; + showStatusModal = true; + refreshCommandSessions(); } // โ”€โ”€ Queue actions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -1354,7 +1412,27 @@ } -
+
+ {#if !isLanding} +
+
+ {displayChatTitle} +
+ +
+ {/if} + {#if isLanding}
@@ -1378,6 +1456,7 @@ {workspace} placeholder={$t('chat.placeholder', { name: workspaceDisplayName })} onsend={send} + onstatus={handleStatusCommand} {queuedMessages} onqueuesendnow={handleQueueSendNow} onqueueedit={handleQueueEdit} @@ -1496,5 +1575,16 @@
{#if showStatusModal} - (showStatusModal = false)} /> + { + showStatusModal = false; + initialCommandSessionId = null; + }} + /> {/if} diff --git a/cptr/frontend/src/lib/components/chat/StatusModal.svelte b/cptr/frontend/src/lib/components/chat/StatusModal.svelte index 94d4a5d..70999df 100644 --- a/cptr/frontend/src/lib/components/chat/StatusModal.svelte +++ b/cptr/frontend/src/lib/components/chat/StatusModal.svelte @@ -1,20 +1,53 @@ - -
-
-

- {$t('chat.commandStatus')} -

- - {contextUsage ? `${contextPercent}%` : $t('chat.statusUnknown')} - -
+ function elapsedLabel(ts: number): string { + const seconds = Math.max(0, Math.floor(Date.now() / 1000 - ts)); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + return `${Math.floor(minutes / 60)}h`; + } -
-
-
-
-
- - {$t('chat.statusEstimated')} - - - {#if contextUsage} - {tokenCount.toLocaleString()} / {contextUsage.threshold.toLocaleString()} - {:else} - {$t('chat.statusUnknown')} - {/if} - -
-
+ function shortCommand(command: string): string { + return command.length > 92 ? `${command.slice(0, 92)}...` : command; + } -
-
- {$t('chat.statusChatId')} -
- {#if chatId} + function formatTokenCount(value: number): string { + if (value >= 1_000_000) return `${trimNumber(value / 1_000_000)}m`; + if (value >= 1_000) return `${trimNumber(value / 1_000)}k`; + return String(value); + } + + function trimNumber(value: number): string { + return value >= 10 ? String(Math.round(value)) : value.toFixed(1).replace(/\.0$/, ''); + } + + function sessionLabel(session: CommandSession): string { + return session.command_session_id; + } + + function statusLabel(session: CommandSession): string { + if (!session.done) return elapsedLabel(session.created_at); + if (session.exit_code && session.exit_code !== 0) return `Failed ${session.exit_code}`; + return 'Finished'; + } + + function terminateSelectedSession() { + if (!selectedSession || terminatingSelectedSession) return; + terminatingSessionId = selectedSession.command_session_id; + forceStopSignal += 1; + } + + $effect(() => { + if (!initialCommandSessionId) return; + const exists = runningSessions.some( + (session) => session.command_session_id === initialCommandSessionId + ); + if (exists) { + selectedSessionId = initialCommandSessionId; + appliedInitialSessionId = initialCommandSessionId; + missingSessionId = null; + } else if (initialCommandSessionId !== appliedInitialSessionId) { + selectedSessionId = null; + appliedInitialSessionId = initialCommandSessionId; + missingSessionId = initialCommandSessionId; + } + }); + + +{#if selectedSession} + +
+
- {:else} -
- {$t('chat.statusNoChat')} +
+
+ {sessionLabel(selectedSession)} +
+ +
+
+ +
+
+ +{:else} + +
+ {#if missingSessionId} +
+ Live session unavailable. Saved output remains in the chat. +
+ {/if} + + {#if runningSessions.length} +
+
+ Sessions +
+ {#each runningSessions as session} +
+ + + {statusLabel(session)} + +
+ {/each} +
{/if} + +
+
+
+ Context + + {contextValue} + +
+
+
+
+
+ + {#if queuedMessages.length} +
+ Queued messages + + {queuedMessages.length} + +
+ {/if} + + {#if chatId} +
+ Chat ID + +
+ {:else} +
+ Chat ID + + {$t('chat.statusNoChat')} + +
+ {/if} +
-
-
+ +{/if} diff --git a/cptr/frontend/src/lib/components/chat/ToolCallCollapsible.svelte b/cptr/frontend/src/lib/components/chat/ToolCallCollapsible.svelte index 880ae73..6f1d1a6 100644 --- a/cptr/frontend/src/lib/components/chat/ToolCallCollapsible.svelte +++ b/cptr/frontend/src/lib/components/chat/ToolCallCollapsible.svelte @@ -46,7 +46,6 @@ return pairedOutput.output; } }); - function toggleExpanded() { expanded = !expanded; } @@ -57,6 +56,7 @@ toggleExpanded(); } } +
@@ -178,7 +178,7 @@ > {:else} -
+
tuple: return new_file, new_size -async def _collect_bg_output(task_id: str): - """Read output from a background process (PTY or pipe) into memory + JSONL log.""" - task = _bg_tasks.get(task_id) - if not task: +async def stream_command_session_output(command_session_id: str): + """Read output from a command process into memory + JSONL log.""" + session = command_sessions.get(command_session_id) + if not session: return - master_fd = task.get("master_fd") - proc = task["proc"] - log_path = task.get("log_path") + master_fd = session.get("master_fd") + proc = session["proc"] + log_path = session.get("log_path") log_file = None log_bytes = 0 + loop = asyncio.get_event_loop() try: if log_path: @@ -132,7 +133,7 @@ async def _collect_bg_output(task_id: str): json.dumps( { "type": "start", - "command": task["command"], + "command": session["command"], "pid": proc.pid, "ts": time.time(), } @@ -143,8 +144,6 @@ async def _collect_bg_output(task_id: str): log_file.flush() log_bytes += len(entry.encode("utf-8", errors="replace")) - loop = asyncio.get_event_loop() - while True: # Read from PTY fd (Unix) or subprocess pipe (Windows fallback) if master_fd is not None: @@ -159,12 +158,14 @@ async def _collect_bg_output(task_id: str): if not chunk: break - task = _bg_tasks.get(task_id) - if task: - task["output"].extend(chunk) - task["total_bytes"] += len(chunk) - if len(task["output"]) > 256 * 1024: - task["output"] = task["output"][-256 * 1024 :] + session = command_sessions.get(command_session_id) + if session: + session["output"].extend(chunk) + session["total_bytes"] += len(chunk) + if len(session["output"]) > 256 * 1024: + session["output"] = session["output"][-256 * 1024 :] + async with session["condition"]: + session["condition"].notify_all() if log_file: entry = ( @@ -187,7 +188,7 @@ async def _collect_bg_output(task_id: str): pass finally: # Wait for the process to finish and collect exit code - task = _bg_tasks.get(task_id) + session = command_sessions.get(command_session_id) if master_fd is not None: exit_code = await loop.run_in_executor(None, proc.wait) try: @@ -198,10 +199,12 @@ async def _collect_bg_output(task_id: str): await proc.wait() exit_code = proc.returncode - if task: - task["done"] = True - task["exit_code"] = exit_code - task["master_fd"] = None # fd is closed + if session: + session["done"] = True + session["exit_code"] = exit_code + session["master_fd"] = None # fd is closed + async with session["condition"]: + session["condition"].notify_all() if log_file: log_file.write( @@ -238,6 +241,114 @@ def _truncate_output(text: str, max_chars: int = 80_000) -> str: return text[:half] + "\n\n... (truncated) ...\n\n" + text[-half:] +def _command_session_snapshot(command_session_id: str, session: dict) -> dict[str, Any]: + return { + "command_session_id": command_session_id, + "task_id": command_session_id, # legacy UI/tool wording + "workspace": session.get("workspace", ""), + "chat_id": session.get("chat_id"), + "message_id": session.get("message_id"), + "call_id": session.get("call_id"), + "command": session.get("command", ""), + "created_at": session.get("created_at", 0), + "status": "completed" if session.get("done") else "running", + "done": bool(session.get("done")), + "exit_code": session.get("exit_code"), + "total_bytes": int(session.get("total_bytes") or 0), + "output": bytes(session.get("output") or b"").decode(errors="replace"), + } + + +def list_command_sessions( + workspace: str | None = None, chat_id: str | None = None +) -> list[dict[str, Any]]: + sessions: list[dict[str, Any]] = [] + for command_session_id, session in command_sessions.items(): + if session.get("done"): + continue + if workspace and session.get("workspace") != workspace: + continue + if chat_id and session.get("chat_id") != chat_id: + continue + sessions.append(_command_session_snapshot(command_session_id, session)) + sessions.sort(key=lambda item: (item["status"] != "running", -float(item["created_at"] or 0))) + return sessions + + +def get_command_session(command_session_id: str) -> dict | None: + return command_sessions.get(command_session_id) + + +def command_session_bytes_since(session: dict, offset: int) -> tuple[bytes, int]: + buf = session["output"] + total = int(session.get("total_bytes") or 0) + buf_start = total - len(buf) + if offset <= buf_start: + raw = bytes(buf) + else: + raw = bytes(buf[offset - buf_start :]) + return raw, total + + +def send_command_session_input(command_session_id: str, data: bytes) -> str | None: + session = command_sessions.get(command_session_id) + if not session: + return "command session not found" + if session.get("done"): + return "command session already exited" + + master_fd = session.get("master_fd") + if master_fd is not None: + try: + os.write(master_fd, data) + except OSError: + return "PTY closed" + else: + proc = session["proc"] + if proc.stdin is None: + return "stdin unavailable" + try: + proc.stdin.write(data) + if hasattr(proc.stdin, "drain"): + # asyncio subprocess pipe + return None + except (BrokenPipeError, ConnectionResetError, OSError): + return "stdin closed" + return None + + +async def drain_command_session_input(command_session_id: str) -> None: + session = command_sessions.get(command_session_id) + proc = session.get("proc") if session else None + stdin = getattr(proc, "stdin", None) + if stdin is not None and hasattr(stdin, "drain"): + await stdin.drain() + + +def resize_command_session(command_session_id: str, rows: int, cols: int) -> None: + session = command_sessions.get(command_session_id) + if not session or session.get("done"): + return + master_fd = session.get("master_fd") + if master_fd is None or not _PTY_AVAILABLE: + return + try: + winsize = struct.pack("HHHH", rows, cols, 0, 0) + fcntl.ioctl(master_fd, termios.TIOCSWINSZ, winsize) + except OSError: + pass + + +def stop_command_session(command_session_id: str, force: bool = False) -> str | None: + session = command_sessions.get(command_session_id) + if not session: + return "command session not found" + if session.get("done"): + return None + _kill_process_group(session["proc"].pid, force=force) + return None + + # โ”€โ”€ Image support โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ IMAGE_EXTENSIONS = { @@ -834,20 +945,21 @@ async def run_command( cwd: str = ".", wait: Optional[int] = None, *, - workspace: str, + __context__: dict, ) -> str: """Run a shell command. Returns a task_id for status checks and input. :param command: The shell command to execute. :param cwd: Working directory relative to workspace root. :param wait: Seconds to wait for the command to finish before returning (max 300). Returns early if done sooner. Null returns immediately. Use 30-60 for installs and builds, 5-10 for quick commands, null or 0 for long-lived servers. """ + workspace = __context__["workspace"] work_dir = _resolve_path(cwd, workspace) if not work_dir.is_dir(): return f"Error: not a directory: {cwd}" - active = sum(1 for t in _bg_tasks.values() if not t.get("done")) - if active >= _BG_TASK_LIMIT: - return f"Error: too many running tasks ({active}/{_BG_TASK_LIMIT}). Kill one first." + active = sum(1 for t in command_sessions.values() if not t.get("done")) + if active >= MAX_COMMAND_SESSIONS: + return f"Error: too many running command sessions ({active}/{MAX_COMMAND_SESSIONS}). Stop one first." env = {**os.environ, "PAGER": "cat", "GIT_PAGER": "cat"} master_fd = None @@ -867,24 +979,31 @@ async def run_command( except Exception as e: return f"Error: {e}" - task_id = uuid.uuid4().hex[:8] + command_session_id = uuid.uuid4().hex[:8] log_dir = Path(workspace) / ".cptr" / "task_logs" log_dir.mkdir(parents=True, exist_ok=True) - log_path = log_dir / f"{task_id}.jsonl" + log_path = log_dir / f"{command_session_id}.jsonl" - _bg_tasks[task_id] = { + command_sessions[command_session_id] = { + "command_session_id": command_session_id, "master_fd": master_fd, "proc": proc, "output": bytearray(), "total_bytes": 0, "command": command, + "workspace": workspace, + "chat_id": __context__.get("chat_id"), + "message_id": __context__.get("message_id"), + "call_id": __context__.get("call_id"), + "created_at": time.time(), "done": False, "exit_code": None, "log_path": str(log_path), "log_task": None, + "condition": asyncio.Condition(), } - log_task = asyncio.create_task(_collect_bg_output(task_id)) - _bg_tasks[task_id]["log_task"] = log_task + log_task = asyncio.create_task(stream_command_session_output(command_session_id)) + command_sessions[command_session_id]["log_task"] = log_task # Wait for the command to finish inline (matches open-terminal behaviour) if wait is None and EXECUTE_TIMEOUT: @@ -895,7 +1014,7 @@ async def run_command( except asyncio.TimeoutError: pass - task = _bg_tasks.get(task_id) + task = command_sessions.get(command_session_id) output = task["output"].decode(errors="replace") if task else "" output = _truncate_output(output, max_chars=CHAT_TOOL_COMMAND_MAX_CHARS) done = task.get("done", False) if task else True @@ -907,9 +1026,7 @@ async def run_command( else: status = "running" - return ( - f"Task {task_id}: {status}\nCommand: {command}\nnext_offset: {next_offset}\n---\n{output}" - ) + return f"Task {command_session_id}: {status}\nCommand: {command}\nnext_offset: {next_offset}\n---\n{output}" async def check_task( @@ -920,9 +1037,9 @@ async def check_task( :param offset: Byte offset from previous check. Pass next_offset from the last response to get only new output. :param wait: Seconds to wait for the task to finish before returning (max 300). Returns early if done sooner. Null returns immediately. """ - task = _bg_tasks.get(task_id) + task = command_sessions.get(task_id) if not task: - available = list(_bg_tasks.keys()) + available = list(command_sessions.keys()) return f"Error: no task with id '{task_id}'. Active tasks: {available or 'none'}" # Optionally wait for the task to finish @@ -965,19 +1082,16 @@ async def kill_task(task_id: str, force: bool = False, *, workspace: str) -> str :param task_id: The task ID to kill. :param force: Send SIGKILL instead of SIGTERM for immediate termination. """ - task = _bg_tasks.get(task_id) + task = command_sessions.get(task_id) if not task: - available = list(_bg_tasks.keys()) + available = list(command_sessions.keys()) return f"Error: no task with id '{task_id}'. Active tasks: {available or 'none'}" if task.get("done", False): exit_code = task.get("exit_code") - _bg_tasks.pop(task_id, None) return f"Task {task_id} already finished (code {exit_code})" - proc = task["proc"] - _kill_process_group(proc.pid, force=force) - _bg_tasks.pop(task_id, None) + stop_command_session(task_id, force=force) action = "Killed" if force else "Terminated" return f"{action} task {task_id}" @@ -988,9 +1102,9 @@ async def send_input(task_id: str, input: str, *, workspace: str) -> str: :param task_id: The task ID returned by run_command. :param input: Text to send. Use \\n for Enter, \\x03 for Ctrl-C, \\x04 for Ctrl-D. """ - task = _bg_tasks.get(task_id) + task = command_sessions.get(task_id) if not task: - available = list(_bg_tasks.keys()) + available = list(command_sessions.keys()) return f"Error: no task '{task_id}'. Active: {available or 'none'}" if task.get("done", False): @@ -1002,23 +1116,10 @@ async def send_input(task_id: str, input: str, *, workspace: str) -> str: except (UnicodeDecodeError, ValueError): text = input - master_fd = task.get("master_fd") - if master_fd is not None: - # PTY mode: write to master fd - try: - os.write(master_fd, text.encode()) - except OSError: - return f"Error: PTY closed for task {task_id}" - else: - # Pipe mode fallback - proc = task["proc"] - if proc.stdin is None: - return f"Error: task {task_id} has no stdin" - try: - proc.stdin.write(text.encode()) - await proc.stdin.drain() - except (BrokenPipeError, ConnectionResetError, OSError): - return f"Error: stdin closed for task {task_id}" + error = send_command_session_input(task_id, text.encode()) + if error: + return f"Error: {error} for task {task_id}" + await drain_command_session_input(task_id) return f"Sent {len(text)} bytes to task {task_id}" diff --git a/pyproject.toml b/pyproject.toml index d289a71..b261fb3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cptr" -version = "0.7.3" +version = "0.7.4" description = "Your computer, from anywhere. Code, manage, and control your machine from the web." license = {file = "LICENSE"} readme = "README.md" diff --git a/uv.lock b/uv.lock index 333ba2a..9f74db6 100644 --- a/uv.lock +++ b/uv.lock @@ -284,7 +284,7 @@ wheels = [ [[package]] name = "cptr" -version = "0.6.2" +version = "0.7.4" source = { editable = "." } dependencies = [ { name = "aiosqlite" },