diff --git a/docs/developer/AGENT_RUNTIME.md b/docs/developer/AGENT_RUNTIME.md index 22f29bb..8fab5be 100644 --- a/docs/developer/AGENT_RUNTIME.md +++ b/docs/developer/AGENT_RUNTIME.md @@ -41,6 +41,15 @@ The runtime keeps provider-specific code out of the core app. Adapters know how to talk to a specific backend or protocol. +The composed system prompt includes the live registry catalog plus bounded +session context: cwd, space name, active editor file, selected diff file, open +browser tabs, terminal status, running dev-server URLs, recent browser console +errors, current git summary, and project instruction files discovered from the +session cwd (`AGENTS.md`, `.cursorrules`, `CLAUDE.md`, and GitHub Copilot +instructions). Prompt text also states precedence: app safety and tool schemas +win first, then the latest user request, then project instruction files with +more-specific files overriding broader ones. + ## Session Model An agent session contains: diff --git a/docs/developer/ARCHITECTURE.md b/docs/developer/ARCHITECTURE.md index 69fa58a..438b136 100644 --- a/docs/developer/ARCHITECTURE.md +++ b/docs/developer/ARCHITECTURE.md @@ -87,8 +87,9 @@ use, discovery data lives under `~/.meith`: protocol version, user data path, instance path, and managed CLI launcher. - `~/.meith/instances/.json` records live runtime instances so the CLI can list, target, or kill them. -- `/state.json` stores spaces, projects, browser tabs, workspace tabs, - file edit events, plugins, and app settings. +- `/state.json` stores spaces, projects, browser tabs, workspace tabs + (including editor file focus and diff-tab selected file), file edit events, + plugins, and app settings. - `/logs.jsonl` stores app logs. - `/audit.jsonl` stores tool authorization/audit records. - `/artifacts/` stores screenshot and bug-report artifacts. @@ -176,8 +177,9 @@ Major surfaces: - `TerminalView` for PTY sessions. - `AgentView` for session list, transcript, composer, stop button, and permission cards. -- `DiffView` for a working-tree diff tab with a folder tree, cached summary - counts, and lazy patch loading for the selected file. +- `DiffView` for a working-tree diff tab with a folder tree, refreshed summary + counts, persisted selected-file state, and lazy patch loading for the selected + file. - `SettingsView` for app preferences, per-project run commands, agent config, plugin management, and about info. - `DebugPanel` for tool runner, state, logs, and output diagnostics. diff --git a/docs/developer/TOOL_PROTOCOL.md b/docs/developer/TOOL_PROTOCOL.md index 729706a..b55d848 100644 --- a/docs/developer/TOOL_PROTOCOL.md +++ b/docs/developer/TOOL_PROTOCOL.md @@ -252,7 +252,8 @@ and unstaged changes against `HEAD` plus untracked files: `includePatches: false` returns file status and line-count summaries without patch bodies. The renderer uses that cheap summary for the top-bar diff chip and -polling. The diff tab then requests a selected file on demand: +visible diff tab, refreshing while they are shown. The diff tab then requests a +selected file on demand: ```json { diff --git a/docs/user/SPACES.md b/docs/user/SPACES.md index 45a18b9..07b48a7 100644 --- a/docs/user/SPACES.md +++ b/docs/user/SPACES.md @@ -22,6 +22,7 @@ Within each workspace you can have: - browser tabs for previewing localhost or research, - editor tabs for project files, +- diff tabs for reviewing git working-tree changes, - terminal tabs, - agent chat tabs, - run commands and environment configurations, @@ -33,6 +34,11 @@ Split panes to arrange your browser, editor, terminal, or agent next to each other. A common layout is an agent chat on the left with a live localhost preview on the right, so you can watch changes land as the agent works. +Clicking inside a pane makes that pane active and highlights its active tab in +orange. Opening the git diff from the top bar defaults to the right pane, +creating split view from a single-pane layout; you can still drag the diff tab +elsewhere. + ## Run commands & preview Start and stop your project's run command from the top bar, then preview the diff --git a/docs/user/TOOLS.md b/docs/user/TOOLS.md index 2c7cff5..e965e21 100644 --- a/docs/user/TOOLS.md +++ b/docs/user/TOOLS.md @@ -28,10 +28,10 @@ they can request immediately. | --- | --- | --- | | App diagnostics | Inspect app state, logs, health, instances, screenshots, debug mode, and bug reports. | `app_get_state`, `app_get_logs`, `app_list_instances`, `app_health`, `app_screenshot`, `app_set_debug_mode`, `app_export_bug_report` | | Browser control | Open, focus, navigate, inspect, automate, screenshot, and debug browser tabs. | `get_tabs`, `get_active_tab`, `open_browser_tab`, `navigate`, `go_back`, `go_forward`, `refresh`, `focus_browser_tab`, `close_browser_tab`, `browser_use_start`, `browser_use_end`, `get_browser_state`, `click_element`, `type_text`, `scroll_page`, `send_keys`, `cdp_command`, `get_console_logs`, `get_network_logs`, `take_screenshot` | -| Spaces & workspace tabs | Manage spaces plus editor, terminal, agent, and preview tabs inside them. | `list_spaces`, `create_space`, `update_space`, `switch_space`, `close_space`, `open_workspace_tab`, `set_workspace_tab_file`, `set_workspace_tab_terminal`, `focus_workspace_tab`, `close_workspace_tab` | +| Spaces & workspace tabs | Manage spaces plus editor, terminal, agent, preview, and diff tabs inside them. `set_workspace_tab_file` tracks editor focus/open files and the selected file in a diff tab. | `list_spaces`, `create_space`, `update_space`, `switch_space`, `close_space`, `open_workspace_tab`, `set_workspace_tab_file`, `set_workspace_tab_terminal`, `focus_workspace_tab`, `close_workspace_tab` | | Projects | Detect, open, generate, prewarm, allocate, configure, run, and stop projects. | `project_list`, `project_detect`, `project_open`, `project_start_dev_server`, `project_stop_dev_server`, `project_run`, `project_set_run_config`, `project_list_templates`, `project_create`, `project_create_plugin`, `project_prewarm`, `project_prewarm_status`, `project_allocate` | | Files & diagnostics | List, read, search, edit, undo, and inspect TypeScript or JavaScript diagnostics. | `workspace_list_files`, `workspace_read_file`, `workspace_search`, `workspace_write_file`, `workspace_apply_patch`, `workspace_undo`, `get_diagnostics` | -| Git | Inspect a project's working-tree changes as cached summaries, per-file counts, and lazy-loaded patches. | `git_diff` | +| Git | Inspect a project's working-tree changes as refreshed summaries, per-file counts, and lazy-loaded patches. | `git_diff` | | Processes & terminals | Create terminals, manage dev servers, inspect process trees, and read or stream logs. | `create_terminal`, `list_terminals`, `write_terminal`, `resize_terminal`, `kill_terminal`, `close_terminal`, `get_terminal_snapshot`, `start_dev_server`, `list_dev_servers`, `stop_dev_server`, `get_process_tree`, `get_process_logs`, `attach_process_logs` | | Storage | Inspect durable storage collections and export persisted state for backup or debugging. | `storage_list_collections`, `storage_read_collection`, `storage_export_state` | | Plugins | Install, grant, enable, open, list, and uninstall controlled plugin tabs. | `list_plugins`, `install_plugin`, `approve_plugin_grants`, `set_plugin_enabled`, `open_plugin_tab`, `uninstall_plugin` | diff --git a/docs/user/USING_MEITH.md b/docs/user/USING_MEITH.md index 7acc437..7dc40bc 100644 --- a/docs/user/USING_MEITH.md +++ b/docs/user/USING_MEITH.md @@ -38,7 +38,8 @@ outside in, the window is made of a few stable regions: scopes every tab you open to that project. 2. Split the workspace so you can see more than one thing at once. A common layout is an agent chat on the left and a live `localhost` preview on the - right. + right. Clicking inside either pane makes that pane active; its active tab is + highlighted in orange. 3. Start your project's run command from the title bar. The status bar shows the running server and its port, and the embedded browser tab can point straight at it. @@ -55,6 +56,11 @@ project's working-tree changes, including staged, unstaged, and brand-new files, and presents them as per-file diffs with added and removed line counts. You can see exactly what was rewritten before you keep it. +The git diff view opens on the right by default. From a single-pane layout, +opening the diff starts split view; from an existing split layout, it focuses or +moves the diff tab into the right pane. The tab remains draggable if you want a +different arrangement. + > **Edits are reversible** > > File edits land as reviewable diffs, and a workspace-level undo lets you roll diff --git a/docs/user/WORKING_WITH_AGENTS.md b/docs/user/WORKING_WITH_AGENTS.md index 47af6ac..22dc856 100644 --- a/docs/user/WORKING_WITH_AGENTS.md +++ b/docs/user/WORKING_WITH_AGENTS.md @@ -16,6 +16,11 @@ project. It can read and edit your app's code, run the dev server, drive an embedded browser to check the live preview, and inspect dev-server logs. All of that happens through the same shared tool registry the rest of the app uses. +Before each run, meith also gives the agent a bounded snapshot of the workbench: +the active editor file, selected diff file, open browser tabs, terminal and dev +server status, recent browser console errors, the current git summary, and +project-specific instruction files such as `AGENTS.md` when they exist. + ## Provider-agnostic by design meith does not lock you into one AI provider. The agent runtime uses an adapter @@ -47,6 +52,11 @@ provider-native helpers cannot bypass the shared registry. > When an agent edits a file, the change lands as an inline diff with a gutter > marker, so you can see exactly what was rewritten and undo it. +The top-bar diff chip and the diff tab refresh while visible, so changes made by +agents, tools, or terminal commands should appear without waiting for a manual +refresh. Opening the git diff from the top bar opens it on the right side by +default, creating a split pane when needed. + ## Learn more For how permissions and tool calls work under the hood, see diff --git a/packages/desktop/src/main/__tests__/agent.test.ts b/packages/desktop/src/main/__tests__/agent.test.ts index c3e7062..834a2fc 100644 --- a/packages/desktop/src/main/__tests__/agent.test.ts +++ b/packages/desktop/src/main/__tests__/agent.test.ts @@ -19,6 +19,7 @@ import { buildSystemPrompt, renderToolCatalog } from "../agent/systemPrompt.js"; import type { AgentAdapter, AgentSession, AgentStreamChunk } from "../agent/types.js"; import { AgentConfigStore } from "../services/AgentConfigStore.js"; import { AgentService } from "../services/AgentService.js"; +import { AppStateService } from "../services/AppStateService.js"; import { Logger } from "../services/Logger.js"; import { ToolRegistry } from "../tools/registry.js"; @@ -83,6 +84,63 @@ describe("system prompt builder", () => { it("handles an empty registry gracefully", () => { expect(renderToolCatalog([])).toContain("No tools are currently registered"); }); + + it("renders live IDE context, instructions, and precedence rules", () => { + const prompt = buildSystemPrompt([], { + cwd: "/repo", + spaceName: "Project", + activeEditorFile: { tabTitle: "Editor", cwd: "/repo", path: "src/app.ts" }, + selectedDiffFile: { tabTitle: "Diff", cwd: "/repo", path: "src/app.ts" }, + openTabs: [{ title: "Local app", url: "http://localhost:3000" }], + terminals: [ + { + id: "term_1", + tabTitle: "Terminal", + cwd: "/repo", + status: "running", + pid: 123, + exitCode: null, + active: true, + }, + ], + devServers: [ + { + id: "dev_1", + cwd: "/repo", + status: "running", + command: "pnpm dev", + url: "http://localhost:3000", + pid: 456, + }, + ], + consoleErrors: [ + { + tabTitle: "Local app", + url: "http://localhost:3000", + text: "Uncaught Error: boom", + }, + ], + git: { + branch: "main", + status: "changes", + summary: "1 changed file(s)", + files: ["M src/app.ts"], + }, + instructionFiles: [ + { path: "/repo/AGENTS.md", content: "Use pnpm.", truncated: false }, + ], + }); + + expect(prompt).toContain("## Instruction precedence"); + expect(prompt).toContain("latest user request defines the task"); + expect(prompt).toContain("## Project instructions"); + expect(prompt).toContain("Use pnpm."); + expect(prompt).toContain("Active editor file: `src/app.ts`"); + expect(prompt).toContain("Selected diff file: `src/app.ts`"); + expect(prompt).toContain("http://localhost:3000"); + expect(prompt).toContain("Uncaught Error: boom"); + expect(prompt).toContain("Git: changes on main"); + }); }); describe("ACP adapter prompt", () => { @@ -473,6 +531,64 @@ describe("AgentService host context", () => { // The host exposes a registry-derived prompt to the adapter. expect(captured.systemPrompt).toContain("__probe"); }); + + it("builds prompt context from app state and project instruction files", async () => { + const { mkdtempSync, writeFileSync } = await import("node:fs"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const cwd = mkdtempSync(join(tmpdir(), "meith-agent-context-")); + writeFileSync(join(cwd, "AGENTS.md"), "Use pnpm for this project.\n", "utf8"); + + const logger = new Logger(); + const appState = new AppStateService(join(cwd, "state.json"), logger, 0); + const spaceId = appState.getState().activeSpaceId ?? appState.getState().spaces[0].id; + appState.update((draft) => { + draft.workspaceTabs.push( + { + id: "w_editor", + spaceId, + title: "Editor", + cwd, + kind: "editor", + active: false, + activeFilePath: "src/app.ts", + createdAt: 1, + }, + { + id: "w_diff", + spaceId, + title: "Diff", + cwd, + kind: "diff", + active: true, + selectedDiffFilePath: "src/app.ts", + createdAt: 2, + }, + ); + }); + const registry = new ToolRegistry(); + registry.register( + defineTool({ + name: "__probe", + description: "captures call context", + inputSchema: z.object({}), + execute: () => ({}), + }), + ); + const service = new AgentService(registry, logger, { appState }); + const { adapter, captured } = makeCapturingAdapter(); + service.registerAdapter(adapter); + const session = service.createSession({ cwd, spaceId }); + + for await (const _chunk of service.run(session.id)) { + void _chunk; + } + + expect(captured.systemPrompt).toContain("Use pnpm for this project."); + expect(captured.systemPrompt).toContain("Active editor file: `src/app.ts`"); + expect(captured.systemPrompt).toContain("Selected diff file: `src/app.ts`"); + expect(captured.systemPrompt).toContain("Git: not a git repository"); + }); }); describe("AgentService permission model", () => { diff --git a/packages/desktop/src/main/__tests__/electronHardening.test.ts b/packages/desktop/src/main/__tests__/electronHardening.test.ts index f415f1c..a4b6d28 100644 --- a/packages/desktop/src/main/__tests__/electronHardening.test.ts +++ b/packages/desktop/src/main/__tests__/electronHardening.test.ts @@ -103,9 +103,9 @@ describe("ElectronBrowserViewHost Electron permission hardening", () => { permissionHandler = null; windowOpenHandler = null; // Reset the mock factory so each test gets its own view. - const { WebContentsView } = vi.mocked( - await import("electron"), - ) as { WebContentsView: ReturnType }; + const { WebContentsView } = vi.mocked(await import("electron")) as unknown as { + WebContentsView: ReturnType; + }; WebContentsView.mockImplementation(() => makeMockView()); }); diff --git a/packages/desktop/src/main/__tests__/integration.test.ts b/packages/desktop/src/main/__tests__/integration.test.ts index 7f3c5ac..f4e285e 100644 --- a/packages/desktop/src/main/__tests__/integration.test.ts +++ b/packages/desktop/src/main/__tests__/integration.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { ToolClient } from "@meith/cli/client"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { type ServiceContainer, bootstrap } from "../bootstrap.js"; +import { CURRENT_STATE_VERSION } from "../storage/migrations.js"; function sendRawFrame( socketPath: string, @@ -322,8 +323,8 @@ describe("socket integration", () => { dataDirectory: string; state: { version: number }; }; - expect(snapshot.stateVersion).toBe(4); - expect(snapshot.state.version).toBe(4); + expect(snapshot.stateVersion).toBe(CURRENT_STATE_VERSION); + expect(snapshot.state.version).toBe(CURRENT_STATE_VERSION); }); it("returns app health and live instances over the socket", async () => { @@ -389,7 +390,7 @@ describe("socket integration", () => { }; expect(content.path).toBeTruthy(); expect(content.report.schema).toBe("meith-bug-report/v1"); - expect(content.report.stateSummary.version).toBe(4); + expect(content.report.stateSummary.version).toBe(CURRENT_STATE_VERSION); expect(content.report.toolRegistry.some((t) => t.name === "app_health")).toBe(true); }); }); diff --git a/packages/desktop/src/main/__tests__/spaces.test.ts b/packages/desktop/src/main/__tests__/spaces.test.ts index 88090df..3776c3a 100644 --- a/packages/desktop/src/main/__tests__/spaces.test.ts +++ b/packages/desktop/src/main/__tests__/spaces.test.ts @@ -173,6 +173,21 @@ describe("BrowserTabService workspace tabs", () => { expect(cleared.terminalId).toBeUndefined(); }); + it("persists the selected file on diff workspace tabs", () => { + const tab = ctx.tabs.openWorkspaceTab({ + title: "Diff", + cwd: "/tmp/project", + kind: "diff", + }); + + const updated = ctx.tabs.setWorkspaceTabFile(tab.id, { + selectedDiffFilePath: "src/app.ts", + }); + + expect(updated.selectedDiffFilePath).toBe("src/app.ts"); + expect(ctx.tabs.listWorkspaceTabs()[0].selectedDiffFilePath).toBe("src/app.ts"); + }); + it("rejects terminal session ids on non-terminal workspace tabs", () => { const tab = ctx.tabs.openWorkspaceTab({ title: "Editor", cwd: "/tmp/project" }); expect(() => ctx.tabs.setWorkspaceTabTerminal(tab.id, "term_123")).toThrow( diff --git a/packages/desktop/src/main/__tests__/storage.test.ts b/packages/desktop/src/main/__tests__/storage.test.ts index 8894a5d..c603add 100644 --- a/packages/desktop/src/main/__tests__/storage.test.ts +++ b/packages/desktop/src/main/__tests__/storage.test.ts @@ -38,12 +38,22 @@ describe("migrations", () => { it("passes through a valid current-version state", () => { const valid = { - version: 3, - spaces: [{ id: "s1", name: "S", createdAt: 1 }], + version: CURRENT_STATE_VERSION, + spaces: [{ id: "s1", name: "S", projectId: null, createdAt: 1 }], activeSpaceId: "s1", browserTabs: [], workspaceTabs: [], projects: [], + workspaceFileEvents: [], + plugins: [], + settings: { + autoRunOnOpen: false, + confirmOnClose: true, + stopServersOnClose: true, + showOutputOnRun: true, + defaultPackageManager: "unknown", + debugMode: false, + }, }; expect(migrateAppState(valid).activeSpaceId).toBe("s1"); }); @@ -116,6 +126,43 @@ describe("migrations", () => { }); }); + it("migrates v4 -> v5 by preserving selected diff file state", () => { + const v4 = { + version: 4, + spaces: [{ id: "s1", name: "S", createdAt: 1, projectId: null }], + activeSpaceId: "s1", + browserTabs: [], + workspaceTabs: [ + { + id: "w1", + spaceId: "s1", + title: "Diff", + cwd: "/tmp/proj", + kind: "diff", + selectedDiffFilePath: "src/app.ts", + active: true, + createdAt: 1, + }, + ], + projects: [], + workspaceFileEvents: [], + plugins: [], + settings: { + autoRunOnOpen: false, + confirmOnClose: true, + stopServersOnClose: true, + showOutputOnRun: true, + defaultPackageManager: "unknown", + debugMode: false, + }, + }; + + const migrated = migrateAppState(v4); + + expect(migrated.version).toBe(CURRENT_STATE_VERSION); + expect(migrated.workspaceTabs[0].selectedDiffFilePath).toBe("src/app.ts"); + }); + it("throws on a newer-than-supported version", () => { expect(() => migrateAppState({ version: 99 })).toThrow(); }); diff --git a/packages/desktop/src/main/agent/systemPrompt.ts b/packages/desktop/src/main/agent/systemPrompt.ts index 53aa06e..cd4d9e0 100644 --- a/packages/desktop/src/main/agent/systemPrompt.ts +++ b/packages/desktop/src/main/agent/systemPrompt.ts @@ -59,6 +59,20 @@ The host (the Electron main process) is the authority for all state and actions. and keep final answers focused on what changed and how it was verified. - Never assume a browser tab, workspace tab, or process exists — list it first. +## Instruction precedence + +- App safety rules, permission boundaries, and the tool call contract are + mandatory. Do not bypass them to satisfy user or repo instructions. +- Tool descriptors, schemas, and tool results are authoritative for how to call + Meith tools and interpret their output. +- The latest user request defines the task. It overrides project instruction + files when they conflict, unless doing so would violate app safety rules or + tool contracts. +- Project-specific instruction files apply to work inside their project scope. + More specific nested files override broader repo files. +- If instructions conflict and the safe precedence is unclear, explain the + conflict and ask before making a risky change. + ## Tool call contract Each tool has a name (snake_case), a description, and a JSON Schema for input. @@ -97,6 +111,20 @@ export function renderContext(context: AgentPromptContext): string { const lines: string[] = ["## Current context", ""]; lines.push(`- Working directory: \`${context.cwd}\``); if (context.spaceName) lines.push(`- Space: ${context.spaceName}`); + if (context.activeEditorFile) { + lines.push( + `- Active editor file: \`${context.activeEditorFile.path}\` (${context.activeEditorFile.tabTitle}, cwd \`${context.activeEditorFile.cwd}\`)`, + ); + } else { + lines.push("- Active editor file: none"); + } + if (context.selectedDiffFile) { + lines.push( + `- Selected diff file: \`${context.selectedDiffFile.path}\` (${context.selectedDiffFile.tabTitle}, cwd \`${context.selectedDiffFile.cwd}\`)`, + ); + } else { + lines.push("- Selected diff file: none"); + } if (context.openTabs && context.openTabs.length > 0) { lines.push("- Open browser tabs:"); for (const tab of context.openTabs) { @@ -105,6 +133,75 @@ export function renderContext(context: AgentPromptContext): string { } else { lines.push("- Open browser tabs: none"); } + if (context.terminals && context.terminals.length > 0) { + lines.push("- Terminal status:"); + for (const terminal of context.terminals) { + const title = terminal.tabTitle ? `${terminal.tabTitle}: ` : ""; + const active = terminal.active ? ", active" : ""; + const exit = + terminal.status === "exited" ? `, exit=${terminal.exitCode ?? "null"}` : ""; + lines.push( + ` - ${title}\`${terminal.id}\` ${terminal.status}${active}${exit}, cwd \`${terminal.cwd}\`, pid ${terminal.pid ?? "n/a"}`, + ); + } + } else { + lines.push("- Terminal status: no relevant terminals"); + } + if (context.devServers && context.devServers.length > 0) { + lines.push("- Running dev servers:"); + for (const server of context.devServers) { + const name = server.name ? `${server.name}: ` : ""; + const url = server.url ? `, url ${server.url}` : ""; + lines.push( + ` - ${name}\`${server.id}\` ${server.status}${url}, command \`${server.command}\`, cwd \`${server.cwd}\`, pid ${server.pid ?? "n/a"}`, + ); + } + } else { + lines.push("- Running dev servers: none"); + } + if (context.consoleErrors && context.consoleErrors.length > 0) { + lines.push("- Recent console errors:"); + for (const entry of context.consoleErrors) { + const source = entry.source ? ` (${entry.source})` : ""; + lines.push( + ` - ${entry.tabTitle || "(untitled)"} — ${entry.url}: ${oneLine(entry.text)}${source}`, + ); + } + } else { + lines.push("- Recent console errors: none"); + } + if (context.git) { + const branch = context.git.branch ? ` on ${context.git.branch}` : ""; + const summary = context.git.summary ? ` — ${context.git.summary}` : ""; + lines.push(`- Git: ${context.git.status}${branch}${summary}`); + if (context.git.files && context.git.files.length > 0) { + for (const file of context.git.files) lines.push(` - ${file}`); + } + } + return lines.join("\n"); +} + +/** Render project-specific instruction files discovered for the session cwd. */ +export function renderInstructionFiles(context: AgentPromptContext): string { + if (!context.instructionFiles || context.instructionFiles.length === 0) { + return [ + "## Project instructions", + "", + "_No project-specific instruction files were found for this session cwd._", + ].join("\n"); + } + const lines = [ + "## Project instructions", + "", + "Instruction files are listed from broadest to most specific. Apply the", + "precedence rules above when they conflict.", + ]; + for (const file of context.instructionFiles) { + lines.push("", `### ${file.path}${file.truncated ? " (truncated)" : ""}`, ""); + lines.push("```"); + lines.push(file.content); + lines.push("```"); + } return lines.join("\n"); } @@ -142,5 +239,10 @@ export function buildSystemPrompt( ? `${SYSTEM_PROMPT_BASE}\n\n${catalog}` : `${SYSTEM_PROMPT_BASE.slice(0, idx)}${catalog}\n\n${SYSTEM_PROMPT_BASE.slice(idx)}`; if (!context) return base; - return `${base}\n\n${renderContext(context)}\n\n${renderSafety(context)}`; + return `${base}\n\n${renderInstructionFiles(context)}\n\n${renderContext(context)}\n\n${renderSafety(context)}`; +} + +function oneLine(value: string): string { + const text = value.replace(/\s+/g, " ").trim(); + return text.length > 240 ? `${text.slice(0, 237)}...` : text; } diff --git a/packages/desktop/src/main/agent/types.ts b/packages/desktop/src/main/agent/types.ts index cd56ee5..0194180 100644 --- a/packages/desktop/src/main/agent/types.ts +++ b/packages/desktop/src/main/agent/types.ts @@ -47,8 +47,38 @@ export interface AgentPromptContext { cwd: string; /** Active space name, when the session is associated with one. */ spaceName?: string; + /** Active editor file in this space, when the editor has one focused. */ + activeEditorFile?: { tabTitle: string; cwd: string; path: string }; + /** Selected file in the active/relevant diff surface. */ + selectedDiffFile?: { tabTitle: string; cwd: string; path: string }; /** URLs/titles of browser tabs currently open in the session's space. */ openTabs?: Array<{ title: string; url: string }>; + /** Recent console errors captured from browser tabs in the session's space. */ + consoleErrors?: Array<{ tabTitle: string; url: string; text: string; source?: string }>; + /** Live terminal sessions relevant to the session's space/cwd. */ + terminals?: Array<{ + id: string; + tabTitle?: string; + cwd: string; + status: string; + pid: number | null; + exitCode: number | null; + active: boolean; + }>; + /** Managed dev servers relevant to the session's space/cwd. */ + devServers?: Array<{ + id: string; + name?: string; + cwd: string; + status: string; + command: string; + url?: string; + pid: number | null; + }>; + /** Current git state for the session cwd. */ + git?: { branch?: string; status: string; summary?: string; files?: string[] }; + /** Project-specific instruction files discovered for the session cwd. */ + instructionFiles?: Array<{ path: string; content: string; truncated: boolean }>; /** Whether gated tool calls are auto-accepted (affects the safety section). */ autoAccept?: boolean; } diff --git a/packages/desktop/src/main/bootstrap.ts b/packages/desktop/src/main/bootstrap.ts index 55b777c..8cd394e 100644 --- a/packages/desktop/src/main/bootstrap.ts +++ b/packages/desktop/src/main/bootstrap.ts @@ -18,6 +18,7 @@ import { import { AcpAdapter } from "./agent/adapters/AcpAdapter.js"; import { MockAdapter } from "./agent/adapters/MockAdapter.js"; import type { BrowserViewHost } from "./browser/BrowserViewHost.js"; +import type { PtyHost } from "./process/PtyHost.js"; import { buildDesktopExecutablePath, findBundledNodeExecutable, @@ -25,7 +26,6 @@ import { findBundledNpmExecutable, findBundledNpxExecutable, } from "./process/executablePath.js"; -import type { PtyHost } from "./process/PtyHost.js"; import { type RuntimeInstallInfo, installRuntimeCli } from "./runtime/install.js"; import { AgentConfigStore } from "./services/AgentConfigStore.js"; import { AgentService } from "./services/AgentService.js"; @@ -276,9 +276,7 @@ export async function bootstrap( // Runtime environment injected into every spawned terminal / dev server so // tools and plugins launched inside them can reach this running app: the // socket path for dev-log attachment plus app-scoped identifiers. - const runtimePathBins = [ - runtimeInstall?.binDir ?? join(home, "bin"), - ]; + const runtimePathBins = [runtimeInstall?.binDir ?? join(home, "bin")]; const runtimeEnv: Record = { MEITH_SOCKET: socketPath, MEITH_HOME: home, @@ -393,6 +391,9 @@ export async function bootstrap( store: agentStore, configStore: agentConfig, appState, + browserTabs, + terminals, + devServers, mcpBridge, permissions, // Probe ACP agents on demand (install detection + advertised model/reasoning diff --git a/packages/desktop/src/main/services/AgentService.ts b/packages/desktop/src/main/services/AgentService.ts index 64641c8..d83ae0d 100644 --- a/packages/desktop/src/main/services/AgentService.ts +++ b/packages/desktop/src/main/services/AgentService.ts @@ -1,7 +1,15 @@ +import { execFileSync } from "node:child_process"; import { randomUUID } from "node:crypto"; import { EventEmitter } from "node:events"; -import { copyFileSync, mkdirSync, statSync, writeFileSync } from "node:fs"; -import { basename, extname, join } from "node:path"; +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + statSync, + writeFileSync, +} from "node:fs"; +import { basename, extname, join, relative, resolve, sep } from "node:path"; import type { ToolDescriptor } from "@meith/protocol"; import { type AgentAttachment, @@ -35,14 +43,20 @@ import type { ToolRegistry } from "../tools/registry.js"; import type { AgentConfigStore } from "./AgentConfigStore.js"; import type { AgentMessagePatch, AgentStore } from "./AgentStore.js"; import type { AppStateService } from "./AppStateService.js"; +import type { BrowserTabService } from "./BrowserTabService.js"; +import type { DevServerService } from "./DevServerService.js"; import type { Logger } from "./Logger.js"; import type { McpBridgeService, McpSessionEndpoint } from "./McpBridgeService.js"; import type { PermissionService } from "./PermissionService.js"; +import type { TerminalService } from "./TerminalService.js"; export interface AgentServiceOptions { store?: AgentStore; configStore?: AgentConfigStore; appState?: AppStateService; + browserTabs?: BrowserTabService; + terminals?: TerminalService; + devServers?: DevServerService; mcpBridge?: McpBridgeService; permissions?: PermissionService; /** @@ -430,10 +444,98 @@ export class AgentService extends EventEmitter { if (state) { const space = state.spaces.find((s) => s.id === session.spaceId); if (space) ctx.spaceName = space.name; - ctx.openTabs = state.browserTabs + const browserTabs = state.browserTabs.filter((t) => + session.spaceId ? t.spaceId === session.spaceId : true, + ); + const workspaceTabs = state.workspaceTabs.filter((t) => + session.spaceId ? t.spaceId === session.spaceId : true, + ); + const activeEditor = + workspaceTabs.find((t) => t.kind === "editor" && t.active && t.activeFilePath) ?? + workspaceTabs.find((t) => t.kind === "editor" && t.activeFilePath); + if (activeEditor?.activeFilePath) { + ctx.activeEditorFile = { + tabTitle: activeEditor.title, + cwd: activeEditor.cwd, + path: activeEditor.activeFilePath, + }; + } + const selectedDiff = + workspaceTabs.find( + (t) => t.kind === "diff" && t.active && t.selectedDiffFilePath, + ) ?? workspaceTabs.find((t) => t.kind === "diff" && t.selectedDiffFilePath); + if (selectedDiff?.selectedDiffFilePath) { + ctx.selectedDiffFile = { + tabTitle: selectedDiff.title, + cwd: selectedDiff.cwd, + path: selectedDiff.selectedDiffFilePath, + }; + } + ctx.openTabs = browserTabs .filter((t) => (session.spaceId ? t.spaceId === session.spaceId : true)) .map((t) => ({ title: t.title, url: t.url })); + ctx.consoleErrors = browserTabs.flatMap((tab) => + (this.options.browserTabs?.getConsoleLogSnapshot(tab.id, 10) ?? []) + .filter((entry) => entry.level === "error") + .slice(-3) + .map((entry) => ({ + tabTitle: tab.title, + url: tab.url, + text: entry.text, + source: entry.source, + })), + ); + const relevantCwds = new Set([ + resolve(session.cwd), + ...workspaceTabs.map((t) => resolve(t.cwd)), + ...state.projects + .filter((project) => + session.spaceId + ? project.spaceId === session.spaceId + : project.cwd === session.cwd, + ) + .map((project) => resolve(project.cwd)), + ]); + const terminalTabs = new Map( + workspaceTabs + .filter((tab) => tab.kind === "terminal" && tab.terminalId) + .map((tab) => [tab.terminalId as string, tab]), + ); + ctx.terminals = (this.options.terminals?.list() ?? []) + .filter( + (terminal) => + terminalTabs.has(terminal.id) || relevantCwds.has(resolve(terminal.cwd)), + ) + .map((terminal) => { + const tab = terminalTabs.get(terminal.id); + return { + id: terminal.id, + tabTitle: tab?.title, + cwd: terminal.cwd, + status: terminal.status, + pid: terminal.pid, + exitCode: terminal.exitCode, + active: tab?.active ?? false, + }; + }); + ctx.devServers = (this.options.devServers?.list() ?? []) + .filter( + (server) => + (server.status === "running" || server.status === "starting") && + relevantCwds.has(resolve(server.cwd)), + ) + .map((server) => ({ + id: server.id, + name: server.name, + cwd: server.cwd, + status: server.status, + command: [server.command, ...server.args].join(" "), + url: server.port ? `http://localhost:${server.port}` : undefined, + pid: server.pid, + })); } + ctx.git = readGitSummary(session.cwd); + ctx.instructionFiles = readInstructionFiles(session.cwd); return ctx; } @@ -902,6 +1004,92 @@ function inferAttachmentKind( : "file"; } +const INSTRUCTION_FILE_NAMES = [ + "AGENTS.md", + "CLAUDE.md", + ".cursorrules", + ".github/copilot-instructions.md", +] as const; +const MAX_INSTRUCTION_BYTES = 24_000; + +function readGitSummary(cwd: string): AgentPromptContext["git"] { + if (runGit(cwd, ["rev-parse", "--is-inside-work-tree"]) !== "true") { + return { status: "not a git repository" }; + } + const branch = + runGit(cwd, ["branch", "--show-current"]) || + runGit(cwd, ["rev-parse", "--short", "HEAD"]); + const statusText = runGit(cwd, ["status", "--short"]) ?? ""; + const files = statusText + .split("\n") + .map((line) => line.trimEnd()) + .filter(Boolean); + return { + branch, + status: files.length === 0 ? "clean" : "changes", + summary: + files.length === 0 ? "working tree clean" : `${files.length} changed file(s)`, + files: files.slice(0, 12), + }; +} + +function readInstructionFiles(cwd: string): AgentPromptContext["instructionFiles"] { + const root = runGit(cwd, ["rev-parse", "--show-toplevel"]) || cwd; + const dirs = instructionSearchDirs(root, cwd); + const seen = new Set(); + const files: NonNullable = []; + for (const dir of dirs) { + for (const name of INSTRUCTION_FILE_NAMES) { + const path = resolve(dir, name); + if (seen.has(path) || !isRegularFile(path)) continue; + seen.add(path); + const raw = readFileSync(path); + const truncated = raw.byteLength > MAX_INSTRUCTION_BYTES; + const content = raw.subarray(0, MAX_INSTRUCTION_BYTES).toString("utf8").trimEnd(); + files.push({ path, content, truncated }); + } + } + return files; +} + +function instructionSearchDirs(root: string, cwd: string): string[] { + const base = resolve(root); + const current = resolve(cwd); + const rel = relative(base, current); + if (rel.startsWith("..") || rel === "" || rel.split(sep).includes("..")) { + return rel === "" ? [base] : [current]; + } + const dirs = [base]; + let cursor = base; + for (const part of rel.split(sep).filter(Boolean)) { + cursor = resolve(cursor, part); + dirs.push(cursor); + } + return dirs; +} + +function isRegularFile(path: string): boolean { + try { + return existsSync(path) && statSync(path).isFile(); + } catch { + return false; + } +} + +function runGit(cwd: string, args: string[]): string | undefined { + try { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 750, + maxBuffer: 128 * 1024, + }).trim(); + } catch { + return undefined; + } +} + function appendAssistantText( assistant: AgentMessage, text: string, diff --git a/packages/desktop/src/main/services/BrowserTabService.ts b/packages/desktop/src/main/services/BrowserTabService.ts index 3dd1b60..ed88460 100644 --- a/packages/desktop/src/main/services/BrowserTabService.ts +++ b/packages/desktop/src/main/services/BrowserTabService.ts @@ -513,6 +513,18 @@ export class BrowserTabService { return this.host.getConsoleLogs(id, limit); } + /** + * Read whatever console messages are already available without creating or + * hydrating a browser view. Used while composing synchronous agent context. + */ + getConsoleLogSnapshot(id: string, limit?: number): ConsoleLogEntry[] { + try { + return this.host.getConsoleLogs(id, limit); + } catch { + return []; + } + } + /** Read observed network requests for a tab (read-only). */ async getNetworkLogs(id: string, limit?: number): Promise { const tab = this.requireTab(id); @@ -526,6 +538,7 @@ export class BrowserTabService { kind?: WorkspaceTab["kind"]; spaceId?: string; terminalId?: string; + selectedDiffFilePath?: string; }): WorkspaceTab { const spaceId = input.spaceId ?? this.activeSpaceId(); const tab: WorkspaceTab = { @@ -535,6 +548,8 @@ export class BrowserTabService { cwd: input.cwd, kind: input.kind ?? "editor", terminalId: input.terminalId, + selectedDiffFilePath: + input.kind === "diff" ? input.selectedDiffFilePath : undefined, active: true, createdAt: Date.now(), }; @@ -567,19 +582,29 @@ export class BrowserTabService { } /** - * Persist the editor session state of an editor workspace tab: the focused - * file and the list of open files (paths are relative to the tab's cwd). This - * lets the renderer editor, CLI, and agents agree on what is open via state. + * Persist file-focused state for workspace tabs. Editor tabs keep their + * focused/open files; diff tabs keep the selected changed file. Paths are + * relative to the tab's cwd. */ setWorkspaceTabFile( id: string, - input: { activeFilePath?: string | null; openFilePaths?: string[] }, + input: { + activeFilePath?: string | null; + openFilePaths?: string[]; + selectedDiffFilePath?: string | null; + }, ): WorkspaceTab { const tab = this.appState.getState().workspaceTabs.find((t) => t.id === id); if (!tab) throw new Error(`Unknown workspace tab: ${id}`); - if (tab.kind !== "editor") { + const editsEditor = + input.activeFilePath !== undefined || input.openFilePaths !== undefined; + const editsDiff = input.selectedDiffFilePath !== undefined; + if (editsEditor && tab.kind !== "editor") { throw new Error(`Workspace tab is not an editor: ${id}`); } + if (editsDiff && tab.kind !== "diff") { + throw new Error(`Workspace tab is not a diff view: ${id}`); + } this.appState.update((draft) => { const t = draft.workspaceTabs.find((w) => w.id === id); if (!t) return; @@ -593,6 +618,9 @@ export class BrowserTabService { t.activeFilePath = input.openFilePaths[input.openFilePaths.length - 1]; } } + if (input.selectedDiffFilePath !== undefined) { + t.selectedDiffFilePath = input.selectedDiffFilePath ?? undefined; + } }, "set_workspace_tab_file"); const next = this.appState.getState().workspaceTabs.find((t) => t.id === id); if (!next) throw new Error(`Unknown workspace tab: ${id}`); diff --git a/packages/desktop/src/main/storage/migrations.ts b/packages/desktop/src/main/storage/migrations.ts index aebbb98..3105aa3 100644 --- a/packages/desktop/src/main/storage/migrations.ts +++ b/packages/desktop/src/main/storage/migrations.ts @@ -1,7 +1,7 @@ import { type AppState, AppStateSchema } from "@meith/shared"; /** The state version this build writes and migrates up to. */ -export const CURRENT_STATE_VERSION = 4; +export const CURRENT_STATE_VERSION = 5; type RawState = Record; @@ -68,6 +68,19 @@ const migrations: Record RawState> = { }; }), }), + + // 4 -> 5: diff workspace tabs persist their selected changed file so agents + // and restored UI sessions can agree on the currently inspected diff. + 4: (raw) => ({ + ...raw, + version: 5, + workspaceTabs: (Array.isArray(raw.workspaceTabs) ? raw.workspaceTabs : []).map( + (tab) => { + const t = (tab ?? {}) as RawState; + return { ...t, selectedDiffFilePath: t.selectedDiffFilePath ?? undefined }; + }, + ), + }), }; function rawVersion(raw: unknown): number { diff --git a/packages/desktop/src/main/tools/spaceTools.ts b/packages/desktop/src/main/tools/spaceTools.ts index 4306487..2390aff 100644 --- a/packages/desktop/src/main/tools/spaceTools.ts +++ b/packages/desktop/src/main/tools/spaceTools.ts @@ -70,6 +70,7 @@ export function createSpaceTools(deps: ToolDeps): ToolDefinition[] { kind: z.enum(["editor", "terminal", "agent", "preview", "diff"]).optional(), spaceId: z.string().optional(), terminalId: z.string().optional(), + selectedDiffFilePath: z.string().optional(), }), execute: (_ctx, input) => deps.browserTabs.openWorkspaceTab(input), }); @@ -89,17 +90,19 @@ export function createSpaceTools(deps: ToolDeps): ToolDefinition[] { const setWorkspaceTabFile = defineTool({ name: "set_workspace_tab_file", description: - "Set the focused file and/or open files of an editor workspace tab (paths relative to its cwd).", + "Set file-focused workspace tab state: editor focused/open files or diff selected file (paths relative to cwd).", capabilities: ["writes-files"], inputSchema: z.object({ tabId: z.string(), activeFilePath: z.string().nullable().optional(), openFilePaths: z.array(z.string()).optional(), + selectedDiffFilePath: z.string().nullable().optional(), }), execute: (_ctx, input) => deps.browserTabs.setWorkspaceTabFile(input.tabId, { activeFilePath: input.activeFilePath, openFilePaths: input.openFilePaths, + selectedDiffFilePath: input.selectedDiffFilePath, }), }); diff --git a/packages/desktop/src/renderer/src/App.tsx b/packages/desktop/src/renderer/src/App.tsx index c31d941..5ccc53d 100644 --- a/packages/desktop/src/renderer/src/App.tsx +++ b/packages/desktop/src/renderer/src/App.tsx @@ -538,13 +538,17 @@ export function App() { // Open (or focus, if one already exists for this space) the diff surface for // the active project. const openDiffTab = useCallback( - (pane: PaneId = "primary") => { + (pane: PaneId = "secondary") => { setSettingsOpen(false); const existing = workspaceTabs.find( (t) => t.kind === "diff" && t.spaceId === activeSpaceId, ); if (existing) { - layout.setActive(pane, existing.id); + if (layout.paneOf(existing.id) !== pane) { + layout.moveTabToPane(existing.id, pane); + } else { + layout.setActive(pane, existing.id); + } void run("focus_workspace_tab", { tabId: existing.id }); return; } @@ -907,7 +911,7 @@ export function App() { openDiffTab("primary")} + onOpenDiff={openDiffTab} refreshKey={workspaceFileEvents.length} /> layout.setFocused("primary")} + onPointerDownCapture={() => layout.setFocused("primary")} style={effectiveSplit ? { width: splitPane.size } : { flex: "1 1 0%" }} > {/* Secondary pane: its own tab strip + active surface. */} -
+
layout.setFocused("secondary")} + onPointerDownCapture={() => layout.setFocused("secondary")} + > t.id === args.tabId); if (!tab) return errorResult("TOOL_FAILED", "Unknown workspace tab"); - if (tab.kind !== "editor") { + const editsEditor = + args.activeFilePath !== undefined || Array.isArray(args.openFilePaths); + const editsDiff = args.selectedDiffFilePath !== undefined; + if (editsEditor && tab.kind !== "editor") { return errorResult("TOOL_FAILED", "Workspace tab is not an editor"); } + if (editsDiff && tab.kind !== "diff") { + return errorResult("TOOL_FAILED", "Workspace tab is not a diff view"); + } if (args.activeFilePath !== undefined) { tab.activeFilePath = (args.activeFilePath as string | null) ?? undefined; } if (Array.isArray(args.openFilePaths)) { tab.openFilePaths = args.openFilePaths as string[]; } + if (args.selectedDiffFilePath !== undefined) { + tab.selectedDiffFilePath = + (args.selectedDiffFilePath as string | null) ?? undefined; + } emitState(); return okResult(structuredClone(tab)); } diff --git a/packages/desktop/src/renderer/src/components/DiffView.tsx b/packages/desktop/src/renderer/src/components/DiffView.tsx index c7178cc..ebb840b 100644 --- a/packages/desktop/src/renderer/src/components/DiffView.tsx +++ b/packages/desktop/src/renderer/src/components/DiffView.tsx @@ -50,9 +50,13 @@ type DiffTreeNode = DiffTreeDir | DiffTreeFile; export function DiffView({ tab, call, refreshKey }: DiffViewProps) { const { data, loading, error, refresh } = useGitDiff(call, tab.cwd, { refreshKey, + pollMs: 2500, + forcePoll: true, includePatches: false, }); - const [selectedPath, setSelectedPath] = useState(null); + const [selectedPath, setSelectedPath] = useState( + tab.selectedDiffFilePath ?? null, + ); const [patchByPath, setPatchByPath] = useState>({}); const [patchLoading, setPatchLoading] = useState(false); const [patchError, setPatchError] = useState(null); @@ -95,6 +99,15 @@ export function DiffView({ tab, call, refreshKey }: DiffViewProps) { } }, [data.files, selectedPath]); + useEffect(() => { + const persistedPath = tab.selectedDiffFilePath ?? null; + if (selectedPath === persistedPath) return; + void call("set_workspace_tab_file", { + tabId: tab.id, + selectedDiffFilePath: selectedPath, + }); + }, [call, selectedPath, tab.id, tab.selectedDiffFilePath]); + // Keep selected files visible in the tree, and drop expansion entries for // directories that no longer exist after a diff refresh. useEffect(() => { diff --git a/packages/desktop/src/renderer/src/components/TabStrip.tsx b/packages/desktop/src/renderer/src/components/TabStrip.tsx index b8263a1..1d73183 100644 --- a/packages/desktop/src/renderer/src/components/TabStrip.tsx +++ b/packages/desktop/src/renderer/src/components/TabStrip.tsx @@ -240,9 +240,11 @@ export function TabStrip({ }} className={cn( "group relative flex h-full min-w-32 max-w-52 items-center gap-2 border-r border-border px-3 text-sm transition-colors", - isActive - ? "bg-background text-foreground" - : "text-muted-foreground hover:bg-accent/40 hover:text-foreground", + isFocused + ? "bg-orange-50 text-orange-950 dark:bg-orange-950/30 dark:text-orange-100" + : isActive + ? "bg-background text-foreground" + : "text-muted-foreground hover:bg-accent/40 hover:text-foreground", isDropTarget && "bg-primary/10", dragId === item.tab.id && "opacity-50", )} @@ -252,7 +254,7 @@ export function TabStrip({ aria-hidden className={cn( "pointer-events-none absolute inset-x-0 top-0 h-0.5", - isFocused ? "bg-primary" : "bg-border", + isFocused ? "bg-orange-500" : "bg-border", )} /> )} @@ -265,7 +267,7 @@ export function TabStrip({ {label} diff --git a/packages/desktop/src/renderer/src/components/TopBarGitDiff.tsx b/packages/desktop/src/renderer/src/components/TopBarGitDiff.tsx index cbd6b98..9a838e5 100644 --- a/packages/desktop/src/renderer/src/components/TopBarGitDiff.tsx +++ b/packages/desktop/src/renderer/src/components/TopBarGitDiff.tsx @@ -22,7 +22,8 @@ interface TopBarGitDiffProps { */ export function TopBarGitDiff({ cwd, call, onOpenDiff, refreshKey }: TopBarGitDiffProps) { const { data, loading } = useGitDiff(call, cwd, { - pollMs: 8000, + pollMs: 2500, + forcePoll: true, refreshKey, includePatches: false, }); @@ -37,7 +38,7 @@ export function TopBarGitDiff({ cwd, call, onOpenDiff, refreshKey }: TopBarGitDi render={