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
3 changes: 2 additions & 1 deletion src/main/agent-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,12 @@ export class AgentManager {

spawn(params: AgentSpawnParams & { paneId: PaneId; workspaceId: WorkspaceId }): { agentId: AgentId; surfaceId: SurfaceId } {
const agentId: AgentId = `agent-${uuid()}`;
const surfaceId = this.ptyManager.create({
const created = this.ptyManager.create({
shell: '', // Use default shell (resolves to pwsh/powershell/bash, not hardcoded cmd.exe)
cwd: params.cwd || process.env.USERPROFILE || 'C:\\',
env: { ...(params.env || {}), WMUX_AGENT_ID: agentId, WMUX_AGENT_LABEL: params.label },
});
const surfaceId = created.id;

setTimeout(() => {
if (this.ptyManager.has(surfaceId)) {
Expand Down
5 changes: 3 additions & 2 deletions src/main/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ export function registerIpcHandlers(windowManager: WindowManager, cdpProxyInstan
...options,
cwd: options.cwd || process.env.USERPROFILE || 'C:\\',
};
const id = ptyManager.create(resolvedOptions);
const created = ptyManager.create(resolvedOptions);
const id = created.id;
const window = BrowserWindow.fromWebContents(_event.sender);
const unsubData = ptyManager.onData(id, (data) => {
if (window && !window.isDestroyed()) {
Expand All @@ -57,7 +58,7 @@ export function registerIpcHandlers(windowManager: WindowManager, cdpProxyInstan
unsubData();
unsubExit();
});
return id;
return created;
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to create terminal: ${msg}`);
Expand Down
4 changes: 2 additions & 2 deletions src/main/pty-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export interface CreateOptions {
export class PtyManager {
private ptys = new Map<SurfaceId, PtyEntry>();

create(options: CreateOptions): SurfaceId {
create(options: CreateOptions): { id: SurfaceId; shell: string } {
const id: SurfaceId = options.surfaceId ?? `surf-${uuidv4()}` as SurfaceId;

const shell = resolveShell(options.shell);
Expand Down Expand Up @@ -165,7 +165,7 @@ export class PtyManager {
});

this.ptys.set(id, entry);
return id;
return { id, shell };
}

write(id: SurfaceId, data: string): void {
Expand Down
2 changes: 1 addition & 1 deletion src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { IPC_CHANNELS } from '../shared/types';
contextBridge.exposeInMainWorld('wmux', {
pty: {
create: (options: { shell: string; cwd: string; env: Record<string, string>; surfaceId?: string }) =>
ipcRenderer.invoke(IPC_CHANNELS.PTY_CREATE, options),
ipcRenderer.invoke(IPC_CHANNELS.PTY_CREATE, options) as Promise<{ id: string; shell: string }>,
write: (id: string, data: string) =>
ipcRenderer.send(IPC_CHANNELS.PTY_WRITE, id, data),
resize: (id: string, cols: number, rows: number) =>
Expand Down
1 change: 1 addition & 0 deletions src/renderer/components/SplitPane/PaneWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ export default function PaneWrapper({ leaf, workspaceId, isFocused }: PaneWrappe
<div className={`pane-wrapper ${isFocused ? 'pane-wrapper--focused' : ''} ${dragActive ? 'pane-wrapper--drag-active' : ''}`}>
<SurfaceTabBar
paneId={paneId}
workspaceShell={workspace?.shell}
surfaces={surfaces}
activeSurfaceIndex={activeSurfaceIndex}
onSelect={handleSelectSurface}
Expand Down
23 changes: 19 additions & 4 deletions src/renderer/components/SplitPane/SurfaceTabBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useStore } from '../../store';

interface SurfaceTabBarProps {
paneId: PaneId;
workspaceShell?: string;
surfaces: SurfaceRef[];
activeSurfaceIndex: number;
onSelect: (index: number) => void;
Expand All @@ -29,11 +30,24 @@ function surfaceIcon(type: string, isAgent: boolean): string {
}
}

function surfaceLabel(surface: SurfaceRef, agentLabel?: string): string {
function getShellLabel(shell?: string): string | null {
if (!shell) return null;
const normalized = shell.replace(/\\/g, '/').split('/').pop()?.toLowerCase() || shell.toLowerCase();
if (normalized === 'pwsh.exe' || normalized === 'pwsh') return 'PowerShell';
if (normalized === 'powershell.exe' || normalized === 'powershell') return 'Windows PowerShell';
if (normalized === 'cmd.exe' || normalized === 'cmd') return 'Command Prompt';
if (normalized === 'bash.exe' || normalized === 'bash') return 'Bash';
if (normalized === 'zsh' || normalized === 'zsh.exe') return 'Zsh';
if (normalized === 'wsl.exe' || normalized === 'wsl') return 'WSL';
if (normalized === 'git-bash.exe') return 'Git Bash';
return normalized.replace(/\.exe$/i, '').replace(/[-_]/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase());
}

function surfaceLabel(surface: SurfaceRef, agentLabel?: string, workspaceShell?: string): string {
if (surface.customTitle) return surface.customTitle;
if (agentLabel) return agentLabel;
switch (surface.type) {
case 'terminal': return 'Terminal';
case 'terminal': return getShellLabel(surface.shell || workspaceShell) || 'Terminal';
case 'browser': return 'Browser';
case 'markdown': return 'Markdown';
case 'diff': return 'Diff';
Expand All @@ -43,6 +57,7 @@ function surfaceLabel(surface: SurfaceRef, agentLabel?: string): string {

export default function SurfaceTabBar({
paneId,
workspaceShell,
surfaces,
activeSurfaceIndex,
onSelect,
Expand Down Expand Up @@ -199,10 +214,10 @@ export default function SurfaceTabBar({
}}
onBlur={commitRename}
onClick={(e) => e.stopPropagation()}
placeholder={surfaceLabel(surface, agentMeta?.label)}
placeholder={surfaceLabel(surface, agentMeta?.label, workspaceShell)}
/>
) : (
<span className="surface-tab__label">{surfaceLabel(surface, agentMeta?.label)}</span>
<span className="surface-tab__label">{surfaceLabel(surface, agentMeta?.label, workspaceShell)}</span>
)}
{surfaces.length > 1 && !isRenaming && (
<button
Expand Down
36 changes: 34 additions & 2 deletions src/renderer/hooks/useTerminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { WebLinksAddon } from '@xterm/addon-web-links';
import { SearchAddon } from '@xterm/addon-search';
import { Unicode11Addon } from '@xterm/addon-unicode11';
import { ImageAddon } from '@xterm/addon-image';
import { useStore } from '../store';
import { SplitNode } from '../../shared/types';
import '@xterm/xterm/css/xterm.css';

declare global {
Expand All @@ -29,6 +31,30 @@ interface UseTerminalResult {
searchAddonRef: React.RefObject<SearchAddon | null>;
}

function treeHasSurface(node: SplitNode, surfaceId: string): boolean {
if (node.type === 'leaf') return node.surfaces.some((surface) => surface.id === surfaceId);
return treeHasSurface(node.children[0], surfaceId) || treeHasSurface(node.children[1], surfaceId);
}

function findSurfaceLocation(node: SplitNode, surfaceId: string): { paneId: string } | null {
if (node.type === 'leaf') {
return node.surfaces.some((surface) => surface.id === surfaceId)
? { paneId: node.paneId }
: null;
}
return findSurfaceLocation(node.children[0], surfaceId) || findSurfaceLocation(node.children[1], surfaceId);
}

function setResolvedShellForSurface(surfaceId: string | undefined, resolvedShell: string): void {
if (!surfaceId || !resolvedShell) return;
const state = useStore.getState();
const workspace = state.workspaces.find((ws) => treeHasSurface(ws.splitTree, surfaceId));
if (!workspace) return;
const location = findSurfaceLocation(workspace.splitTree, surfaceId);
if (!location) return;
state.updateSurface(workspace.id, location.paneId as any, surfaceId as any, { shell: resolvedShell });
}

export function useTerminal({ surfaceId, shell, cwd, visible = true }: UseTerminalOptions = {}): UseTerminalResult {
const terminalRef = useRef<HTMLDivElement | null>(null);
const xtermRef = useRef<Terminal | null>(null);
Expand Down Expand Up @@ -263,14 +289,20 @@ export function useTerminal({ surfaceId, shell, cwd, visible = true }: UseTermin
} else {
// No existing PTY — create a new one, passing surfaceId so PTY ID = Surface ID
window.wmux.pty.create({ shell: shell || '', cwd: cwd ?? '', env: {}, surfaceId })
.then(attachToPty)
.then((created: { id: string; shell: string }) => {
setResolvedShellForSurface(surfaceId, created.shell);
attachToPty(created.id);
})
.catch((err: unknown) => terminal.writeln(`\r\n\x1b[31m[failed to create PTY: ${err}]\x1b[0m`));
}
});
} else {
// No surfaceId hint — always create new PTY
window.wmux.pty.create({ shell: shell || '', cwd: cwd ?? '', env: {} })
.then(attachToPty)
.then((created: { id: string; shell: string }) => {
setResolvedShellForSurface(surfaceId, created.shell);
attachToPty(created.id);
})
.catch((err: unknown) => terminal.writeln(`\r\n\x1b[31m[failed to create PTY: ${err}]\x1b[0m`));
}

Expand Down
16 changes: 16 additions & 0 deletions src/renderer/store/surface-slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ export interface SurfaceSlice {
/** Rename a surface (set custom tab title) */
renameSurface: (workspaceId: WorkspaceId, paneId: PaneId, surfaceId: SurfaceId, customTitle: string) => void;

/** Update a surface without moving it */
updateSurface: (workspaceId: WorkspaceId, paneId: PaneId, surfaceId: SurfaceId, patch: Partial<SurfaceRef>) => void;

/** Split a pane and move a surface into the new pane (drag to edge) */
splitAndMoveSurface: (
workspaceId: WorkspaceId,
Expand Down Expand Up @@ -227,6 +230,19 @@ export const createSurfaceSlice: StateCreator<SliceState, [], [], SurfaceSlice>
updateSplitTree(workspaceId, updatedTree);
},

updateSurface(workspaceId, paneId, surfaceId, patch) {
const { workspaces, updateSplitTree } = get();
const ws = workspaces.find((w) => w.id === workspaceId);
if (!ws) return;

const leaf = findLeaf(ws.splitTree, paneId);
if (!leaf) return;

const newSurfaces = leaf.surfaces.map((s) => (s.id === surfaceId ? { ...s, ...patch } : s));
const updatedTree = patchLeaf(ws.splitTree, paneId, { surfaces: newSurfaces });
updateSplitTree(workspaceId, updatedTree);
},

splitAndMoveSurface(workspaceId, targetPaneId, sourcePaneId, surfaceId, direction) {
const { workspaces, updateSplitTree } = get();
const ws = workspaces.find((w) => w.id === workspaceId);
Expand Down
1 change: 1 addition & 0 deletions src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface SurfaceRef {
id: SurfaceId;
type: SurfaceType;
customTitle?: string;
shell?: string;
}

// Workspace
Expand Down