Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,29 @@ 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.5.3] - 2026-06-17

### Added

- 🔊 **Auto-stream TTS.** New toggle in Audio settings that automatically reads AI responses aloud as they stream in, without needing to manually enable playback each time.
- 🔀 **Branch management.** You can now rename and delete branches directly from the Git panel. Right-click (or tap the action button) on any branch to see your options.
- 🔍 **Branch search.** The branch picker now has a search bar so you can quickly find branches in large repos.
- 💾 **Stash awareness when switching branches.** If you have uncommitted changes and try to switch or create a branch, you'll be asked whether to bring the changes along or leave them behind.

### Fixed

- 🧩 **MCP tool server errors are now helpful.** If the MCP package isn't installed, you'll get a clear message telling you how to install it instead of a confusing traceback.
- 🔧 **AI tool calls no longer break on empty arrays.** Fixed a subtle issue where providers returning empty tool call lists could cause errors during streaming.
- 🗂️ **Git file list no longer shows duplicates.** Files that were both staged and modified used to appear twice in the changed files list. They now show as a single entry with the correct status.

### Changed

- 📋 **Dropdown menus support action buttons.** Menu items can now have a secondary action icon on the right side, used for things like branch context menus.
- 📂 **Home page lists are trimmed.** The recent files and folder suggestions on the welcome screen now show at most 5 items each to keep things tidy.
- 📦 **Added an "all" install extra.** You can now `pip install cptr[all]` to get every optional dependency (MCP, document support, PAM) in one go.
- 🔊 **Sticky save button in Audio settings.** The save button in Audio settings now stays visible at the bottom of the panel as you scroll.
- 💾 **Stash includes untracked files.** When stashing changes, new files that haven't been committed yet are now included automatically.

## [0.5.2] - 2026-06-16

### Fixed
Expand Down
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ WORKDIR /home/cptr
# Install the wheel into an isolated venv
COPY --chown=cptr:cptr --from=backend-builder /dist/*.whl /tmp/
RUN uv venv /home/cptr/.venv && \
uv pip install --python /home/cptr/.venv/bin/python /tmp/*.whl && \
set -- /tmp/*.whl && \
uv pip install --python /home/cptr/.venv/bin/python "$1[all]" && \
rm /tmp/*.whl

ENV PATH="/home/cptr/.venv/bin:$PATH"
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

`cptr` (short for "computer") runs on your machine and serves your whole computer (files, terminal, editor, git) to any browser. It literally is your computer.

Phone, tablet, laptop, another computer, even the one it's running on. Designed to feel native on every screen. Plug in an AI that can actually read, write, and run things on your machine, or bring your favourite terminal agent. Terminal multiplexer, parallel AI agents, full workstation, one tool, any device.
Use it from your phone, tablet, laptop, another computer, or the machine it's running on. Designed to feel native on every screen. Plug in an AI that can actually read, write, and run things on your machine, or bring your favourite terminal agent. Terminal multiplexer, parallel AI agents, full workstation, one tool, any device.

## Install

Expand All @@ -25,6 +25,10 @@ pip install cptr
cptr run
```

MCP tool servers require the optional MCP dependencies: `pip install 'cptr[mcp]'`.
To install every optional feature group, use `pip install 'cptr[all]'`.
The Docker image includes all optional feature groups.

Or with [uv](https://docs.astral.sh/uv/): `uvx cptr@latest run`

Opens in your browser at `http://localhost:8000`.
Expand Down
23 changes: 23 additions & 0 deletions cptr/frontend/src/lib/apis/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ export const getGitShow = (root: string, ref: string) =>
export const getGitBranches = (root: string) =>
fetchJSON(`/api/git/branches?root=${encodeURIComponent(root)}`);

export const getGitStashes = (root: string) =>
fetchJSON(`/api/git/stashes?root=${encodeURIComponent(root)}`);

export const stageFiles = (root: string, files: string[]) =>
fetchJSON('/api/git/stage', jsonBody({ root, files }));

Expand Down Expand Up @@ -72,9 +75,29 @@ export const gitPush = (

export const gitUncommit = (root: string) => fetchJSON('/api/git/uncommit', jsonBody({ root }));

export const gitStash = (root: string, message?: string) =>
fetchJSON('/api/git/stash', jsonBody({ root, message }));

export const gitUnstash = (root: string, index = 0) =>
fetchJSON('/api/git/unstash', jsonBody({ root, index }));

export const createGitBranch = (root: string, name: string) =>
fetchJSON('/api/git/branch', jsonBody({ root, name }));

export const renameGitBranch = (root: string, old_name: string, new_name: string) =>
fetchJSON('/api/git/branch', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ root, old_name, new_name })
});

export const deleteGitBranch = (root: string, name: string) =>
fetchJSON('/api/git/branch', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ root, name })
});

export const checkoutBranch = (root: string, branch: string) =>
fetchJSON('/api/git/checkout', jsonBody({ root, branch }));

Expand Down
112 changes: 90 additions & 22 deletions cptr/frontend/src/lib/components/Admin/AudioSettings.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
let ttsVoice = $state('alloy');
let ttsFormat = $state('mp3');
let ttsPlaybackSpeed = $state(1);
let ttsAutoStreamEnabled = $state(false);
let hasExistingTtsKey = $state(false);
let voiceModeSystemPrompt = $state('');
let voiceModeSttMode = $state<'browser' | 'provider'>('browser');
Expand All @@ -49,6 +50,7 @@
ttsFormat = (config['audio.tts_format'] as string) || 'mp3';
const speed = Number(config['audio.tts_playback_speed']);
ttsPlaybackSpeed = Number.isFinite(speed) ? Math.min(Math.max(speed, 0.5), 2) : 1;
ttsAutoStreamEnabled = config['audio.tts_auto_stream_enabled'] === true;
hasExistingTtsKey = !!config['audio.tts_api_key'];
voiceModeSystemPrompt = (config['audio.voice_mode_system_prompt'] as string) || '';
voiceModeSttMode =
Expand All @@ -72,6 +74,7 @@
'audio.tts_voice': ttsVoice,
'audio.tts_format': ttsFormat,
'audio.tts_playback_speed': ttsPlaybackSpeed,
'audio.tts_auto_stream_enabled': ttsAutoStreamEnabled,
'audio.voice_mode_system_prompt': voiceModeSystemPrompt,
'audio.voice_mode_stt_mode': voiceModeSttMode
};
Expand Down Expand Up @@ -105,23 +108,41 @@

<div class="flex flex-col gap-2.5">
<label class="flex items-center justify-between cursor-pointer">
<span class="text-xs text-gray-600 dark:text-gray-400">{$t('admin.audio.enableVoiceMemos')}</span>
<ToggleSwitch value={voiceMemosEnabled} onchange={(v) => { voiceMemosEnabled = v; }} />
<span class="text-xs text-gray-600 dark:text-gray-400"
>{$t('admin.audio.enableVoiceMemos')}</span
>
<ToggleSwitch
value={voiceMemosEnabled}
onchange={(v) => {
voiceMemosEnabled = v;
}}
/>
</label>
<p class="text-[11px] text-gray-400 dark:text-gray-600 -mt-1">
{$t('admin.audio.voiceMemosHint')}
</p>

<label class="flex items-center justify-between cursor-pointer">
<span class="text-xs text-gray-600 dark:text-gray-400">{$t('admin.audio.autoTranscribe')}</span>
<ToggleSwitch value={transcribeEnabled} onchange={(v) => { transcribeEnabled = v; }} />
<span class="text-xs text-gray-600 dark:text-gray-400"
>{$t('admin.audio.autoTranscribe')}</span
>
<ToggleSwitch
value={transcribeEnabled}
onchange={(v) => {
transcribeEnabled = v;
}}
/>
</label>
<p class="text-[11px] text-gray-400 dark:text-gray-600 -mt-1">
{transcribeEnabled ? $t('admin.audio.transcribeOnHint') : $t('admin.audio.transcribeOffHint')}
{transcribeEnabled
? $t('admin.audio.transcribeOnHint')
: $t('admin.audio.transcribeOffHint')}
</p>

<div class="flex items-center justify-between">
<span class="text-xs text-gray-600 dark:text-gray-400">{$t('admin.audio.recordingQuality')}</span>
<span class="text-xs text-gray-600 dark:text-gray-400"
>{$t('admin.audio.recordingQuality')}</span
>
<select
bind:value={quality}
class="bg-transparent text-xs text-gray-600 dark:text-gray-400 outline-none cursor-pointer"
Expand All @@ -132,7 +153,11 @@
</select>
</div>
<p class="text-[11px] text-gray-400 dark:text-gray-600 -mt-1">
{quality === 'high' ? $t('admin.audio.qualityHintHigh') : quality === 'medium' ? $t('admin.audio.qualityHintMedium') : $t('admin.audio.qualityHintLow')}
{quality === 'high'
? $t('admin.audio.qualityHintHigh')
: quality === 'medium'
? $t('admin.audio.qualityHintMedium')
: $t('admin.audio.qualityHintLow')}
</p>
</div>

Expand All @@ -141,7 +166,9 @@

<div class="flex flex-col gap-2.5">
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="stt-base-url">{$t('connections.baseUrl')}</label>
<label class="text-xs text-gray-600 dark:text-gray-400" for="stt-base-url"
>{$t('connections.baseUrl')}</label
>
<input
id="stt-base-url"
type="text"
Expand All @@ -151,7 +178,9 @@
/>
</div>
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="stt-api-key">{$t('connections.apiKey')}</label>
<label class="text-xs text-gray-600 dark:text-gray-400" for="stt-api-key"
>{$t('connections.apiKey')}</label
>
<input
id="stt-api-key"
type="password"
Expand All @@ -161,7 +190,9 @@
/>
</div>
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="stt-model">{$t('automations.model')}</label>
<label class="text-xs text-gray-600 dark:text-gray-400" for="stt-model"
>{$t('automations.model')}</label
>
<input
id="stt-model"
type="text"
Expand All @@ -181,13 +212,34 @@
<div class="flex flex-col gap-2.5">
<label class="flex items-center justify-between cursor-pointer">
<span class="text-xs text-gray-600 dark:text-gray-400">{$t('admin.audio.enableTts')}</span>
<ToggleSwitch value={ttsEnabled} onchange={(v) => { ttsEnabled = v; }} />
<ToggleSwitch
value={ttsEnabled}
onchange={(v) => {
ttsEnabled = v;
}}
/>
</label>
<p class="text-[11px] text-gray-400 dark:text-gray-600 -mt-1">
{$t('admin.audio.ttsEnabledHint')}
</p>
<label class="flex items-center justify-between cursor-pointer">
<span class="text-xs text-gray-600 dark:text-gray-400"
>{$t('admin.audio.ttsAutoStream')}</span
>
<ToggleSwitch
value={ttsAutoStreamEnabled}
onchange={(v) => {
ttsAutoStreamEnabled = v;
}}
/>
</label>
<p class="text-[11px] text-gray-400 dark:text-gray-600 -mt-1">
{$t('admin.audio.ttsAutoStreamHint')}
</p>
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="tts-base-url">{$t('connections.baseUrl')}</label>
<label class="text-xs text-gray-600 dark:text-gray-400" for="tts-base-url"
>{$t('connections.baseUrl')}</label
>
<input
id="tts-base-url"
type="text"
Expand All @@ -197,7 +249,9 @@
/>
</div>
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="tts-api-key">{$t('connections.apiKey')}</label>
<label class="text-xs text-gray-600 dark:text-gray-400" for="tts-api-key"
>{$t('connections.apiKey')}</label
>
<input
id="tts-api-key"
type="password"
Expand All @@ -207,7 +261,9 @@
/>
</div>
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="tts-model">{$t('automations.model')}</label>
<label class="text-xs text-gray-600 dark:text-gray-400" for="tts-model"
>{$t('automations.model')}</label
>
<input
id="tts-model"
type="text"
Expand All @@ -217,7 +273,9 @@
/>
</div>
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="tts-voice">{$t('admin.audio.ttsVoice')}</label>
<label class="text-xs text-gray-600 dark:text-gray-400" for="tts-voice"
>{$t('admin.audio.ttsVoice')}</label
>
<input
id="tts-voice"
type="text"
Expand Down Expand Up @@ -254,7 +312,9 @@
bind:value={ttsPlaybackSpeed}
class="w-28 accent-gray-700 dark:accent-gray-300"
/>
<span class="w-9 text-right text-xs text-gray-500 dark:text-gray-400">{ttsPlaybackSpeed.toFixed(2)}x</span>
<span class="w-9 text-right text-xs text-gray-500 dark:text-gray-400"
>{ttsPlaybackSpeed.toFixed(2)}x</span
>
</div>
</div>
<p class="text-[11px] text-gray-400 dark:text-gray-600">
Expand All @@ -263,11 +323,15 @@
</div>

<!-- Voice Mode -->
<h3 class="text-xs text-gray-400 dark:text-gray-600 mb-2 mt-5">{$t('admin.audio.voiceMode')}</h3>
<h3 class="text-xs text-gray-400 dark:text-gray-600 mb-2 mt-5">
{$t('admin.audio.voiceMode')}
</h3>

<div class="flex flex-col gap-2.5">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-600 dark:text-gray-400">{$t('admin.audio.voiceModeSttMode')}</span>
<span class="text-xs text-gray-600 dark:text-gray-400"
>{$t('admin.audio.voiceModeSttMode')}</span
>
<select
bind:value={voiceModeSttMode}
class="bg-transparent text-xs text-gray-600 dark:text-gray-400 outline-none cursor-pointer"
Expand All @@ -277,7 +341,9 @@
</select>
</div>
<p class="text-[11px] text-gray-400 dark:text-gray-600 -mt-1">
{voiceModeSttMode === 'browser' ? $t('admin.audio.voiceModeBrowserSttHint') : $t('admin.audio.voiceModeProviderSttHint')}
{voiceModeSttMode === 'browser'
? $t('admin.audio.voiceModeBrowserSttHint')
: $t('admin.audio.voiceModeProviderSttHint')}
</p>
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="voice-mode-system-prompt">
Expand All @@ -297,12 +363,14 @@
</div>

<!-- Save -->
<div class="mt-auto pt-6 flex justify-end">
<div
class="sticky bottom-0 -mx-4 md:-mx-5 mt-6 flex justify-end border-t border-gray-100 bg-white/95 px-4 py-3 backdrop-blur dark:border-white/6 dark:bg-black/95 md:px-5"
>
<button
class="text-[13px] text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors duration-100 disabled:opacity-50"
onclick={() => save()}
disabled={saving}
>{$t('settings.save')}</button>
disabled={saving}>{$t('settings.save')}</button
>
</div>
{/if}
</div>
Loading
Loading