Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2f7dbf9
feat(terminal): consolidate terminal modules from devtools/ into feat…
May 20, 2026
f9ff007
feat(terminal): promote terminal to first-class workspace feature
May 20, 2026
2ae4086
feat(terminal): add Claude Code CLI as built-in agent provider
May 20, 2026
2f9452a
feat(terminal): apply NeverWrite themes to xterm.js ANSI colour palette
May 20, 2026
ae923d5
fix(providers): require API key for Claude ACP; remove subscription auth
May 20, 2026
f743d1d
fix(terminal): address Opus code review findings
May 20, 2026
baf99df
docs: add terminal integration follow-up plan from Opus review
May 20, 2026
b560831
refactor(terminal): implement follow-up items from Opus review
May 20, 2026
d094662
Open Claude Code from agents sidebar
jsgrrchg May 21, 2026
cfaff3a
Harden terminal integration tests
jsgrrchg May 21, 2026
28e9c18
Keep terminal tab numbering separate
jsgrrchg May 21, 2026
c4a96c9
Merge remote-tracking branch 'origin/main' into feature/terminal-firs…
jsgrrchg May 21, 2026
164abc1
Prevent Claude Code from starting ACP chats
jsgrrchg May 21, 2026
685c651
Make workspace terminal always available
jsgrrchg May 21, 2026
91d58a6
Remove obsolete developer mode setting
jsgrrchg May 21, 2026
656cac9
Fix Claude Code default runtime selection
jsgrrchg May 21, 2026
a042f6b
Harden Claude Code terminal launch args
jsgrrchg May 21, 2026
091c596
Respect last selected agent runtime
jsgrrchg May 21, 2026
01db158
Require terminal palettes for all themes
jsgrrchg May 21, 2026
94db8a2
Ignore unavailable default agent runtime
jsgrrchg May 21, 2026
cf7d56c
Escape Claude Code file mentions
jsgrrchg May 21, 2026
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
33 changes: 33 additions & 0 deletions apps/desktop/native-backend/src/devtools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ struct DevTerminalCreateInput {
cwd: Option<String>,
cols: Option<u16>,
rows: Option<u16>,
#[serde(default)]
extra_env: HashMap<String, String>,
}

#[derive(Debug, Clone, Deserialize)]
Expand Down Expand Up @@ -156,6 +158,32 @@ impl DevTerminalManager {
let session_id = required_string(&args, &["sessionId", "session_id"])?;
Ok(json!(self.snapshot(&session_id)?))
}
"devtools_check_binary" => {
let name = required_string(&args, &["name"])?;
// Reject anything that isn't a plain binary name to prevent
// shell injection when interpolating into the sh -lc command.
if !name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.')
{
return Err(format!("Invalid binary name: {name}"));
}
// Use a login shell so the full user PATH is available (important
// on macOS where Electron inherits a stripped environment PATH).
#[cfg(unix)]
let found = std::process::Command::new("sh")
.args(["-lc", &format!("command -v {name}")])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
#[cfg(windows)]
let found = std::process::Command::new("where.exe")
.arg(&name)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
Ok(json!({ "found": found }))
}
_ => Err(format!("Unknown devtools command: {command}")),
}
}
Expand Down Expand Up @@ -187,6 +215,7 @@ impl DevTerminalManager {
cwd: Some(snapshot.cwd),
cols: Some(snapshot.cols),
rows: Some(snapshot.rows),
extra_env: HashMap::new(),
},
)
}
Expand Down Expand Up @@ -343,8 +372,12 @@ impl DevTerminalManager {
command.args(&launch_config.args);
command.cwd(&launch_config.cwd);
command.env("TERM", "xterm-256color");
command.env("COLORTERM", "truecolor");
command.env("COLUMNS", cols.to_string());
command.env("LINES", rows.to_string());
for (key, value) in &input.extra_env {
command.env(key, value);
}

let child = pair.slave.spawn_command(command).map_err(|error| {
format!(
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/native-backend/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -893,7 +893,8 @@ impl NativeBackend {
| "devtools_resize_terminal_session"
| "devtools_restart_terminal_session"
| "devtools_close_terminal_session"
| "devtools_get_terminal_session_snapshot" => self.devtools.invoke(command, args),
| "devtools_get_terminal_session_snapshot"
| "devtools_check_binary" => self.devtools.invoke(command, args),
"spellcheck_list_languages"
| "spellcheck_list_catalog"
| "spellcheck_check_text"
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src-electron/main/nativeBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ const SUPPORTED_COMMANDS = new Set([
"devtools_restart_terminal_session",
"devtools_close_terminal_session",
"devtools_get_terminal_session_snapshot",
"devtools_check_binary",
"spellcheck_list_languages",
"spellcheck_list_catalog",
"spellcheck_check_text",
Expand Down
45 changes: 3 additions & 42 deletions apps/desktop/src/App.noteWindow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,14 +177,10 @@ describe("App note window", () => {
expect(useEditorStore.getState().focusedPaneId).toBe("primary");
});

it("opens workspace terminals from the developer terminal command", async () => {
it("opens workspace terminals from the terminal command", async () => {
detachedWindowMock.label = "main";
detachedWindowMock.mode = "main";
window.history.replaceState({}, "", "/");
useSettingsStore.setState({
developerModeEnabled: true,
developerTerminalEnabled: true,
});

renderComponent(<App />);
await flushPromises();
Expand All @@ -197,7 +193,7 @@ describe("App note window", () => {
).toBe(true);

await act(async () => {
useCommandStore.getState().execute("developer:new-terminal-tab");
useCommandStore.getState().execute("workspace:new-terminal-tab");
await Promise.resolve();
});
await flushPromises();
Expand All @@ -214,10 +210,6 @@ describe("App note window", () => {
detachedWindowMock.label = "main";
detachedWindowMock.mode = "main";
window.history.replaceState({}, "", "/");
useSettingsStore.setState({
developerModeEnabled: true,
developerTerminalEnabled: true,
});

renderComponent(<App />);
await flushPromises();
Expand All @@ -244,34 +236,6 @@ describe("App note window", () => {
expect(activeTab && isTerminalTab(activeTab)).toBe(true);
});

it("does not open workspace terminals from the shortcut when disabled", async () => {
detachedWindowMock.label = "main";
detachedWindowMock.mode = "main";
window.history.replaceState({}, "", "/");
useSettingsStore.setState({
developerModeEnabled: true,
developerTerminalEnabled: false,
});

renderComponent(<App />);
await flushPromises();

const platform = getDesktopPlatform();

await act(async () => {
window.dispatchEvent(
new KeyboardEvent("keydown", {
key: "r",
metaKey: platform === "macos",
ctrlKey: platform !== "macos",
}),
);
await Promise.resolve();
});
await flushPromises();

expect(useEditorStore.getState().tabs.some(isTerminalTab)).toBe(false);
});

it("starts workspace terminal runtimes inside detached note windows", async () => {
mockInvoke().mockResolvedValue({
Expand Down Expand Up @@ -308,6 +272,7 @@ describe("App note window", () => {
cwd: "/vault",
cols: 120,
rows: 24,
extraEnv: {},
},
},
);
Expand All @@ -323,10 +288,6 @@ describe("App note window", () => {
detachedWindowMock.label = "main";
detachedWindowMock.mode = "main";
window.history.replaceState({}, "", "/");
useSettingsStore.setState({
developerModeEnabled: true,
developerTerminalEnabled: true,
});
const restartSpy = vi
.spyOn(useTerminalRuntimeStore.getState(), "restart")
.mockResolvedValue(undefined);
Expand Down
22 changes: 13 additions & 9 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { OutlinePanel } from "./features/notes/OutlinePanel";
import { AIChatWorkspaceHost } from "./features/ai/AIChatWorkspaceHost";
import { AIChatDetachedWindowHost } from "./features/ai/AIChatDetachedWindowHost";
import { createNewChatInWorkspace } from "./features/ai/chatPaneMovement";
import { CLAUDE_TERMINAL_RUNTIME_ID } from "./features/ai/utils/runtimeMetadata";
import { openClaudeCodeTerminalWithContext } from "./features/terminal/claudeCodeTerminal";
import { WorkspaceTerminalHost } from "./features/terminal/WorkspaceTerminalHost";
import { migrateLegacyTerminalTabsToWorkspace } from "./features/terminal/legacyTerminalMigration";
import { UnifiedBar } from "./features/editor/UnifiedBar";
Expand Down Expand Up @@ -588,16 +590,12 @@ function useRegisterCommands(
? selectPaneNeighbor(state, focusedPaneId, direction) !== null
: false;
};
const developerModeEnabled = () =>
developerCommandsEnabled &&
useSettingsStore.getState().developerModeEnabled &&
useSettingsStore.getState().developerTerminalEnabled;
const activeTerminalTab = () => {
const tab = selectFocusedEditorTab(useEditorStore.getState());
return tab && isTerminalTab(tab) ? tab : null;
};
const canRestartActiveTerminal = () =>
developerModeEnabled() && activeTerminalTab() !== null;
developerCommandsEnabled && activeTerminalTab() !== null;

// Navigation
register({
Expand Down Expand Up @@ -685,7 +683,14 @@ function useRegisterCommands(
category: newAgentShortcut.category,
when: hasVault,
execute: () => {
void createNewChatInWorkspace();
if (
useChatStore.getState().getDefaultNewChatRuntimeId() ===
CLAUDE_TERMINAL_RUNTIME_ID
) {
void openClaudeCodeTerminalWithContext();
} else {
void createNewChatInWorkspace();
}
},
});

Expand Down Expand Up @@ -934,11 +939,10 @@ function useRegisterCommands(
});

register({
id: "developer:new-terminal-tab",
id: "workspace:new-terminal-tab",
label: newTerminalShortcut.label,
shortcut: formatShortcutAction(newTerminalShortcut.id, platform),
category: newTerminalShortcut.category,
when: developerModeEnabled,
execute: () => {
useEditorStore.getState().openTerminal();
},
Expand Down Expand Up @@ -1046,7 +1050,7 @@ function useGlobalShortcuts(openSettings: () => void) {

if (matchesShortcutAction(e, "new_terminal", platform)) {
e.preventDefault();
useCommandStore.getState().execute("developer:new-terminal-tab");
useCommandStore.getState().execute("workspace:new-terminal-tab");
return;
}

Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/app/shortcuts/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ describe("shortcut registry formatting", () => {
entries.find((entry) => entry.id === "new_terminal"),
).toMatchObject({
label: "New Terminal",
category: "Developer",
category: "Workspace",
shortcut: "Ctrl+R",
});
expect(entries.find((entry) => entry.id === "zoom_in")).toMatchObject({
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/app/shortcuts/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ const shortcutDefinitions = [
{
id: "new_terminal",
label: "New Terminal",
category: "Developer",
category: "Workspace",
bindings: {
macos: [{ key: "r", modifiers: ["meta"] }],
windows: [{ key: "r", modifiers: ["ctrl"] }],
Expand Down
34 changes: 34 additions & 0 deletions apps/desktop/src/app/store/editorStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2025,6 +2025,40 @@ describe("editorStore tab management", () => {
).toBeNull();
});

it("numbers normal terminal tabs independently from Claude Code terminals", () => {
useVaultStore.setState({ vaultPath: "/vaults/project-alpha" });
useEditorStore.getState().hydrateWorkspace(
[
{
id: "primary",
tabs: [
makeTerminalTab({
id: "claude-code-tab-1",
terminalId: "claude-code-runtime-1",
title: "Claude Code 1",
cwd: "/vaults/project-alpha",
}),
],
activeTabId: "claude-code-tab-1",
},
],
"primary",
);

const tabId = useEditorStore.getState().openTerminal();

const state = useEditorStore.getState();
expect(tabId).toBeTruthy();
expect(state.tabs).toContainEqual(
expect.objectContaining({
id: tabId,
kind: "terminal",
title: "Terminal 1",
cwd: "/vaults/project-alpha",
}),
);
});

it("remembers terminal tabs in recently closed tabs", () => {
useEditorStore.setState({
tabs: [
Expand Down
15 changes: 13 additions & 2 deletions apps/desktop/src/app/store/editorWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1486,9 +1486,20 @@ function getTabOpenBehavior() {
return useSettingsStore.getState().tabOpenBehavior;
}

const DEFAULT_TERMINAL_TITLE_PATTERN = /^Terminal(?: (\d+))?$/;

function getNextTerminalTitle(tabs: readonly Tab[]) {
const count = tabs.filter((tab) => isTerminalTab(tab)).length;
return `Terminal ${count + 1}`;
const maxExistingIndex = tabs.reduce((maxIndex, tab) => {
if (!isTerminalTab(tab)) return maxIndex;

const match = tab.title.trim().match(DEFAULT_TERMINAL_TITLE_PATTERN);
if (!match) return maxIndex;

const index = match[1] ? Number(match[1]) : 1;
return Number.isFinite(index) ? Math.max(maxIndex, index) : maxIndex;
}, 0);

return `Terminal ${maxExistingIndex + 1}`;
}

function resolvePinnedAwareTabReorder(
Expand Down
Loading
Loading