diff --git "a/piweb/hf-design.patch" "b/piweb/hf-design.patch" --- "a/piweb/hf-design.patch" +++ "b/piweb/hf-design.patch" @@ -1,11 +1,15 @@ diff --git a/AGENTS.md b/AGENTS.md -index a5c8983..d2e5062 100644 +index a5c8983..1a87be9 100644 --- a/AGENTS.md +++ b/AGENTS.md -@@ -67,6 +67,11 @@ app/api/ +@@ -65,8 +65,13 @@ app/api/ + git/status/route.ts GET changed files for a cwd + git/diff/route.ts GET diff of one changed file worktrees/route.ts GET/POST/DELETE git worktrees - terminal/route.ts POST create a terminal session - terminal/[id]/route.ts GET { id, cwd } (404 once closed; stream at [id]/events) | POST input/resize | DELETE kill +- terminal/route.ts POST create a terminal session +- terminal/[id]/route.ts GET { id, cwd } (404 once closed; stream at [id]/events) | POST input/resize | DELETE kill ++ terminal/route.ts POST { id, cwd, cols, rows } attach to the id's shell, or start it: { id, created } ++ terminal/[id]/route.ts GET { id, cwd } (404 once closed; stream at [id]/events: output, exit with code and signal, closed) | POST input/resize | DELETE kill + browser/route.ts GET shared browser state (never launches) | POST { action } navigate/back/forward/reload/stop/new_tab/select_tab/close_tab/viewport/clear_data/shutdown + browser/events/route.ts GET SSE: state, the active tab's screencast frames, console entries + browser/input/route.ts POST mouse / key / text input on the active tab (CSS pixels of the viewport) @@ -14,7 +18,7 @@ index a5c8983..d2e5062 100644 mcp/route.ts GET [?cwd=] Settings › MCP overview, files only | POST add/enable/disable/remove/undo/set-enabled/set-exposure/set-in-project/sign-out mcp/test/route.ts POST { scope, name, cwd? } test one server once (entry read from its file) mcp/sign-in/route.ts POST { scope, name, cwd? } start or join an OAuth sign-in -@@ -138,9 +143,17 @@ lib/ +@@ -138,9 +143,22 @@ lib/ extension-ui-queue.ts FIFO queues for extension dialogs and custom panels, by request id; isBlockingExtensionUiRequest() (which method a run waits on) markdown.ts shared markdown helpers markdown-blocks.ts splitMarkdownBlocks(): where a streaming reply may be cut into blocks parsed apart @@ -24,6 +28,11 @@ index a5c8983..d2e5062 100644 + trace-line.ts client-safe: a tool call's line in words ("Edited app.py +8 −2"): verb key, target from the project, detail + shortcuts.ts client-safe: the pane and mode keys, claude.ai Code tab's browser set (Ctrl+`, ⌃⇧D, ⌘⌥P, ⌘⇧F, ⌘\, ⌘⇧\, ⌘⇧M); what a terminal lets through + tile-layout.ts client-safe: Claude Code Desktop's tiles: the layout tree, placement, closed slots, sizes, resize, drops, the stored layouts ++ terminal-tabs.ts client-safe: the Terminal pane's tabs grouped by folder: numbers, close and active rules, rename, move, the window of tabs that fit, user exits, attachments, sessionStorage ++ terminal-theme.ts client-safe: the terminal's colours read from app/globals.css (`--terminal-*`) ++ terminal-settings.ts client-safe: the terminal's settings (cursor, blink, copy on select, scrollback) in localStorage, live ++ terminal-manager.ts the shells (node-pty): one per id, output backlog and offsets, exit with code and signal, the 2-minute lease without a window ++ terminal-client.ts client: terminal requests (errors with their status) and the ordered input writer + mcp-presets.ts client-safe: one-click connectors (the Hugging Face MCP server, its token read by a `!command` header) + diffs-theme.ts the Shiki themes all code is highlighted with: dark+/light+ with their blue tokens in neutral grays, keywords bold + diff-patch.ts split diff rows (apply_patch, previews) as one unified patch for the diff viewer @@ -34,7 +43,7 @@ index a5c8983..d2e5062 100644 node-cli.ts locate bundled npm-cli.js / npx-cli.js to spawn npm/npx without a shell (Windows) npx.ts npx runner for skill install plugin-updates.ts npm view update checks for /api/plugins/check -@@ -170,11 +183,29 @@ lib/ +@@ -170,11 +188,29 @@ lib/ mcp-import.ts pure paste importer (+ mcp-import-core/json/cli/links.ts) mcp-server-display.ts client-safe display helpers for McpServerInfo (hidden-character escapes, labels) mcp-tool-display.ts server/tool label of an mcp__ call from its result details, never the name @@ -66,7 +75,7 @@ index a5c8983..d2e5062 100644 SessionTree.tsx virtualized rows of the session tree and the archive view SidebarMenu.tsx sidebar popup menu (bottom sheet on phones), portaled to body: filter field, item secondary actions, the files tab's classic look ProjectWorktreePicker.tsx project and worktree boxes + menus: the files' (one row below the sessions, stacked in the tab) and the new-session bar's -@@ -183,18 +214,21 @@ components/ +@@ -183,18 +219,21 @@ components/ SidebarToast.tsx sidebar one-line toast: archive Undo/View, failed saves SidebarIcons.tsx sidebar stroke icons ChatWindow.tsx chat composition + completion sound @@ -91,12 +100,14 @@ index a5c8983..d2e5062 100644 PluginsConfig.tsx Settings › Plugins: installed package plugins SkillsConfig.tsx Settings › Skills: loaded, search, install McpConfig.tsx Settings › MCP: servers, switches, exposure, remove/undo, Test, sign-in, Code mode, trust -@@ -207,21 +241,21 @@ components/ +@@ -207,21 +246,23 @@ components/ FileIcons.tsx file icon helpers FileViewer.tsx file content in a tab DelimitedTable.tsx a CSV/TSV file's Preview: virtualized table, gutter of file lines - TabBar.tsx file panel tab bar (file, terminal and subagent tabs) -+ TabBar.tsx the Files and Terminal panes' tab bars (file, terminal and subagent tabs) ++ TabBar.tsx the Files pane's tab bar (file and subagent tabs) ++ TerminalTabs.tsx the Terminal pane's header: its tabs (Desktop's strip: numbers, ×, menu, rename, drag, More terminals), "+" and the ⋮ settings ++ TerminalPanel.tsx one tab's shell: xterm on the code surface, attach or start, reconnect, the states over it (Shell exited, disconnected, failed), Attach as context, its menu hooks/ useAgentSession.ts messages, streaming, SSE, fork/navigate, reconciliation; built-in slash commands (/session, bare /mcp) @@ -116,7 +127,7 @@ index a5c8983..d2e5062 100644 useTheme.ts theme state ``` -@@ -231,7 +265,7 @@ hooks/ +@@ -231,7 +272,7 @@ hooks/ Design decisions and traps live in `docs/agents/`, one note per area. Read every note whose files a change touches before making it. Add new notes to the area's file, not here. @@ -125,16 +136,16 @@ index a5c8983..d2e5062 100644 - [tools.md](docs/agents/tools.md): tool presets and Chat only, exact system prompts, tool exposure, the codemode / tool-search / mcp built-ins, the read-only MCP policy, the Code mode and PowerShell `defaultTools` switches. Files: `lib/tool-presets.ts`, `lib/tool-preset-preference.ts`, `lib/chat-only.ts`, `lib/exact-system-prompt.ts`, `lib/builtin-extensions.ts`, `lib/mcp-read-only-policy.ts`, `lib/codemode-settings.ts`, `lib/powershell-settings.ts`, `lib/global-settings-file.ts`, `app/api/agent/new/route.ts`, `app/api/tools/settings/route.ts`, tool selection in `lib/rpc-manager.ts`. - [mcp-runtime.md](docs/agents/mcp-runtime.md): the per-session MCP host (when servers register and connect, reported states, trust read on every sync, idle release); `/mcp` in the composer. Files: `lib/mcp-host.ts`, `lib/mcp-transport.ts`, `lib/mcp-status.ts`, `lib/mcp-command.ts`, `lib/mcp-config-key.ts`, MCP wiring in `lib/rpc-manager.ts` and `lib/builtin-extensions.ts`, `/mcp` handling in `hooks/useAgentSession.ts`. - [mcp-settings.md](docs/agents/mcp-settings.md): Settings › MCP reads without running anything, masking, the trust dialog's server list, row states, notices, Code mode choice, trust from Settings, Escape stacking, every `mcp.json` write and undo. Files: `app/api/mcp/route.ts`, `app/api/project-trust/route.ts`, `lib/mcp-config-read.ts`, `lib/mcp-config-file.ts`, `lib/mcp-override.ts`, `lib/mcp-undo.ts`, `lib/mcp-secrets.ts`, `lib/mcp-server-display.ts`, `lib/mcp-json-error.ts`, `lib/project-trust.ts`, `lib/regular-file.ts`, `lib/stacked-dialog.ts`, `lib/settings-navigation.ts`, `components/McpConfig.tsx`, `components/mcp-config-helpers.ts`, `components/ProjectTrustDialog.tsx`, `components/SettingsPanel.tsx`. -@@ -241,6 +275,8 @@ Design decisions and traps live in `docs/agents/`, one note per area. Read every +@@ -241,6 +282,8 @@ Design decisions and traps live in `docs/agents/`, one note per area. Read every - [files-and-access.md](docs/agents/files-and-access.md): worktrees and project grouping, the file access allow-list (the `/api/files` security boundary), file tree visibility and its open directories per folder, uploads and chat file drops, the CSV/TSV table view, web password throttling. Files: `app/api/files/**`, `app/api/cwd/**`, `app/api/worktrees/**`, `app/api/file-index/**`, `app/api/web-auth/**`, `proxy.ts`, `lib/path-security.ts`, `lib/file-access.ts`, `lib/linked-directory.ts`, `lib/session-file-references*.ts`, `lib/file-tree-visibility.ts`, `lib/file-tree-expansion.ts`, `lib/file-upload-client.ts`, `lib/worktree.ts`, `lib/paths.ts`, `lib/auth-throttle.ts`, `components/ProjectWorktreePicker.tsx`, `components/NewSessionContextBar.tsx`, `components/FileExplorer.tsx`, `lib/delimited-table.ts`, `components/DelimitedTable.tsx`, the table branch of `components/FileViewer.tsx`, `hooks/useDragDrop.ts`. - [settings-ui.md](docs/agents/settings-ui.md): General's sidebar file-browser choice, Plugins and Skills routes, sidebar group switches, the shared `SettingsUi` blocks every settings panel and add pane uses. Files: `app/api/plugins/**`, `app/api/skills/**`, `components/SettingsUi.tsx`, `components/settings-ui-helpers.ts`, `components/SkillsConfig.tsx`, `components/PluginsConfig.tsx`, `hooks/useFilesPlacement.ts`; also before adding a settings section or add pane. - [subagents.md](docs/agents/subagents.md): the built-in subagent setting, profiles and their files, run status, completion notifications, the subagent viewer tab. Files: `lib/subagent*.ts`, `app/api/subagents/**`, `components/AgentsConfig.tsx`, `components/SubagentViewer.tsx`, `components/subagent-viewer-state.ts`. +- [browser.md](docs/agents/browser.md): the shared Browser pane's server side: one lazily launched Chromium per process (profile, executable, idle close, crashes, stale profile locks), tabs, console ring, dialogs and downloads, the screencast and the event stream's backpressure, snapshots and refs, the browser_* tools, the HTTP API, input, the element picker, the ports list, limits. Files: `lib/browser/**`, `app/api/browser/**`, `createBrowserExtension()` in `lib/rpc-manager.ts`. -+- [desktop-shell.md](docs/agents/desktop-shell.md): the Claude Code Desktop layout on Base UI: the composer (the controlled-popover "/" and "@" list, the token backdrop, modes, the model menu, the context meter), every dropdown's proportions (the references' measures as `--popup-*`), the header, its session menu and pane buttons, the tiles (layout tree, placement and closed slots, resize, move, expand, keys, focus, stored layouts, phones), the file section portaled from the sidebar, the Changes pane's review comments, the Browser pane's stream and input, the trace lines, the Hugging Face connector. Files: `components/ui/*`, `app/ui.css`, `app/shell.css`, `components/ChatInput.tsx`, `components/Composer*.tsx`, `components/ContextMeter.tsx`, `components/SessionHeader.tsx`, `components/TileLayout.tsx`, `components/ChangesPane.tsx`, `components/BrowserPane.tsx`, `components/TerminalPanel.tsx`'s focus and keys, the pane parts of `components/AppShell.tsx`, `lib/tile-layout.ts`, `hooks/usePaneLayout.ts`, `lib/trace-line.ts`, `lib/shortcuts.ts`, `lib/mcp-presets.ts`, `hooks/useFilesPlacement.ts`. ++- [desktop-shell.md](docs/agents/desktop-shell.md): the Claude Code Desktop layout on Base UI: the composer (the controlled-popover "/" and "@" list, the token backdrop, modes, the model menu, the context meter), every dropdown's proportions (the references' measures as `--popup-*`), the header, its session menu and pane buttons, the tiles (layout tree, placement and closed slots, resize, move, expand, keys, focus, stored layouts, phones), the file section portaled from the sidebar, the Terminal pane (tabs in the header, groups per folder, states, reattach or a new shell after a restart, Attach as context, settings), the Changes pane's review comments, the Browser pane's stream and input, the trace lines, the Hugging Face connector. Files: `components/ui/*`, `app/ui.css`, `app/shell.css`, `components/ChatInput.tsx`, `components/Composer*.tsx`, `components/ContextMeter.tsx`, `components/SessionHeader.tsx`, `components/TileLayout.tsx`, `components/ChangesPane.tsx`, `components/BrowserPane.tsx`, `components/TerminalTabs.tsx`, `components/TerminalPanel.tsx`, `lib/terminal-tabs.ts`, `lib/terminal-theme.ts`, `lib/terminal-settings.ts`, `lib/terminal-client.ts`, `lib/terminal-manager.ts`, `app/api/terminal/**`, the pane parts of `components/AppShell.tsx`, `lib/tile-layout.ts`, `hooks/usePaneLayout.ts`, `lib/trace-line.ts`, `lib/shortcuts.ts`, `lib/mcp-presets.ts`, `hooks/useFilesPlacement.ts`. - [client-platform.md](docs/agents/client-platform.md): mobile software keyboard and viewport height, the terminal's extra keys, completion sound. Files: `hooks/useViewportHeight.ts`, `hooks/useAudio.ts`, `components/TerminalExtraKeys.tsx`, `lib/terminal-extra-keys.ts`, the keyboard-open CSS. --- -@@ -248,7 +284,7 @@ Design decisions and traps live in `docs/agents/`, one note per area. Read every +@@ -248,7 +291,7 @@ Design decisions and traps live in `docs/agents/`, one note per area. Read every ## Old Safari (iOS 16.2) - `/` renders entirely on the client, so one script chunk the browser cannot parse is a blank page. Next 16 targets Safari 16.4+; the `browserslist` in `package.json` lowers Safari and iOS to 16.2 so SWC turns class `static {}` blocks into private static fields. That covers Next's client runtime; other node_modules keep their syntax unless listed in `transpilePackages` (mermaid and `@mermaid-js/parser` are, for their lazy diagram chunks). Keep the other browserslist entries at Next's defaults. @@ -143,7 +154,7 @@ index a5c8983..d2e5062 100644 ## Pi Session File Format -@@ -274,3 +310,28 @@ Location: `~/.pi/agent/sessions//_.jsonl` +@@ -274,3 +317,28 @@ Location: `~/.pi/agent/sessions//_.jsonl` --accent --user-bg --tool-bg --font-mono ``` @@ -402,6 +413,65 @@ index 9dc27bb..0362104 100644 } catch (error) { return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 }); } +diff --git a/app/api/terminal/[id]/events/route.ts b/app/api/terminal/[id]/events/route.ts +index 92511c1..baeec2f 100644 +--- a/app/api/terminal/[id]/events/route.ts ++++ b/app/api/terminal/[id]/events/route.ts +@@ -33,6 +33,11 @@ export async function GET( + const abort = () => cleanup(true); + const send = (event: TerminalEvent) => { + if (closed) return; ++ // The server is stopping: end the stream without a word, so the page comes back. ++ if (event.type === "shutdown") { ++ cleanup(true); ++ return; ++ } + try { + if ((controller.desiredSize ?? 0) <= 0) { + cleanup(true); +@@ -54,7 +59,7 @@ export async function GET( + unsubscribe = subscription.unsubscribe; + controller.enqueue(encoder.encode(":\n\n")); + send(subscription.output); +- if (subscription.exited) send({ type: "exit", exitCode: subscription.exitCode ?? 0 }); ++ if (subscription.exited) send({ type: "exit", exitCode: subscription.exitCode ?? 0, ...(subscription.signal ? { signal: subscription.signal } : {}) }); + if (closed) return; + req.signal.addEventListener("abort", abort, { once: true }); + if (req.signal.aborted) { +diff --git a/app/api/terminal/route.ts b/app/api/terminal/route.ts +index 62ca3c6..89e633c 100644 +--- a/app/api/terminal/route.ts ++++ b/app/api/terminal/route.ts +@@ -2,7 +2,7 @@ import { stat } from "fs/promises"; + import { resolve } from "path"; + import { NextResponse } from "next/server"; + import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access"; +-import { createTerminal } from "@/lib/terminal-manager"; ++import { createTerminal, getTerminalCwd } from "@/lib/terminal-manager"; + + export const dynamic = "force-dynamic"; + export const runtime = "nodejs"; +@@ -17,6 +17,11 @@ export async function POST(req: Request) { + return NextResponse.json({ error: "cwd required" }, { status: 400 }); + } + const cwd = resolve(body.cwd); ++ // Idempotent per id: a tab that comes back (a reload, a reconnect) reattaches to its ++ // shell while the server still has it, and `created` says when a new one started instead. ++ if (typeof body.id === "string" && getTerminalCwd(body.id) !== undefined) { ++ return NextResponse.json({ id: createTerminal(cwd, 80, 24, body.id), created: false }); ++ } + if (!(await stat(cwd)).isDirectory()) { + return NextResponse.json({ error: "cwd must be a directory" }, { status: 400 }); + } +@@ -30,7 +35,7 @@ export async function POST(req: Request) { + typeof body.rows === "number" ? body.rows : 24, + body.id as string | undefined, + ); +- return NextResponse.json({ id }); ++ return NextResponse.json({ id, created: true }); + } catch (error) { + return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 }); + } diff --git a/app/favicon.ico b/app/favicon.ico index 2da6b7dc00c64c59ef23cff153a6051478ad9315..b407f41443e3e94f4204a1862227e0d457f7d823 100644 GIT binary patch @@ -1116,10 +1186,10 @@ x=WyK>Q474TgO3Dx+=lt**@4jiTmoXh>QX;DqIUnCXbm^o4n^-mRR&Qm{tL_JHERF> diff --git a/app/focus.css b/app/focus.css new file mode 100644 -index 0000000..f1f04d3 +index 0000000..f8b1d1c --- /dev/null +++ b/app/focus.css -@@ -0,0 +1,280 @@ +@@ -0,0 +1,293 @@ +/* Keyboard focus, one way everywhere (the design system's "Motion and states"). + Imported last (app/layout.tsx), so these rules win over each component's. + @@ -1225,6 +1295,19 @@ index 0000000..f1f04d3 + border-radius: var(--radius-sm); +} + ++/* A menu's surface takes focus as it opens (Base UI), but its highlighted row is where ++ focus shows: the surface draws no ring, even when focus came from a text field (the ++ terminal, the composer), from which the browser counts any move as keyboard focus. */ ++.ui-menu:focus-visible { outline: none; } ++ ++/* A Terminal tab being renamed: the tab is the field, its pill takes the ring (Desktop's ++ has-[:focus-visible] on the tab), the bare input none. */ ++.terminal-tab-input:focus-visible { outline: none; } ++.terminal-tab:has(.terminal-tab-input:focus-visible) { ++ outline: 2px solid var(--focus-ring); ++ outline-offset: -2px; ++} ++ +/* An option whose input is visually hidden: the option is the control. */ +.settings-theme-option:has(input:focus-visible), +.settings-send-mode-option:has(input:focus-visible) { @@ -3727,7 +3810,7 @@ index 0000000..8d003fe +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/globals.css b/app/globals.css -index d6b5535..3af6f68 100644 +index d6b5535..14ba3ec 100644 --- a/app/globals.css +++ b/app/globals.css @@ -24,46 +24,109 @@ @@ -4025,80 +4108,267 @@ index d6b5535..3af6f68 100644 background: var(--accent); color: var(--accent-contrast); cursor: pointer; -@@ -339,7 +422,7 @@ html.dark .web-login-error { +@@ -332,110 +415,139 @@ html.dark .web-login-error { + cursor: not-allowed; + } + ++/* The terminal (components/TerminalPanel.tsx), as Claude Code Desktop's: it paints its ++ code theme, the background the Changes and Files panes' code has (--bg), the theme's ++ text, and Desktop's Claude Light / Claude Dark ANSI palettes (programs keep their ++ hues; lib/terminal-theme.ts reads these). The cursor and the selection are ink, not ++ Desktop's blue. 8px above, below and left of the text; the scrollbar flush right. */ ++:root { ++ --terminal-bg: var(--bg); ++ --terminal-fg: var(--text); ++ --terminal-cursor: var(--text-strong); ++ --terminal-selection: color-mix(in srgb, var(--text-strong) 20%, transparent); ++ --terminal-selection-inactive: color-mix(in srgb, var(--text-strong) 10%, transparent); ++ --terminal-scrollbar: color-mix(in srgb, var(--text-strong) 35%, transparent); ++ --terminal-scrollbar-hover: color-mix(in srgb, var(--text-strong) 50%, transparent); ++ --terminal-scrollbar-active: color-mix(in srgb, var(--text-strong) 60%, transparent); ++ --terminal-ansi-black: #1a1a1a; ++ --terminal-ansi-red: #ff3a30; ++ --terminal-ansi-green: #1e9e3c; ++ --terminal-ansi-yellow: #98801f; ++ --terminal-ansi-blue: #0073e6; ++ --terminal-ansi-magenta: #cd2054; ++ --terminal-ansi-cyan: #8e6bd9; ++ --terminal-ansi-white: #999999; ++ --terminal-ansi-bright-black: #666666; ++ --terminal-ansi-bright-red: #ff5047; ++ --terminal-ansi-bright-green: #1e9e3c; ++ --terminal-ansi-bright-yellow: #98801f; ++ --terminal-ansi-bright-blue: #0078f0; ++ --terminal-ansi-bright-magenta: #cd2054; ++ --terminal-ansi-bright-cyan: #8e6bd9; ++ --terminal-ansi-bright-white: #d6d6d6; ++} ++html.dark { ++ --terminal-ansi-black: #262626; ++ --terminal-ansi-red: #ff3a30; ++ --terminal-ansi-green: #32d74b; ++ --terminal-ansi-yellow: #ffd014; ++ --terminal-ansi-blue: #0099ff; ++ --terminal-ansi-magenta: #ff2c56; ++ --terminal-ansi-cyan: #b796ff; ++ --terminal-ansi-white: #a6a6a6; ++ --terminal-ansi-bright-black: #525252; ++ --terminal-ansi-bright-red: #ff5047; ++ --terminal-ansi-bright-green: #32d74b; ++ --terminal-ansi-bright-yellow: #ffd014; ++ --terminal-ansi-bright-blue: #0087ee; ++ --terminal-ansi-bright-magenta: #ff2c56; ++ --terminal-ansi-bright-cyan: #b796ff; ++ --terminal-ansi-bright-white: #ededed; ++} ++ + .terminal-panel { ++ position: relative; + display: grid; +- grid-template-rows: 38px auto minmax(0, 1fr); ++ grid-template-rows: minmax(0, 1fr) auto; ++ flex: 1 1 auto; + width: 100%; height: 100%; min-width: 0; min-height: 0; - background: #111318; -+ background: #111111; ++ background: var(--terminal-bg); ++ color: var(--terminal-fg); } - .terminal-panel-header { -@@ -350,7 +433,7 @@ html.dark .web-login-error { - padding: 0 10px 0 13px; - border-bottom: 1px solid #2f3540; - background: #181b21; +-.terminal-panel-header { +- display: flex; +- align-items: center; +- justify-content: space-between; +- min-width: 0; +- padding: 0 10px 0 13px; +- border-bottom: 1px solid #2f3540; +- background: #181b21; - color: #9ca3af; -+ color: #a3a3a3; - font-family: var(--font-mono); - font-size: 11px; +- font-family: var(--font-mono); +- font-size: 11px; +-} +- +-.terminal-panel-path { +- display: flex; +- align-items: center; ++.terminal-xterm { + min-width: 0; +- gap: 8px; +-} +- +-.terminal-panel-path span:last-child { ++ min-height: 0; ++ padding: 8px 0; + overflow: hidden; +- text-overflow: ellipsis; +- white-space: nowrap; +-} +- +-.terminal-status-dot { +- width: 7px; +- height: 7px; +- flex: 0 0 7px; +- border-radius: 50%; +- background: #facc15; } -@@ -388,23 +471,23 @@ html.dark .web-login-error { - flex-shrink: 0; - padding: 0 8px; - border: 1px solid #343a46; +- +-.terminal-status-dot.is-ready { background: #4ade80; } +-.terminal-status-dot.is-exited, +-.terminal-status-dot.is-error { background: #f87171; } +- +-.terminal-panel-header button { +- display: inline-flex; +- align-items: center; +- gap: 6px; +- height: 27px; +- flex-shrink: 0; +- padding: 0 8px; +- border: 1px solid #343a46; - border-radius: 5px; -+ border-radius: var(--radius-sm); - background: transparent; +- background: transparent; - color: #9ca3af; -+ color: #a3a3a3; - cursor: pointer; - font: inherit; - } - - .terminal-panel-exit { - padding: 7px 12px; +- cursor: pointer; +- font: inherit; +-} +- +-.terminal-panel-exit { +- padding: 7px 12px; - color: #9ca3af; -+ color: #a3a3a3; - font-family: var(--font-mono); - font-size: 11px; +- font-family: var(--font-mono); +- font-size: 11px; ++.terminal-xterm[hidden] { display: none; } ++.terminal-xterm-host { ++ width: 100%; ++ height: 100%; } - - .terminal-panel-header button:hover { - background: #242932; +- +-.terminal-panel-header button:hover { +- background: #242932; - color: #e5e7eb; -+ color: #e5e5e5; ++.terminal-xterm .xterm { ++ height: 100%; ++ padding-left: 8px; ++} ++.terminal-xterm .xterm .xterm-viewport { background-color: transparent; } ++.terminal-xterm textarea { caret-color: transparent; } ++/* xterm's own scrollbar (VS Code's): a 6px slider, rounded, 6px into its 14px lane, no shadows. */ ++.terminal-xterm .xterm-scrollable-element > .scrollbar.vertical > .slider { ++ left: 6px !important; ++ width: 6px !important; ++ border-radius: 3px; + } ++.terminal-xterm .xterm-scrollable-element > .shadow { display: none !important; } + +-.terminal-panel-error { +- padding: 7px 12px; +- border-bottom: 1px solid #5f2424; +- background: #321b1b; +- color: #fca5a5; +- font-family: var(--font-mono); +- font-size: 11px; ++/* What covers the terminal while its shell is not running: "Shell exited", "Shell ++ disconnected", "Failed to start shell", centred, with the one thing to do. */ ++.terminal-overlay { ++ position: absolute; ++ inset: 0; ++ z-index: 3; ++ display: flex; ++ background: var(--bg-panel); + } +- +-.terminal-xterm { +- grid-row: -2 / -1; +- min-width: 0; +- min-height: 0; +- padding: 10px 8px 22px 12px; +- overflow: hidden; ++/* A dropped stream the browser is getting back: the shell is still there. */ ++.terminal-reconnecting { ++ position: absolute; ++ top: 8px; ++ right: 20px; ++ z-index: 2; ++ padding: 2px 8px; ++ border-radius: var(--radius-sm); ++ background: var(--bg-elevated); ++ box-shadow: var(--popup-shadow); ++ color: var(--text-muted); ++ font-size: 12px; ++ line-height: 18px; ++ pointer-events: none; + } +- +-.terminal-xterm .xterm { +- height: 100%; ++/* "Attach as context" beside a selection the pointer made. */ ++.terminal-attach-pill { ++ position: absolute; ++ z-index: 4; ++ padding: 2px; ++ border-radius: var(--radius-md); ++ background: var(--bg-elevated); ++ box-shadow: var(--popup-shadow); ++ transform: translate(-50%, -100%); + } ++.terminal-attach-pill.is-below { transform: translate(-50%, 0); } ++.terminal-attach-pill .ui-btn { color: var(--text-strong); } ++.terminal-attach-pill .ui-kbd { margin-left: 2px; } + + .terminal-extra-keys { +- grid-row: 4; + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + grid-auto-rows: 36px; + padding-bottom: env(safe-area-inset-bottom); +- border-top: 1px solid #2f3540; +- background: #181b21; ++ border-top: 1px solid var(--line); ++ background: var(--bg-panel); } - .terminal-panel-error { -@@ -451,7 +534,7 @@ html[data-keyboard-open] .terminal-extra-keys { + .terminal-xterm:has(+ .terminal-extra-keys) { +@@ -451,7 +563,7 @@ html[data-keyboard-open] .terminal-extra-keys { padding: 0; border: none; background: transparent; - color: #d7dce5; -+ color: #d4d4d4; ++ color: var(--text); font-family: var(--font-mono); font-size: 12px; touch-action: manipulation; -@@ -471,7 +554,7 @@ html[data-keyboard-open] .terminal-extra-keys { +@@ -462,7 +574,7 @@ html[data-keyboard-open] .terminal-extra-keys { } - .terminal-extra-keys button.is-active { -- color: #60a5fa; -+ color: #e5e5e5; + .terminal-extra-keys button:active { +- background: #242932; ++ background: var(--bg-hover); } - .terminal-xterm-host { -@@ -480,7 +563,7 @@ html[data-keyboard-open] .terminal-extra-keys { + .terminal-extra-keys .is-rotated { +@@ -471,17 +583,10 @@ html[data-keyboard-open] .terminal-extra-keys { } - .terminal-xterm .xterm-viewport { + .terminal-extra-keys button.is-active { +- color: #60a5fa; +-} +- +-.terminal-xterm-host { +- width: 100%; +- height: 100%; +-} +- +-.terminal-xterm .xterm-viewport { - background-color: #111318; -+ background-color: #111111; ++ background: var(--bg-selected); ++ color: var(--text-strong); } ++/* end terminal */ /* -@@ -502,7 +585,7 @@ html[data-keyboard-open] .terminal-extra-keys { + * Chromium-only scrollbars. Firefox renders its native ones, which the standard +@@ -502,7 +607,7 @@ html[data-keyboard-open] .terminal-extra-keys { background-color: color-mix(in srgb, var(--text-dim) 45%, transparent); background-clip: padding-box; border: 2px solid transparent; @@ -4107,7 +4377,7 @@ index d6b5535..3af6f68 100644 } /* Only ever set background-color here: the `background` shorthand resets background-clip and would snap the thumb to the full 10px on hover. */ -@@ -538,7 +621,7 @@ html[data-keyboard-open] .terminal-extra-keys { +@@ -538,21 +643,10 @@ html[data-keyboard-open] .terminal-extra-keys { } ::-webkit-scrollbar-thumb { border-width: 1px; @@ -4116,7 +4386,21 @@ index d6b5535..3af6f68 100644 } } -@@ -703,9 +786,9 @@ button.extension-widget-trigger:focus-visible { +-/* The terminal keeps its dark surface in every theme, so --text-dim (a dark +- grey in the light themes) would disappear against it. */ +-.terminal-xterm .xterm-viewport::-webkit-scrollbar-thumb { +- background-color: rgba(156, 163, 175, 0.45); +-} +-.terminal-xterm .xterm-viewport::-webkit-scrollbar-thumb:hover { +- background-color: rgba(156, 163, 175, 0.72); +-} +-.terminal-xterm .xterm-viewport::-webkit-scrollbar-thumb:active { +- background-color: rgb(156, 163, 175); +-} + + + .extension-status-shelf { +@@ -703,9 +797,9 @@ button.extension-widget-trigger:focus-visible { .extension-widget-placement-icon { display: block; @@ -4129,7 +4413,7 @@ index d6b5535..3af6f68 100644 fill: currentColor; } -@@ -826,8 +909,8 @@ button.extension-widget-trigger:focus-visible { +@@ -826,8 +920,8 @@ button.extension-widget-trigger:focus-visible { } :root { @@ -4140,7 +4424,7 @@ index d6b5535..3af6f68 100644 --font-ui: var(--font-ui-default); --font-mono: var(--font-mono-default); --font-ui-weight: 400; -@@ -883,11 +966,11 @@ pre, code { +@@ -883,11 +977,11 @@ pre, code { .markdown-body ol > li { list-style-type: decimal; } .markdown-body li > p { margin: 0; } .markdown-body li::marker { @@ -4155,7 +4439,7 @@ index d6b5535..3af6f68 100644 font-weight: 700; } .markdown-body em { color: var(--text-muted); } -@@ -896,24 +979,23 @@ pre, code { +@@ -896,24 +990,23 @@ pre, code { text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 3px; @@ -4184,7 +4468,7 @@ index d6b5535..3af6f68 100644 } .markdown-body .contains-task-list { padding-left: 0; -@@ -935,7 +1017,7 @@ pre, code { +@@ -935,7 +1028,7 @@ pre, code { height: 14px; margin: 0; border: 1px solid var(--border); @@ -4193,7 +4477,7 @@ index d6b5535..3af6f68 100644 background: var(--bg); display: inline-grid; place-content: center; -@@ -967,8 +1049,8 @@ pre, code { +@@ -967,8 +1060,8 @@ pre, code { margin: 8px 0; overflow-x: auto; overflow-y: hidden; @@ -4204,7 +4488,7 @@ index d6b5535..3af6f68 100644 } .markdown-body table { border-collapse: separate; -@@ -1088,7 +1170,7 @@ pre, code { +@@ -1088,7 +1181,7 @@ pre, code { padding: 7px 8px; overflow: auto; border: 1px solid var(--border); @@ -4213,7 +4497,7 @@ index d6b5535..3af6f68 100644 background: var(--bg-panel); font-family: var(--font-mono); font-size: 11px; -@@ -1108,16 +1190,13 @@ pre, code { +@@ -1108,16 +1201,13 @@ pre, code { min-width: 0; max-width: 100%; margin: 6px 0; @@ -4233,7 +4517,7 @@ index d6b5535..3af6f68 100644 color: var(--text-dim); font-size: 11px; display: flex; -@@ -1137,7 +1216,7 @@ pre, code { +@@ -1137,7 +1227,7 @@ pre, code { } .markdown-code-action { border: 1px solid var(--border); @@ -4242,7 +4526,7 @@ index d6b5535..3af6f68 100644 padding: 2px 7px; background: var(--bg); color: var(--text-muted); -@@ -1228,7 +1307,7 @@ pre, code { +@@ -1228,7 +1318,7 @@ pre, code { max-width: 100%; max-height: 100%; object-fit: contain; @@ -4251,7 +4535,7 @@ index d6b5535..3af6f68 100644 box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4); } -@@ -1244,7 +1323,7 @@ pre, code { +@@ -1244,7 +1334,7 @@ pre, code { height: 36px; padding: 0; border: 1px solid var(--border); @@ -4260,7 +4544,7 @@ index d6b5535..3af6f68 100644 background: var(--bg-panel); color: var(--text-muted); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.28); -@@ -1320,7 +1399,7 @@ pre, code { +@@ -1320,7 +1410,7 @@ pre, code { height: 24px; overflow: hidden; border: 1px solid var(--border); @@ -4269,7 +4553,7 @@ index d6b5535..3af6f68 100644 background: var(--bg); } .mermaid-zoom-stepper button, -@@ -1347,7 +1426,7 @@ pre, code { +@@ -1347,7 +1437,7 @@ pre, code { .mermaid-zoom-icon-button { flex: 0 0 24px; border: 1px solid var(--border); @@ -4278,7 +4562,7 @@ index d6b5535..3af6f68 100644 } .mermaid-zoom-stepper button:hover:not(:disabled), .mermaid-zoom-icon-button:hover { -@@ -1384,7 +1463,7 @@ pre, code { +@@ -1384,7 +1474,7 @@ pre, code { .mermaid-zoom-canvas { padding: 12px; border: 1px solid var(--border); @@ -4287,7 +4571,7 @@ index d6b5535..3af6f68 100644 background: var(--bg-panel); margin: 0 auto; transition: width 0.15s ease; -@@ -1460,7 +1539,7 @@ pre, code { +@@ -1460,7 +1550,7 @@ pre, code { height: 24px; overflow: hidden; border: 1px solid var(--border); @@ -4296,7 +4580,7 @@ index d6b5535..3af6f68 100644 } .file-viewer-mode-button { -@@ -1503,12 +1582,11 @@ pre, code { +@@ -1503,12 +1593,11 @@ pre, code { height: 32px; padding: 0; box-sizing: border-box; @@ -4311,7 +4595,7 @@ index d6b5535..3af6f68 100644 opacity: 0; visibility: hidden; pointer-events: none; -@@ -1584,22 +1662,21 @@ pre, code { +@@ -1584,22 +1673,21 @@ pre, code { .new-session-logo { flex: none; @@ -4340,7 +4624,7 @@ index d6b5535..3af6f68 100644 line-height: 1.15; white-space: nowrap; } -@@ -1613,7 +1690,8 @@ pre, code { +@@ -1613,7 +1701,8 @@ pre, code { gap: 6px; min-width: 0; color: var(--text-dim); @@ -4350,7 +4634,7 @@ index d6b5535..3af6f68 100644 line-height: 1.4; white-space: nowrap; } -@@ -1638,7 +1716,7 @@ pre, code { +@@ -1638,7 +1727,7 @@ pre, code { gap: 3px; margin: -3px -4px; padding: 3px 4px; @@ -4359,7 +4643,7 @@ index d6b5535..3af6f68 100644 color: var(--accent); font-weight: 600; text-decoration: none; -@@ -1652,7 +1730,7 @@ pre, code { +@@ -1652,7 +1741,7 @@ pre, code { /* The ring inside the box, clear of the "→" and "·" beside it. */ .new-session-update:focus-visible { @@ -4368,7 +4652,7 @@ index d6b5535..3af6f68 100644 outline-offset: -2px; } -@@ -1678,12 +1756,12 @@ pre, code { +@@ -1678,12 +1767,12 @@ pre, code { } .new-session-logo { @@ -4384,7 +4668,7 @@ index d6b5535..3af6f68 100644 } } -@@ -1722,14 +1800,14 @@ pre, code { +@@ -1722,14 +1811,14 @@ pre, code { min-width: 0; height: 29px; padding: 0 10px; @@ -4402,7 +4686,7 @@ index d6b5535..3af6f68 100644 } /* Above a fresh composer (inline): no box until hovered or open, 12px, the -@@ -1752,7 +1830,7 @@ pre, code { +@@ -1752,7 +1841,7 @@ pre, code { height: 26px; padding: 0 6px; border: 0; @@ -4411,7 +4695,7 @@ index d6b5535..3af6f68 100644 background: transparent; } -@@ -1781,8 +1859,9 @@ pre, code { +@@ -1781,8 +1870,9 @@ pre, code { max-width: 28ch; overflow: hidden; color: var(--text-dim); @@ -4422,7 +4706,7 @@ index d6b5535..3af6f68 100644 } .project-picker-parent { -@@ -1851,7 +1930,8 @@ pre, code { +@@ -1851,7 +1941,8 @@ pre, code { path's color and code type, cut at its end. */ .project-picker-label.is-alias { color: var(--text); @@ -4432,7 +4716,7 @@ index d6b5535..3af6f68 100644 } /* A path or a branch, cut at its left (rtl), the text itself kept left to -@@ -1901,7 +1981,8 @@ pre, code { +@@ -1901,7 +1992,8 @@ pre, code { border-color: color-mix(in srgb, var(--accent) 40%, transparent); background: color-mix(in srgb, var(--accent) 6%, transparent); color: var(--text-dim); @@ -4442,7 +4726,7 @@ index d6b5535..3af6f68 100644 } /* No worktree list here (the files tab): a box that says why, and does nothing. */ -@@ -1978,7 +2059,7 @@ pre, code { +@@ -1978,7 +2070,7 @@ pre, code { box-sizing: border-box; flex: 0 0 24px; border: 1px solid var(--border); @@ -4451,7 +4735,7 @@ index d6b5535..3af6f68 100644 background: transparent; color: var(--text-muted); cursor: pointer; -@@ -2037,7 +2118,7 @@ pre, code { +@@ -2037,7 +2129,7 @@ pre, code { .markdown-file-preview pre { background: var(--bg-panel); border: 1px solid var(--border); @@ -4460,7 +4744,7 @@ index d6b5535..3af6f68 100644 padding: 12px 16px; overflow-x: auto; font-family: var(--font-mono); -@@ -2049,7 +2130,7 @@ pre, code { +@@ -2049,7 +2141,7 @@ pre, code { font-size: 0.9em; background: var(--bg-panel); padding: 1px 5px; @@ -4469,7 +4753,7 @@ index d6b5535..3af6f68 100644 } .markdown-file-preview pre code { background: none; -@@ -2061,7 +2142,7 @@ pre, code { +@@ -2061,7 +2153,7 @@ pre, code { margin-bottom: 20px; padding: 14px 16px 12px; border: 1px solid var(--border); @@ -4478,7 +4762,7 @@ index d6b5535..3af6f68 100644 background: var(--bg-panel); } .markdown-frontmatter-title { -@@ -2114,18 +2195,6 @@ pre, code { +@@ -2114,18 +2206,6 @@ pre, code { text-underline-offset: 2px; } @@ -4497,7 +4781,7 @@ index d6b5535..3af6f68 100644 @keyframes drop-zone-in { from { opacity: 0; transform: scale(0.97); } to { opacity: 1; transform: scale(1); } -@@ -2230,10 +2299,6 @@ span.token.table { +@@ -2230,10 +2310,6 @@ span.token.table { .panel-resize-handle.is-resizing::after { background: color-mix(in srgb, var(--text-muted) 70%, var(--border)); } @@ -4508,7 +4792,7 @@ index d6b5535..3af6f68 100644 /* Desktop sidebar: animate width */ @media (min-width: 641px) { .sidebar-overlay-backdrop { -@@ -2264,119 +2329,6 @@ span.token.table { +@@ -2264,119 +2340,6 @@ span.token.table { } } @@ -4628,7 +4912,7 @@ index d6b5535..3af6f68 100644 /* Mobile sidebar: slide in/out as overlay */ @media (max-width: 640px) { .chat-stats-center { -@@ -2401,31 +2353,7 @@ span.token.table { +@@ -2401,31 +2364,7 @@ span.token.table { box-shadow: none; } @@ -4661,7 +4945,7 @@ index d6b5535..3af6f68 100644 display: none; } .directory-picker-back, -@@ -2474,3 +2402,475 @@ span.token.table { +@@ -2474,3 +2413,475 @@ span.token.table { display: none !important; } } @@ -5955,10 +6239,10 @@ index 0bf21eb..e8788d8 100644 font-family: var(--font-mono); diff --git a/app/shell.css b/app/shell.css new file mode 100644 -index 0000000..8dbaf17 +index 0000000..7cefafc --- /dev/null +++ b/app/shell.css -@@ -0,0 +1,595 @@ +@@ -0,0 +1,720 @@ +/* The app's frame, as Claude Code Desktop's: the conversation's header (the + title and its menu; a button per pane on the right), the tiles (the chat + and the panes it opened: Terminal, Changes, Browser, Files) and the Changes @@ -6271,7 +6555,132 @@ index 0000000..8dbaf17 + box-shadow: var(--shadow-pop); + } +} -+.pane-terminal-body { position: relative; display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; } ++.pane-terminals { position: relative; } ++.pane-terminal { display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; } ++.pane-terminal[hidden] { display: none; } ++ ++/* A pane's state block (Desktop's status block, STAT): centred, a 13px title and a quieter ++ 12px line under it, 4px apart, the actions 12px below; at most 36 characters wide. */ ++.pane-status { ++ display: flex; ++ flex: 1 1 auto; ++ flex-direction: column; ++ align-items: center; ++ justify-content: center; ++ gap: 4px; ++ padding: 12px; ++ text-align: center; ++} ++.pane-status-title, ++.pane-status-body { max-width: 36ch; margin: 0; overflow-wrap: anywhere; text-wrap: pretty; } ++.pane-status-title { color: var(--text-strong); font-size: 13px; line-height: 19px; } ++.pane-status-body { color: var(--text-dim); font-size: 12px; line-height: 17px; } ++.pane-status-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 4px; margin-top: 8px; } ++ ++/* ---------------------------------------------------------------- terminal tabs ++ The Terminal pane's tabs where the other panes have their title ++ (components/TerminalTabs.tsx), in Desktop's compact sizes (its view tab bar): ++ 24px pills 4px apart, 2px inside, the label 8px in, 13px, at most 140px, 6px ++ corners; the picked one filled (10% ink, 15% in dark), a hover lighter (5%, ++ 7.5%), the × 20px with 4px corners on every tab once there are two. A lone ++ "Terminal" has no pill and no ×: it is the title, where the other titles are. */ ++.pane-tile-title.is-custom { display: flex; align-items: center; align-self: stretch; padding-left: 0; overflow: visible; color: inherit; } ++.terminal-tabs { ++ --terminal-tab-hover: color-mix(in srgb, var(--text-strong) 5%, transparent); ++ --terminal-tab-selected: color-mix(in srgb, var(--text-strong) 10%, transparent); ++ display: flex; ++ flex: 1 1 auto; ++ align-items: center; ++ gap: 2px; ++ min-width: 0; ++ height: 100%; ++} ++html.dark .terminal-tabs { ++ --terminal-tab-hover: color-mix(in srgb, var(--text-strong) 7.5%, transparent); ++ --terminal-tab-selected: color-mix(in srgb, var(--text-strong) 15%, transparent); ++} ++.terminal-tablist { ++ display: flex; ++ flex: 0 1 auto; ++ align-items: center; ++ gap: 4px; ++ min-width: 0; ++ margin: 0 -4px; ++ padding: 4px; ++ overflow: hidden; ++} ++.terminal-tab { ++ position: relative; ++ display: flex; ++ flex: none; ++ align-items: center; ++ gap: 2px; ++ max-width: 192px; ++ height: 24px; ++ padding: 2px 2px 2px 8px; ++ border-radius: var(--radius-sm); ++ color: var(--text-muted); ++ font-size: 13px; ++ line-height: 19px; ++ white-space: nowrap; ++ cursor: default; ++ user-select: none; ++ touch-action: none; ++} ++.terminal-tab:hover { background: var(--terminal-tab-hover); } ++.terminal-tab.is-active { background: var(--terminal-tab-selected); color: var(--text-strong); } ++.terminal-tab:not(:has(.terminal-tab-close)) { padding-right: 8px; } ++.terminal-tab[hidden] { display: none; } ++.terminal-tablist.is-solo .terminal-tab { margin-left: -4px; background: none; color: var(--text-muted); touch-action: auto; } ++.terminal-tablist.is-dragging .terminal-tab { transition: transform 160ms cubic-bezier(0.165, 0.84, 0.44, 1); } ++.terminal-tablist.is-dragging .terminal-tab.is-dragged { ++ z-index: 1; ++ background: linear-gradient(var(--terminal-tab-selected), var(--terminal-tab-selected)), var(--tile-surface); ++ box-shadow: 0 1px 2px rgb(0 0 0 / 0.12), 0 2px 6px rgb(0 0 0 / 0.1); ++ transition: none; ++} ++.terminal-tab-text { display: flex; flex: 1 1 auto; min-width: 0; overflow: hidden; } ++.terminal-tab-label { flex: none; max-width: 140px; overflow: hidden; text-overflow: ellipsis; } ++.terminal-tab-description { min-width: 0; margin-left: 6px; overflow: hidden; color: var(--text-dim); text-overflow: ellipsis; } ++/* Not room for the shells' titles: labels only, the titles in the tooltips. */ ++.terminal-tablist.is-compact .terminal-tab-description { max-width: 0; margin-left: 0; } ++/* Less room still: the inactive tabs drop their × (Delete, a middle click and the menu still close them). */ ++.terminal-tablist.is-tight .terminal-tab:not(.is-active) { padding-right: 8px; } ++.terminal-tablist.is-tight .terminal-tab:not(.is-active) .terminal-tab-close { display: none; } ++.terminal-tab-close { ++ display: grid; ++ flex: none; ++ place-items: center; ++ width: 20px; ++ height: 20px; ++ padding: 0; ++ border: 0; ++ border-radius: var(--radius-xs); ++ background: transparent; ++ color: var(--text-dim); ++ cursor: pointer; ++} ++.terminal-tab-close:hover { background: var(--terminal-tab-hover); color: var(--text-strong); } ++.terminal-tab.is-active .terminal-tab-close { color: var(--text-strong); } ++.terminal-tab.is-renaming { width: 160px; } ++.terminal-tab-input { ++ width: 100%; ++ min-width: 0; ++ padding: 0; ++ border: 0; ++ background: transparent; ++ color: var(--text-strong); ++ font: inherit; ++ outline: none; ++} ++.terminal-tabs > .ui-btn { flex: none; color: var(--text-muted); } ++.terminal-tabs > .ui-btn:hover { color: var(--text-strong); } ++.terminal-tab-tip { display: flex; flex-direction: column; gap: 2px; } ++.terminal-tab-tip-detail { color: var(--text-dim); font-family: var(--font-mono); font-size: 11px; } ++/* Touch: tabs and their × as tall as the header's other buttons. */ ++.tiles-compact .terminal-tab { height: 32px; } ++.tiles-compact .terminal-tab-close { width: 28px; height: 28px; } ++.tiles-compact .terminal-tabs > .ui-btn--xs { width: var(--size-control-sm); height: var(--size-control-sm); } + +/* ---------------------------------------------------------------- Changes */ +.changes-pane { display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; } @@ -8598,8 +9007,40 @@ index 861a1c6..d5d466e 100644 )} {savedOk ? t("i18n.saved") : saving ? t("agents.saving") : t("agents.save")} +diff --git a/components/AppShell.terminal.test.mjs b/components/AppShell.terminal.test.mjs +deleted file mode 100644 +index e232a55..0000000 +--- a/components/AppShell.terminal.test.mjs ++++ /dev/null +@@ -1,26 +0,0 @@ +-import assert from "node:assert/strict"; +-import test from "node:test"; +-import { newTerminalTab, restoreTerminalTabs } from "./terminal-tab-state.ts"; +- +-test("restored workspace tabs retain terminal identity and never request a new shell", () => { +- const first = newTerminalTab("/repo/worktree-a"); +- const second = newTerminalTab("/repo/worktree-b"); +- assert.notEqual(first.id, second.id); +- const saved = restoreTerminalTabs(JSON.stringify({ tabs: [first, second], activeId: second.id, open: true })); +- assert.deepEqual(saved, { +- tabs: [{ ...first, restored: true }, { ...second, restored: true }], +- activeId: second.id, +- open: true, +- }); +-}); +- +-test("storage corruption cannot create invalid or duplicate terminal tabs", () => { +- assert.deepEqual(restoreTerminalTabs("broken"), { tabs: [], activeId: null, open: false }); +- assert.deepEqual(restoreTerminalTabs(null), { tabs: [], activeId: null, open: false }); +- const tab = newTerminalTab("/repo"); +- const saved = restoreTerminalTabs(JSON.stringify({ +- tabs: [null, {}, tab, tab, { ...tab, id: "../../bad" }, { ...tab, cwd: null }], +- activeId: "missing", +- })); +- assert.deepEqual(saved, { tabs: [{ ...tab, restored: true }], activeId: null, open: false }); +-}); diff --git a/components/AppShell.tsx b/components/AppShell.tsx -index c8c4ba9..f19a9f0 100644 +index c8c4ba9..a7151eb 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -3,6 +3,8 @@ @@ -8611,7 +9052,39 @@ index c8c4ba9..f19a9f0 100644 import { SessionSidebar, type SelectSessionOptions, type SessionSidebarControl } from "./SessionSidebar"; import { ChatWindow } from "./ChatWindow"; import { NewSessionContextBar, type NewSessionContextControl } from "./NewSessionContextBar"; -@@ -55,12 +57,7 @@ import { +@@ -17,8 +19,29 @@ import { SystemPromptPanel } from "./SystemPromptPanel"; + import { ToolDefinitionsPanel } from "./ToolDefinitionsPanel"; + import { AgentSessionPanel } from "./AgentSessionPanel"; + import { SubagentViewer } from "./SubagentViewer"; +-import { TerminalPanel } from "./TerminalPanel"; +-import { newTerminalTab, restoreTerminalTabs, TERMINAL_TABS_KEY, type TerminalTab } from "./terminal-tab-state"; ++import { TerminalPanel, type TerminalExitInfo, type TerminalHandle } from "./TerminalPanel"; ++import { TerminalSettingsMenu, TerminalTabs } from "./TerminalTabs"; ++import { ++ activeTab as activeTerminalTab, ++ addTab as addTerminalTab, ++ canCloseTab as canCloseTerminalTab, ++ EMPTY_TERMINAL_TABS, ++ formatTerminalAttachment, ++ groupTabs as terminalGroup, ++ moveTab as moveTerminalTab, ++ otherTabs as otherTerminalTabs, ++ removeTab as removeTerminalTab, ++ renameTab as renameTerminalTab, ++ restoreTerminalTabs, ++ selectTab as selectTerminalTab, ++ serializeTerminalTabs, ++ tabLabel as terminalTabLabel, ++ TERMINAL_TABS_KEY, ++ titleDescription as terminalTitleDescription, ++ updateTab as updateTerminalTab, ++ type TerminalTab, ++ type TerminalTabsState, ++} from "@/lib/terminal-tabs"; + import { useTheme } from "@/hooks/useTheme"; + import { useFontPreferences } from "@/hooks/useFontPreferences"; + import { useI18n } from "@/hooks/useI18n"; +@@ -55,12 +78,7 @@ import { workspaceKeyOf, } from "@/lib/workspace-memory"; import { @@ -8624,7 +9097,7 @@ index c8c4ba9..f19a9f0 100644 SIDEBAR_DEFAULT_WIDTH, SIDEBAR_MAX_WIDTH, SIDEBAR_MIN_WIDTH, -@@ -73,7 +70,18 @@ import type { SessionStatsInfo } from "@/lib/pi-types"; +@@ -73,7 +91,18 @@ import type { SessionStatsInfo } from "@/lib/pi-types"; import type { FileViewerState } from "@/lib/file-viewer-state"; import { PRESET_READ_ONLY, type ToolEntry } from "@/lib/tool-presets"; import { getSessionFamily } from "@/lib/session-family"; @@ -8644,7 +9117,7 @@ index c8c4ba9..f19a9f0 100644 type SessionCopyField = "file" | "id" | "projectDir" | "gitBranch" | "gitWorktree"; type AutoNameStatus = -@@ -82,7 +90,12 @@ type AutoNameStatus = +@@ -82,7 +111,12 @@ type AutoNameStatus = | { kind: "success" } | { kind: "error"; message: string }; @@ -8658,7 +9131,7 @@ index c8c4ba9..f19a9f0 100644 const AGENT_PANEL_WIDTH = 420; // pi's built-in tools that never change a file; any other tool may (#1144). const READ_ONLY_TOOL_NAMES = new Set(PRESET_READ_ONLY); -@@ -227,41 +240,36 @@ export function AppShell() { +@@ -227,41 +261,36 @@ export function AppShell() { const [projectTrustError, setProjectTrustError] = useState(null); const [sidebarOpen, setSidebarOpen] = useState(() => !initialNavigation.sidebarCollapsed); const desktopSidebarOpenRef = useRef(!initialNavigation.sidebarCollapsed); @@ -8723,7 +9196,7 @@ index c8c4ba9..f19a9f0 100644 ); const sidebarResizer = useResizablePanel({ ariaLabel: translate("layout.resizeSidebar"), -@@ -274,20 +282,6 @@ export function AppShell() { +@@ -274,20 +303,6 @@ export function AppShell() { storageKey: "pi-sidebar-width", widthRef: sidebarWidthRef, }); @@ -8744,7 +9217,7 @@ index c8c4ba9..f19a9f0 100644 // On mobile the sidebar is an overlay drawer; hide it by default so the chat // is visible on load. Runs once the breakpoint resolves after hydration. // Mobile drawer actions must not change the remembered desktop preference. -@@ -297,11 +291,6 @@ export function AppShell() { +@@ -297,11 +312,6 @@ export function AppShell() { useEffect(() => { setMobileSidebarReady(true); }, []); @@ -8756,7 +9229,7 @@ index c8c4ba9..f19a9f0 100644 const chatInputRef = useRef(null); const [pendingQuotePrompt, setPendingQuotePrompt] = useState<{ sessionId: string; text: string } | null>(null); const topBarRef = useRef(null); -@@ -329,7 +318,6 @@ export function AppShell() { +@@ -329,7 +339,6 @@ export function AppShell() { const [systemInfoLoading, setSystemInfoLoading] = useState(false); const systemInfoLoaderRef = useRef<(() => Promise) | null>(null); const systemInfoLoadIdRef = useRef(0); @@ -8764,7 +9237,7 @@ index c8c4ba9..f19a9f0 100644 const handleSystemPromptChange = useCallback((prompt: string | null) => { setSystemPrompt(prompt); -@@ -394,10 +382,6 @@ export function AppShell() { +@@ -394,10 +403,6 @@ export function AppShell() { } }, [hasSubagentSessions]); @@ -8775,7 +9248,7 @@ index c8c4ba9..f19a9f0 100644 const toggleTopPanel = useCallback(( panel: "agents" | "branches" | "system" | "tools" | "session", keepMobileToolbarOpen = false, -@@ -451,26 +435,6 @@ export function AppShell() { +@@ -451,26 +456,6 @@ export function AppShell() { }); }, [isMobile]); @@ -8802,32 +9275,73 @@ index c8c4ba9..f19a9f0 100644 useEffect(() => { if (!mobileToolbarMoreOpen) return; -@@ -553,10 +517,7 @@ export function AppShell() { +@@ -518,7 +503,7 @@ export function AppShell() { + return () => ro.disconnect(); + }, [activeTopPanel, isMobile]); + +- // Files unmount when inactive; workspace terminals stay mounted until closed. ++ // Files unmount when inactive; terminals stay mounted until closed (their shells' leases). + const [fileTabs, setFileTabs] = useState([]); + const [activeFileTabId, setActiveFileTabId] = useState(null); + const fileWorkspaceStatesRef = useRef(new Map()); +@@ -527,20 +512,17 @@ export function AppShell() { + // tab just parked goes to its workspace, not to a same-path tab restored. + const [fileWorkspaceGeneration, setFileWorkspaceGeneration] = useState(0); + const fileWorkspaceGenerationRef = useRef(0); +- const [terminalTabs, setTerminalTabs] = useState([]); ++ // The Terminal pane's tabs, a group per working folder (lib/terminal-tabs.ts), and what ++ // their shells title themselves (not stored). ++ const [terminals, setTerminals] = useState(EMPTY_TERMINAL_TABS); + const [terminalsRestored, setTerminalsRestored] = useState(false); ++ const [terminalTitles, setTerminalTitles] = useState>({}); ++ const terminalHandlesRef = useRef(new Map()); + // Subagent work tabs are session-scoped and transient: no persistence, and + // they are dropped when the project changes (they belong to its sessions). + const [agentTabs, setAgentTabs] = useState<{ sessionId: string; label: string }[]>([]); + const panelTabs: Tab[] = [ + ...fileTabs, +- ...terminalTabs.map((tab) => ({ +- id: tab.id, +- label: getFileName(tab.cwd) || tab.cwd, +- filePath: tab.cwd, +- kind: "terminal" as const, +- closing: Boolean(tab.closing), +- })), + ...agentTabs.map((tab) => ({ + id: `agent:${tab.sessionId}`, + label: tab.label, +@@ -551,12 +533,8 @@ export function AppShell() { + + useEffect(() => { try { - const saved = restoreTerminalTabs(window.sessionStorage.getItem(TERMINAL_TABS_KEY)); - setTerminalTabs(saved.tabs); +- const saved = restoreTerminalTabs(window.sessionStorage.getItem(TERMINAL_TABS_KEY)); +- setTerminalTabs(saved.tabs); - if (saved.activeId) { - setActiveFileTabId(saved.activeId); - setRightPanelOpen(saved.open); - } -+ if (saved.activeId) setActiveFileTabId(saved.activeId); ++ // Read once (React's second mount in development reads the same). ++ setTerminals((current) => (current.tabs.length ? current : restoreTerminalTabs(window.sessionStorage.getItem(TERMINAL_TABS_KEY)))); } catch { /* storage is optional */ } setTerminalsRestored(true); }, []); -@@ -567,10 +528,10 @@ export function AppShell() { - window.sessionStorage.setItem(TERMINAL_TABS_KEY, JSON.stringify({ - tabs: terminalTabs.map(({ id, cwd }) => ({ id, cwd })), - activeId: activeFileTabId, +@@ -564,13 +542,9 @@ export function AppShell() { + useEffect(() => { + if (!terminalsRestored) return; + try { +- window.sessionStorage.setItem(TERMINAL_TABS_KEY, JSON.stringify({ +- tabs: terminalTabs.map(({ id, cwd }) => ({ id, cwd })), +- activeId: activeFileTabId, - open: rightPanelOpen, -+ open: false, - })); +- })); ++ window.sessionStorage.setItem(TERMINAL_TABS_KEY, serializeTerminalTabs(terminals)); } catch { /* storage is optional */ } - }, [terminalTabs, activeFileTabId, rightPanelOpen, terminalsRestored]); -+ }, [terminalTabs, activeFileTabId, terminalsRestored]); ++ }, [terminals, terminalsRestored]); const handleFileViewerStateChange = useCallback(( generation: number, -@@ -591,7 +552,7 @@ export function AppShell() { +@@ -591,37 +565,34 @@ export function AppShell() { const next = switchFileWorkspace(fileWorkspaceStatesRef.current, currentKey, nextKey, { tabs: fileTabs, activeTabId: activeFileId, @@ -8836,13 +9350,17 @@ index c8c4ba9..f19a9f0 100644 }, fileWorkspaceGenerationRef.current); fileWorkspaceGenerationRef.current += 1; setFileWorkspaceGeneration(fileWorkspaceGenerationRef.current); -@@ -601,27 +562,26 @@ export function AppShell() { - // Terminal tabs span workspaces and keep control of the panel while active. - if (!activeFileTabId || activeFileId || activeFileTabId.startsWith("agent:")) { - setActiveFileTabId(next.activeTabId); + setFileTabs(next.tabs); + // Subagent tabs belong to the previous project's sessions: dropped, not parked. + setAgentTabs([]); +- // Terminal tabs span workspaces and keep control of the panel while active. +- if (!activeFileTabId || activeFileId || activeFileTabId.startsWith("agent:")) { +- setActiveFileTabId(next.activeTabId); - setRightPanelOpen(next.open); - } +- } - }, [activeFileTabId, fileTabs, rightPanelOpen]); ++ // The Terminal pane follows the folder by itself: its tabs are grouped by it. ++ setActiveFileTabId(next.activeTabId); + }, [activeFileTabId, fileTabs]); // Same @mention format as the chat input's @ autocomplete, so the agent's @@ -8871,7 +9389,7 @@ index c8c4ba9..f19a9f0 100644 const initialSessionId = initialNavigation.sessionId; const initialLinkedSessionId = linkedSessionId(initialNavigation); -@@ -969,10 +929,12 @@ export function AppShell() { +@@ -969,10 +940,12 @@ export function AppShell() { invalidateWorkspaceRestore(); activeNewSessionDraftKeyRef.current = null; setNewSessionCwd(null); @@ -8885,7 +9403,7 @@ index c8c4ba9..f19a9f0 100644 const deliverSessionNotification = useCallback(({ targetSession, -@@ -1183,38 +1145,38 @@ export function AppShell() { +@@ -1183,66 +1156,246 @@ export function AppShell() { tabId, })); setActiveFileTabId(tabId); @@ -8902,17 +9420,19 @@ index c8c4ba9..f19a9f0 100644 }, [handleOpenFile, selectedSession?.id]); - const handleOpenTerminal = useCallback((cwd: string) => { -+ const handleOpenTerminal = useCallback((cwd: string, options?: { focus?: boolean }) => { - const existing = terminalTabs.find((tab) => tab.cwd === cwd); - const tab = existing ?? newTerminalTab(cwd); - if (!existing) setTerminalTabs((tabs) => [...tabs, tab]); - setActiveFileTabId(tab.id); +- const existing = terminalTabs.find((tab) => tab.cwd === cwd); +- const tab = existing ?? newTerminalTab(cwd); +- if (!existing) setTerminalTabs((tabs) => [...tabs, tab]); +- setActiveFileTabId(tab.id); - setRightPanelOpen(true); ++ /** The Terminal pane, on the folder's group: its active tab, or a first one. */ ++ const handleOpenTerminal = useCallback((cwd: string, options?: { focus?: boolean }) => { ++ setTerminals((state) => (terminalGroup(state, cwd).length ? state : addTerminalTab(state, cwd).state)); + openPaneTile("terminal"); + if (options?.focus !== false) focusPane("terminal"); if (isMobile) setSidebarOpen(false); - }, [terminalTabs, isMobile]); -+ }, [terminalTabs, isMobile, openPaneTile, focusPane]); ++ }, [isMobile, openPaneTile, focusPane]); // Subagent work opens in a right-panel tab instead of hijacking the main chat. const handleOpenSubagentTab = useCallback((sessionId: string, label: string) => { @@ -8922,20 +9442,68 @@ index c8c4ba9..f19a9f0 100644 + openPaneTile("files"); if (isMobile) setSidebarOpen(false); - }, [isMobile]); -+ }, [isMobile, openPaneTile]); - - const handleTerminalClosed = (tab: TerminalTab) => { - const replacement = tab.closing === "restart" ? newTerminalTab(tab.cwd) : null; - const remaining = terminalTabs.filter((item) => item.id !== tab.id); - setTerminalTabs((tabs) => tabs.flatMap((item) => item.id !== tab.id ? [item] : replacement ? [replacement] : [])); - setActiveFileTabId((current) => current !== tab.id ? current : replacement?.id ?? remaining.at(-1)?.id ?? fileTabs.at(-1)?.id ?? lastAgentTabId(agentTabs)); +- +- const handleTerminalClosed = (tab: TerminalTab) => { +- const replacement = tab.closing === "restart" ? newTerminalTab(tab.cwd) : null; +- const remaining = terminalTabs.filter((item) => item.id !== tab.id); +- setTerminalTabs((tabs) => tabs.flatMap((item) => item.id !== tab.id ? [item] : replacement ? [replacement] : [])); +- setActiveFileTabId((current) => current !== tab.id ? current : replacement?.id ?? remaining.at(-1)?.id ?? fileTabs.at(-1)?.id ?? lastAgentTabId(agentTabs)); - if (!replacement && !remaining.length && !fileTabs.length && !agentTabs.length) setRightPanelOpen(false); - }; +- }; ++ }, [isMobile, openPaneTile]); ++ ++ // ── the Terminal pane's tabs ────────────────────────────────────────────── ++ /** Stops a tab's shell; the tab goes (close), starts again under the same id (restart), or waits to be shown (sleep). */ ++ const stopTerminal = useCallback((id: string, closing: NonNullable) => { ++ setTerminals((state) => { ++ const tab = state.tabs.find((item) => item.id === id); ++ if (!tab || tab.closing) return state; ++ if (closing === "close" && !canCloseTerminalTab(state, id)) return state; ++ return updateTerminalTab(state, id, { closing }); ++ }); ++ }, []); ++ const handleTerminalStopped = useCallback((tab: TerminalTab) => { ++ setTerminals((state) => { ++ const current = state.tabs.find((item) => item.id === tab.id); ++ if (!current) return state; ++ if (current.closing === "close") return removeTerminalTab(state, tab.id); ++ // A fresh xterm and shell under the same id; "sleep" only when the tab is next shown. ++ return updateTerminalTab(state, tab.id, { ++ closing: undefined, ++ restored: false, ++ dormant: current.closing === "sleep", ++ generation: (current.generation ?? 0) + 1, ++ }); ++ }); ++ setTerminalTitles((titles) => { ++ if (!(tab.id in titles)) return titles; ++ const rest = { ...titles }; ++ delete rest[tab.id]; ++ return rest; ++ }); ++ }, []); ++ const handleNewTerminal = useCallback(() => { ++ if (!activeCwd) return; ++ setTerminals((state) => addTerminalTab(state, activeCwd).state); ++ focusPane("terminal"); ++ }, [activeCwd, focusPane]); ++ const handleSelectTerminal = useCallback((id: string, options: { focus: boolean }) => { ++ setTerminals((state) => selectTerminalTab(state, id)); ++ if (options.focus) focusPane("terminal"); ++ }, [focusPane]); const handleCloseFileTab = useCallback((tabId: string) => { -@@ -1229,14 +1191,9 @@ export function AppShell() { +- if (terminalTabs.some((tab) => tab.id === tabId)) { +- setTerminalTabs((tabs) => tabs.map((tab) => tab.id === tabId && !tab.closing ? { ...tab, closing: "close" } : tab)); +- return; +- } + if (tabId.startsWith("agent:")) { + const remainingAgents = agentTabs.filter((tab) => `agent:${tab.sessionId}` !== tabId); + setAgentTabs(remainingAgents); + setActiveFileTabId((cur) => { if (cur !== tabId) return cur; - return fileTabs.at(-1)?.id ?? terminalTabs.at(-1)?.id ?? lastAgentTabId(remainingAgents); +- return fileTabs.at(-1)?.id ?? terminalTabs.at(-1)?.id ?? lastAgentTabId(remainingAgents); ++ return fileTabs.at(-1)?.id ?? lastAgentTabId(remainingAgents); }); - if (fileTabs.length === 0 && terminalTabs.length === 0 && remainingAgents.length === 0) setRightPanelOpen(false); return; @@ -8949,24 +9517,22 @@ index c8c4ba9..f19a9f0 100644 setActiveFileTabId((cur) => { if (cur !== tabId) return cur; const remaining = fileTabs.filter((t) => t.id !== tabId); -@@ -1244,6 +1201,165 @@ export function AppShell() { - }); - }, [fileTabs, terminalTabs, agentTabs]); - +- return remaining.at(-1)?.id ?? terminalTabs.at(-1)?.id ?? lastAgentTabId(agentTabs); ++ return remaining.at(-1)?.id ?? lastAgentTabId(agentTabs); ++ }); ++ }, [fileTabs, agentTabs]); ++ + // ── the right pane's views ──────────────────────────────────────────────── -+ // Files and Terminal keep the one list of tabs; each view shows its own -+ // kind and comes back to the tab it showed last. ++ // The Files pane's tabs (files and subagents); it comes back to the tab it showed last. + const lastFilesTabIdRef = useRef(null); -+ const lastTerminalTabIdRef = useRef(null); -+ const filesPaneTabs = panelTabs.filter((tab) => tab.kind !== "terminal"); -+ const terminalPaneTabs = panelTabs.filter((tab) => tab.kind === "terminal"); -+ if (activeFileTabId && terminalPaneTabs.some((tab) => tab.id === activeFileTabId)) lastTerminalTabIdRef.current = activeFileTabId; -+ else if (activeFileTabId && filesPaneTabs.some((tab) => tab.id === activeFileTabId)) lastFilesTabIdRef.current = activeFileTabId; ++ const filesPaneTabs = panelTabs; ++ if (activeFileTabId && filesPaneTabs.some((tab) => tab.id === activeFileTabId)) lastFilesTabIdRef.current = activeFileTabId; + const pickPaneTab = (tabs: Tab[], preferred: (string | null)[]) => ( + preferred.find((id) => id !== null && tabs.some((tab) => tab.id === id)) ?? tabs.at(-1)?.id ?? null + ); + const filesActiveTabId = pickPaneTab(filesPaneTabs, [activeFileTabId, lastFilesTabIdRef.current]); -+ const terminalActiveTabId = pickPaneTab(terminalPaneTabs, [activeFileTabId, lastTerminalTabIdRef.current]); ++ // The Terminal pane shows the folder's group, on its active tab. ++ const shownTerminal = activeTerminalTab(terminals, activeCwd); + const shownFileTab = fileTabs.find((tab) => tab.id === filesActiveTabId) ?? null; + const shownAgentTab = agentTabs.find((tab) => `agent:${tab.sessionId}` === filesActiveTabId) ?? null; + // The file tree (the sidebar's file section, portaled here): beside the open file, or alone. @@ -8977,12 +9543,12 @@ index c8c4ba9..f19a9f0 100644 + /** A pane opened from a button or a key: the terminal pane opens with a terminal in it. */ + const showPane = useCallback((id: PaneId) => { + setActiveTopPanel(null); -+ if (id === "terminal" && terminalTabs.length === 0 && activeCwd) handleOpenTerminal(activeCwd); ++ if (id === "terminal" && activeCwd) handleOpenTerminal(activeCwd); + else openPaneTile(id); + // The terminal takes the caret when it opens, as Desktop's does. + if (id === "terminal") focusPane("terminal"); + if (isMobile) setSidebarOpen(false); -+ }, [terminalTabs.length, activeCwd, handleOpenTerminal, openPaneTile, focusPane, isMobile]); ++ }, [activeCwd, handleOpenTerminal, openPaneTile, focusPane, isMobile]); + + /** A header button (Desktop's toggleSidePane): an open pane closes, a closed one opens. */ + const togglePane = useCallback((id: PaneId) => { @@ -8997,7 +9563,8 @@ index c8c4ba9..f19a9f0 100644 + if (!focusWasInside) return; + requestAnimationFrame(() => { + if (!neighbour || !focusTile(neighbour)) focusTile("chat"); -+ }); + }); +- }, [fileTabs, terminalTabs, agentTabs]); + }, [panes.layout, closePaneTile]); + + // The header's "+N −M": the working tree against HEAD, refreshed when the agent's tools ran. @@ -9076,16 +9643,18 @@ index c8c4ba9..f19a9f0 100644 + + // The Browser pane, once opened, stays mounted; a picked element goes to the composer. + const [browserPaneOpened, setBrowserPaneOpened] = useState(false); -+ // A Terminal pane the layout brings back (a reload, another session) starts its terminal -+ // when there is none: once per session per page, so closing the last tab still empties it. -+ const terminalPaneOpen = panes.isOpen("terminal"); -+ const terminalAutostartRef = useRef(new Set()); ++ // The Terminal pane in sight always has a terminal, as Desktop's (a session's tab "0"): ++ // the folder's first starts without focus (a reload, another session's folder), and a ++ // tab whose shell ended with the pane wakes up with a new one. ++ const terminalInSight = panes.isOpen("terminal") && (!panes.expanded || panes.expanded === "terminal"); + useEffect(() => { -+ const key = selectedSession?.id ?? "new"; -+ if (!terminalsRestored || !terminalPaneOpen || !activeCwd || terminalAutostartRef.current.has(key)) return; -+ terminalAutostartRef.current.add(key); -+ if (terminalTabs.length === 0) handleOpenTerminal(activeCwd, { focus: false }); -+ }, [terminalsRestored, terminalPaneOpen, activeCwd, selectedSession?.id, terminalTabs.length, handleOpenTerminal]); ++ if (!terminalsRestored || !terminalInSight || !activeCwd) return; ++ setTerminals((state) => { ++ if (terminalGroup(state, activeCwd).length === 0) return addTerminalTab(state, activeCwd).state; ++ const tab = activeTerminalTab(state, activeCwd); ++ return tab?.dormant ? updateTerminalTab(state, tab.id, { dormant: false }) : state; ++ }); ++ }, [terminalsRestored, terminalInSight, activeCwd, shownTerminal?.id, shownTerminal?.dormant]); + + const browserOpen = panes.isOpen("browser"); + useEffect(() => { @@ -9111,11 +9680,10 @@ index c8c4ba9..f19a9f0 100644 + const handleSendReviewComments = useCallback((message: string) => { + chatInputRef.current?.sendText(message); + }, []); -+ + const handleViewFullHistory = useCallback(() => { if (!selectedSession) return; - window.open( -@@ -1379,9 +1495,6 @@ export function AppShell() { +@@ -1379,9 +1532,6 @@ export function AppShell() { if (cwd === projectTrustCwd) setProjectTrust(status); }, [projectTrustCwd]); @@ -9125,7 +9693,7 @@ index c8c4ba9..f19a9f0 100644 const activeCwdName = activeCwd ? getFileName(activeCwd) || activeCwd : null; const windowTitle = activeCwdName ? `${activeCwdName} - Pi Web` : "Pi Web"; -@@ -1396,6 +1509,28 @@ export function AppShell() { +@@ -1396,6 +1546,28 @@ export function AppShell() { return () => observer.disconnect(); }, [windowTitle]); @@ -9154,7 +9722,7 @@ index c8c4ba9..f19a9f0 100644 const sidebarContent = ( <> ); -@@ -1502,524 +1606,180 @@ export function AppShell() { +@@ -1502,524 +1643,207 @@ export function AppShell() { }} data-mobile-trust-banner={mobileBanner ? "true" : undefined} > @@ -9283,7 +9851,59 @@ index c8c4ba9..f19a9f0 100644 - onMouseLeave={(event) => { - event.currentTarget.style.color = selectedSession ? "var(--text-muted)" : "var(--text-dim)"; - event.currentTarget.style.background = "none"; -- }} ++ // The panes as tiles (TileLayout): a pane is "visible" while it has its rectangle, i.e. open ++ // and not under another expanded one. Files and the terminals stay mounted closed (the ++ // sidebar's file tree is portaled into Files; terminals keep running); the browser once opened. ++ const paneVisible = (id: PaneId) => panes.isOpen(id) && (!panes.expanded || panes.expanded === id); ++ const paneTitle = (id: PaneId) => translate(PANES[id].labelKey); ++ // A shell's own `exit` closes its tab, or the pane with a lone tab, whose next showing ++ // starts a new shell (Desktop's "shell_exit"); any other end shows "Shell exited". ++ const handleTerminalExit = (tab: TerminalTab, exit: TerminalExitInfo) => { ++ if (!exit.userExit) return; ++ if (terminalGroup(terminals, tab.cwd).length > 1) { ++ stopTerminal(tab.id, "close"); ++ return; ++ } ++ stopTerminal(tab.id, "sleep"); ++ if (tab.cwd === activeCwd && paneVisible("terminal")) closePaneKeepingFocus("terminal"); ++ }; ++ const handleTerminalAttach = (text: string, label: string) => { ++ chatInputRef.current?.insertText(formatTerminalAttachment(text, label)); ++ if (isMobile) dismissMobilePane(); ++ }; ++ // Kept while nothing changes: the strip measures its tabs when they do, not on every render. ++ const terminalItems = useMemo(() => { ++ const group = terminalGroup(terminals, activeCwd); ++ return group.map((tab) => { ++ const title = tab.name ? undefined : terminalTitles[tab.id]; ++ return { ++ id: tab.id, ++ label: terminalTabLabel(tab, group.length, translate), ++ description: terminalTitleDescription(title), ++ detail: title?.trim() || undefined, ++ closing: Boolean(tab.closing), ++ }; ++ }); ++ }, [terminals, activeCwd, terminalTitles, translate]); ++ const paneTiles: PaneTileSpec[] = [ ++ { ++ id: "terminal", ++ title: paneTitle("terminal"), ++ // Its tabs are its title (Desktop draws them in the header), the "+" after them. ++ header: activeCwd ? ( ++ stopTerminal(id, "close")} ++ onCloseOthers={(id) => { for (const other of otherTerminalTabs(terminals, id)) stopTerminal(other.id, "close"); }} ++ onRename={(id, name) => setTerminals((state) => renameTerminalTab(state, id, name))} ++ onMove={(id, index) => setTerminals((state) => moveTerminalTab(state, id, index))} ++ onClear={(id) => { ++ terminalHandlesRef.current.get(id)?.clear(); ++ handleSelectTerminal(id, { focus: true }); + }} - data-mobile-toolbar-action={mobile ? "history" : undefined} - > - ++ ) : undefined, ++ actions: , ++ body: ( ++
++ {!activeCwd &&
{translate("terminal.noProject")}
} ++ {/* Every tab's shell stays attached (a shell with no window ends after two minutes). */} ++ {terminals.tabs.map((tab) => { ++ if (tab.dormant) return null; ++ const label = terminalTabLabel(tab, terminalGroup(terminals, tab.cwd).length, translate); + return ( - -- ); ++ handleTerminalExit(tab, exit)} ++ onTitle={(title) => setTerminalTitles((titles) => (titles[tab.id] === title ? titles : { ...titles, [tab.id]: title }))} ++ onRestart={() => stopTerminal(tab.id, "restart")} ++ onAttach={(text) => handleTerminalAttach(text, label)} ++ onClosed={() => handleTerminalStopped(tab)} ++ onCloseError={() => setTerminals((state) => updateTerminalTab(state, tab.id, { closing: undefined }))} ++ onHandle={(handle) => { ++ if (handle) terminalHandlesRef.current.set(tab.id, handle); ++ else terminalHandlesRef.current.delete(tab.id); ++ }} ++ /> ++
+ ); - })()} - {hasSubagentSessions && ( -+ // The panes as tiles (TileLayout): a pane is "visible" while it has its rectangle, i.e. open -+ // and not under another expanded one. Files and the terminals stay mounted closed (the -+ // sidebar's file tree is portaled into Files; terminals keep running); the browser once opened. -+ const paneVisible = (id: PaneId) => panes.isOpen(id) && (!panes.expanded || panes.expanded === id); -+ const paneTitle = (id: PaneId) => translate(PANES[id].labelKey); -+ const paneTiles: PaneTileSpec[] = [ -+ { -+ id: "terminal", -+ title: paneTitle("terminal"), -+ actions: activeCwd ? ( -+ - +- - )} - {sessionHasBranches && (mobile ? ( -+ -+ ) : null, -+ body: ( -+
-+
-+
-+ { -+ setActiveFileTabId(id); -+ focusPane("terminal"); -+ }} -+ onCloseTab={handleCloseFileTab} -+ /> -+
-+
-+
-+ {terminalTabs.length === 0 && ( -+
-+ {activeCwd ? ( -+ -+ ) : translate("changes.noProject")} -+
-+ )} -+ {terminalTabs.map((tab) => ( -+ -+ ))} -+
++ })} +
+ ), + }, @@ -9898,7 +10494,7 @@ index c8c4ba9..f19a9f0 100644