mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-12 19:55:19 +02:00
add terminal
This commit is contained in:
parent
e255420fb2
commit
b6c3d11e7a
12 changed files with 463 additions and 11 deletions
|
|
@ -11,6 +11,9 @@
|
|||
|
||||
import * as esbuild from 'esbuild';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// In CommonJS, import.meta.url doesn't exist. We need to polyfill it.
|
||||
// The banner defines __import_meta_url at the top of the bundle,
|
||||
|
|
@ -24,7 +27,11 @@ await esbuild.build({
|
|||
platform: 'node',
|
||||
target: 'node20',
|
||||
outfile: './.package/dist/main.cjs',
|
||||
external: ['electron'], // Provided by Electron runtime
|
||||
// electron is provided by the runtime. node-pty is a NATIVE module: it can't
|
||||
// be inlined (its loader requires .node binaries + a spawn-helper relative to
|
||||
// its own package dir), so it stays external and is copied into
|
||||
// .package/node_modules below, where require() from dist/main.cjs finds it.
|
||||
external: ['electron', 'node-pty'],
|
||||
// Use CommonJS format - many dependencies use require() which doesn't work
|
||||
// well with esbuild's ESM shim. CJS handles dynamic requires natively.
|
||||
format: 'cjs',
|
||||
|
|
@ -42,4 +49,23 @@ await esbuild.build({
|
|||
},
|
||||
});
|
||||
|
||||
// Ship node-pty next to the bundle. Resolve through pnpm's symlink to the real
|
||||
// package dir and copy only what's needed at runtime (compiled JS + prebuilt
|
||||
// binaries). The macOS spawn-helper must be executable — pnpm extraction drops
|
||||
// the bit, and a non-executable helper makes every PTY spawn fail.
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ptySrc = fs.realpathSync(path.join(here, 'node_modules', 'node-pty'));
|
||||
const ptyDest = path.join(here, '.package', 'node_modules', 'node-pty');
|
||||
fs.rmSync(ptyDest, { recursive: true, force: true });
|
||||
fs.mkdirSync(ptyDest, { recursive: true });
|
||||
for (const item of ['package.json', 'lib', 'prebuilds']) {
|
||||
fs.cpSync(path.join(ptySrc, item), path.join(ptyDest, item), { recursive: true, dereference: true });
|
||||
}
|
||||
const prebuildsDir = path.join(ptyDest, 'prebuilds');
|
||||
for (const dir of fs.readdirSync(prebuildsDir)) {
|
||||
const helper = path.join(prebuildsDir, dir, 'spawn-helper');
|
||||
if (fs.existsSync(helper)) fs.chmodSync(helper, 0o755);
|
||||
}
|
||||
console.log('✅ node-pty staged in .package/node_modules');
|
||||
|
||||
console.log('✅ Main process bundled to .package/dist-bundle/main.js');
|
||||
|
|
|
|||
|
|
@ -32,10 +32,12 @@ module.exports = {
|
|||
// Since we bundle everything with esbuild, we don't need node_modules at all.
|
||||
// These settings prevent Forge's dependency walker (flora-colossus) from trying
|
||||
// to analyze/copy node_modules, which fails with pnpm's symlinked workspaces.
|
||||
// Regexes are ANCHORED to the app root: .package/node_modules (where
|
||||
// bundle.mjs stages the native node-pty module) must survive packaging.
|
||||
prune: false,
|
||||
ignore: [
|
||||
/src\//,
|
||||
/node_modules\//,
|
||||
/^\/src\//,
|
||||
/^\/node_modules\//,
|
||||
/.gitignore/,
|
||||
/bundle\.mjs/,
|
||||
/tsconfig.json/,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
"electron-squirrel-startup": "^1.0.1",
|
||||
"html-to-docx": "^1.8.0",
|
||||
"mammoth": "^1.11.0",
|
||||
"node-pty": "^1.1.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"update-electron-app": "^3.1.2",
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import { CodeSessionService } from '@x/core/dist/code-mode/sessions/service.js';
|
|||
import { CodeSessionStatusTracker } from '@x/core/dist/code-mode/sessions/status-tracker.js';
|
||||
import * as codeGit from '@x/core/dist/code-mode/git/service.js';
|
||||
import { readProjectDir, readProjectFile } from '@x/core/dist/code-mode/projects/fs.js';
|
||||
import { ensureTerminal, writeTerminal, resizeTerminal, disposeTerminal } from './terminal.js';
|
||||
import type { CodeSession } from '@x/shared/dist/code-sessions.js';
|
||||
import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js';
|
||||
import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js';
|
||||
|
|
@ -724,6 +725,7 @@ export function setupIpcHandlers() {
|
|||
},
|
||||
'codeSession:delete': async (_event, args) => {
|
||||
const service = container.resolve<CodeSessionService>('codeSessionService');
|
||||
disposeTerminal(args.sessionId);
|
||||
await service.delete(args.sessionId, {
|
||||
removeWorktree: args.removeWorktree,
|
||||
deleteBranch: args.deleteBranch,
|
||||
|
|
@ -934,6 +936,21 @@ export function setupIpcHandlers() {
|
|||
}
|
||||
return { path: result.filePaths[0] ?? null };
|
||||
},
|
||||
'terminal:ensure': async (_event, args) => {
|
||||
return ensureTerminal(args.id, args.cwd, args.cols, args.rows);
|
||||
},
|
||||
'terminal:input': async (_event, args) => {
|
||||
writeTerminal(args.id, args.data);
|
||||
return { success: true };
|
||||
},
|
||||
'terminal:resize': async (_event, args) => {
|
||||
resizeTerminal(args.id, args.cols, args.rows);
|
||||
return { success: true };
|
||||
},
|
||||
'terminal:dispose': async (_event, args) => {
|
||||
disposeTerminal(args.id);
|
||||
return { success: true };
|
||||
},
|
||||
'dialog:openFiles': async (event, args) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
const result = await dialog.showOpenDialog(win!, {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
stopServicesWatcher,
|
||||
stopWorkspaceWatcher
|
||||
} from "./ipc.js";
|
||||
import { disposeAllTerminals } from "./terminal.js";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { dirname } from "node:path";
|
||||
import { updateElectronApp, UpdateSourceType } from "update-electron-app";
|
||||
|
|
@ -428,6 +429,8 @@ app.on("before-quit", () => {
|
|||
} catch {
|
||||
// nothing live to dispose
|
||||
}
|
||||
// Kill embedded terminal shells.
|
||||
disposeAllTerminals();
|
||||
shutdownLocalSites().catch((error) => {
|
||||
console.error('[LocalSites] Failed to shut down cleanly:', error);
|
||||
});
|
||||
|
|
|
|||
126
apps/x/apps/main/src/terminal.ts
Normal file
126
apps/x/apps/main/src/terminal.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { BrowserWindow } from 'electron';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
// node-pty is a NATIVE module: it stays external to the esbuild bundle and is
|
||||
// shipped alongside it in .package/node_modules (see bundle.mjs).
|
||||
import * as pty from 'node-pty';
|
||||
|
||||
// One PTY per coding session, kept alive while the app runs so the terminal
|
||||
// survives pane collapses and session switches. The renderer view re-attaches
|
||||
// via `terminal:ensure`, which replays the recent backlog.
|
||||
|
||||
const BACKLOG_LIMIT = 400_000; // chars (~400KB) of scrollback replay
|
||||
|
||||
interface TerminalEntry {
|
||||
proc: pty.IPty;
|
||||
cwd: string;
|
||||
backlog: string;
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
const terminals = new Map<string, TerminalEntry>();
|
||||
|
||||
function broadcast(channel: 'terminal:data' | 'terminal:exit', payload: unknown): void {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed() && win.webContents) {
|
||||
win.webContents.send(channel, payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pnpm extracts node-pty's prebuilt macOS spawn-helper without its executable
|
||||
// bit, which makes every spawn fail with "posix_spawnp failed". Repair it once.
|
||||
let helperFixed = false;
|
||||
function ensureSpawnHelperExecutable(): void {
|
||||
if (helperFixed || process.platform === 'win32') return;
|
||||
helperFixed = true;
|
||||
try {
|
||||
const pkgDir = path.dirname(require.resolve('node-pty/package.json'));
|
||||
const helper = path.join(pkgDir, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper');
|
||||
if (fs.existsSync(helper)) {
|
||||
fs.chmodSync(helper, 0o755);
|
||||
}
|
||||
} catch {
|
||||
// best effort — spawn() will surface a real error if this mattered
|
||||
}
|
||||
}
|
||||
|
||||
function defaultShell(): { file: string; args: string[] } {
|
||||
if (process.platform === 'win32') {
|
||||
return { file: 'powershell.exe', args: [] };
|
||||
}
|
||||
// Login shell so the user's PATH/aliases match their normal terminal.
|
||||
return { file: process.env.SHELL || '/bin/zsh', args: ['-l'] };
|
||||
}
|
||||
|
||||
function spawnEntry(id: string, cwd: string, cols: number, rows: number): TerminalEntry {
|
||||
ensureSpawnHelperExecutable();
|
||||
const { file, args } = defaultShell();
|
||||
const proc = pty.spawn(file, args, {
|
||||
name: 'xterm-256color',
|
||||
cwd,
|
||||
cols,
|
||||
rows,
|
||||
env: { ...process.env, TERM_PROGRAM: 'rowboat' } as Record<string, string>,
|
||||
});
|
||||
const entry: TerminalEntry = { proc, cwd, backlog: '', running: true };
|
||||
proc.onData((data) => {
|
||||
entry.backlog = (entry.backlog + data).slice(-BACKLOG_LIMIT);
|
||||
broadcast('terminal:data', { id, data });
|
||||
});
|
||||
proc.onExit(({ exitCode }) => {
|
||||
entry.running = false;
|
||||
broadcast('terminal:exit', { id, exitCode });
|
||||
});
|
||||
terminals.set(id, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
// Create-or-attach. A cwd change (e.g. the session's worktree was removed) or
|
||||
// an exited shell gets a fresh PTY; otherwise the live one is reused and the
|
||||
// caller repaints from the backlog.
|
||||
export function ensureTerminal(id: string, cwd: string, cols: number, rows: number): { backlog: string; running: boolean } {
|
||||
const existing = terminals.get(id);
|
||||
if (existing && existing.running && existing.cwd === cwd) {
|
||||
existing.proc.resize(cols, rows);
|
||||
return { backlog: existing.backlog, running: true };
|
||||
}
|
||||
if (existing) {
|
||||
disposeTerminal(id);
|
||||
}
|
||||
const fallbackCwd = fs.existsSync(cwd) ? cwd : os.homedir();
|
||||
const entry = spawnEntry(id, fallbackCwd, cols, rows);
|
||||
return { backlog: entry.backlog, running: entry.running };
|
||||
}
|
||||
|
||||
export function writeTerminal(id: string, data: string): void {
|
||||
const entry = terminals.get(id);
|
||||
if (entry?.running) entry.proc.write(data);
|
||||
}
|
||||
|
||||
export function resizeTerminal(id: string, cols: number, rows: number): void {
|
||||
const entry = terminals.get(id);
|
||||
if (entry?.running) {
|
||||
try {
|
||||
entry.proc.resize(cols, rows);
|
||||
} catch {
|
||||
// resizing a dying pty throws — harmless
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function disposeTerminal(id: string): void {
|
||||
const entry = terminals.get(id);
|
||||
if (!entry) return;
|
||||
terminals.delete(id);
|
||||
try {
|
||||
entry.proc.kill();
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
|
||||
export function disposeAllTerminals(): void {
|
||||
for (const id of [...terminals.keys()]) disposeTerminal(id);
|
||||
}
|
||||
|
|
@ -44,6 +44,8 @@
|
|||
"@tiptap/starter-kit": "3.22.4",
|
||||
"@x/preload": "workspace:*",
|
||||
"@x/shared": "workspace:*",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"ai": "^5.0.117",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Bot, Code2, GitBranch } from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Bot, ChevronDown, ChevronUp, Code2, GitBranch, Terminal as TerminalIcon } from 'lucide-react'
|
||||
import type { CodeSession, CodeSessionStatus } from '@x/shared/src/code-sessions.js'
|
||||
import type { ApprovalPolicy } from '@x/shared/src/code-mode.js'
|
||||
import { toast } from 'sonner'
|
||||
|
|
@ -25,6 +25,18 @@ import { useCodeSessions } from './use-code-sessions'
|
|||
import { SessionRail } from './session-rail'
|
||||
import { NewSessionDialog } from './new-session-dialog'
|
||||
import { WorkspacePane } from './workspace-pane'
|
||||
import { TerminalPane } from './terminal-pane'
|
||||
|
||||
const TERMINAL_HEIGHT_STORAGE_KEY = 'x:code-terminal-height'
|
||||
const TERMINAL_MIN_HEIGHT = 120
|
||||
const TERMINAL_MAX_HEIGHT = 600
|
||||
|
||||
function readStoredTerminalHeight(): number {
|
||||
if (typeof window === 'undefined') return 240
|
||||
const raw = Number(window.localStorage.getItem(TERMINAL_HEIGHT_STORAGE_KEY))
|
||||
if (!Number.isFinite(raw) || raw <= 0) return 240
|
||||
return Math.min(TERMINAL_MAX_HEIGHT, Math.max(TERMINAL_MIN_HEIGHT, raw))
|
||||
}
|
||||
|
||||
const AGENT_LABEL: Record<string, string> = { claude: 'Claude Code', codex: 'Codex' }
|
||||
const POLICY_LABEL: Record<ApprovalPolicy, string> = {
|
||||
|
|
@ -56,6 +68,32 @@ export function CodeView({
|
|||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
|
||||
const [newSessionProjectId, setNewSessionProjectId] = useState<string | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<CodeSession | null>(null)
|
||||
const [terminalOpen, setTerminalOpen] = useState(false)
|
||||
const [terminalHeight, setTerminalHeight] = useState(readStoredTerminalHeight)
|
||||
const dragStateRef = useRef<{ startY: number; startHeight: number } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(TERMINAL_HEIGHT_STORAGE_KEY, String(terminalHeight))
|
||||
}, [terminalHeight])
|
||||
|
||||
const handleTerminalDragStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
dragStateRef.current = { startY: e.clientY, startHeight: terminalHeight }
|
||||
const onMove = (event: MouseEvent) => {
|
||||
const drag = dragStateRef.current
|
||||
if (!drag) return
|
||||
// Terminal sits at the bottom: dragging up grows it.
|
||||
const next = drag.startHeight + (drag.startY - event.clientY)
|
||||
setTerminalHeight(Math.min(TERMINAL_MAX_HEIGHT, Math.max(TERMINAL_MIN_HEIGHT, next)))
|
||||
}
|
||||
const onUp = () => {
|
||||
dragStateRef.current = null
|
||||
document.removeEventListener('mousemove', onMove)
|
||||
document.removeEventListener('mouseup', onUp)
|
||||
}
|
||||
document.addEventListener('mousemove', onMove)
|
||||
document.addEventListener('mouseup', onUp)
|
||||
}, [terminalHeight])
|
||||
|
||||
const selectedSession = sessions.find((s) => s.id === selectedSessionId) ?? null
|
||||
const selectedStatus = selectedSession ? statusOf(selectedSession.id) : 'idle'
|
||||
|
|
@ -191,6 +229,40 @@ export function CodeView({
|
|||
onSessionChanged={() => void refresh()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Embedded terminal — a real shell in the session's directory
|
||||
(worktree included). The PTY lives in the main process and
|
||||
survives collapsing this panel. */}
|
||||
<div className="shrink-0 border-t">
|
||||
{terminalOpen && (
|
||||
<div
|
||||
onMouseDown={handleTerminalDragStart}
|
||||
className="h-1 cursor-row-resize bg-transparent transition-colors hover:bg-sidebar-border"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTerminalOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
>
|
||||
<TerminalIcon className="size-3.5" />
|
||||
<span className="font-medium">Terminal</span>
|
||||
{selectedSession.worktree && !selectedSession.worktree.removedAt && (
|
||||
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px]">worktree</span>
|
||||
)}
|
||||
<span className="flex-1" />
|
||||
{terminalOpen ? <ChevronDown className="size-3.5" /> : <ChevronUp className="size-3.5" />}
|
||||
</button>
|
||||
{terminalOpen && (
|
||||
<div style={{ height: terminalHeight }}>
|
||||
<TerminalPane
|
||||
key={selectedSession.id}
|
||||
terminalId={selectedSession.id}
|
||||
cwd={selectedSession.cwd}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-center">
|
||||
|
|
|
|||
110
apps/x/apps/renderer/src/components/code/terminal-pane.tsx
Normal file
110
apps/x/apps/renderer/src/components/code/terminal-pane.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { useTheme } from '@/contexts/theme-context'
|
||||
|
||||
// xterm color schemes tuned to the app's light/dark backgrounds.
|
||||
const DARK_THEME = {
|
||||
background: '#1b1b1f',
|
||||
foreground: '#d4d4d8',
|
||||
cursor: '#d4d4d8',
|
||||
selectionBackground: 'rgba(120, 140, 255, 0.3)',
|
||||
}
|
||||
const LIGHT_THEME = {
|
||||
background: '#ffffff',
|
||||
foreground: '#27272a',
|
||||
cursor: '#27272a',
|
||||
selectionBackground: 'rgba(60, 90, 220, 0.2)',
|
||||
}
|
||||
|
||||
// One embedded terminal view, attached to a per-session PTY in the main
|
||||
// process. The PTY outlives this component (collapse/switch just detaches);
|
||||
// on mount we re-attach and repaint from the backlog the main process keeps.
|
||||
export function TerminalPane({ terminalId, cwd }: { terminalId: string; cwd: string }) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const termRef = useRef<Terminal | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const term = new Terminal({
|
||||
fontSize: 12,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
cursorBlink: true,
|
||||
scrollback: 5000,
|
||||
theme: resolvedTheme === 'dark' ? DARK_THEME : LIGHT_THEME,
|
||||
})
|
||||
const fit = new FitAddon()
|
||||
term.loadAddon(fit)
|
||||
term.open(container)
|
||||
fit.fit()
|
||||
termRef.current = term
|
||||
|
||||
let disposed = false
|
||||
|
||||
// Attach (or spawn) the PTY at the current size, then repaint history.
|
||||
void window.ipc.invoke('terminal:ensure', {
|
||||
id: terminalId,
|
||||
cwd,
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
}).then(({ backlog }) => {
|
||||
if (disposed) return
|
||||
if (backlog) term.write(backlog)
|
||||
term.focus()
|
||||
})
|
||||
|
||||
const dataDisposable = term.onData((data) => {
|
||||
void window.ipc.invoke('terminal:input', { id: terminalId, data })
|
||||
})
|
||||
|
||||
const offData = window.ipc.on('terminal:data', (payload) => {
|
||||
if (payload.id === terminalId) term.write(payload.data)
|
||||
})
|
||||
const offExit = window.ipc.on('terminal:exit', (payload) => {
|
||||
if (payload.id !== terminalId) return
|
||||
term.write(`\r\n\x1b[2m[process exited with code ${payload.exitCode} — press Enter to restart]\x1b[0m\r\n`)
|
||||
})
|
||||
|
||||
// Restart the shell on Enter after it exited (ensure() respawns dead PTYs).
|
||||
const keyDisposable = term.onKey(({ domEvent }) => {
|
||||
if (domEvent.key !== 'Enter') return
|
||||
void window.ipc.invoke('terminal:ensure', {
|
||||
id: terminalId,
|
||||
cwd,
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
})
|
||||
})
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (container.clientHeight === 0) return
|
||||
fit.fit()
|
||||
void window.ipc.invoke('terminal:resize', { id: terminalId, cols: term.cols, rows: term.rows })
|
||||
})
|
||||
resizeObserver.observe(container)
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
resizeObserver.disconnect()
|
||||
offData()
|
||||
offExit()
|
||||
dataDisposable.dispose()
|
||||
keyDisposable.dispose()
|
||||
term.dispose()
|
||||
termRef.current = null
|
||||
}
|
||||
// The PTY is keyed by terminalId; cwd changes (worktree cleanup) respawn via ensure.
|
||||
}, [terminalId, cwd])
|
||||
|
||||
// Live theme switches restyle the existing terminal without a respawn.
|
||||
useEffect(() => {
|
||||
const term = termRef.current
|
||||
if (term) term.options.theme = resolvedTheme === 'dark' ? DARK_THEME : LIGHT_THEME
|
||||
}, [resolvedTheme])
|
||||
|
||||
return <div ref={containerRef} className="h-full w-full overflow-hidden px-2 pt-1" />
|
||||
}
|
||||
|
|
@ -449,6 +449,21 @@ export function SidebarContentPanel({
|
|||
const [emailThreads, setEmailThreads] = useState<SidebarEmailThread[]>([])
|
||||
const [meetings, setMeetings] = useState<UpcomingMeeting[]>([])
|
||||
const [quickAccessExpanded, setQuickAccessExpanded] = useState(true)
|
||||
// The Code section only makes sense with a coding agent available — same
|
||||
// flag the chat composer's code chip uses (auto-on when Claude Code or
|
||||
// Codex is installed + signed in; explicit toggle in settings wins).
|
||||
const [codeModeEnabled, setCodeModeEnabled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const load = () => {
|
||||
window.ipc.invoke('codeMode:getConfig', null)
|
||||
.then((r) => setCodeModeEnabled(r.enabled))
|
||||
.catch(() => setCodeModeEnabled(false))
|
||||
}
|
||||
load()
|
||||
window.addEventListener('code-mode-config-changed', load)
|
||||
return () => window.removeEventListener('code-mode-config-changed', load)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
|
@ -837,12 +852,14 @@ export function SidebarContentPanel({
|
|||
</div>
|
||||
) : null}
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton isActive={activeNav === 'code'} onClick={onOpenCode}>
|
||||
<Code2 className="size-4 shrink-0" />
|
||||
<span className="flex-1 truncate">Code</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
{codeModeEnabled && (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton isActive={activeNav === 'code'} onClick={onOpenCode}>
|
||||
<Code2 className="size-4 shrink-0" />
|
||||
<span className="flex-1 truncate">Code</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={activeNav === 'knowledge'}
|
||||
|
|
|
|||
|
|
@ -632,6 +632,51 @@ const ipcSchemas = {
|
|||
}),
|
||||
res: z.null(),
|
||||
},
|
||||
// ==========================================================================
|
||||
// Embedded terminal (Code section): one PTY per coding session
|
||||
// ==========================================================================
|
||||
// Create-or-attach. Returns the scrollback backlog so a remounted view can
|
||||
// repaint what happened while it was closed.
|
||||
'terminal:ensure': {
|
||||
req: z.object({
|
||||
id: z.string(),
|
||||
cwd: z.string(),
|
||||
cols: z.number().int().positive(),
|
||||
rows: z.number().int().positive(),
|
||||
}),
|
||||
res: z.object({
|
||||
backlog: z.string(),
|
||||
running: z.boolean(),
|
||||
}),
|
||||
},
|
||||
'terminal:input': {
|
||||
req: z.object({
|
||||
id: z.string(),
|
||||
data: z.string(),
|
||||
}),
|
||||
res: z.object({ success: z.literal(true) }),
|
||||
},
|
||||
'terminal:resize': {
|
||||
req: z.object({
|
||||
id: z.string(),
|
||||
cols: z.number().int().positive(),
|
||||
rows: z.number().int().positive(),
|
||||
}),
|
||||
res: z.object({ success: z.literal(true) }),
|
||||
},
|
||||
'terminal:dispose': {
|
||||
req: z.object({ id: z.string() }),
|
||||
res: z.object({ success: z.literal(true) }),
|
||||
},
|
||||
// main → renderer streams
|
||||
'terminal:data': {
|
||||
req: z.object({ id: z.string(), data: z.string() }),
|
||||
res: z.null(),
|
||||
},
|
||||
'terminal:exit': {
|
||||
req: z.object({ id: z.string(), exitCode: z.number() }),
|
||||
res: z.null(),
|
||||
},
|
||||
'granola:setConfig': {
|
||||
req: z.object({
|
||||
enabled: z.boolean(),
|
||||
|
|
|
|||
31
apps/x/pnpm-lock.yaml
generated
31
apps/x/pnpm-lock.yaml
generated
|
|
@ -76,6 +76,9 @@ importers:
|
|||
mammoth:
|
||||
specifier: ^1.11.0
|
||||
version: 1.11.0
|
||||
node-pty:
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0
|
||||
papaparse:
|
||||
specifier: ^5.5.3
|
||||
version: 5.5.3
|
||||
|
|
@ -249,6 +252,12 @@ importers:
|
|||
'@x/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared
|
||||
'@xterm/addon-fit':
|
||||
specifier: ^0.11.0
|
||||
version: 0.11.0
|
||||
'@xterm/xterm':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
ai:
|
||||
specifier: ^5.0.117
|
||||
version: 5.0.117(zod@4.2.1)
|
||||
|
|
@ -4064,6 +4073,12 @@ packages:
|
|||
resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==}
|
||||
engines: {node: '>=14.6'}
|
||||
|
||||
'@xterm/addon-fit@0.11.0':
|
||||
resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
|
||||
|
||||
'@xterm/xterm@6.0.0':
|
||||
resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==}
|
||||
|
||||
'@xtuc/ieee754@1.2.0':
|
||||
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
|
||||
|
||||
|
|
@ -6659,6 +6674,9 @@ packages:
|
|||
resolution: {integrity: sha512-sn9Et4N3ynsetj3spsZR729DVlGH6iBG4RiDMV7HEp3guyOW6W3S0unGpLDxT50mXortGUMax/ykUNQXdqc/Xg==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
node-addon-api@7.1.1:
|
||||
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
||||
|
||||
node-api-version@0.2.1:
|
||||
resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==}
|
||||
|
||||
|
|
@ -6687,6 +6705,9 @@ packages:
|
|||
node-html-parser@6.1.13:
|
||||
resolution: {integrity: sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==}
|
||||
|
||||
node-pty@1.1.0:
|
||||
resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==}
|
||||
|
||||
node-releases@2.0.27:
|
||||
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
||||
|
||||
|
|
@ -12951,6 +12972,10 @@ snapshots:
|
|||
|
||||
'@xmldom/xmldom@0.9.10': {}
|
||||
|
||||
'@xterm/addon-fit@0.11.0': {}
|
||||
|
||||
'@xterm/xterm@6.0.0': {}
|
||||
|
||||
'@xtuc/ieee754@1.2.0': {}
|
||||
|
||||
'@xtuc/long@4.2.2': {}
|
||||
|
|
@ -16119,6 +16144,8 @@ snapshots:
|
|||
dependencies:
|
||||
semver: 7.7.3
|
||||
|
||||
node-addon-api@7.1.1: {}
|
||||
|
||||
node-api-version@0.2.1:
|
||||
dependencies:
|
||||
semver: 7.7.3
|
||||
|
|
@ -16146,6 +16173,10 @@ snapshots:
|
|||
css-select: 5.2.2
|
||||
he: 1.2.0
|
||||
|
||||
node-pty@1.1.0:
|
||||
dependencies:
|
||||
node-addon-api: 7.1.1
|
||||
|
||||
node-releases@2.0.27: {}
|
||||
|
||||
nopt@6.0.0:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue