diff --git a/.changeset/agent-manager-sidebar-density.md b/.changeset/agent-manager-sidebar-density.md new file mode 100644 index 00000000000..08eb1cf9c18 --- /dev/null +++ b/.changeset/agent-manager-sidebar-density.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix the Agent Manager sidebar keyboard shortcut badge so it appears on the right edge of local rows and only while hovered or holding the jump modifier, give worktree titles more room by no longer reserving space for hidden row actions, align project names and section headings with the row icons below them, and show which worktree a session belongs to in the search palette. diff --git a/.changeset/bright-model-search.md b/.changeset/bright-model-search.md new file mode 100644 index 00000000000..6bdf91a54df --- /dev/null +++ b/.changeset/bright-model-search.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Improve model search relevance with provider-aware results and personalized usage suggestions. diff --git a/.changeset/bright-project-picker.md b/.changeset/bright-project-picker.md new file mode 100644 index 00000000000..18458aaa856 --- /dev/null +++ b/.changeset/bright-project-picker.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Let Agent Manager users choose the repository when creating or importing a worktree in multi-project mode. diff --git a/.changeset/cache-worktree-dialog-selections.md b/.changeset/cache-worktree-dialog-selections.md new file mode 100644 index 00000000000..f13128f7b9c --- /dev/null +++ b/.changeset/cache-worktree-dialog-selections.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Remember Agent Manager worktree dialog model, variant, mode, and sandbox selections when reopened. diff --git a/.changeset/calm-agent-manager-git-polling.md b/.changeset/calm-agent-manager-git-polling.md new file mode 100644 index 00000000000..5f3314267ab --- /dev/null +++ b/.changeset/calm-agent-manager-git-polling.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Reduce Agent Manager background Git polling load across large worktree sets. diff --git a/.changeset/calm-skill-examples.md b/.changeset/calm-skill-examples.md new file mode 100644 index 00000000000..a1ac64ae30e --- /dev/null +++ b/.changeset/calm-skill-examples.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Prevent built-in skill documentation examples from triggering shell permission prompts. diff --git a/.changeset/fix-gh-windows-console.md b/.changeset/fix-gh-windows-console.md new file mode 100644 index 00000000000..f9fc3a1e5cc --- /dev/null +++ b/.changeset/fix-gh-windows-console.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Prevent extension-managed GitHub CLI commands from opening transient Windows Terminal windows. diff --git a/.changeset/fix-interactive-terminal-input.md b/.changeset/fix-interactive-terminal-input.md new file mode 100644 index 00000000000..b9d53915489 --- /dev/null +++ b/.changeset/fix-interactive-terminal-input.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Restore keyboard input for interactive terminal prompts when the CLI session uses a workspace. diff --git a/.changeset/fix-multi-project-session-scope.md b/.changeset/fix-multi-project-session-scope.md index d56c4c93018..0f32e3327ac 100644 --- a/.changeset/fix-multi-project-session-scope.md +++ b/.changeset/fix-multi-project-session-scope.md @@ -2,4 +2,4 @@ "kilo-code": patch --- -Scope Agent Manager session events and Git status to the active project, including edits inside nested repositories. +Speed up local VS Code snapshot installs and scope Agent Manager session events and Git status to the active project, including edits inside nested repositories. diff --git a/.changeset/forked-subagent-resume.md b/.changeset/forked-subagent-resume.md new file mode 100644 index 00000000000..2a4f8cd4837 --- /dev/null +++ b/.changeset/forked-subagent-resume.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Allow subagent tasks to be resumed after their parent session is forked. diff --git a/.changeset/friendly-llamas-manage.md b/.changeset/friendly-llamas-manage.md new file mode 100644 index 00000000000..494e5cc0658 --- /dev/null +++ b/.changeset/friendly-llamas-manage.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Support the Agent Manager tool with llama.cpp servers that reject prefix-only JSON Schema patterns. diff --git a/.changeset/green-greps-settle.md b/.changeset/green-greps-settle.md new file mode 100644 index 00000000000..9f7cacabf79 --- /dev/null +++ b/.changeset/green-greps-settle.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Add bounded, context-aware grep controls without leaving agents waiting on completed searches. diff --git a/.changeset/jetbrains-cli-mode-visibility.md b/.changeset/jetbrains-cli-mode-visibility.md new file mode 100644 index 00000000000..bc9cf774d13 --- /dev/null +++ b/.changeset/jetbrains-cli-mode-visibility.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": minor +--- + +Log whether the JetBrains plugin downloads Core or uses the bundled/cached version, and mark the Core version shown in the popup as "Bundled" when it wasn't downloaded. diff --git a/.changeset/jetbrains-revert-diff-card.md b/.changeset/jetbrains-revert-diff-card.md new file mode 100644 index 00000000000..fe8f75e3d3b --- /dev/null +++ b/.changeset/jetbrains-revert-diff-card.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Improve JetBrains session transcript layout, icons, reverted-change summaries, and multi-hunk diff rendering. diff --git a/.changeset/jetbrains-slash-completion.md b/.changeset/jetbrains-slash-completion.md new file mode 100644 index 00000000000..e2ec3c49969 --- /dev/null +++ b/.changeset/jetbrains-slash-completion.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Improve slash command completion to match separators, camel-case humps, and contained command names. diff --git a/.changeset/opencode-v1-17-9-to-v1-17-13.md b/.changeset/opencode-v1-17-9-to-v1-17-13.md index df1deffa15f..529fce727e5 100644 --- a/.changeset/opencode-v1-17-9-to-v1-17-13.md +++ b/.changeset/opencode-v1-17-9-to-v1-17-13.md @@ -5,11 +5,7 @@ Changes from opencode v1.17.9 to v1.17.13 upstream: -- Core Improvements: Sessions gain a snapshot and revert system for staging, clearing and committing file reverts. -- Core Improvements: Durable session history is served in finite pages and exposed through the SDK. - Core Improvements: MCP servers can append their instructions to the model context, and MCP resources are available as tools with template listing. -- Core Improvements: MCP tools use the `mcp__server__tool` naming convention, with legacy names still accepted. -- Core Improvements: Plugins can use the v2 effect host and a namespaced hook API. - Core Improvements: Model variants are generated from models.dev data, including modes exposed as models. - Core Improvements: Tool definitions pass `strict` through for Codex parity, and Gemini requests support video and audio media. - Core Bugfixes: Interrupted assistant steps settle instead of leaving sessions stuck busy. @@ -18,8 +14,8 @@ Changes from opencode v1.17.9 to v1.17.13 upstream: - Core Bugfixes: Stale GitHub Copilot Responses item IDs are no longer replayed, and OpenAI reasoning variants are forced where required. - Core Bugfixes: Adaptive thinking is enabled for Claude Sonnet 5, and expired promos were removed from the zen catalog. - Core Bugfixes: Preserve released prompt history during database replay and keep native event streams connected for all supported Kilo events. -- Core Bugfixes: Remote skills refresh atomically with version pinning, and skill base directories are emitted as filesystem paths. -- CLI Improvements: `kilo run --mini` provides a compact interactive mode, and ports increment from the default when busy. +- Core Bugfixes: Remote skill manifests support optional per-skill versions; changing a version refreshes the cached skill atomically, and skill base directories are emitted as filesystem paths. +- CLI Improvements: Ports increment from the default when busy. - CLI Improvements: Use `--auto` to start the TUI in a run-scoped auto-approve mode, and leave the mode mid-session from the command palette. -- TUI Improvements: Redesigned crash screen, model picker sorted by release date, a diff viewer keybind, main-branch diff source, bindable move-session command, and inline skill load errors. +- TUI Improvements: Redesigned crash screen, model picker sorted by release date, bindable diff viewer and Move Session commands, main-branch diff source, and inline skill load errors. - TUI Bugfixes: File autocomplete is scoped to the session, multi-day durations format correctly, and root sessions load in the session switcher. diff --git a/.changeset/privacy-mode-tui.md b/.changeset/privacy-mode-tui.md new file mode 100644 index 00000000000..9131915a7e9 --- /dev/null +++ b/.changeset/privacy-mode-tui.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Add a privacy mode that blurs PII in the TUI (personal balance, Kilo Pass usage, etc.) and requires confirmation before `/profile` reveals email, name, balance, and team. Toggle with the new `/privacy` command or by setting `privacy_mode` in `kilo.json`. The `kilo profile` CLI command is unaffected. \ No newline at end of file diff --git a/.changeset/quick-git-launches.md b/.changeset/quick-git-launches.md new file mode 100644 index 00000000000..6c29101ce1f --- /dev/null +++ b/.changeset/quick-git-launches.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Reduce Agent Manager Git polling overhead by reusing the validated Git executable and bypassing the macOS developer-tool launcher when safe. diff --git a/.changeset/quiet-embedded-logo.md b/.changeset/quiet-embedded-logo.md new file mode 100644 index 00000000000..7623cf0e739 --- /dev/null +++ b/.changeset/quiet-embedded-logo.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Use a compatible Kilo wordmark in embedded Agent Manager terminals. diff --git a/.changeset/quiet-jetbrains-watchers.md b/.changeset/quiet-jetbrains-watchers.md new file mode 100644 index 00000000000..3e07e6c6981 --- /dev/null +++ b/.changeset/quiet-jetbrains-watchers.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Fix high CPU and runaway memory growth in the JetBrains background `kilo serve` process on macOS by no longer eagerly starting native file watchers, matching the VS Code backend. diff --git a/.changeset/quiet-tui-config-reloads.md b/.changeset/quiet-tui-config-reloads.md new file mode 100644 index 00000000000..8c21c818bb9 --- /dev/null +++ b/.changeset/quiet-tui-config-reloads.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Prevent TUI config reload logs from corrupting the interactive terminal. diff --git a/.changeset/safe-credential-reconciliation.md b/.changeset/safe-credential-reconciliation.md new file mode 100644 index 00000000000..abb8dd5f36a --- /dev/null +++ b/.changeset/safe-credential-reconciliation.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Prevent concurrent Kilo startups from rewriting unchanged credentials, retry transient database locks, and redact bound values from database errors. diff --git a/.changeset/sync-inspector-width.md b/.changeset/sync-inspector-width.md index 02eeb4e7239..9372f7ace4b 100644 --- a/.changeset/sync-inspector-width.md +++ b/.changeset/sync-inspector-width.md @@ -2,4 +2,4 @@ "kilo-code": patch --- -Persist the Agent Manager inspector width and share it between the terminal and diff viewer. +Persist the Agent Manager inspector width, share it between the terminal and diff viewer, and keep resizing responsive. diff --git a/.kilo/plans/agent-manager-multi-project-sidebar-density.md b/.kilo/plans/agent-manager-multi-project-sidebar-density.md new file mode 100644 index 00000000000..28790c6a3bf --- /dev/null +++ b/.kilo/plans/agent-manager-multi-project-sidebar-density.md @@ -0,0 +1,437 @@ +# Plan: Agent Manager sidebar — shortcut badge fix + reclaim title width + +Worktree: `/Users/marius/Documents/git/kilocode/.kilo/worktrees/mewing-profit` +All paths below are relative to `packages/kilo-vscode/`. + +Rules: + +- Do exactly these edits. Do not refactor anything else. +- Solid.js, not React: `class=`, not `className=`. +- Do **not** add `kilocode_change` markers. This package is Kilo-owned and CI fails if you do. +- Do **not** add new i18n strings. None are needed. +- Out of scope: the per-project `SESSIONS` list in the tree (tracked in + https://github.com/Kilo-Org/kilocode/issues/12928). Do not touch `UnassignedSessionsSection.tsx`. + +Do task A, verify, then task B, verify, then task C. + +## Task A — Shortcut badge on the right, hidden until hover or ⌘ held + +The `⌘1` badge on "local" rows is always visible, and in multi-project mode it renders on the +*left*, between the icon and the label. Worktree rows already behave correctly. Cause: +`.am-shortcut-badge` has no default `opacity: 0` — worktree badges are only hidden because their +container `.am-wt-hover-actions` is hidden, and local rows never got that container. + +### A1 — `webview-ui/agent-manager/ProjectSidebarBody.tsx` + +Replace the whole ` +``` + +The `−` in `am-stat-deletions` is U+2212 MINUS SIGN, not a hyphen. Copy it verbatim. + +### A2 — `webview-ui/agent-manager/SidebarBody.tsx` + +Same bug in legacy single-project mode, but the badge is already last, so only the wrapper is +missing. + +Insert **before** line **127** (``): + +```tsx +
+``` + +Replace line **182**: + +```tsx + {isMac ? "⌘" : "Ctrl+"}1 +``` + +with: + +```tsx +
+ {isMac ? "⌘" : "Ctrl+"}1 +
+
+``` + +Result: one `am-wt-actions-cell` div containing the skeleton ``, the stats ``, and the +hover-actions div, closing before ``. Prettier fixes indentation in task D. + +### A3 — `webview-ui/agent-manager/agent-manager.css` + +Delete lines **569-575** and replace with the rule that actually works: + +```css +.am-local-item .am-shortcut-badge { + right: 8px; +} + +.am-local-item:hover .am-shortcut-badge { + opacity: 1; +} +``` + +becomes: + +```css +.am-local-item:hover .am-wt-hover-actions { + opacity: 1; + visibility: visible; +} +``` + +(`right: 8px` never applied — the badge is `position: static`. The `opacity: 1` never did anything +because nothing set `opacity: 0` first.) + +### A4 — same file, delete lines **609-611** entirely, add nothing: + +```css +.am-show-shortcuts .am-local-item .am-shortcut-badge { + opacity: 1; +} +``` + +The rule at lines 597-600 is not scoped to worktree items, so after A1/A2 it already covers local +rows. + +### A5 — same file, lines **986-989**, add `visibility: hidden` to match worktree behaviour: + +```css +.am-local-item:hover .am-worktree-stats, +.am-local-item:hover .am-worktree-stats-skeleton { + opacity: 0; + visibility: hidden; +} +``` + +### Verify A + +```bash +cd packages/kilo-vscode && bun run typecheck && bun run lint +``` + +## Task B — Stop reserving width for invisible content + +`.am-wt-actions-cell` is a grid whose children all stack in cell 1/1, so the cell is permanently as +wide as its widest child. `visibility: hidden` does not remove layout, so the invisible hover +actions (~42px) and the loading skeleton (~48px) reserve width on every row forever. That is why +titles truncate ~40px before the right edge. + +All edits in `webview-ui/agent-manager/agent-manager.css`. + +### B1 — Take hover actions out of grid flow + +Lines **475-482**. Add the four `position` lines at the top, keep everything else: + +```css +.am-wt-hover-actions { + position: absolute; + top: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 2px; + opacity: 0; + visibility: hidden; +} +``` + +Absolutely positioned children never size grid tracks. `.am-wt-actions-cell` is already +`position: relative` (line 464). Do not edit the `.am-wt-actions-cell > *` rule at lines 469-472. + +### B2 — Same for the loading skeleton + +Add immediately after the rule from B1: + +```css +/* Skeleton is a placeholder, not real content — it must not reserve title width. */ +.am-wt-actions-cell > .am-worktree-stats-skeleton { + position: absolute; + top: 0; + right: 0; + bottom: 0; +} +``` + +Do not edit the base `.am-worktree-stats-skeleton` rule (lines 744-748) or `.am-pr-badge-skeleton`. + +After B1+B2 the cell is sized only by `.am-worktree-stats` and `.am-worktree-delete-hint`, which +are the only things that should reserve space. + +### B3 — Fade the title under the overlaying actions + +The actions now overlay the row instead of sitting in reserved space, so a long title would render +behind them. Same fix the codebase already uses for the local branch at lines 577-580. Add after +B2: + +```css +.am-worktree-item:hover .am-worktree-branch { + mask-image: linear-gradient(to right, black calc(100% - 48px), transparent 100%); + -webkit-mask-image: linear-gradient(to right, black calc(100% - 48px), transparent 100%); +} +``` + +### B4 — Remove one layer of nested padding + +Lines **365-370**, change `padding: 0 6px;` to `padding: 0;`: + +```css +.am-project-body { + display: flex; + flex-direction: column; + min-height: 0; + padding: 0; +} +``` + +Gains 6px per side on every row in a project. Rows keep their own 10px inset +(`.am-local-item` line 106, `.am-worktree-item` line 425) so they stay indented under the project +header. Do not change those, and leave `.am-project-body .am-section-header` (lines 273-275) alone. + +### B5 — Remove the remaining outer list gutters in multi-project mode + +The sidebar keeps 8px horizontal padding for the header controls, but the project list should use +the full width up to its scrollbar. In the same CSS file, extend `.am-projects-list` with: + +```css +.am-projects-list { + display: flex; + flex-direction: column; + min-height: 0; + overflow-y: auto; + margin-inline: -8px; +} +``` + +Do not remove padding from `.am-sidebar` itself. This makes project cards and rows reach the +scrollbar without moving the `PROJECTS` header and its controls to the edge. + +### Verify B + +```bash +cd packages/kilo-vscode && bun run typecheck && bun run lint +``` + +## Task C — Show where a palette session lives + +In the ⌘F search palette, multi-project session results show only the project name, so you cannot +tell whether a session is in a worktree or at the project root. + +File: `webview-ui/agent-manager/ProjectList.tsx`. In the session loop (lines **90-106**), add the +two `const` lines and change `meta` and `search`: + +```tsx + for (const session of props.sessions[project.id] ?? []) { + const wt = session.worktreeId ? state.worktrees.find((item) => item.id === session.worktreeId) : undefined + const where = wt ? wt.label || wt.branch : props.t("agentManager.local") + items.push({ + key: `${project.id}:session:${session.id}`, + projectId: project.id, + kind: "session", + group: "sessions", + title: session.title || props.t("agentManager.session.untitled"), + meta: [project.label, where], + search: [project.label, where, wt?.branch, session.title, session.id].filter(Boolean).join(" "), + updatedAt: session.updatedAt, + state: "idle", + visible: project.expanded, + sessionId: session.id, + location: session.worktreeId ? "worktree" : "local", + worktreeId: session.worktreeId ?? undefined, + }) + } +``` + +`state` is already in scope (line 55) and already null-checked (line 56). `meta` is joined with +` · ` by the renderer (`SidebarSearchMenu.tsx:143`), so no separator work is needed. + +## Task E — Align the project heading with the row icons + +Every row in the projects tree used a different left inset, which made the sidebar look busy and +indented for no reason. Measured against a full-bleed `.am-projects-list` (starting at x=0): + +| Element | Before | After | +|---|---|---| +| `PROJECTS` label | 16px | 10px | +| Project chevron | 12px | 10px | +| `WORKTREES` / `SESSIONS` label | 6px | 10px | +| `.am-local-item` icon | 10px | 10px | +| `.am-worktree-item` icon | 10px | 10px | + +Changes in `agent-manager.css`: + +- `.am-local-item` padding `8px 10px` → `8px 6px` +- `.am-worktree-item` padding `6px 10px` → `6px 6px` +- `.am-project-item` padding `6px 8px 6px 12px` → `6px 6px` +- `.am-project-body .am-section-header` padding-left `6px` → unchanged at `6px` +- `.am-projects > .am-section-header` gets `padding-left: 2px`, because that heading sits inside + `.am-sidebar`'s own 8px padding rather than in the pulled-out list + +The leading columns carry no padding of their own (`.am-sidebar-header-toggle` and +`.am-sidebar-header-chevron` are bare 16px boxes), so row padding is the only lever. + +### Symmetric gutter + +`.am-projects-list` uses `margin-left: -4px; margin-right: -4px`, pulling out of `.am-sidebar`'s +8px padding to an even 4px gutter. An earlier attempt used `-8px` on the left for true full bleed, +but that is wrong twice over: the resize handle's inner half then covered every card, so card +clicks started a resize, and a selected row's `border-radius: var(--radius-sm)` background clipped +flat against the window edge while still being inset on the right, which read as a bar bleeding off +the sidebar rather than a card. + +4px is also the most reclaimable on the right. The handle's hit area is 8px wide centered on the +border, reaching 5px back into the content area (255-263 in a 260px sidebar). At this gutter the +row's hover actions end at 249, leaving 6px of clearance; anything tighter puts row buttons under +the handle and turns clicks into resize drags. + +### Two tab stops + +Reducing the insets exposed that labels sat at four different offsets: the non-collapsible +`WORKTREES` heading at 6px (it passes no `onToggle`, so `SidebarSectionHeader` renders no chevron), +collapsible headings at 28px, worktree card text at 30px, and local card text at 32px because +`.am-local-icon` was 18px wide while `.am-wt-icon` held a 16px glyph. + +Normalized to one 16px leading column with an 8px gap, giving exactly two tab stops: + +- `.am-sidebar-header-main` gap `6px` → `8px`, matching the card icon gap +- new `.am-project-body .am-sidebar-header:not(.am-sidebar-header-toggleable) .am-sidebar-header-main { padding-left: 24px }` + so a heading with no chevron still reserves the column +- `.am-local-icon` `18px` → `16px` +- `.am-wt-icon` gains `width: 16px` and `justify-content: center` + +Measured result at 260px: + +| Tab stop | Elements | +|---|---| +| 10px | `PROJECTS` label, project chevron, local icon, worktree icon, `SESSIONS` chevron | +| 34px | project name, `WORKTREES` label, `SESSIONS` label, local text, worktree text | + +``` +cardLeftGap=4 | cardRightGap=4 | actionsRight=249 | handleZoneFrom=255 +``` + +`PROJECTS` intentionally stays at the 10px glyph stop rather than being pushed to 34px: it is the +root heading, so indenting it further than the projects beneath it would invert the hierarchy. + +### Rejected: a per-project icon in the heading + +Superset shows a GitHub **owner** avatar per project (`https://github.com/{owner}.png?size=64`, +falling back to the project's initial). Our webview CSP already allows `https:` images, so it was +feasible, but it does not fit this repo set: of the local projects, ~17 are `Kilo-Org` remotes and +would share one identical Kilo logo, and ~20 have no git remote at all and would show nothing. +An owner avatar answers "who owns this", which is not the question the sidebar needs answered. +Superset's repo-file scanner (`favicon-discovery.ts`) is dead code there, so it was not an option +worth copying either. Left out entirely as out of scope. + +## Task D — Final checks + +From `packages/kilo-vscode`: + +```bash +bun run format +bun run typecheck +bun run lint +bun run test:unit +bun run check-kilocode-change +bun run compile +``` + +Then create `.changeset/agent-manager-sidebar-density.md`: + +```md +--- +"kilo-code": patch +--- + +Fix the Agent Manager sidebar keyboard shortcut badge so it appears on the right edge of local rows and only while hovered or holding the jump modifier, give worktree titles more room by no longer reserving space for hidden row actions, and show which worktree a session belongs to in the search palette. +``` + +## Visual regression baselines + +Task A and B change existing snapshots: `WorktreeItemDefault`, `WorktreeItemActive`, +`WorktreeItemPendingDelete`, `WorktreeItemStale`, `WorktreeItemWithStats`, `WorktreeItemGrouped`, +`SidebarSearchOpen`, `MultiProjectSidebar` in `webview-ui/src/stories/agent-manager.stories.tsx`. + +**Do not run or update visual regression tests.** `tests/visual-regression.spec.ts` is skipped on +macOS, so you cannot produce valid Linux baselines locally. CI regenerates them and may push a +baseline commit. If a push is then rejected, do **not** `git pull --rebase`; run +`git fetch && git push --force-with-lease`. + +## Manual test + +Enable `kilo-code.new.experimental.multiProject` in Kilo Settings → Experimental, add a second +project, open Agent Manager (`Cmd+Shift+M`). + +1. Nothing hovered: no `⌘N` badge visible anywhere. +2. Hover a `local` row: badge appears at the **right** edge, git stats fade out. +3. Hold ⌘: badges appear on all local and worktree rows, all right-aligned. Release: all gone. +4. Worktree rows with no git changes show noticeably longer titles than before. Hover one with a + long title: it fades under the badge and trash button instead of colliding. +5. ⌘F: session results read ` · `. +6. Turn the experimental setting off: the `local` row's `⌘1` badge is hidden until hover. + +## Known issue, not fixed here + +`⌘1`–`⌘9` index the full nav order, which includes session rows that render no badge +(`navigate.ts:214-218` + `section-helpers.ts:147-153`). So with 4 worktrees you see `⌘1`–`⌘5` and +`⌘6`–`⌘9` silently land on unlabelled session rows. Deliberately left alone: fixing it means +changing jump semantics and rewriting `tests/unit/navigate.test.ts:745-757`, and it becomes moot +once #12928 moves sessions out of the tree. diff --git a/.kilo/plans/agent-manager-new-worktree-project-selector.md b/.kilo/plans/agent-manager-new-worktree-project-selector.md new file mode 100644 index 00000000000..5f98fd086f7 --- /dev/null +++ b/.kilo/plans/agent-manager-new-worktree-project-selector.md @@ -0,0 +1,834 @@ +# Agent Manager — New Worktree Project Selector + +Status: implemented 2026-08-06. The implementation is uncommitted. + +This is Slice 4 item 6 ("Add project-aware New Worktree targeting") from +`agent-manager-multi-project-uniform-ui.md`, the last unimplemented item of that slice. +`agent-manager-multi-project-runtime.md:29` deferred it out of the backend-first scope. +Everything the extension side needs already exists; this is almost entirely a webview +change. + +Implementation notes: + +- The project catalog is passed into the dialog as an accessor so the picker reflects + registry changes while it remains open. +- The per-project default-base resolver returns `undefined` when the project has no + configured/local branch, allowing the backend-detected branch response to remain the + fallback instead of being replaced by a hardcoded `main`. +- Branch, import-result, and worktree-ready messages carry `projectId` in multi-project + mode, which makes fast project changes and cross-project creation activation safe. +- The slash-command hook accepts optional caller-owned commands; `/project` is scoped to + this dialog and is hidden when multi-project mode is unavailable. + +--- + +## Problem + +With `kilo-code.new.experimental.multiProject` enabled, the New Worktree dialog has no +notion of which repository it targets, and the user cannot see or change it. + +`Cmd+N` opens the dialog with no project at all: + +```tsx +// AgentManagerApp.tsx:1870-1876 +const showNewWorktreeDialog = () => { + if (!loaded()) return + expandSidebar() + dialog.show(() => ( + dialog.close()} defaultBaseBranch={repoDefaultBranch()} /> + )) +} +``` + +`projectId` is `undefined`, so every message the dialog sends omits it +(`agentManager.requestBranches` at `NewWorktreeDialog.tsx:321`, +`agentManager.createMultiVersion` at `:373`, `agentManager.importFromPR` at `:566`, +`agentManager.importFromBranch` at `:575`). The extension then silently falls back to the +active project in `messageProject()` (`AgentManagerProvider.ts:474`) before running the +message inside `ProjectScope`. + +The result is correct but opaque: + +- The dialog never shows which repository the worktree lands in. +- The only way to target a specific project is the per-project `+` button + (`ProjectList.tsx:136-146`), which does pass `projectId` explicitly. +- `defaultBaseBranch` is resolved from the *active* project + (`AgentManagerApp.tsx:261,272`), so even Advanced options' base-branch list and default + badge are implicitly single-project. + +The desired behavior, per the original request: the dialog should show the assigned +project and let the user change it. Defaulting to the last selected project is fine as a +default; it just must be visible and overridable. + +--- + +## Design decisions + +### Placement: inline with the tab switcher + +The selector renders inside the New/Import pill row (`NewWorktreeDialog.tsx:620-699`), +right-aligned with a constrained width, so it is shared by both tabs without adding a +full-width form row. + +``` +┌─ New Worktree ──────────────────────────────┐ +│ [ New ] [ Import ] [ folder kilocode ▾ ] │ ← inline, multiProject only +│ [ Worktree name (optional) ] │ +│ [ prompt … ] Code ▾ GPT-5.6 ▾ None ▾ │ +│ › Advanced options │ +│ VERSIONS 1 2 3 4 ⧉ Compare Models │ +│ [ Create Worktree ] │ +└──────────────────────────────────────────────┘ +``` + +Rejected alternatives and why: + +- **Inside Advanced options.** Wrong category. Advanced options holds refinements of a + known target (branch name, base branch). The project *is* the target: changing it + invalidates the branch list, base branch, default-branch badge, and setup scripts. + Hiding it also fails the stated requirement of seeing which project is assigned. +- **A full-width row directly beneath the tabs.** It covered both tabs, but consumed + unnecessary vertical space and made the project control look like a primary form field. +- **In the dialog title** (`New Worktree in [kilocode ▾]`). Reads nicely but requires + widening `Dialog`'s `title` prop to accept JSX, which touches kilo-ui for one caller. + +### Visibility + +Render the row only when `multiProject` is true. With the flag off (the default) the +dialog stays byte-identical to today, so there is no regression surface for the +all-user path. When the flag is on but only the pinned project exists, still render it: +showing the target is informative and the requirement is explicitly about seeing the +assignment. + +### Default value + +`props.projectId ?? activeProjectId()`. `activeProjectId()` already exists at +`AgentManagerApp.tsx:268` (`projectList().find((p) => p.active)?.id ?? currentProjectId()`). + +No new persistence. The active project is already the durable "last selected" state +(persisted per project as `activeTarget` in each repo's `.kilo/agent-manager.json`, plus +the registry's ordering). Adding a separate "last dialog project" key would create a +second source of truth that can disagree with the sidebar. + +### Reuse, no new CSS + +The row uses the existing `am-advanced-field` + `am-nv-config-label` + +`am-selector-wrapper` + `am-selector-trigger` markup, i.e. exactly the structure the base +branch selector already uses at `NewWorktreeDialog.tsx:813-905`, with `DeferredPopover` +(already imported) instead of `BranchSelectPopover`. + +### Component extraction + +`NewWorktreeDialog.tsx` is already 1136 lines. The selector and its popover list go into a +new `webview-ui/agent-manager/ProjectSelect.tsx` (roughly `BranchSelect.tsx`'s role): +presentational, takes `projects`, `value`, `onSelect`, and labels, and owns nothing but +its own list rendering. The dialog keeps only the signal, the popover trigger, and the +effects. + +Note: `webview-ui/agent-manager/NewWorktreeDialog.tsx` is not under a `maxLines` cap +(`tests/unit/agent-manager-arch.test.ts` caps `src/agent-manager/*.ts` only), but the file +is on the arch test's watched list and the caps exist to discourage exactly this kind of +growth. + +--- + +## Changes + +### 1. `webview-ui/agent-manager/ProjectSelect.tsx` (new) + +Presentational popover body listing projects: + +- Row = project label + dimmed root path (tooltip on the full root, matching + `ProjectsSection.tsx:60`). +- Check mark on the selected project. +- Untrusted and missing projects are disabled and carry the same affordances the accordion + uses: `lock` icon + trust hint, `warning` icon + missing hint + (`ProjectsSection.tsx:67-75`). Selecting them is not possible; trust happens in the + sidebar, not in this dialog. Keeps the dialog free of trust-flow branching. +- There is intentionally no Add project action in this picker. Project registration and + trust management stay in the Agent Manager Projects toolbar. + +### 2. `webview-ui/agent-manager/NewWorktreeDialog.tsx` + +Props change: + +```ts +export const NewWorktreeDialog: Component<{ + onClose: () => void + projectId?: string // now: initial value, not fixed target + projects?: AgentProjectSnapshot[] // omitted / empty => single-project, row hidden + activeProjectId?: string + defaultBase?: (projectId: string) => string // replaces defaultBaseBranch?: string + mode: ModeRouter +}> +``` + +`defaultBaseBranch?: string` must become a per-project lookup because each project has its +own configured default and its own local branch. `ProjectList.tsx:142` already computes +that expression (`state?.defaultBaseBranch ?? props.local[projectId]?.branch`); hoist it +into the callback so both call sites share it. + +New state and derived values: + +```ts +const [project, setProject] = createSignal(props.projectId ?? props.activeProjectId) +const [projectOpen, setProjectOpen] = createSignal(false) +const selectable = () => (props.projects ?? []).filter((p) => p.trusted && !p.missing) +const showProject = () => (props.projects?.length ?? 0) > 0 +``` + +Every outbound message switches from `props.projectId` to `project()`: +`:321` `requestBranches`, `:373` `createMultiVersion`, `:566` `importFromPR`, +`:575` `importFromBranch`. + +Reload branch data on project change, replacing the one-shot `onMount` request at `:319-321`: + +```ts +createEffect( + on(project, (id) => { + setBranches([]) + setBranchSearch("") + setHighlightedIndex(0) + setBaseBranch(null) // custom base is project-specific + setDefaultBranch(props.defaultBase?.(id) ?? "main") + setBranchesLoading(true) + vscode.postMessage({ type: "agentManager.requestBranches", projectId: id }) + }), +) +``` + +Drop stale branch replies in the `agentManager.branches` handler (`:520-525`). +`AgentManagerBranchesMessage` **already declares an optional `projectId`** +(`extension-messages.ts:939-945`, `src/agent-manager/types.ts:293-298`); the field is +simply never populated or read today. Without this guard, switching projects twice quickly +can race a wrong branch list into the base-branch popover: + +```ts +if (ev.projectId && ev.projectId !== project()) return +``` + +Also replace the `if (!props.defaultBaseBranch) setDefaultBranch(ev.defaultBranch)` guard +at `:523` — with a per-project lookup, the guard must consult +`props.defaultBase?.(project())` instead of a fixed prop. + +Preserved across a project change (all project-agnostic): prompt text and its +`advancedDialogPrompt` persistence, images, name, agent, model, variant, versions, compare +allocations, sandbox override. + +Keyboard: add `project` to `WORKTREE_PROMPT_COMMANDS` so `/project` opens the popover, +consistent with mode/model/variant/sandbox already being reachable from the dialog's slash +menu (`:302-314`). Hide it from the list when `showProject()` is false, using the same +`hidden` set mechanism already used for `agents` / `variant` / `sandbox`. + +### 3. `webview-ui/agent-manager/AgentManagerApp.tsx` + +`showNewWorktreeDialog` (`:1870-1876`) passes the catalog and a per-project default +resolver instead of a single branch string: + +```tsx + dialog.close()} + projects={multiProject() ? projectList() : undefined} + activeProjectId={activeProjectId()} + defaultBase={defaultBase} +/> +``` + +where `defaultBase(id)` reads `registry.ensure(id).defaultBaseBranch() ?? registry.ensure(id).localStats()?.branch ?? repoDetectedBranch() ?? "main"`. +`registry.ensure` and both store fields already exist +(`project/registry.ts:39`, `project/store.ts:62,67,113,123`). + +### 4. `webview-ui/agent-manager/ProjectList.tsx` + +`newWorktree(projectId)` (`:136-146`) passes the same `projects` / `activeProjectId` / +`defaultBase` props with `projectId` as the initial value, so the per-project `+` button +opens the dialog pre-scoped but still switchable. Its current inline +`state?.defaultBaseBranch ?? props.local[projectId]?.branch` expression is replaced by the +shared resolver passed down from `AgentManagerApp`. + +### 5. `src/agent-manager/worktree-importer.ts` + +Stamp `projectId` on all three `agentManager.branches` posts (`:27`, `:48`, `:54`). The +field is already in the type; the value is available from the ambient `ProjectScope` +context the message runs in (`AgentManagerProvider.ts:479`). Without this the stale-reply +guard in the webview is inert. + +### 6. Activate the created worktree when the project differs + +Creating in a non-active project currently leaves the sidebar where it is: +`createMultiVersion` never activates (no activation call in `provider-multi-version.ts`), +and the new worktree just appears in that project's accordion. That is right for the +per-project `+` button, but for `Cmd+N` where the user deliberately switched projects, +landing in the new worktree is what the flow implies. + +Post an `agentManager.activateSelection` for the first created worktree when the chosen +project differs from the active one. `activateSelection` already handles readiness, trust, +and stale-target fallback (`project/messages.ts:76-99`), so this is one message, not new +machinery. Hook it to the existing `agentManager.worktreeSetup` / `multiVersionProgress` +handling in `AgentManagerApp.tsx:1453-1471`, which already carries `projectId`. + +### 7. i18n + +New keys in `webview-ui/agent-manager/i18n/en.ts` (near the existing +`agentManager.dialog.*` block at `:130`): + +- `agentManager.dialog.project.select` — "Select project" +- `agentManager.dialog.project.untrusted` — "Trust this project in the sidebar first" +- `agentManager.dialog.project.missing` — "Repository not found" + +Then translate the four keys into the other 20 locale files in that directory via the +`translator` subagent. + +--- + +## Implementation order + +1. `ProjectSelect.tsx` with the presentational list, plus i18n keys in `en.ts`. +2. Dialog: `project` signal, prop rename to `defaultBase`, route all four outbound + messages through `project()`, render the row behind `showProject()`. +3. Dialog: `createEffect(on(project, …))` branch reload, base-branch reset, stale-reply + guard. +4. Call-site updates in `AgentManagerApp.tsx` and `ProjectList.tsx`, shared `defaultBase` + resolver. +5. `projectId` stamp in `worktree-importer.ts`. +6. `/project` slash command. +7. Post-create activation when the target project differs. +8. Locale fan-out. +9. Changeset (`minor`, user-facing): worktree creation targets an explicit project. + +--- + +## Tests + +Existing source-text unit tests already assert against this dialog and will need to stay +green: `tests/unit/new-worktree-dialog-sandbox.test.ts`, +`tests/unit/prompt-input-bidirectional.test.ts`, and the dialog entry in +`tests/unit/agent-manager-arch.test.ts`. + +New coverage: + +- The dialog posts `createMultiVersion` / `requestBranches` / `importFromBranch` / + `importFromPR` with the *selected* project id, not the prop, after a project change. +- A `agentManager.branches` reply carrying a non-current `projectId` does not mutate the + branch list (the race guard). +- Changing project clears `baseBranch` and re-derives `defaultBranch` from `defaultBase`. +- Prompt text survives a project change (no accidental reset through the shared + `advancedDialogPrompt` cache). +- The row does not render when `projects` is empty, so the single-project dialog is + unchanged. +- Untrusted and missing projects are not selectable. + +Checks to run before declaring done, from `packages/kilo-vscode/`: +`bun run typecheck`, `bun run lint`, `bun run test:unit`, `bun run knip`. + +--- + +## Manual verification + +In the isolated harness (`bun run extension:isolated`) with +`kilo-code.new.experimental.multiProject` enabled and two repositories registered: + +1. `Cmd+N` from project A shows "Project: A". Switch to B, create, and confirm the + worktree lands in B's accordion and the sidebar activates it. +2. Switch project with Advanced options open and confirm the base-branch list and default + badge follow the new project rather than showing A's branches. +3. Switch project rapidly back and forth and confirm the branch list matches the selected + project (the race guard). +4. Type a prompt, switch project, confirm the prompt is retained. +5. Use the Import tab after switching project and confirm branches and PR import target + the selected repository. +6. Turn the flag off and confirm the dialog is visually identical to today. + +--- + +## Out of scope + +- Trusting or removing a project from inside the dialog. Trust stays in the sidebar; the + dialog only disables untrusted entries. +- Any change to how the active project is persisted. +- The quick-create path (`Cmd+Shift+N` → `agentManager.createWorktree`, + `AgentManagerApp.tsx:1863-1867`). It has no dialog, so it keeps targeting the active + project. Worth revisiting only if the explicit-target rule should apply there too. +- Per-project setup-script or agent selection in the dialog. + +## Risks + +- **Stale branch data** is the only real correctness risk, and it is why the `projectId` + stamp plus the reply guard are mandatory rather than optional polish. +- **Prop signature change** (`defaultBaseBranch: string` → `defaultBase: (id) => string`) + touches both call sites; a partial migration would silently show one project's default + branch while creating in another. +- **Dialog file growth**; mitigated by extracting `ProjectSelect.tsx`. + +--- + +# Appendix: exact UI and styling specification + +Everything below is copy-paste ready. Class names, tokens, and icon names are all verified +against the current tree. Do not invent new tokens or new class names beyond the ones +listed here. + +## A. Visual layout + +``` +┌─ New Worktree ─────────────────────────────────────────── X ─┐ +│ │ +│ [ New ][ Import ] [ 📁 kilocode ⌃⌄ ] │ +│ └────────────────────────────────────────────┘ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ Worktree name (optional) │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ prompt … │ │ +│ │ Code ▾ OpenAI / GPT-5.6 ▾ None ▾ ✨ 🔒 🎤 │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ › Advanced options │ +│ VERSIONS [1][2][3][4] [⧉ Compare Models] │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ Create Worktree │ │ +│ └──────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────┘ +``` + +Open dropdown (anchored under the trigger, same width as the trigger): + +``` + ┌────────────────────────────────────────────┐ + │ 📁 kilocode ~/Documents/git/kilocode ✓│ ← .am-project-option-active + │ 📁 cloud ~/Documents/git/cloud │ + │ 🔒 sample-app ~/dev/sample-app │ ← disabled, 50% opacity + │ ⚠ old-repo ~/dev/old-repo │ ← disabled, 50% opacity + └────────────────────────────────────────────┘ +``` + +Rules: + +- The selector is inline with the New/Import buttons inside the tab-switcher flex row, so + it applies to New and Import alike. +- The project name and folder icon identify the scope without a separate visible label. +- The trigger is the same control as the Advanced options base-branch trigger + (`.am-selector-trigger`), so the dialog has one visual language for "pick a thing". +- The row is **not rendered at all** when `props.projects` is empty or undefined. That is + the single-project / flag-off case, which must stay pixel-identical to today. + +## B. Exact icon names + +Only these, from `packages/ui/src/components/icon.tsx`: + +| Where | `Icon name` | Notes | +|---|---|---| +| Trigger left | `folder` | Always, regardless of project state. | +| Trigger right | `selector` | Same as every other `.am-selector-trigger`. | +| Option row, normal | `folder` | | +| Option row, untrusted | `lock` | Matches the sidebar accordion affordance. | +| Option row, missing | `warning` | Matches the sidebar accordion affordance. | +| Option row, selected | `check-small` | Right-aligned. | + +All at `size="small"`. Do not use `folder-add-left`, `check`, or `plus`. + +## C. New file: `webview-ui/agent-manager/ProjectSelect.tsx` + +```tsx +// Project picker list for the New Worktree dialog + +/** @jsxImportSource solid-js */ + +import { For, Show, type Component } from "solid-js" +import { Icon } from "@kilocode/kilo-ui/icon" +import type { AgentProjectSnapshot } from "../src/types/messages" + +interface ProjectSelectProps { + projects: AgentProjectSnapshot[] + selected?: string + onSelect: (id: string) => void + labels: { untrusted: string; missing: string } +} + +export const ProjectSelect: Component = (props) => ( +
+ + {(project) => { + const blocked = () => !project.trusted || project.missing + const hint = () => { + if (project.missing) return props.labels.missing + if (!project.trusted) return props.labels.untrusted + return project.root + } + const icon = () => { + if (project.missing) return "warning" as const + if (!project.trusted) return "lock" as const + return "folder" as const + } + return ( + + ) + }} + +
+) +``` + +Notes for the implementer: + +- Wrap in `.am-dropdown-list`, not a bare fragment: that class supplies the scroll cap and + 4px padding, and `.am-dropdown [data-slot="popover-body"]` zeroes the popover padding. +- No search input. A project list is short; adding one would need keyboard nav plumbing for + no benefit. +- `props.labels` is passed in rather than calling `useLanguage()` here, matching how + `BranchSelect` and `SidebarSearchMenu` take label props. + +## D. Exact JSX inserted into `NewWorktreeDialog.tsx` + +### D.1 Imports + +Add to the existing type import block at lines 6-12: + +```ts + AgentProjectSnapshot, +``` + +Add after line 48 (`import { BranchSelect, BranchSelectPopover } …`): + +```ts +import { ProjectSelect } from "./ProjectSelect" +``` + +`Icon`, `Show`, `DeferredPopover`, `createSignal`, `createEffect` are already imported. +`on` from `solid-js` must be added to the line 5 import list. + +### D.2 Props + +Replace the component signature at lines 84-89 with: + +```tsx +export const NewWorktreeDialog: Component<{ + onClose: () => void + /** Resolves the default base branch for one project. */ + defaultBase?: (projectId: string) => string | undefined + /** Initial target project. The user can change it while the dialog is open. */ + projectId?: string + /** Full project catalog. Empty or undefined hides the project row entirely. */ + projects?: () => AgentProjectSnapshot[] + /** Project the sidebar currently has active; used as the default target. */ + activeProjectId?: string + mode: ModeRouter +}> = (props) => { +``` + +### D.3 State + +Immediately after line 101 (`const [tab, setTab] = createSignal("new")`): + +```tsx +const [project, setProject] = createSignal(props.projectId ?? props.activeProjectId) +const [projectOpen, setProjectOpen] = createSignal(false) +const projects = () => props.projects?.() ?? [] +const showProject = () => projects().length > 0 +const projectLabel = () => projects().find((p) => p.id === project())?.label ?? "" +``` + +`defaultBranch` (line 106) changes from `props.defaultBaseBranch ?? "main"` to: + +```tsx +const [defaultBranch, setDefaultBranch] = createSignal( + (project() && props.defaultBase?.(project()!)) || "main", +) +``` + +### D.4 The inline selector + +Insert inside the tab switcher after the Import button: + +```tsx +{/* Project scope — applies to both tabs. Hidden unless multi-project is on. */} + +
+
+ + + + + {t("agentManager.dialog.project.select")} + + } + > + {projectLabel()} + + + + + + + } + > + { + track("project_select", { changed: id !== props.activeProjectId }) + setProject(id) + setProjectOpen(false) + }} + labels={{ + untrusted: t("agentManager.dialog.project.untrusted"), + missing: t("agentManager.dialog.project.missing"), + }} + /> + +
+
+
+``` + +Critical details, in order of how easily they get wrong: + +1. `placement="bottom-start"`, **not** `top-start`. The rest of this dialog uses + `top-start` because those triggers sit near the bottom of the panel. This one sits at + the top, so it must open downward. +2. `portal={false}` plus the escape CSS in section E.3. Do not switch to a portal unless + the clipping fallback in E.3 is needed. +3. `sameWidth` so the dropdown matches the trigger width, consistent with the base-branch + and compare-models popovers. +4. Never send the project label, root, or id as a telemetry property. `track` takes only + the boolean shown above. + +### D.5 Reactive reload on project change + +Delete the one-shot request at lines 319-321 inside `onMount` and replace it with an effect +placed next to the other `createEffect` calls: + +```tsx +// Project scope owns the branch data, the base branch, and the default badge. +// Prompt, name, model, agent, versions and attachments are project-agnostic and survive. +createEffect( + on(project, (id) => { + if (!id) return + setBranches([]) + setBranchSearch("") + setHighlightedIndex(0) + setBaseBranch(null) + setDefaultBranch(props.defaultBase?.(id) ?? "main") + setBranchesLoading(true) + vscode.postMessage({ type: "agentManager.requestBranches", projectId: id }) + }), +) +``` + +`on(project, …)` without `{ defer: true }` runs immediately, which replaces the removed +`onMount` request. Keep the textarea focus logic in `onMount` untouched. + +In the `agentManager.branches` handler (lines 520-525), replace the body with: + +```tsx +if (msg.type === "agentManager.branches") { + const ev = msg as AgentManagerBranchesMessage + if (ev.projectId && ev.projectId !== project()) return + setBranches(ev.branches) + const id = project() + if (!id || !props.defaultBase?.(id)) setDefaultBranch(ev.defaultBranch) + setBranchesLoading(false) +} +``` + +### D.6 Outbound project id + +Four call sites change from `props.projectId` to `project()`: + +| Line | Message | +|---|---| +| 321 (now inside the effect) | `agentManager.requestBranches` | +| 373 | `agentManager.createMultiVersion` | +| 566 | `agentManager.importFromPR` | +| 575 | `agentManager.importFromBranch` | + +Grep afterwards: `props.projectId` must appear exactly once in the file, in the `project` +signal initializer. + +## E. Exact CSS + +All of it goes into `webview-ui/agent-manager/agent-manager.css`. No changes to kilo-ui. + +### E.1 The inline selector + +Insert directly after the `.am-tab-switcher-pill-active` rule (agent-manager.css:3614-3617), +before the `/* Import tab layout */` comment at line 3619: + +```css +/* Project scope selector — inline with the New/Import tabs */ + +.am-nv-project-inline { + display: flex; + align-items: center; + flex-shrink: 0; + flex: 0 1 260px; + min-width: 0; + margin-left: auto; +} + +.am-nv-project-inline .am-selector-wrapper { + width: 100%; + min-width: 0; +} +``` + +The `flex: 0 1 260px` cap keeps the project control compact while allowing long project +names to truncate. `margin-left: auto` keeps it aligned to the right of the New/Import +buttons. + +### E.2 The dropdown rows + +Insert after the `.am-dropdown-empty` rule (agent-manager.css:3863-3868), before the +`/* Import empty state */` comment at line 3870: + +```css +/* Project option rows in the New Worktree project dropdown. + Deliberately distinct from .am-project-item, which styles the sidebar accordion. */ + +.am-project-option { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + padding: 6px 8px; + border: none; + border-radius: var(--radius-sm); + background: none; + color: var(--text-base); + font-size: var(--font-size-base); + font-family: inherit; + text-align: left; + cursor: pointer; +} + +.am-project-option:hover:not(:disabled) { + background: var(--surface-inset-base-hover); +} + +.am-project-option-active { + background: var(--surface-inset-base); +} + +.am-project-option:disabled { + opacity: 0.5; + cursor: default; +} + +.am-project-option-left { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1; +} + +.am-project-option-left [data-component="icon"] { + color: var(--text-weaker); + flex-shrink: 0; +} + +.am-project-option-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex-shrink: 0; + max-width: 45%; +} + +.am-project-option-root { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + font-size: var(--kilo-font-size-11); + color: var(--text-weaker); +} + +``` + +Why not reuse `.am-branch-item`: it is defined twice (lines 3211 and 3810) and the earlier +definition sets `font-family: var(--font-mono, monospace)` on `.am-branch-item-name`, which +would render project labels in monospace. Reusing it also couples project rows to future +branch-row changes. `.am-project-item` is likewise off limits: it already styles the +sidebar project accordion header (line 325). + +### E.3 Popover clipping escape + +`[data-slot="dialog-body"]` is `overflow: hidden` in `packages/ui/src/components/dialog.css:99-105`, +and `[data-slot="dialog-content"]` is `overflow: auto` (line 38). The existing escape rules +at agent-manager.css:2822-2828 only match popovers **inside** `.am-nv-dialog`, and this row +is deliberately outside it. Without the following, the dropdown is clipped by the dialog. + +Add to that same rule group (extend the existing selector list rather than duplicating the +declaration): + +```css +[data-component="dialog"]:has(.am-nv-project-inline [data-component="popover-content"]) [data-slot="dialog-content"], +[data-component="dialog"]:has(.am-nv-project-inline [data-component="popover-content"]) [data-slot="dialog-body"] { + overflow: visible; +} +``` + +Verification step, not optional: open the dropdown with four or more projects registered +and confirm no row is cut off and no inner scrollbar appears on the dialog. If it still +clips, the documented fallback is to drop `portal={false}` from the `DeferredPopover` in +D.4 and delete this rule; the dialog already sets `overflow: visible` on +`[data-slot="dialog-content"]` for portal-based dropdowns (agent-manager.css:2755-2760). + +## F. i18n + +Add to `webview-ui/agent-manager/i18n/en.ts`, immediately after +`"agentManager.dialog.namePlaceholder"`: + +```ts +"agentManager.dialog.project.select": "Select project", +"agentManager.dialog.project.untrusted": "Trust this project in the sidebar first", +"agentManager.dialog.project.missing": "Repository not found", +``` + +Then add the same three keys to all 20 sibling locale files in that directory (`ar bs br da +de es fa fr it ja ko nl no pl ru th tr uk zh zht`) via the `translator` subagent. + +## G. What must not change + +- No new CSS variables or tokens. Only the ones listed above, all already in use in this + file. +- No edits to `packages/kilo-ui/` or `packages/ui/`. +- No change to `.am-project-item`, `.am-branch-item`, `.am-selector-trigger`, + `.am-nv-config-label`, or any other existing rule. The only existing rule touched is the + `overflow: visible` selector group in E.3, and only by adding selectors to it. +- No new message types. `agentManager.requestBranches`, `agentManager.createMultiVersion`, + `agentManager.importFromBranch`, and `agentManager.importFromPR` all already exist and + already accept what is needed. +- With `props.projects` empty, the rendered dialog markup must be identical to before the + change. Verify by toggling `kilo-code.new.experimental.multiProject` off. diff --git a/.kilo/skills/jetbrains-cli-pin/SKILL.md b/.kilo/skills/jetbrains-cli-pin/SKILL.md new file mode 100644 index 00000000000..f04217edecf --- /dev/null +++ b/.kilo/skills/jetbrains-cli-pin/SKILL.md @@ -0,0 +1,92 @@ +--- +name: jetbrains-cli-pin +description: Use when pinning or unpinning the CLI version the Kilo JetBrains plugin uses, or fresh-regenerating the local repo CLI. Cleans all leftover CLI binaries and build artifacts in the current worktree so every operation starts from a fresh, artifact-free state. +--- + +# JetBrains CLI Pin + +Pin the Kilo JetBrains plugin to the latest released CLI, unpin it to use the local +repo CLI, or fresh-regenerate the local CLI while unpinned. Every command first cleans +all CLI/pin build artifacts and binaries in the current worktree so the result never +carries state from a previous run. + +Run all commands from the repository root of the worktree you want to affect. Paths are +relative, so they resolve to the current worktree, not the main checkout. + +## Two Controls + +The plugin's CLI behavior is governed by two independent values: + +| Control | Location | Meaning | +|---|---|---| +| Pin mode | `packages/kilo-jetbrains/gradle.properties` -> `kilo.cli.pinned` | `true` = download the released CLI at build/connect time. `false` = build and bundle the local repo CLI. | +| Pinned version | `packages/kilo-jetbrains/package.json` -> `version` | Which GitHub CLI release is downloaded and generated from when `pinned=true`. | + +"Pin to latest" means `kilo.cli.pinned=true` **and** `package.json` set to the latest +stable CLI release. "Unpin" means `kilo.cli.pinned=false` with a freshly built local CLI +bundled. + +## Commands + +```bash +bun .kilo/skills/jetbrains-cli-pin/script/cli-pin.ts [--no-verify] +``` + +| Command | Steps | +|---|---| +| `pin` | Clean -> set `kilo.cli.pinned=true` -> remove the repo-CLI Bun path hint -> bump `package.json` to latest release (via `set-pin.ts --latest`, which validates release assets) -> verify with a cold `gradlew clean typecheck`. | +| `unpin` | Clean -> set `kilo.cli.pinned=false` -> write the repo-CLI Bun path hint -> `:backend:buildRepoCli` (fresh CLI) -> `:backend:stageRepoCli` -> assert staged `kilo-cli.zip` -> verify with `gradlew typecheck`. | +| `regen` | Fast dev loop while unpinned: refresh the repo-CLI Bun path hint -> `rm -rf dist` -> `buildRepoCli` -> `stageRepoCli`. Refuses to run unless `kilo.cli.pinned=false`. | +| `clean` | Run the shared artifact clean only. | + +`--no-verify` skips the gradle verification build (rewrites + clean only). Use it when +offline or without Java 21. + +## Cleaned Artifacts + +`clean()` runs `./gradlew clean` plus targeted deletes. All paths are gitignored, so +tracked files are never touched. The clean removes the stale artifacts that otherwise +leak across a pin/unpin flip: + +| Artifact | Path | +|---|---| +| Repo CLI binaries | `packages/opencode/dist/` | +| Staged CLI archive | `packages/kilo-jetbrains/backend/build/generated/kilo-cli-res/kilo-cli.zip` | +| Generated props / checksums / OpenAPI client | `packages/kilo-jetbrains/backend/build/generated/` | +| Compiled resources (bundled zip on classpath) | `packages/kilo-jetbrains/backend/build/resources/` | +| CLI download cache | `packages/kilo-jetbrains/backend/build/cli-cache/` | + +The staged `kilo-cli.zip` is the nastiest leak: once it lands in `backend/build/resources/main/` +from an unpinned build, runtime prefers the bundled zip over downloading. A full clean is +the only reliable reset. + +## Bun Path Hint + +In repo CLI mode, Gradle's `generateOpenApiSpec` task runs the local CLI source through +`bun run --conditions=browser ./src/index.ts generate`. IDE-launched Gradle runs can have +a stripped `PATH` where `bun` isn't resolvable. The `unpin`/`regen` commands write an ignored, worktree-local hint: + +```text +packages/kilo-jetbrains/.gradle/kilo-cli-pin.properties +``` + +The file contains `bun.path=` and is consumed by `backend/build.gradle.kts` +for repo CLI tasks. `pin` removes it because pinned mode should not depend on local Bun. + +## Notes + +- Verification builds pass `--no-configuration-cache` so the changed `kilo.cli.pinned` + value is re-read instead of served from the on-disk Gradle configuration cache. +- The `pin` verification is a cold build: it downloads the pinned CLI release via + `generateOpenApiSpec` and needs network access plus Java 21. Use `--no-verify` offline. +- `kilo.cli.pinned=false` is dev-only and not releasable. Production Gradle builds, + `script/build-version.sh`, and the release scripts hard-fail on `false` -- run `pin` + before releasing. + +## Related + +- Version-bump and release-gating logic lives in the `release-jetbrains` skill + (`.kilo/skills/release-jetbrains/SKILL.md`); this skill reuses its `set-pin.ts` and + `pin-common.ts` helpers. +- Background on the build wiring: the "CLI Pinning, Unpinning, and Bumping" and "CLI + Integration" sections of `packages/kilo-jetbrains/AGENTS.md`. diff --git a/.kilo/skills/jetbrains-cli-pin/script/clean.ts b/.kilo/skills/jetbrains-cli-pin/script/clean.ts new file mode 100644 index 00000000000..a58ddcc532a --- /dev/null +++ b/.kilo/skills/jetbrains-cli-pin/script/clean.ts @@ -0,0 +1,23 @@ +import { $ } from "bun" + +// Single source of truth for every CLI/pin artifact that can leak across a mode +// flip in the current worktree. Everything here is gitignored (dist, backend/build, +// .gradle), so cleaning never touches tracked files. +// +// The build's conditional sourceSets/dependsOn wiring in backend/build.gradle.kts only +// produces a correct package from a clean build/ directory. Incremental builds are what +// let a stale kilo-cli.zip survive a pin<->unpin flip, and runtime prefers a bundled +// zip over downloading -- so a full gradle clean is the reliable reset. +export async function clean(jb = "packages/kilo-jetbrains") { + // gradle clean wipes each project's build directory (including backend/build). + await $`./gradlew clean --quiet`.cwd(jb).nothrow() + + // Stale per-platform CLI binaries. build.ts only rm -rf dist for the platforms it + // builds, so old platform dirs can survive; wipe the whole tree. + await $`rm -rf packages/opencode/dist` + + // Belt-and-suspenders in case gradle clean was skipped or ran offline. + await $`rm -rf ${jb}/backend/build/generated`.nothrow() + await $`rm -rf ${jb}/backend/build/resources`.nothrow() + await $`rm -rf ${jb}/backend/build/cli-cache`.nothrow() +} diff --git a/.kilo/skills/jetbrains-cli-pin/script/cli-pin.ts b/.kilo/skills/jetbrains-cli-pin/script/cli-pin.ts new file mode 100644 index 00000000000..a57676806d1 --- /dev/null +++ b/.kilo/skills/jetbrains-cli-pin/script/cli-pin.ts @@ -0,0 +1,114 @@ +#!/usr/bin/env bun + +import { $ } from "bun" +import { parseArgs } from "util" +import { clean } from "./clean" + +const jb = "packages/kilo-jetbrains" +const props = `${jb}/gradle.properties` +const pkg = `${jb}/package.json` +const zip = `${jb}/backend/build/generated/kilo-cli-res/kilo-cli.zip` +const hint = `${jb}/.gradle/kilo-cli-pin.properties` + +const arg = Bun.argv[2] +const cmd = arg && !arg.startsWith("-") ? arg : undefined +const { values } = parseArgs({ + args: cmd ? Bun.argv.slice(3) : Bun.argv.slice(2), + options: { + "no-verify": { type: "boolean", default: false }, + help: { type: "boolean", short: "h", default: false }, + }, +}) + +if (values.help || !cmd) { + console.log(` +Usage: bun .kilo/skills/jetbrains-cli-pin/script/cli-pin.ts [--no-verify] + +Commands: + pin Pin the JetBrains plugin to the latest released CLI. Cleans artifacts, + sets kilo.cli.pinned=true, bumps package.json to the latest release, + then verifies with a cold gradle build (needs network + Java 21). + unpin Use the local repo CLI. Cleans artifacts, sets kilo.cli.pinned=false, + fresh-builds and stages the repo CLI, then verifies with typecheck. + regen Fast dev loop: rebuild + restage the local repo CLI (requires unpinned). + clean Remove all CLI/pin build artifacts and binaries in the current worktree. + +Options: + --no-verify Skip the gradle verification build (rewrites + clean only). + +Run from the repository root of the worktree you want to affect. +`) + process.exit(values.help ? 0 : 1) +} + +async function pinned() { + const text = await Bun.file(props).text() + const line = text.split(/\r?\n/).find((l) => l.startsWith("kilo.cli.pinned=")) + return (line?.split("=", 2)[1]?.trim().toLowerCase() ?? "true") === "true" +} + +async function setPinned(value: boolean) { + const text = await Bun.file(props).text() + if (!/^kilo\.cli\.pinned=.*$/m.test(text)) throw new Error(`kilo.cli.pinned not found in ${props}`) + await Bun.write(props, text.replace(/^kilo\.cli\.pinned=.*$/m, `kilo.cli.pinned=${value}`)) +} + +function bunPath() { + return Bun.which("bun") ?? process.execPath +} + +async function writeBunHint() { + const path = bunPath() + await $`mkdir -p ${jb}/.gradle` + await Bun.write(hint, `# Generated by jetbrains-cli-pin so IDE-launched Gradle can find Bun in repo CLI mode.\nbun.path=${path}\n`) + console.log(`Wrote Bun path hint for repo CLI mode: ${path}`) +} + +async function removeBunHint() { + await $`rm -f ${hint}`.nothrow() +} + +async function report() { + const version = (await Bun.file(pkg).json()).version + console.log(`\nState: kilo.cli.pinned=${await pinned()}, package.json version=${version}`) +} + +if (cmd === "pin") { + await clean() + await setPinned(true) + await removeBunHint() + // set-pin.ts bumps package.json to the latest release and refuses versions with + // missing runtime assets, so we do not reimplement release/asset validation. + await $`bun .kilo/skills/release-jetbrains/script/set-pin.ts --latest` + if (!values["no-verify"]) { + // Cold pinned build downloads the pinned CLI release via generateOpenApiSpec. + await $`./gradlew clean typecheck --no-configuration-cache`.cwd(jb) + } + await report() +} else if (cmd === "unpin") { + await clean() + await setPinned(false) + await writeBunHint() + // build.ts does rm -rf dist internally, producing a fresh single-platform binary. + await $`./gradlew :backend:buildRepoCli --no-configuration-cache`.cwd(jb) + // stageRepoCli has upToDateWhen{false}; force it so the staged zip matches this build. + await $`./gradlew :backend:stageRepoCli --no-configuration-cache`.cwd(jb) + if (!(await Bun.file(zip).exists())) throw new Error(`Expected staged CLI at ${zip} after unpin`) + if (!values["no-verify"]) { + await $`./gradlew typecheck --no-configuration-cache`.cwd(jb) + } + await report() +} else if (cmd === "regen") { + if (await pinned()) throw new Error("regen requires the unpinned state; run 'unpin' first") + await writeBunHint() + await $`rm -rf packages/opencode/dist` + await $`./gradlew :backend:buildRepoCli --no-configuration-cache`.cwd(jb) + await $`./gradlew :backend:stageRepoCli --no-configuration-cache`.cwd(jb) + if (!(await Bun.file(zip).exists())) throw new Error(`Expected staged CLI at ${zip} after regen`) + await report() +} else if (cmd === "clean") { + await clean() + await report() +} else { + throw new Error(`Unknown command '${cmd}'. Run with --help for usage.`) +} diff --git a/package.json b/package.json index 7d22cb0b8e9..518deb2f566 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "type": "module", "packageManager": "bun@1.3.14", "scripts": { - "dev": "bun run --cwd packages/opencode --conditions=node src/index.ts", + "dev": "KILO_CLIENT=cli bun run --cwd packages/opencode --conditions=node src/index.ts", "dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev", "dev:storybook": "bun --cwd packages/storybook storybook", "lint": "oxlint", diff --git a/packages/core/src/credential.ts b/packages/core/src/credential.ts index bed9bb25314..104b9897eb4 100644 --- a/packages/core/src/credential.ts +++ b/packages/core/src/credential.ts @@ -15,6 +15,7 @@ import { Global } from "./global" import { DataMigrationTable } from "./data-migration.sql" import path from "path" import { parse as parseKiloAccounts } from "./kilocode/credential-migration" +import { isBusy } from "./kilocode/sqlite-error" import { NonNegativeInt } from "./schema" // kilocode_change end @@ -170,6 +171,17 @@ export const legacyImportLayer = Layer.effectDiscard( const integration = Integration.ID.make(integrationID.replace(/\/+$/, "")) return [{ integration, value: legacyValue(integration, decoded.value) }] }) + const migrated = yield* db.select().from(DataMigrationTable).where(eq(DataMigrationTable.name, name)).get() + const existing = yield* db.select().from(CredentialTable).orderBy(desc(CredentialTable.time_created)).all() + const same = (left: Value, right: Value) => JSON.stringify(left) === JSON.stringify(right) + if ( + migrated && + values.every((item) => { + const current = existing.find((row) => row.integration_id === item.integration) + return current !== undefined && same(current.value, item.value) + }) + ) + return yield* db.transaction((tx) => Effect.gen(function* () { for (const item of values) { @@ -181,7 +193,12 @@ export const legacyImportLayer = Layer.effectDiscard( .orderBy(desc(CredentialTable.time_created)) // kilocode_change - reconcile the active imported account .get() if (current) { - yield* tx.update(CredentialTable).set({ value: item.value }).where(eq(CredentialTable.id, current.id)).run() + if (!same(current.value, item.value)) + yield* tx + .update(CredentialTable) + .set({ value: item.value }) + .where(eq(CredentialTable.id, current.id)) + .run() continue } yield* tx.insert(CredentialTable).values({ @@ -194,7 +211,15 @@ export const legacyImportLayer = Layer.effectDiscard( yield* tx.insert(DataMigrationTable).values({ name, time_completed: Date.now() }).onConflictDoNothing().run() }), ) - }).pipe(Effect.orDie), + }).pipe( + Effect.retry({ while: isBusy, times: 2 }), + Effect.catch((error) => + isBusy(error) + ? Effect.logWarning("legacy credential reconciliation deferred because the database is busy") + : Effect.fail(error), + ), + Effect.orDie, + ), ) // kilocode_change end diff --git a/packages/core/src/cross-spawn-spawner.ts b/packages/core/src/cross-spawn-spawner.ts index 5888693f014..58fb29912ee 100644 --- a/packages/core/src/cross-spawn-spawner.ts +++ b/packages/core/src/cross-spawn-spawner.ts @@ -3,6 +3,7 @@ import { NodeFileSystem, NodeSink, NodeStream } from "@effect/platform-node" import * as NodePath from "@effect/platform-node/NodePath" import { prepareCommand as prepareSandbox } from "@kilocode/sandbox" // kilocode_change import { tap as tapStdio, tapped } from "./kilocode/stdio-tap" // kilocode_change - Bun drops buffered stdio on close +import * as SpawnExit from "./kilocode/spawn-exit" // kilocode_change import * as SpawnValidation from "./kilocode/spawn-validation" // kilocode_change import { settle } from "./kilocode/exit-code" // kilocode_change - settle signal termination as 128 + signum import * as Deferred from "effect/Deferred" @@ -268,7 +269,11 @@ export const make = Effect.gen(function* () { return { stdout, stderr, all: Stream.merge(stdout, stderr) } } - const spawn = (command: ChildProcess.StandardCommand, opts: NodeChildProcess.SpawnOptions) => + const spawn = ( + command: ChildProcess.StandardCommand, + opts: NodeChildProcess.SpawnOptions, + direct: boolean, // kilocode_change - avoid shadowing settle + ) => Effect.callback((resume) => { const signal = Deferred.makeUnsafe() const proc = launch(command.command, command.args, opts) @@ -280,6 +285,7 @@ export const make = Effect.gen(function* () { }) proc.on("exit", (...args) => { exit = args + if (direct) Deferred.doneUnsafe(signal, Exit.succeed(args)) // kilocode_change - bounded grep must not await inherited pipes }) proc.on("close", (...args) => { if (end) return @@ -326,6 +332,18 @@ export const make = Effect.gen(function* () { return Effect.fail(toPlatformError("kill", new Error("Failed to kill child process"), command)) }) + // kilocode_change start - inspect descendants owned by commands that settle on direct exit + const groupAlive = (proc: NodeChildProcess.ChildProcess) => { + if (process.platform === "win32") return false + try { + process.kill(-proc.pid!, 0) + return true + } catch { + return false + } + } + // kilocode_change end + const timeout = ( proc: NodeChildProcess.ChildProcess, @@ -370,6 +388,7 @@ export const make = Effect.gen(function* () { switch (command._tag) { case "StandardCommand": { const validation = SpawnValidation.take(command) // kilocode_change - retain target validation through preparation + const direct = SpawnExit.take(command) // kilocode_change - opt selected commands into direct-exit settlement const dir = yield* cwd(command.options) // kilocode_change start - prepare agent-scoped commands through the selected sandbox backend const target = yield* prepareSandbox(command, dir, env(command.options)) @@ -396,21 +415,33 @@ export const make = Effect.gen(function* () { const [proc, signal] = yield* Effect.acquireRelease( // kilocode_change start - spawn the prepared command and options - spawn(target, { - cwd: dir, - env: env(target.options), - stdio: stdios(sin, sout, serr, extra), - detached: target.options.detached ?? process.platform !== "win32", - shell: target.options.shell, - // kilocode_change end - windowsHide: process.platform === "win32", - }), + spawn( + target, + { + cwd: dir, + env: env(target.options), + stdio: stdios(sin, sout, serr, extra), + detached: target.options.detached ?? process.platform !== "win32", + shell: target.options.shell, + // kilocode_change end + windowsHide: process.platform === "win32", + }, + direct, // kilocode_change + ), Effect.fnUntraced(function* ([proc, signal]) { const done = yield* Deferred.isDone(signal) const kill = timeout(proc, command, target.options) // kilocode_change if (done) { const [code] = yield* Deferred.await(signal) if (process.platform === "win32") return yield* Effect.void + // kilocode_change start - clean up only descendants owned by direct-settling commands + if (direct && groupAlive(proc)) { + yield* Effect.ignore(killGroup(command, proc, target.options.killSignal ?? "SIGTERM")) + yield* Effect.sleep("100 millis") + if (groupAlive(proc)) yield* Effect.ignore(killGroup(command, proc, "SIGKILL")) + return yield* Effect.void + } + // kilocode_change end if (code !== 0 && Predicate.isNotNull(code)) return yield* Effect.ignore(kill(killGroup)) return yield* Effect.void } diff --git a/packages/core/src/kilocode/database-compat.ts b/packages/core/src/kilocode/database-compat.ts index 9982d3d7285..733b2eece88 100644 --- a/packages/core/src/kilocode/database-compat.ts +++ b/packages/core/src/kilocode/database-compat.ts @@ -4,19 +4,29 @@ import type { Database } from "../database/database" type Db = Database.Interface["db"] export function ensure(db: Db) { - return db.transaction( - (tx) => - Effect.gen(function* () { - const rows = yield* tx.all<{ name: string }>("PRAGMA table_info('session_context_epoch')") - const names = new Set(rows.map((row) => row.name)) + const load = db.all<{ name: string }>("PRAGMA table_info('session_context_epoch')") + const ready = (rows: { name: string }[]) => { + const names = new Set(rows.map((row) => row.name)) + return ["agent", "replacement_seq", "revision"].every((name) => names.has(name)) + } + return load.pipe( + Effect.flatMap((rows) => { + if (ready(rows)) return Effect.void + return db.transaction( + (tx) => + Effect.gen(function* () { + const current = yield* tx.all<{ name: string }>("PRAGMA table_info('session_context_epoch')") + const names = new Set(current.map((row) => row.name)) - if (!names.has("agent")) - yield* tx.run("ALTER TABLE `session_context_epoch` ADD `agent` text DEFAULT 'build' NOT NULL") - if (!names.has("replacement_seq")) - yield* tx.run("ALTER TABLE `session_context_epoch` ADD `replacement_seq` integer") - if (!names.has("revision")) - yield* tx.run("ALTER TABLE `session_context_epoch` ADD `revision` integer DEFAULT 0 NOT NULL") - }), - { behavior: "immediate" }, + if (!names.has("agent")) + yield* tx.run("ALTER TABLE `session_context_epoch` ADD `agent` text DEFAULT 'build' NOT NULL") + if (!names.has("replacement_seq")) + yield* tx.run("ALTER TABLE `session_context_epoch` ADD `replacement_seq` integer") + if (!names.has("revision")) + yield* tx.run("ALTER TABLE `session_context_epoch` ADD `revision` integer DEFAULT 0 NOT NULL") + }), + { behavior: "immediate" }, + ) + }), ) } diff --git a/packages/core/src/kilocode/ripgrep-grep.ts b/packages/core/src/kilocode/ripgrep-grep.ts new file mode 100644 index 00000000000..cac163c7f5d --- /dev/null +++ b/packages/core/src/kilocode/ripgrep-grep.ts @@ -0,0 +1,50 @@ +import type { Match } from "@opencode-ai/schema/filesystem" + +export interface Options { + readonly context?: number + readonly literal?: boolean + readonly ignoreCase?: boolean +} + +export type GrepMatch = Match & { + readonly context: boolean + readonly textTruncated: boolean +} + +export const flags = (input: Options) => [ + ...(input.literal ? ["--fixed-strings"] : []), + ...(input.ignoreCase ? ["--ignore-case"] : []), + ...(input.context ? [`--context=${input.context}`] : []), +] + +export const stop = (limit: number) => { + let matches = 0 + return (row: { readonly context: boolean }) => !row.context && ++matches > limit +} + +export const select = < + A extends { + readonly context: boolean + readonly path: { readonly text: string } + readonly line_number: number + }, +>( + input: { readonly limit: number; readonly context?: number }, + items: readonly A[], +) => { + let count = 0 + const overflow = items.findIndex((row) => !row.context && ++count > input.limit) + const selected = items.slice(0, overflow === -1 ? items.length : overflow) + const matches = selected.filter((row) => !row.context) + return selected.filter( + (row) => + !row.context || + matches.some( + (match) => + match.path.text === row.path.text && Math.abs(match.line_number - row.line_number) <= (input.context ?? 0), + ), + ) +} + +export const decorate = (match: Match, context: boolean, textTruncated: boolean): GrepMatch => + Object.assign(match, { context, textTruncated }) diff --git a/packages/core/src/kilocode/spawn-exit.ts b/packages/core/src/kilocode/spawn-exit.ts new file mode 100644 index 00000000000..3bd4c0f88a9 --- /dev/null +++ b/packages/core/src/kilocode/spawn-exit.ts @@ -0,0 +1,14 @@ +import type { ChildProcess } from "effect/unstable/process" + +const commands = new WeakSet() + +export function attach(command: ChildProcess.StandardCommand) { + commands.add(command) + return command +} + +export function take(command: ChildProcess.StandardCommand) { + const found = commands.has(command) + commands.delete(command) + return found +} diff --git a/packages/core/src/kilocode/sqlite-error.ts b/packages/core/src/kilocode/sqlite-error.ts new file mode 100644 index 00000000000..60f52b531c0 --- /dev/null +++ b/packages/core/src/kilocode/sqlite-error.ts @@ -0,0 +1,10 @@ +import { Cause, Option } from "effect" +import { isSqlError } from "effect/unstable/sql/SqlError" + +export function isBusy(error: unknown): boolean { + if (isSqlError(error)) return error.reason._tag === "LockTimeoutError" + if (typeof error !== "object" || error === null || !("cause" in error) || error.cause === error) return false + if (!Cause.isCause(error.cause)) return isBusy(error.cause) + const failure = Cause.findErrorOption(error.cause) + return Option.isSome(failure) && isBusy(failure.value) +} diff --git a/packages/core/src/ripgrep.ts b/packages/core/src/ripgrep.ts index 5361c083a43..71ee912e249 100644 --- a/packages/core/src/ripgrep.ts +++ b/packages/core/src/ripgrep.ts @@ -1,9 +1,11 @@ export * as Ripgrep from "./ripgrep" -import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect" +import { Context, Duration, Effect, Fiber, Layer, Schema, Stream } from "effect" import { ChildProcess } from "effect/unstable/process" import { makeGlobalNode } from "./effect/app-node" import { Entry, Match } from "@opencode-ai/schema/filesystem" +import * as KiloGrep from "./kilocode/ripgrep-grep" // kilocode_change +import * as SpawnExit from "./kilocode/spawn-exit" // kilocode_change import * as SpawnValidation from "./kilocode/spawn-validation" // kilocode_change import { AppProcess, collectStream, waitForAbort } from "./process" import { NonNegativeInt, PositiveInt, RelativePath } from "./schema" @@ -21,7 +23,7 @@ const MAX_RECORD_BYTES = 64 * 1024 const MAX_SUBMATCHES = 100 const RawMatch = Schema.Struct({ - type: Schema.Literal("match"), + type: Schema.Literals(["match", "context"]), // kilocode_change - retain requested context records data: Schema.Struct({ path: Schema.Struct({ text: Schema.String }), lines: Schema.Struct({ text: Schema.String }), @@ -37,7 +39,7 @@ const RawMatch = Schema.Struct({ }), }) -type RawMatchData = (typeof RawMatch.Type)["data"] +type RawMatchData = (typeof RawMatch.Type)["data"] & { readonly context: boolean } // kilocode_change export class Error extends Schema.TaggedErrorClass()("Ripgrep.Error", { message: Schema.String, @@ -69,7 +71,8 @@ export interface GlobInput { readonly validate?: Effect.Effect // kilocode_change - bind approved searches at spawn } -export interface GrepInput { +export interface GrepInput extends KiloGrep.Options { + // kilocode_change readonly cwd: string readonly pattern: string readonly file?: string @@ -82,7 +85,7 @@ export interface GrepInput { export interface Interface { readonly find: (input: FindInput) => Effect.Effect readonly glob: (input: GlobInput) => Effect.Effect, Error> // kilocode_change - readonly grep: (input: GrepInput) => Effect.Effect, Error | InvalidPatternError> // kilocode_change + readonly grep: (input: GrepInput) => Effect.Effect, Error | InvalidPatternError> // kilocode_change } // kilocode_change start - retain truncation state through model-facing tools @@ -114,6 +117,7 @@ const layer = Layer.effect( readonly parse: (line: string) => Effect.Effect readonly pattern?: string readonly onItem?: (item: A) => Effect.Effect + readonly stop?: (item: A) => boolean // kilocode_change - stop bounded searches at the overflow match readonly validate?: Effect.Effect // kilocode_change - spawn-bound target validation }) => { const program = Effect.scoped( @@ -124,16 +128,24 @@ const layer = Layer.effect( cwd: input.cwd, extendEnv: true, stdin: "ignore", + forceKillAfter: input.stop ? Duration.seconds(1) : undefined, // kilocode_change - bound grep interruption }) - const handle = yield* process.spawn( - input.validate ? SpawnValidation.attach(command, input.validate) : command, - ) + const validated = input.validate ? SpawnValidation.attach(command, input.validate) : command + const spawned = input.stop ? SpawnExit.attach(validated) : validated // kilocode_change + const handle = yield* process.spawn(spawned) // kilocode_change end const stderrFiber = yield* collectStream(handle.stderr, ERROR_BYTES).pipe( Effect.map((output) => output.buffer.toString("utf8")), Effect.forkScoped, ) let observed = 0 + let stopped = false // kilocode_change + const take = input.stop // kilocode_change start + ? Stream.takeUntil((row) => { + stopped = input.stop?.(row) ?? false + return stopped + }) + : Stream.take(input.limit + 1) // kilocode_change end const rows = yield* Stream.decodeText(handle.stdout).pipe( Stream.splitLines, Stream.filter((line) => line.length > 0), @@ -143,11 +155,12 @@ const layer = Layer.effect( if (!input.onItem || observed++ >= input.limit) return Effect.void return input.onItem(row) }), - Stream.take(input.limit + 1), + take, // kilocode_change Stream.runCollect, Effect.map((chunk) => [...chunk]), ) - const truncated = rows.length > input.limit + if (stopped) return { items: rows, truncated: true, partial: false } // kilocode_change + const truncated = input.stop ? false : rows.length > input.limit // kilocode_change - custom stop owns truncation if (truncated) return { items: rows.slice(0, input.limit), truncated, partial: false } const code = yield* handle.exitCode @@ -244,11 +257,13 @@ const layer = Layer.effect( grep: (input) => run({ ...input, + stop: KiloGrep.stop(input.limit), // kilocode_change args: [ "--no-config", "--json", "--hidden", "--no-messages", + ...KiloGrep.flags(input), // kilocode_change ...(input.include ? [`--glob=${input.include}`] : []), "--glob=!**/.git/**", "--", @@ -264,13 +279,19 @@ const layer = Layer.effect( }) ).pipe( Effect.flatMap((json) => { - if (!json || typeof json !== "object" || !("type" in json) || json.type !== "match") + if ( + !json || + typeof json !== "object" || + !("type" in json) || + (json.type !== "match" && json.type !== "context") // kilocode_change + ) return Effect.succeed(undefined) return Schema.decodeUnknownEffect(RawMatch)(json).pipe( Effect.map((match) => ({ ...match.data, path: { text: match.data.path.text.replace(/^\.[\\/]/, "") }, submatches: match.data.submatches.slice(0, MAX_SUBMATCHES), + context: match.type === "context", // kilocode_change })), Effect.mapError((cause) => failure("Invalid ripgrep match output", cause)), ) @@ -280,12 +301,12 @@ const layer = Layer.effect( // kilocode_change start - retain spawn metadata after mapping matches Effect.map((result) => ({ ...result, - items: result.items.map((match) => { + items: KiloGrep.select(input, result.items).map((match) => { const relative = match.path.text .replace(/^(?:\.[\\/])+/u, "") .replace(/^[\\/]+/u, "") .replaceAll("\\", "/") - return Match.make({ + const item = Match.make({ entry: Entry.make({ path: RelativePath.make(relative), type: "file", @@ -299,6 +320,7 @@ const layer = Layer.effect( end: submatch.end, })), }) + return KiloGrep.decorate(item, match.context, match.lines.text.length > 2_000) }), })), // kilocode_change end diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index 77f5d55160d..8b58a7f3da8 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -129,6 +129,10 @@ export const Info = Schema.Struct({ hide_prompt_training_models: Schema.optional(Schema.Boolean).annotate({ description: "Hide Kilo Gateway models that may train on your prompts from model listings", }), + privacy_mode: Schema.optional(Schema.Boolean).annotate({ + description: + "Blur personally identifiable information (account email, balance, team name, etc.) in the TUI and require confirmation before showing profile details", + }), sandbox: Schema.optional( Schema.Struct({ enabled: Schema.optional( diff --git a/packages/core/test/credential.test.ts b/packages/core/test/credential.test.ts index e6cbf820bb1..164f297cbc7 100644 --- a/packages/core/test/credential.test.ts +++ b/packages/core/test/credential.test.ts @@ -1,11 +1,15 @@ import path from "path" +import { Database as SQLite } from "bun:sqlite" // kilocode_change import { describe, expect } from "bun:test" +import { eq } from "drizzle-orm" // kilocode_change import { Effect, Layer } from "effect" import { Credential } from "@opencode-ai/core/credential" +import { CredentialTable } from "@opencode-ai/core/credential/sql" // kilocode_change import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Integration } from "@opencode-ai/core/integration" // kilocode_change start import { Database } from "@opencode-ai/core/database/database" +import { FSUtil } from "@opencode-ai/core/fs-util" import { Global } from "@opencode-ai/core/global" // kilocode_change end import { tmpdir } from "./fixture/tmpdir" @@ -20,6 +24,16 @@ function localLayer(directory: string) { ) } +// kilocode_change start +function importer(dir: string, store: Database.Interface) { + return Credential.legacyImportLayer.pipe( + Layer.provide(Layer.succeed(Database.Service, store)), + Layer.provide(FSUtil.defaultLayer), + Layer.provide(Global.layerWith({ data: dir })), + ) +} +// kilocode_change end + describe("Credential", () => { it.live("stores, updates, lists, and removes credentials", () => Effect.acquireUseRelease( @@ -196,6 +210,70 @@ describe("Credential", () => { ), ) + it.live("skips unchanged legacy writes and defers locked reconciliation", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => { + const file = path.join(tmp.path, "credential.db") + const auth = path.join(tmp.path, "auth.json") + const write = (key: string) => + Effect.promise(() => Bun.write(auth, JSON.stringify({ kilo: { type: "api", key } }))) + return Effect.gen(function* () { + yield* write("first") + const store = yield* Database.Service + const layer = importer(tmp.path, store) + yield* Layer.build(Layer.fresh(layer)) + + const before = yield* store.db + .select() + .from(CredentialTable) + .where(eq(CredentialTable.integration_id, Integration.ID.make("kilo"))) + .get() + yield* Layer.build(Layer.fresh(layer)) + const unchanged = yield* store.db + .select() + .from(CredentialTable) + .where(eq(CredentialTable.integration_id, Integration.ID.make("kilo"))) + .get() + expect(unchanged?.time_updated).toBe(before?.time_updated) + + yield* write("second") + yield* store.db.run("PRAGMA busy_timeout = 0") + yield* Effect.acquireUseRelease( + Effect.sync(() => { + const holder = new SQLite(file) + holder.run("PRAGMA busy_timeout = 0") + holder.run("BEGIN IMMEDIATE") + return holder + }), + () => Layer.build(Layer.fresh(layer)), + (holder) => + Effect.sync(() => { + if (holder.inTransaction) holder.run("ROLLBACK") + holder.close() + }), + ) + + const stale = yield* store.db + .select() + .from(CredentialTable) + .where(eq(CredentialTable.integration_id, Integration.ID.make("kilo"))) + .get() + expect(stale?.value).toMatchObject({ type: "key", key: "first" }) + + yield* Layer.build(Layer.fresh(layer)) + const reconciled = yield* store.db + .select() + .from(CredentialTable) + .where(eq(CredentialTable.integration_id, Integration.ID.make("kilo"))) + .get() + expect(reconciled?.value).toMatchObject({ type: "key", key: "second" }) + }).pipe(Effect.provide(Database.layerFromPath(file)), Effect.scoped) + }, + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) + it.live("dual-writes stored credentials for released auth.json readers", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), diff --git a/packages/core/test/kilocode/database-migration-compat.test.ts b/packages/core/test/kilocode/database-migration-compat.test.ts index 8546df16b37..83d35cf1666 100644 --- a/packages/core/test/kilocode/database-migration-compat.test.ts +++ b/packages/core/test/kilocode/database-migration-compat.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import { Database as SQLite } from "bun:sqlite" import { SqliteClient } from "@effect/sql-sqlite-bun" import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" import { DatabaseMigration } from "@opencode-ai/core/database/migration" @@ -159,6 +160,22 @@ describe("database migration compatibility", () => { sql`SELECT agent, replacement_seq AS replacementSeq, revision FROM session_context_epoch WHERE session_id = 'session'`, ), ).toEqual({ agent: "build", replacementSeq: 4, revision: 1 }) + + yield* db.run("PRAGMA busy_timeout = 0") + yield* Effect.acquireUseRelease( + Effect.sync(() => { + const holder = new SQLite(filename) + holder.run("PRAGMA busy_timeout = 0") + holder.run("BEGIN IMMEDIATE") + return holder + }), + () => ensure(db), + (holder) => + Effect.sync(() => { + if (holder.inTransaction) holder.run("ROLLBACK") + holder.close() + }), + ) }), ).pipe(Effect.provide(Database.layerFromPath(filename)), Effect.scoped), ) diff --git a/packages/core/test/kilocode/ripgrep-settlement.test.ts b/packages/core/test/kilocode/ripgrep-settlement.test.ts new file mode 100644 index 00000000000..955aa424a08 --- /dev/null +++ b/packages/core/test/kilocode/ripgrep-settlement.test.ts @@ -0,0 +1,208 @@ +import { describe, expect } from "bun:test" +import fs from "node:fs/promises" +import path from "node:path" +import { Effect, Fiber, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Ripgrep } from "@opencode-ai/core/ripgrep" +import { RipgrepBinary } from "@opencode-ai/core/ripgrep/binary" +import { tmpdir } from "../fixture/tmpdir" +import { it } from "../lib/effect" + +const record = (type: "match" | "context", line: number, text: string) => + JSON.stringify({ + type, + data: { + path: { text: "fixture.ts" }, + lines: { text: `${text}\n` }, + line_number: line, + absolute_offset: line * 10, + submatches: type === "match" ? [{ match: { text: "NEEDLE.*" }, start: 0, end: 8 }] : [], + }, + }) + +const alive = (pid: number) => { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +const read = async (file: string) => { + const deadline = Date.now() + 1_000 + while (Date.now() < deadline) { + const value = await fs.readFile(file, "utf8").catch(() => undefined) + if (value) return value + await Bun.sleep(10) + } + throw new Error(`Timed out waiting for ${file}`) +} + +const cleanup = async (file: string) => { + const pid = Number(await fs.readFile(file, "utf8").catch(() => "")) + if (!pid || !alive(pid)) return + try { + process.kill(pid, "SIGKILL") + } catch (err) { + if (alive(pid)) throw err + } +} + +const gone = async (pid: number) => { + const deadline = Date.now() + 1_000 + while (Date.now() < deadline) { + if (!alive(pid)) return true + await Bun.sleep(10) + } + return !alive(pid) +} + +const fixture = async (dir: string, source: string) => { + if (process.platform !== "win32") { + const binary = path.join(dir, "rg") + await fs.writeFile(binary, `#!${process.execPath}\n${source}`, { mode: 0o755 }) + return binary + } + + const script = path.join(dir, "fake-rg.cjs") + const binary = path.join(dir, "rg.cmd") + await fs.writeFile(script, source) + await fs.writeFile(binary, `@echo off\r\n"${process.execPath}" "%~dp0fake-rg.cjs" %*\r\n`) + return binary +} + +const layer = (binary: string) => + LayerNode.compile(Ripgrep.node, [ + [ + RipgrepBinary.node, + Layer.succeed(RipgrepBinary.Service, RipgrepBinary.Service.of({ filepath: Effect.succeed(binary) })), + ], + ] as const) + +describe("Kilo ripgrep settlement", () => { + it.live( + "settles a bounded parameterized grep when inherited output stays open", + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => + Effect.gen(function* () { + const retained = path.join(tmp.path, "retained.pid") + const owned = path.join(tmp.path, "owned.pid") + const args = path.join(tmp.path, "args.json") + const output = + [ + record("context", 1, "before"), + record("match", 2, "NEEDLE.*"), + record("context", 3, "after"), + record("context", 5, "later before"), + record("match", 6, "NEEDLE.*"), + ].join("\n") + "\n" + const source = `const { spawn } = require("node:child_process") +const { writeFileSync, writeSync } = require("node:fs") +const retained = spawn(process.execPath, ["-e", "setTimeout(() => process.exit(0), 30_000)"], { + detached: true, + stdio: ["ignore", "inherit", "inherit"], +}) +retained.unref() +${ + process.platform === "win32" + ? "" + : `const owned = spawn(process.execPath, ["-e", "setTimeout(() => process.exit(0), 30_000)"], { + stdio: "ignore", +}) +owned.unref()` +} +writeFileSync(${JSON.stringify(retained)}, String(retained.pid)) +${process.platform === "win32" ? "" : `writeFileSync(${JSON.stringify(owned)}, String(owned.pid))`} +writeFileSync(${JSON.stringify(args)}, JSON.stringify(process.argv.slice(2))) +writeSync(1, ${JSON.stringify(output)}) +` + const binary = yield* Effect.promise(() => fixture(tmp.path, source)) + + const result = yield* Ripgrep.Service.pipe( + Effect.flatMap((ripgrep) => + ripgrep.grep({ + cwd: tmp.path, + pattern: "NEEDLE.*", + include: "*.ts", + context: 1, + limit: 1, + literal: true, + ignoreCase: true, + }), + ), + Effect.provide(layer(binary)), + Effect.timeout("5 seconds"), + ) + const pid = Number(yield* Effect.promise(() => read(retained))) + const passed: unknown = JSON.parse(yield* Effect.promise(() => read(args))) + if (!Array.isArray(passed) || !passed.every((arg) => typeof arg === "string")) { + throw new Error("Fake ripgrep did not capture string arguments") + } + + expect(result.truncated).toBe(true) + expect(result.items.map((item) => [item.context, item.line, item.text.trim()])).toEqual([ + [true, 1, "before"], + [false, 2, "NEEDLE.*"], + [true, 3, "after"], + ]) + expect(passed).toContain("--fixed-strings") + expect(passed).toContain("--ignore-case") + expect(passed).toContain("--context=1") + expect(passed).toContain("--glob=*.ts") + expect(alive(pid)).toBe(true) + if (process.platform !== "win32") { + const child = Number(yield* Effect.promise(() => read(owned))) + expect(yield* Effect.promise(() => gone(child))).toBe(true) + } + }), + (tmp) => + Effect.promise(async () => { + await cleanup(path.join(tmp.path, "retained.pid")) + await cleanup(path.join(tmp.path, "owned.pid")) + await tmp[Symbol.asyncDispose]() + }), + ), + 10_000, + ) + + it.live( + "force kills a bounded grep that does not exit after cancellation", + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => + Effect.gen(function* () { + const ready = path.join(tmp.path, "ready.pid") + const source = `const { writeFileSync } = require("node:fs") +if (process.platform !== "win32") process.on("SIGTERM", () => {}) +writeFileSync(${JSON.stringify(ready)}, String(process.pid)) +setInterval(() => {}, 10_000) +` + const binary = yield* Effect.promise(() => fixture(tmp.path, source)) + + const controller = new AbortController() + const fiber = yield* Ripgrep.Service.pipe( + Effect.flatMap((ripgrep) => + ripgrep.grep({ cwd: tmp.path, pattern: "needle", context: 1, limit: 1, signal: controller.signal }), + ), + Effect.provide(layer(binary)), + Effect.exit, + Effect.forkScoped, + ) + const pid = Number(yield* Effect.promise(() => read(ready))) + controller.abort() + const exit = yield* Fiber.join(fiber).pipe(Effect.timeout("5 seconds")) + + expect(exit._tag).toBe("Failure") + expect(alive(pid)).toBe(false) + }), + (tmp) => + Effect.promise(async () => { + await cleanup(path.join(tmp.path, "ready.pid")) + await tmp[Symbol.asyncDispose]() + }), + ), + 10_000, + ) +}) diff --git a/packages/effect-drizzle-sqlite/src/sqlite-core/effect/session.ts b/packages/effect-drizzle-sqlite/src/sqlite-core/effect/session.ts index 15a56f2ca7d..535b232ac4b 100644 --- a/packages/effect-drizzle-sqlite/src/sqlite-core/effect/session.ts +++ b/packages/effect-drizzle-sqlite/src/sqlite-core/effect/session.ts @@ -279,7 +279,13 @@ export class SQLiteEffectPreparedQuery< assertUnreachable(cacheStrat) }).pipe( Effect.catch((e) => { - return Effect.fail(new EffectDrizzleQueryError({ query: queryString, params, cause: Cause.fail(e) })) + return Effect.fail( + new EffectDrizzleQueryError({ + query: queryString, + params: params.map(() => ""), // kilocode_change - bound values may contain credentials + cause: Cause.fail(e), + }), + ) }), ) } diff --git a/packages/effect-drizzle-sqlite/test/sqlite.test.ts b/packages/effect-drizzle-sqlite/test/sqlite.test.ts index 5303ee069ac..0148a1b9d4d 100644 --- a/packages/effect-drizzle-sqlite/test/sqlite.test.ts +++ b/packages/effect-drizzle-sqlite/test/sqlite.test.ts @@ -130,6 +130,24 @@ test("preserves failed transaction begin errors", async () => { } }) +// kilocode_change start - query errors must never expose bound credential values +test("redacts bound values from query errors", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + const secret = "must-not-leak" + yield* db.insert(users).values({ id: 1, name: "Ada" }) + + const error = yield* db.insert(users).values({ id: 1, name: secret }).pipe(Effect.flip) + + expect(error.message).not.toContain(secret) + expect(error.params).not.toContain(secret) + expect(error.params.every((param) => param === "")).toBe(true) + }), + ) +}) +// kilocode_change end + test("supports returning and rejects empty update sets", async () => { await run( Effect.gen(function* () { diff --git a/packages/kilo-docs/pages/automate/mcp/using-in-cli.md b/packages/kilo-docs/pages/automate/mcp/using-in-cli.md index 865cfa1ac28..a07800555a4 100644 --- a/packages/kilo-docs/pages/automate/mcp/using-in-cli.md +++ b/packages/kilo-docs/pages/automate/mcp/using-in-cli.md @@ -165,6 +165,8 @@ MCP tools use the same permission system as built-in tools (`allow`, `ask`, `den For full details and examples, see [MCP Tool Permissions](/docs/automate/mcp/using-in-kilo-code#auto-approve-tools). +Connected servers can also add usage instructions to the model context and expose resources, including parameterized resource templates. See [Server instructions and resources](/docs/automate/mcp/using-in-kilo-code#server-instructions-and-resources). + ## Environment Variables Use `{env:VARIABLE_NAME}` syntax in config files to reference environment variables: diff --git a/packages/kilo-docs/pages/automate/mcp/using-in-kilo-code.md b/packages/kilo-docs/pages/automate/mcp/using-in-kilo-code.md index 304457a8ddd..ca711b30aa3 100644 --- a/packages/kilo-docs/pages/automate/mcp/using-in-kilo-code.md +++ b/packages/kilo-docs/pages/automate/mcp/using-in-kilo-code.md @@ -513,6 +513,12 @@ After configuring an MCP server, Kilo Code will automatically detect available t Example: "Analyze the performance of my API" might use an MCP tool that tests API endpoints. +### Server instructions and resources + +When a connected MCP server provides instructions, Kilo adds them to the model context so the agent can follow the server's usage guidance. Kilo omits those instructions when every tool from that server is denied. + +Resource-capable servers also make the `list_mcp_resources`, `list_mcp_resource_templates`, and `read_mcp_resource` tools available to the agent. Resource templates describe parameterized URIs; the agent fills in a template, then reads the resulting resource URI. Resource listing and reads use Kilo's normal read approval flow. + ## Troubleshooting MCP Servers {% tabs %} diff --git a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md index a287cf094c6..d09392976dc 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md @@ -10,7 +10,6 @@ Auto Model is a smart routing system that selects an underlying model for each r | Tier | Best For | Pricing | |---|---|---| | `kilo-auto/frontier` | Maximum capability with the best available models | Paid | -| `kilo-auto/balanced` | Strong performance at a lower cost | Paid | | `kilo-auto/efficient` | Lowest cost per task, with capability matched to difficulty | Paid | | `kilo-auto/free` | The best free models available | Free | @@ -31,8 +30,7 @@ The underlying models behind each Auto Model tier are updated server-side as bet ## Tiers - **Frontier** — Routes to the latest and most capable paid models. Uses different models for reasoning-heavy tasks (planning, architecture, debugging) versus implementation tasks (coding, building, exploring), pairing the right capability to each type of work. -- **Balanced** — Routes to a cost-effective model for all modes. The specific model is selected based on the API interface in use, but does not vary by mode. A good default for most developers who want strong AI assistance without paying frontier prices. -- **Efficient** — Session-aware routing that classifies the difficulty of each request in real time and routes it to the cheapest model proven accurate enough for that task, based on Kilo's continuously-run benchmarks. Routine work stays lean while harder tasks get a more capable model. Because it watches your session in context, it keeps using a model across related turns and only switches when a cheaper option is clearly worth it. If a routing decision can't be made, it falls back to the Balanced tier, so quality never drops below Balanced. +- **Efficient** — Session-aware routing that classifies the difficulty of each request in real time and routes it to the cheapest model proven accurate enough for that task, based on Kilo's continuously-run benchmarks. Routine work stays lean while harder tasks get a more capable model. Because it watches your session in context, it keeps using a model across related turns and only switches when a cheaper option is clearly worth it. If a routing decision can't be made, it falls back to a fixed, cost-effective baseline model, so quality never drops below that baseline. A good default for most developers who want strong AI assistance without paying frontier prices. - **Free** — Routes to the best available free models on OpenRouter, splitting traffic across them. Because free model availability shifts over time as providers change promotional periods, the mapping is updated server-side — you always get the best free option without having to track what's currently available. Quality will be lower than paid tiers, and the models may change over time. ### How Auto Efficient routing works @@ -61,7 +59,7 @@ New entries are benchmarked on demand before they can serve traffic. Each entry | Failed | Benchmarking failed — retry the entry | | Unavailable | The model or variant is no longer in your catalog — remove the entry | -Routing decides only among ready entries. If no pool entry can serve a request, the request falls back to the Balanced tier, so quality never drops below Balanced. +Routing decides only among ready entries. If no pool entry can serve a request, the request falls back to a fixed, cost-effective baseline model, so quality never drops below that baseline. {% callout type="note" %} You can benchmark up to 10 new or retried pairs per owner per rolling 24 hours. Entries that are already ready or benchmarking don't count against this limit. @@ -85,7 +83,7 @@ No need to manually switch models when changing modes. Auto Model handles routin ### Flexible Cost Control -Pick the tier that fits your budget. Frontier gives you the best models for demanding work; Balanced offers capable models at a fraction of the cost; Efficient minimizes cost per task by matching model capability to task difficulty; Free costs nothing. +Pick the tier that fits your budget. Frontier gives you the best models for demanding work; Efficient minimizes cost per task by matching model capability to task difficulty, at a fraction of Frontier's cost; Free costs nothing. ## Requirements @@ -101,7 +99,7 @@ Select an Auto Model tier from the model dropdown in the Kilo Code chat interfac 1. Open Kilo Code in VS Code or JetBrains 2. Click the model selector dropdown -3. Choose an Auto Model such as `kilo-auto/frontier` or `kilo-auto/balanced` +3. Choose an Auto Model such as `kilo-auto/frontier` or `kilo-auto/efficient` 4. Start chatting - the right model is selected automatically based on your current mode ## When to Use Auto Model diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/index.md b/packages/kilo-docs/pages/code-with-ai/gastown/index.md index 55f665f5896..b7bba0b60e2 100644 --- a/packages/kilo-docs/pages/code-with-ai/gastown/index.md +++ b/packages/kilo-docs/pages/code-with-ai/gastown/index.md @@ -5,7 +5,7 @@ description: "Autonomous AI agent orchestration for your codebase" # {% $markdoc.frontmatter.title %} -Gastown by Kilo is an autonomous agent orchestration platform that manages teams of AI agents working on your codebase. Built on [Gastown](https://gastown.dev) — the open protocol for agent orchestration — Kilo's implementation coordinates coding agents, a code review agent, and a conversational coordinator to ship features, fix bugs, and maintain your projects with minimal human intervention. +Gastown by Kilo is an autonomous agent orchestration platform that manages teams of AI agents working on your codebase. Built on [Gastown](https://github.com/gastownhall/gastown) — the open protocol for agent orchestration — Kilo's implementation coordinates coding agents, a code review agent, and a conversational coordinator to ship features, fix bugs, and maintain your projects with minimal human intervention. You describe the work. Agents figure out how to do it, write the code, review each other's output, and land clean PRs — while you stay in control of what ships. diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/settings.md b/packages/kilo-docs/pages/code-with-ai/gastown/settings.md index d95fa6bf147..7512e72e6d6 100644 --- a/packages/kilo-docs/pages/code-with-ai/gastown/settings.md +++ b/packages/kilo-docs/pages/code-with-ai/gastown/settings.md @@ -17,7 +17,7 @@ The primary model used by all agents (polecats, refinery, mayor). This affects q Popular choices: - **Kilo Auto Frontier** — highest quality models, best results (recommended) -- **Kilo Auto Balanced** — good balance of quality and cost (minimum for Gas Town) +- **Kilo Auto Efficient** — cheapest model proven accurate enough for each task, with capability matched to difficulty (minimum for Gas Town) ### Role-Specific Models diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/troubleshooting.md b/packages/kilo-docs/pages/code-with-ai/gastown/troubleshooting.md index 62067e340f1..63b3fcc7880 100644 --- a/packages/kilo-docs/pages/code-with-ai/gastown/troubleshooting.md +++ b/packages/kilo-docs/pages/code-with-ai/gastown/troubleshooting.md @@ -124,7 +124,7 @@ Beads automatically escalate after 3 failed review cycles. If a bead is genuinel **Fix:** 1. Review failed bead descriptions — make them more specific 2. Ensure the repo builds cleanly (agents struggle with pre-existing broken builds) -3. Consider upgrading the model (Auto Balanced → Auto Frontier for complex work) +3. Consider upgrading the model (Auto Efficient → Auto Frontier for complex work) 4. Add custom instructions to guide agents: test commands, build steps, conventions ## Getting Help diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/cli.md b/packages/kilo-docs/pages/code-with-ai/platforms/cli.md index a8b1d5e9b61..9f099096e93 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/cli.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/cli.md @@ -97,6 +97,8 @@ The `kilo console` command and its browser interface are deprecated and will be | `/copy` | - | Copy latest agent response | | `/copy-session` | - | Copy session transcript | | `/export` | - | Export session transcript | +| `/move` | - | Move the current session to another project directory | +| `/diff` | - | Open the diff viewer | | `/timestamps` | `/toggle-timestamps` | Show/hide timestamps | | `/thinking` | `/toggle-thinking` | Show/hide thinking blocks | @@ -219,6 +221,19 @@ There is no notification slash command or command-palette toggle. Use `tui.json` The CLI's interactive mode supports slash commands for common operations. The main commands are documented above in the [Interactive Slash Commands](#interactive-slash-commands) section. +Use `/diff` to review working-tree changes. From the diff viewer, switch the source to the current branch compared with the main branch or to changes from the last assistant turn. Use `/move` to move the current session to another project directory. + +The `diff_open` and `session_move` TUI keybindings run the same actions and are unbound by default. Set them under `keybinds` in `tui.jsonc`: + +```jsonc +{ + "keybinds": { + "diff_open": "d", + "session_move": "o", + }, +} +``` + ## Permissions Kilo Code uses the permission config to decide whether a given action should run automatically, prompt you, or be blocked. diff --git a/packages/kilo-docs/pages/customize/skills.md b/packages/kilo-docs/pages/customize/skills.md index 9f6520b370a..415e1522157 100644 --- a/packages/kilo-docs/pages/customize/skills.md +++ b/packages/kilo-docs/pages/customize/skills.md @@ -105,17 +105,20 @@ The remote server must serve an `index.json` file at the URL path with the follo ```json { "skills": [ - { "name": "skill-name", "files": ["SKILL.md", "references/file.md"] } + { "name": "skill-name", "version": "2", "files": ["SKILL.md", "references/file.md"] } ] } ``` Each skill object contains: - `name`: The skill name (must match the directory name) +- `version`: Optional version string for refreshing cached skill files - `files`: Array of files to fetch for this skill (must include `SKILL.md`) Files are downloaded from `{url}/{skill-name}/{file}` paths. +When you change a remote skill's contents or file list, also change its `version`. On the next skill rediscovery (`/reload` or a new session), Kilo downloads the complete new version before atomically replacing the cached directory. If any download fails, Kilo keeps the previous cached version. + {% /tab %} {% tab label="CLI" %} @@ -174,17 +177,20 @@ The remote server must serve an `index.json` file at the URL path with the follo ```json { "skills": [ - { "name": "skill-name", "files": ["SKILL.md", "references/file.md"] } + { "name": "skill-name", "version": "2", "files": ["SKILL.md", "references/file.md"] } ] } ``` Each skill object contains: - `name`: The skill name (must match the directory name) +- `version`: Optional version string for refreshing cached skill files - `files`: Array of files to fetch for this skill (must include `SKILL.md`) Files are downloaded from `{url}/{skill-name}/{file}` paths. +When you change a remote skill's contents or file list, also change its `version`. On the next skill rediscovery (`/reload` or a new session), Kilo downloads the complete new version before atomically replacing the cached directory. If any download fails, Kilo keeps the previous cached version. + {% /tab %} {% /tabs %} diff --git a/packages/kilo-docs/pages/deploy-secure/security-reviews.md b/packages/kilo-docs/pages/deploy-secure/security-reviews.md index 68d6677aa08..22c25c8fdc7 100644 --- a/packages/kilo-docs/pages/deploy-secure/security-reviews.md +++ b/packages/kilo-docs/pages/deploy-secure/security-reviews.md @@ -243,9 +243,9 @@ General settings include: |---|---|---| | Security Agent enabled | Off until you turn it on | Turning it on queues an initial sync for the selected repository scope. | | Repository selection | Selected repositories during setup | Choose all accessible repositories or selected repositories. | -| Triage model | Kilo Balanced | Used for initial triage and exploitability recommendations. | -| Analysis model | Kilo Balanced | Used for sandbox analysis and result extraction. | -| Remediation model | Kilo Balanced | Used by Cloud Agent for remediation PR work. | +| Triage model | Kilo Efficient | Used for initial triage and exploitability recommendations. | +| Analysis model | Kilo Efficient | Used for sandbox analysis and result extraction. | +| Remediation model | Kilo Efficient | Used by Cloud Agent for remediation PR work. | | Analysis mode | Auto | Auto, Shallow, or Deep. | #### Turn Security Agent on or off @@ -271,7 +271,7 @@ Security Agent uses a separate model for each stage: - The Analysis model runs sandbox analysis and extracts the result. - The Remediation model is used by Cloud Agent to prepare remediation pull requests. -Kilo Balanced is the default for all three stages. You can change each model independently. The model recorded in finding details is the model used when that analysis or remediation attempt ran. AI triage, sandbox analysis, and remediation consume Kilo Code credits. +Kilo Efficient is the default for all three stages. You can change each model independently. The model recorded in finding details is the model used when that analysis or remediation attempt ran. AI triage, sandbox analysis, and remediation consume Kilo Code credits. #### Choose an analysis mode diff --git a/packages/kilo-docs/pages/gateway/models-and-providers.md b/packages/kilo-docs/pages/gateway/models-and-providers.md index 922706b984b..b7a78df9ca0 100644 --- a/packages/kilo-docs/pages/gateway/models-and-providers.md +++ b/packages/kilo-docs/pages/gateway/models-and-providers.md @@ -73,7 +73,7 @@ For NVIDIA free endpoints (Super/Ultra/etc): Trial use only - do not submit pers ## Auto models -Auto virtual models select an underlying model using tier-specific routing. Frontier uses the `x-kilocode-mode` request header. Balanced uses the API interface, Free uses deterministic affinity across available candidates, and Small uses account balance. +Auto virtual models select an underlying model using tier-specific routing. Frontier uses the `x-kilocode-mode` request header. Efficient classifies task difficulty in session context and falls back to the API interface for its baseline model, Free uses deterministic affinity across available candidates, and Small uses account balance. {% callout type="info" title="Underlying models can change" %} The mappings below reflect the current routing. The underlying models behind each `kilo-auto/*` tier are updated server-side as better options become available or as providers change pricing and availability — the tier IDs themselves remain stable. @@ -89,9 +89,9 @@ Highest performance and capability for any task. Frontier requests are sent with | `build`, `explore`, `code` | `anthropic/claude-sonnet-4.6` | | Default (no / unknown mode) | `anthropic/claude-sonnet-4.6` | -### `kilo-auto/balanced` +### `kilo-auto/efficient` -Great balance of price and capability. The resolved model depends on the API interface used by the client. +Session-aware routing that classifies each request by difficulty and routes to the cheapest model proven accurate enough for the task. When no confident routing decision can be made, requests fall back to a baseline model resolved by the API interface used by the client. | API interface | Resolved Model | Reasoning effort | |---|---|---| @@ -132,5 +132,5 @@ curl -X POST "https://api.kilo.ai/api/gateway/chat/completions" \ -H "Authorization: Bearer $KILO_API_KEY" \ -H "x-kilocode-mode: plan" \ -H "Content-Type: application/json" \ - -d '{"model": "kilo-auto/balanced", "messages": [{"role": "user", "content": "Design a database schema"}]}' + -d '{"model": "kilo-auto/efficient", "messages": [{"role": "user", "content": "Design a database schema"}]}' ``` diff --git a/packages/kilo-docs/pages/getting-started/rate-limits-and-costs.md b/packages/kilo-docs/pages/getting-started/rate-limits-and-costs.md index 8b49b32ea69..9850e877655 100644 --- a/packages/kilo-docs/pages/getting-started/rate-limits-and-costs.md +++ b/packages/kilo-docs/pages/getting-started/rate-limits-and-costs.md @@ -14,7 +14,6 @@ Auto Model is Kilo's smart routing system. Instead of selecting a specific provi | Tier | Name | Best For | Cost | |---|---|---|---| | `kilo-auto/frontier` | Auto Frontier | Maximum capability — routes to top-tier models for planning/architect/debug and high-quality models for coding | Paid (highest) | -| `kilo-auto/balanced` | Auto Balanced | Strong performance at a predictably lower cost — routes every request to one fixed high-quality model | Paid | | `kilo-auto/efficient` | Auto Efficient | Lowest cost per task — classifies each request by difficulty and routes to the cheapest benchmark-proven model for that task | Paid (lowest) | | `kilo-auto/free` | Auto Free | No credits required — rotates through available free models | Free | @@ -22,20 +21,14 @@ Auto Model is Kilo's smart routing system. Instead of selecting a specific provi The underlying models behind each tier are updated server-side as better options become available or as providers change pricing. See [kilo.ai/models](https://kilo.ai/models) for current model assignments and live pricing. {% /callout %} -## Balanced vs Efficient — What's the Difference? +## What Makes Auto Efficient Efficient? -Both tiers are paid, but they optimize for different things. +**Auto Efficient** observes your coding session in context, classifies the difficulty of each request in real time, and routes it to the *cheapest model proven accurate enough* for that specific task, based on Kilo's continuously running benchmarks. Routine tasks (small edits, lookups, quick explanations) are handled by leaner models; harder tasks (architecture, debugging, complex refactors) automatically get a more capable model. -**Auto Balanced** routes every request to a single, fixed high-quality model. You get consistent, strong results with predictable cost — a reliable default for most developers. - -**Auto Efficient** goes further. It observes your coding session in context, classifies the difficulty of each request in real time, and routes it to the *cheapest model proven accurate enough* for that specific task, based on Kilo's continuously running benchmarks. Routine tasks (small edits, lookups, quick explanations) are handled by leaner models; harder tasks (architecture, debugging, complex refactors) automatically get a more capable model. - -Efficient is also session-aware: it stays with a model across related turns and only switches when a cheaper option is clearly worth it. If it cannot make a routing decision with confidence, it falls back to Balanced — so quality never drops below Balanced. - -Think of Efficient as Balanced with an intelligent cost optimizer layered on top. +Efficient is also session-aware: it stays with a model across related turns and only switches when a cheaper option is clearly worth it. If it cannot make a routing decision with confidence, it falls back to a fixed, high-quality baseline model — so quality never drops below that baseline. You get consistent, strong results with predictable cost, plus an intelligent cost optimizer layered on top. {% callout type="tip" %} -For everyday coding tasks, start with **Auto Efficient** or **Auto Balanced**. Switch to **Auto Frontier** for complex architecture sessions or deep debugging where maximum capability matters. +For everyday coding tasks, start with **Auto Efficient**. Switch to **Auto Frontier** for complex architecture sessions or deep debugging where maximum capability matters. {% /callout %} ## How to Switch Auto Models diff --git a/packages/kilo-docs/pages/kiloclaw/end-to-end.md b/packages/kilo-docs/pages/kiloclaw/end-to-end.md index 2ff8e011726..901f76dab82 100644 --- a/packages/kilo-docs/pages/kiloclaw/end-to-end.md +++ b/packages/kilo-docs/pages/kiloclaw/end-to-end.md @@ -158,6 +158,6 @@ Or ask your Claw to build a custom skill from scratch — it has a built-in skil ## Manage inference -**Model picker:** Balanced is a good starting point. Frontier is more capable but significantly more expensive. +**Model picker:** Efficient is a good starting point. Frontier is more capable but significantly more expensive. You can also use your [Kilo Pass](https://kilo.ai/pricing/kilo-pass) credits — find this under **Profile** in the dashboard. diff --git a/packages/kilo-docs/pages/kiloclaw/overview.md b/packages/kilo-docs/pages/kiloclaw/overview.md index a55e43395fa..8af8b3ef184 100644 --- a/packages/kilo-docs/pages/kiloclaw/overview.md +++ b/packages/kilo-docs/pages/kiloclaw/overview.md @@ -37,7 +37,7 @@ Depending on your setup, you can also use: {% image src="/docs/img/kiloclaw/profile-claw-nav.png" alt="Profile page showing Claw navigation" width="400" caption="Claw navigation in profile sidebar" /%} 3. Click **Create Instance** -4. Your instance will use **Kilo Auto Balanced** as the default model. You can optionally select a different model from the dropdown — see all available models at the [Kilo Leaderboard](https://kilo.ai/leaderboard#all-models). +4. Your instance will use **Kilo Auto Efficient** as the default model. You can optionally select a different model from the dropdown — see all available models at the [Kilo Leaderboard](https://kilo.ai/leaderboard#all-models). {% image src="/docs/img/kiloclaw/create-instance.png" alt="Create instance modal with model selection" width="600" caption="Model selection during instance creation" /%} diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/all-colors-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/all-colors-chromium-linux.png index eadcbe9dbf5..239ee516fc2 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/all-colors-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/all-colors-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e9241e89fae82f69f1fab0371423ab45c905b54ff0c60ec1408b54d60f6c4c6c -size 19636 +oid sha256:785cb43d5208977a69f03056bf16e0bbc32b29aeba1d14257848a32d19590bd7 +size 19702 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/collapsed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/collapsed-chromium-linux.png index b24dac2c741..0753c0ae837 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/collapsed-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/collapsed-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a96dd287b6ffdbdc0360baa9af99d09c3b709a5adaef18f5e7b7e43a83e44746 -size 3134 +oid sha256:950cf0a3053a283651b2232b4a4e2cfdced3c6feb2f053b0a5976f8d169cfcf1 +size 3193 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/default-color-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/default-color-chromium-linux.png index e1edce07827..78a5302b80d 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/default-color-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/default-color-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:60daae227133cedd35f93753128453177400f83233268ae19af984bc9a72ad4b -size 5203 +oid sha256:c02aed8f52a7b22d14d2068e1f87b65bb42ed6737115dadf89615ff5a4c870e5 +size 5214 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/dense-sidebar-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/dense-sidebar-chromium-linux.png index de65b45f226..0e8d89dc2b8 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/dense-sidebar-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/dense-sidebar-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4cda12827fa0de0590119ab707f6403fc98291843701ab0d4300e493b4b284aa -size 14965 +oid sha256:faae43fcbe8c42aef80184b4a815bbe6a024e0345243b5b8eb1be04d62b8611f +size 15184 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/empty-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/empty-chromium-linux.png index d6ae3eb676f..9b33fc4b7ed 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/empty-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/empty-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8099695f3cb2f79a54f6ada28eaf30a4e31afc5e660e293853d1ff34a2afaed1 -size 1605 +oid sha256:82ee97e40bd03a7c126e154c1644d878e374f3a573d81b22daf602a8e74ed840 +size 1596 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/expanded-with-items-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/expanded-with-items-chromium-linux.png index 7c85edd3e20..a05c8b3d286 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/expanded-with-items-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/expanded-with-items-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0bee9b58fb35c2484a9da4bffcae07f98a942c1097df33b549e13de781790824 -size 8026 +oid sha256:4d7fa00cb51b6ab591a6f25f6f863d17b78cfcd8166cf09d179ab485e394a084 +size 8149 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/first-and-last-section-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/first-and-last-section-chromium-linux.png index 9bec9f013a3..b21483ccfaa 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/first-and-last-section-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/first-and-last-section-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bf6056375e5bf9d9ad78487d58a7fc1fb2d570333d057d412f1d70893a73e5df -size 6176 +oid sha256:1d841a283f99962a7aa3e0ecc1b24aba5a5351534e74ebe3a6794f79e6d9f374 +size 6204 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/long-section-name-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/long-section-name-chromium-linux.png index 71e123b86a3..e351fd7ab0d 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/long-section-name-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/long-section-name-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1ebd138c8d5e59bc09ce0d8d571d5598622fb61631cf5a698c80380872bef0a4 -size 7031 +oid sha256:adb4fa97f6e2dfbf1d5dd7aa58181d548c99cc0ca170f8618af72d3e6f252974 +size 7008 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/multiple-sections-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/multiple-sections-chromium-linux.png index b0039f2efd0..1eaa9c12566 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/multiple-sections-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/multiple-sections-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b629ad25ef4f3da8bd8a9e59c64cc05279dfc262db2d8d9d25c3697f48ef8393 -size 12834 +oid sha256:8caa6d6b2d7e1b0bbab8e47a486e27bcbfd7716ef84ad8771980a658c8cc41ab +size 13002 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-active-worktree-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-active-worktree-chromium-linux.png index a227600e9c2..c48be143e8a 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-active-worktree-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-active-worktree-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b78544f9c306dbcb0278502b1f929f0af5389a90b73362abbea6f76612c5def9 -size 7761 +oid sha256:089e30f789f0f592e0ee3794dfe736fc503290d05e300830fa720a5b75046894 +size 7768 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-busy-worktree-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-busy-worktree-chromium-linux.png index e269e186d42..caea07cf963 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-busy-worktree-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-busy-worktree-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4785426a34c06b0c74fe62c117528bb16ea579b2d677902c839336f6a29a380e -size 5926 +oid sha256:3792bcfd82e37fd2ae9664f0abfbd50a6823be2da55b96640557d3696b490ab9 +size 6018 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-pr-badges-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-pr-badges-chromium-linux.png index 42e7b3b4b6a..66d76c1e33b 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-pr-badges-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-pr-badges-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e2b861de27fb8e6d64becd50bb4b3b05d748fba74b98aafaf5cb2a182ff74655 -size 26259 +oid sha256:f65d353dac1fd4bf4ddaee9f7171e348b19bb279cb220b7f745651c3d65ad821 +size 26237 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-stale-worktree-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-stale-worktree-chromium-linux.png index 3e2363c6477..41f77fa6094 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-stale-worktree-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-stale-worktree-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:afa789c231b370c2ba874029a8a0597b5ca7ffff5624ddd343fcd54139a94012 -size 6132 +oid sha256:4a133a9d0b13ba7effcd1dab38a1843526b7aac99a9be5df9647d1ba7fb8d858 +size 6140 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-versions-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-versions-chromium-linux.png index 136ca2845b2..d8de11ca4b2 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-versions-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-versions-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a0f11ac60e7dd3e393739e85d279bca7e53dc22af01bade22a741e5267d96300 -size 11053 +oid sha256:aad33864d63a6d5eb7868daaa7989e3271806ddd385b4847a5a4221d189c4d5c +size 11063 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/multi-project-sidebar-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/multi-project-sidebar-chromium-linux.png index c5f0c077fcb..9a31bdf34b3 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/multi-project-sidebar-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/multi-project-sidebar-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:94227c822d88aa83f286136c05d92c8209b2cb08eaf6870cf79172aa004e8402 -size 39173 +oid sha256:d1c6647a2cb4da318b9ec60761c248345c2a6fdc6e95a0dc5e03c31a939ace4c +size 38950 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/new-worktree-project-dropdown-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/new-worktree-project-dropdown-chromium-linux.png new file mode 100644 index 00000000000..3f0dd7a5811 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/new-worktree-project-dropdown-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:88a4490322f4f70694f7b785cff41c5900b4ef8c14da104396fe55d264a624b4 +size 14535 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-approved-checks-failing-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-approved-checks-failing-chromium-linux.png index 5b2f303660f..4720473ebb2 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-approved-checks-failing-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-approved-checks-failing-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:facae793453671cf34b950352dc19041d420d096fac2a7358d1a4550e05a8033 -size 3383 +oid sha256:f2349f8c0d7049811702e5b8cbeb5fc2f402bdda8630d1a5210a970a13284197 +size 3694 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-approved-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-approved-chromium-linux.png index 784bf935344..b481fb3ef0a 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-approved-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-approved-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fc81d91792781a9784775db3af44ea8058e87df9e029c2d7fc5acf1548fa7ec7 -size 3423 +oid sha256:311bb5062867449b9d5e2de41e77b399be1953fa6663097fa730218bed64a30a +size 3753 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-changes-requested-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-changes-requested-chromium-linux.png index ebf4954c6bf..87cf0cf6a55 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-changes-requested-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-changes-requested-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2923838fbc9ce24851e2b8ed6c5d4c2a098db9667fc528d1c00162595640bc05 -size 3550 +oid sha256:52cd6565a96bee5f55872fad4faf1c88030bec70d223187e51942bb70c2da253 +size 3687 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-checks-failing-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-checks-failing-chromium-linux.png index fdd58cf7fc4..4720473ebb2 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-checks-failing-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-checks-failing-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c087f8e2b50bf43f9ca03ab31e8955e995304473688bf8db88fb6901d743a1d4 -size 3541 +oid sha256:f2349f8c0d7049811702e5b8cbeb5fc2f402bdda8630d1a5210a970a13284197 +size 3694 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-checks-pending-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-checks-pending-chromium-linux.png index ebf4954c6bf..7db717a6fac 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-checks-pending-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-checks-pending-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2923838fbc9ce24851e2b8ed6c5d4c2a098db9667fc528d1c00162595640bc05 -size 3550 +oid sha256:4e29131b2a6e68fc26d032396c872698d1882eacc3830fe4059fca810981915c +size 3679 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-closed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-closed-chromium-linux.png index 05c387099b1..4380385eb52 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-closed-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-closed-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6c13cc44cec6092d8294a535a3e6057d6e3a2b2d59cb18e09b0a073576972f80 -size 3582 +oid sha256:62b2731184acdc022a75338779296b8d217d5abe565d8d763075bfc68939b2e8 +size 3695 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-draft-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-draft-chromium-linux.png index fc5b1dc4911..481d4f5cc97 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-draft-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-draft-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:79f5495b171fc9de3d04b7e15cc30ac2f5082c6d7db3818b3e288ce9c3ba41f3 -size 3461 +oid sha256:b36b2ab3912ec70139285c19a226e02c35cb982a321e3cd140d84a29756d817e +size 3590 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-merged-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-merged-chromium-linux.png index e819a16198a..2cda01638ff 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-merged-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-merged-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dda50cfb4ebebd29e6f0791ec13ee9e16e48c9fa6f6d6be03c78b1d900516427 -size 3604 +oid sha256:1b787fe5310a002e7c5ac2bf8101964c0e4576d16db973e77a5e893b0db1fb66 +size 3728 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-no-review-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-no-review-chromium-linux.png index 8858ea455c7..3b1b3de84d1 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-no-review-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-no-review-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2680e8f97baee13ab92ac6fb3bcce56eb9cbb7e0c5fb0f76a76251abc016eac3 -size 3567 +oid sha256:e2bcc9c6e0dbf398194431bdbca27138608304e95c6de5874aeff2ca06fe73e6 +size 3684 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-pending-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-pending-chromium-linux.png index 8858ea455c7..3b1b3de84d1 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-pending-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-pending-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2680e8f97baee13ab92ac6fb3bcce56eb9cbb7e0c5fb0f76a76251abc016eac3 -size 3567 +oid sha256:e2bcc9c6e0dbf398194431bdbca27138608304e95c6de5874aeff2ca06fe73e6 +size 3684 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-active-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-active-chromium-linux.png index b657e55ee74..bfe0176bc25 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-active-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-active-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:270e0dafc2601996e7d048873e55db4680e8803d6800dc44b1fa6ce6239827cc -size 2017 +oid sha256:769935237790bbf85293a260a4dded22ad0f33dcffb41cbcd0d7695136775bd2 +size 2215 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-default-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-default-chromium-linux.png index bc4f471b091..4a0f64a1775 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-default-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-default-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5feb854ee2693b655ab504b8e2ed8794af77a357c3d716e2deb9668a08f3aa0c -size 1847 +oid sha256:6905b74776e20e3bdf0ca2ba6a89888e9160f5055d2f1719bc0cafa59f808134 +size 2056 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-pending-delete-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-pending-delete-chromium-linux.png index 2ec70d4ede2..7301e8f0b84 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-pending-delete-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-pending-delete-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1d9935cc07b9eefe0c1eca1852b00ac2b39b76cdf12ee2f01c52e4ac0771b39e -size 2910 +oid sha256:8cb40c61ea8472804f6a067d64cec84a6c492756abf386acded8e2df57868653 +size 3012 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-stale-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-stale-chromium-linux.png index 9cdd496d4a1..72a6a34103c 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-stale-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-stale-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:54e7142d591391c6e99631c9bf87b011321e52f17a8ee39e5aeeaa9b1350f2f9 -size 1831 +oid sha256:3ed27d7e73679fe2ccbe267cf6ca52143882ae72026d01beb8862e6add661531 +size 2207 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-with-stats-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-with-stats-chromium-linux.png index 43f94d6b804..460be84ca5c 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-with-stats-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-with-stats-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7e4a37b5b7d71e0eb7f043176f410030385a8a61f3913bd6419b1d96c6cad2de -size 2422 +oid sha256:71034449e848b4d6f2fe88624694842cfddd8a1890996f6cbc66499cbefafb33 +size 2547 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/shared/model-selector-most-used-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/shared/model-selector-most-used-chromium-linux.png new file mode 100644 index 00000000000..f6894817821 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/shared/model-selector-most-used-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:89fa6a619fc2089fd0bb03dabe191394d3dcf3423ba066f1e8f2e2c6aac38838 +size 1085 diff --git a/packages/kilo-jetbrains/AGENTS.md b/packages/kilo-jetbrains/AGENTS.md index 29012410381..254d66698f3 100644 --- a/packages/kilo-jetbrains/AGENTS.md +++ b/packages/kilo-jetbrains/AGENTS.md @@ -170,6 +170,8 @@ For blocking I/O in coroutines, move the dispatcher switch inside the callee usi The JetBrains plugin has two independent CLI controls. Use the commands below directly when asked to change either one; do not hand-edit versions by guesswork. +For a one-shot pin/unpin/regen that also cleans every leftover CLI binary and build artifact in the current worktree, use the `jetbrains-cli-pin` skill (`.kilo/skills/jetbrains-cli-pin/SKILL.md`): `bun .kilo/skills/jetbrains-cli-pin/script/cli-pin.ts `. + **Pin mode** (`kilo.cli.pinned` in `packages/kilo-jetbrains/gradle.properties`) controls release CLI vs local repo CLI. | Ask | Do | diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index d000b8245ae..8f50db751a9 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -128,6 +128,60 @@ ## [Unreleased] +## [7.0.13] - 2026-08-05 + +### Added + +- Show the pinned Kilo Core version and whether JetBrains is using a downloaded or bundled CLI build. + +### Fixed + +- Avoid GitHub checksum API rate limits when JetBrains verifies downloaded Kilo Core CLI assets. +- Add dropped files as JetBrains file references so attachments are available to Kilo reliably. +- Stop eager Kilo Core file watchers when running from JetBrains to reduce unnecessary background work. +- Improve JetBrains session diff rendering, including full-file editor diffs, multi-hunk diffs, fallback handling, gutter line numbers, and session-scoped diff paths. +- Speed up local recall searches in Kilo Core. +- Omit persona details from generated session names. +- Make invalid tool-argument errors clearer and more actionable to the model. +- Handle SQLite lock errors more gracefully. + +### Changed + +- Bump the JetBrains CLI pin to Kilo CLI v7.4.20. +- Include upstream OpenCode updates through v1.17.13. +- Adopt upstream reasoning variant metadata from OpenCode v1.18.11. + +## [7.0.13-rc.1] - 2026-08-05 + +### Added + +- Show the pinned Kilo Core version and whether JetBrains is using a downloaded or bundled CLI build. +- Add JetBrains developer tooling for pinning, unpinning, and updating the bundled Kilo Core CLI used by the plugin. +- Support resuming Claude and Codex sessions through the bundled Kilo Core runtime. +- Add remote CLI file delivery support for attachment flows. + +### Fixed + +- Avoid GitHub checksum API rate limits when JetBrains verifies downloaded Kilo Core CLI assets. +- Add dropped files as JetBrains file references so attachments are available to Kilo reliably. +- Stop eager Kilo Core file watchers when running from JetBrains to reduce unnecessary background work. +- Improve JetBrains session diff rendering, including full-file editor diffs, multi-hunk diffs, fallback handling, gutter line numbers, and session-scoped diff paths. +- Preserve configured subagent routing in Kilo Core. +- Defer threshold compaction during active tool loops so long-running sessions do not compact at unsafe points. +- Speed up local recall searches in Kilo Core. +- Stop inline skill-shell documentation examples from triggering permission prompts. +- Omit persona details from generated session names. +- Skip Kilo Core startup work for informational commands. +- Make invalid tool-argument errors clearer and more actionable to the model. +- Allow explicit external markdown sources in Kilo Core. +- Handle SQLite lock errors more gracefully. + +### Changed + +- Bump the JetBrains CLI pin to Kilo CLI v7.4.20. +- Include upstream OpenCode updates through v1.17.13. +- Adopt upstream reasoning variant metadata from OpenCode v1.18.11. + ## [7.0.12] - 2026-08-01 ### Added diff --git a/packages/kilo-jetbrains/backend/build.gradle.kts b/packages/kilo-jetbrains/backend/build.gradle.kts index e09355e19f2..bcba0effb4c 100644 --- a/packages/kilo-jetbrains/backend/build.gradle.kts +++ b/packages/kilo-jetbrains/backend/build.gradle.kts @@ -26,6 +26,13 @@ val repoCli = pinned.map { !it } val bundled = providers.gradleProperty("kilo.cli.bundled").map { it.trim().toBoolean() }.orElse(false) val downloadsCli = repoCli.zip(bundled) { repo, bundle -> !repo && !bundle } val repoRootDir = rootProject.layout.projectDirectory.dir("../opencode") +val local = rootProject.layout.projectDirectory.file(".gradle/kilo-cli-pin.properties") +val bunPathProvider = providers.fileContents(local).asText.map { text -> + text.lineSequence().firstNotNullOfOrNull { line -> + val pair = line.split("=", limit = 2) + if (pair.getOrNull(0)?.trim() == "bun.path") pair.getOrNull(1)?.trim()?.takeIf { it.isNotEmpty() } else null + } ?: "bun" +}.orElse("bun") val pinnedCliVersion = providers.fileContents(rootProject.layout.projectDirectory.file("package.json")).asText.map { text -> Regex("\"version\"\\s*:\\s*\"([^\"]+)\"").find(text)?.groupValues?.get(1) @@ -64,12 +71,13 @@ val generateOpenApiSpec by tasks.registering(GenerateOpenApiSpecTask::class) { ) cacheDir.set(layout.buildDirectory.dir("cli-cache")) spec.set(rawSpec) + bunPath.set(bunPathProvider) } val buildRepoCli by tasks.registering(Exec::class) { description = "Build the local repo CLI for the current platform" workingDir = repoRootDir.asFile - commandLine("bun", "run", "script/build.ts", "--single", "--skip-install") + commandLine(bunPathProvider.get(), "run", "script/build.ts", "--single", "--skip-install") } fun platform(): String { diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt index f48dbf90387..e5d5a002c10 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt @@ -344,8 +344,16 @@ class KiloBackendSessionManager( files = count(files), ) - private fun revertDto(s: ai.kilocode.jetbrains.api.model.SessionRevert?) = s?.let { - revertDto(it.messageID, it.partID, it.snapshot, it.diff) + private fun revertDto(s: Any?) = when (s) { + null -> null + is ai.kilocode.jetbrains.api.model.SessionRevert -> revertDto(s.messageID, s.partID, s.snapshot, s.diff) + else -> runCatching { + val cls = s.javaClass + fun str(name: String) = cls.methods.firstOrNull { it.name == name && it.parameterCount == 0 }?.invoke(s) as? String + val message = str("getMessageID") + ?: return@runCatching null.also { log.info("revertDto reflective getMessageID missing on ${cls.name}") } + revertDto(message, str("getPartID"), str("getSnapshot"), str("getDiff")) + }.onFailure { log.info("revertDto reflective decode failed for ${s.javaClass.name}: ${it.message}") }.getOrNull() } private fun revertDto(message: String, part: String?, snapshot: String?, diff: String?) = diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt index 9425f0779f7..580d226c33d 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt @@ -126,14 +126,18 @@ class KiloBackendCliManager( private suspend fun resolveCli(onProgress: (CliDownload) -> Unit): File { val force = forceExtract forceExtract = false + val version = KiloProps.cliVersion() + val platform = KiloCliPlatform.current() if (KiloRepoCli.available()) { - if (force) log.info("Force re-extracting bundled CLI ${KiloProps.cliVersion()}") + if (force) log.info("Force re-extracting bundled CLI $version") + log.info("Kilo CLI mode: BUNDLED — using CLI $version ($platform) shipped in the plugin; no download needed") val cli = KiloRepoCli.extract(force) - onProgress(CliDownload(100, KiloProps.cliVersion(), KiloCliPlatform.current())) + onProgress(CliDownload(100, version, platform)) return cli } - if (force) log.info("Force re-downloading CLI ${KiloProps.cliVersion()}") - return KiloCliDownloader(log = log).resolve(KiloProps.cliVersion(), force, onProgress) + if (force) log.info("Force re-downloading CLI $version") + log.info("Kilo CLI mode: DOWNLOAD — resolving CLI $version ($platform) from the GitHub release") + return KiloCliDownloader(log = log).resolve(version, force, onProgress) } // Must be called from a background thread — devStorageEnv() performs blocking I/O (mkdirs). diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index d18a8c0dee4..25c44b5d90b 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -1516,14 +1516,71 @@ object KiloCliDataParser { private fun parseRevert(obj: JsonObject?): SessionRevertDto? { if (obj == null) return null val message = obj.str("messageID") ?: return null + val diff = obj.str("diff") return SessionRevertDto( messageID = message, partID = obj.str("partID"), snapshot = obj.str("snapshot"), - diff = obj.str("diff"), + diff = diff, + diffs = parseUnifiedDiff(diff), ) } + private fun parseUnifiedDiff(diff: String?): List { + if (diff.isNullOrBlank()) return emptyList() + val lines = diff.lines() + val starts = lines.mapIndexedNotNull { index, line -> if (line.startsWith("diff --git ")) index else null } + if (starts.isEmpty()) return emptyList() + return starts.mapIndexedNotNull { index, start -> + val end = starts.getOrNull(index + 1) ?: lines.size + parseUnifiedBlock(lines.subList(start, end).joinToString("\n")) + } + } + + private fun parseUnifiedBlock(block: String): DiffFileDto? { + val lines = block.lines() + val file = unifiedFile(lines) ?: return null + return DiffFileDto( + file = file, + additions = lines.count { it.startsWith("+") && !it.startsWith("+++") }, + deletions = lines.count { it.startsWith("-") && !it.startsWith("---") }, + patch = block, + status = unifiedStatus(lines), + ) + } + + private fun unifiedFile(lines: List): String? { + val next = lines.firstOrNull { it.startsWith("+++ ") }?.removePrefix("+++ ") + val prev = lines.firstOrNull { it.startsWith("--- ") }?.removePrefix("--- ") + val path = sequenceOf(next, prev) + .filterNotNull() + .firstOrNull { it != "/dev/null" } + ?: lines.firstOrNull()?.let(::gitDiffTarget) + return path?.let(::cleanDiffPath) + } + + private fun unifiedStatus(lines: List): String = when { + lines.any { it == "new file mode" || it.startsWith("new file mode ") } -> "added" + lines.any { it == "deleted file mode" || it.startsWith("deleted file mode ") } -> "deleted" + lines.any { it.startsWith("--- /dev/null") } -> "added" + lines.any { it.startsWith("+++ /dev/null") } -> "deleted" + else -> "modified" + } + + private fun gitDiffTarget(line: String): String? { + val match = Regex("^diff --git a/(.*) b/(.*)$").find(line) ?: return null + return match.groupValues.getOrNull(2) + } + + private fun cleanDiffPath(path: String): String { + val text = path.trim().trim('"') + return when { + text.startsWith("a/") -> text.removePrefix("a/") + text.startsWith("b/") -> text.removePrefix("b/") + else -> text + } + } + // ================================================================ // Internal — status parsing // ================================================================ diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt index bee74c12bb4..11cb6754ffe 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt @@ -112,7 +112,7 @@ class KiloCliDownloader( "completeExists=${done.isFile} digestValid=$valid exe=${exe.absolutePath} complete=${done.absolutePath}" ) if (!exe.isFile || !valid) return null - log.info("Using cached Kilo CLI $version for $platform at ${exe.absolutePath}") + log.info("Kilo CLI $version ($platform) already cached at ${exe.absolutePath}; skipping download and extraction") if (!SystemInfo.isWindows) exe.setExecutable(true) prune(version) return exe diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloRepoCli.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloRepoCli.kt index 8138ae4daed..617e805ba76 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloRepoCli.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloRepoCli.kt @@ -36,10 +36,12 @@ object KiloRepoCli { val exe = File(root, "$platform/bin/${KiloCliPlatform.exe()}") val done = File(root, ".complete") if (!force && done.isFile && exe.isFile) { + log.info("Bundled Kilo CLI ${KiloProps.cliVersion()} ($platform) already extracted at ${exe.absolutePath}; skipping extraction") if (!SystemInfo.isWindows) exe.setExecutable(true) if (cleanup) prune(root) return@withContext exe } + log.info("Extracting bundled Kilo CLI ${KiloProps.cliVersion()} ($platform) into ${root.absolutePath}") if (root.exists() && !root.deleteRecursively()) { throw IllegalStateException("Failed to delete local repo CLI under ${root.absolutePath}") diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/diff/DiffFullReconstruct.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/diff/DiffFullReconstruct.kt new file mode 100644 index 00000000000..0b1f063f759 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/diff/DiffFullReconstruct.kt @@ -0,0 +1,86 @@ +package ai.kilocode.backend.diff + +/** + * Rebuilds the full "before" content of a modified file by reverse-applying a unified diff hunk + * patch to the current working-tree content. This lets the JetBrains diff editor show a whole-file + * diff (with collapsible unchanged regions) from the limited-context patch the CLI already returns — + * no CLI change required. + * + * Added and deleted files are intentionally rejected: their patches already carry every line, so the + * frontend reconstructs those full sides directly. Binary patches and any drift between the patch's + * after side and the real file (a stale/historical turn) also return null so the caller can fall back + * to the hunk-only view instead of rendering a wrong diff. + * + * Known limitation: `\ No newline at end of file` markers are dropped rather than tracked per side, so + * the reconstructed `before` inherits the after side's trailing-newline state. When exactly one side + * lacks a trailing newline, the whole-file fallback view will not surface that EOF-newline change. This + * is cosmetic and rare (the scoped hunk view still shows the marker); tracking it per side would require + * remembering which side the marker followed. + */ +internal object DiffFullReconstruct { + private val HUNK = Regex("^@@ -\\d+(?:,\\d+)? \\+(\\d+)(?:,(\\d+))? @@") + + fun before(after: String, patch: String?): String? { + if (patch.isNullOrBlank() || binary(patch) || added(patch) || deleted(patch)) return null + + val lines = if (after.isEmpty()) emptyList() else after.split("\n") + val out = ArrayList(lines.size) + var cursor = 0 // next after-line index still to emit (0-based) + var open = false + var start = 0 // 0-based after index where the current hunk begins + val afterBody = ArrayList() + val beforeBody = ArrayList() + + fun flush(): Boolean { + if (!open) return true + if (start < cursor) return false // overlapping or out-of-order hunks + while (cursor < start) { + if (cursor >= lines.size) return false + out.add(lines[cursor]); cursor++ + } + for (i in afterBody.indices) { + val idx = start + i + if (idx >= lines.size || lines[idx] != afterBody[i]) return false // working tree drifted + } + out.addAll(beforeBody) + cursor = start + afterBody.size + afterBody.clear(); beforeBody.clear() + open = false + return true + } + + // git patches are newline-terminated; drop the trailing split artifact so it is not read as a + // blank context line. Real blank context lines are " " (space-prefixed), never "". + for (raw in patch.split("\n").dropLastWhile { it.isEmpty() }) { + if (raw.startsWith("@@")) { + if (!flush()) return null + val match = HUNK.find(raw) ?: return null + val newStart = match.groupValues[1].toIntOrNull() ?: return null + val newLen = match.groupValues[2].ifEmpty { "1" }.toInt() + // For a zero-length new range git reports the line preceding the removed block, so the + // removed lines are reinserted at `newStart`; otherwise the region starts at newStart-1. + start = if (newLen == 0) newStart else newStart - 1 + open = true + continue + } + if (!open) continue // skip file headers (diff/index/---/+++) + if (raw.startsWith("\\")) continue // "\ No newline at end of file" + when (raw.firstOrNull()) { + ' ' -> { val body = raw.substring(1); afterBody.add(body); beforeBody.add(body) } + '+' -> afterBody.add(raw.substring(1)) + '-' -> beforeBody.add(raw.substring(1)) + null -> { afterBody.add(""); beforeBody.add("") } + else -> return null + } + } + if (!flush()) return null + while (cursor < lines.size) { out.add(lines[cursor]); cursor++ } + return out.joinToString("\n") + } + + fun added(patch: String): Boolean = patch.lineSequence().any { it == "--- /dev/null" } + + fun deleted(patch: String): Boolean = patch.lineSequence().any { it == "+++ /dev/null" } + + private fun binary(patch: String): Boolean = patch.lineSequence().any { it.startsWith("Binary files ") } +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt index 58e929dcd7e..2fe3edca360 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt @@ -11,6 +11,7 @@ import ai.kilocode.backend.app.LoadProgress import ai.kilocode.backend.app.ProfileResult import ai.kilocode.backend.cli.KiloCliPlatform import ai.kilocode.backend.cli.KiloProps +import ai.kilocode.backend.cli.KiloRepoCli import ai.kilocode.jetbrains.api.model.KiloProfile200Response import ai.kilocode.rpc.dto.ConfigPatchDto import ai.kilocode.rpc.KiloAppRpcApi @@ -57,6 +58,8 @@ class KiloAppRpcApiImpl : KiloAppRpcApi { override suspend fun cliPlatform(): String = KiloCliPlatform.current() + override suspend fun cliBundled(): Boolean = KiloRepoCli.available() + override suspend fun retry() = app.retry() override suspend fun restart() = app.restart() diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt index 82442564a8c..3861abeb3d3 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt @@ -33,6 +33,16 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onStart +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.Request +import ai.kilocode.backend.diff.DiffFullReconstruct +import java.nio.file.Files +import java.nio.file.Path /** * Backend implementation of [KiloSessionRpcApi]. @@ -155,6 +165,76 @@ class KiloSessionRpcApiImpl internal constructor( } } + override suspend fun diffSides(sessionId: String?, directory: String, file: DiffFileDto, messageId: String?): DiffFileDto? { + val patch = file.patch + if (patch.isNullOrBlank()) return null + log.info("diffSides start file=${file.file} session=${!sessionId.isNullOrBlank()} message=${!messageId.isNullOrBlank()} patch=${patch.length}") + // 1) Authoritative: a CLI with full/file support returns whole before/after from the snapshot, + // correct even for historical turns. Older CLIs ignore the params, so we detect the missing + // content and fall through to local reconstruction. + if (!sessionId.isNullOrBlank()) authoritative(sessionId, directory, file, messageId)?.let { + log.info("diffSides authoritative file=${file.file} before=${it.before?.length ?: 0} after=${it.after?.length ?: 0}") + return it + } + // 2) Fallback: read the working-tree file and reverse-apply the hunk patch to recover the whole + // "before". No CLI round-trip, so this works against any pinned CLI. + return withContext(Dispatchers.IO) { + val path = resolve(directory, file.file) + val after = path?.let { runCatching { Files.readString(it) }.getOrNull() } + val before = after?.let { DiffFullReconstruct.before(it, patch) } + log.info("diffSides fallback file=${file.file} path=${path ?: ""} after=${after?.length ?: 0} before=${before?.length ?: 0}") + if (after != null && before != null) file.copy(before = before, after = after) else null + } + } + + private fun resolve(directory: String, file: String): Path? { + val direct = Path.of(directory).resolve(file).normalize() + if (Files.isRegularFile(direct)) return direct + // dev-only: a stored diff may reference another worktree (relative, or absolute into a sibling + // worktree that isn't checked out here). Re-root onto the running worktree by trying progressively + // shorter path suffixes until one exists, so full-file diffs work across dev worktrees. + val root = System.getProperty("kilo.dev.worktree.root")?.takeIf { it.isNotBlank() }?.let(Path::of) ?: return null + val segs = Path.of(file).toList() + for (i in segs.indices) { + val candidate = segs.drop(i).fold(root) { acc, seg -> acc.resolve(seg) }.normalize() + if (Files.isRegularFile(candidate)) return candidate + } + return null + } + + // Ask the CLI for full before/after via GET /session/:id/diff?full=true&file=...; returns null when + // the pinned CLI lacks full/file support (it omits before/after) so the caller falls back locally. + private suspend fun authoritative(sessionId: String, directory: String, file: DiffFileDto, messageId: String?): DiffFileDto? { + val api = app.api ?: return null + return withContext(Dispatchers.IO) { + runCatching { + val url = (api.baseUrl.trimEnd('/') + "/").toHttpUrlOrNull() + ?.newBuilder() + ?.addPathSegment("session") + ?.addPathSegment(sessionId) + ?.addPathSegment("diff") + ?.addQueryParameter("directory", directory) + ?.addQueryParameter("full", "true") + ?.addQueryParameter("file", file.file) + ?.apply { if (!messageId.isNullOrBlank()) addQueryParameter("messageID", messageId) } + ?.build() + ?: return@runCatching null + api.client.newCall(Request.Builder().url(url).get().build()).execute().use { response -> + if (!response.isSuccessful) { + log.info("diffSides authoritative file=${file.file} http=${response.code} messageID=${messageId ?: "none"}") + return@runCatching null + } + val arr = Json.parseToJsonElement(response.body?.string().orEmpty()).jsonArray + val item = arr.firstOrNull { it.jsonObject["file"]?.jsonPrimitive?.contentOrNull == file.file }?.jsonObject + val before = item?.get("before")?.jsonPrimitive?.contentOrNull + val after = item?.get("after")?.jsonPrimitive?.contentOrNull + log.info("diffSides authoritative file=${file.file} items=${arr.size} matched=${item != null} before=${before?.length ?: 0} after=${after?.length ?: 0}") + if (before != null && after != null) file.copy(before = before, after = after) else null + } + }.onFailure { log.info("diffSides authoritative file=${file.file} error=${it.message}") }.getOrNull() + } + } + override suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto? = ready { chat.attachmentPart(id, directory, messageId, partId, attachmentKey) } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index 93514a7a750..ab67406dbf2 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -691,7 +691,7 @@ class KiloCliDataParserTest { "messageID": "msg_rollback", "partID": "prt_rollback", "snapshot": "snap_rollback", - "diff": "diff --git a/file b/file" + "diff": "diff --git a/src/A.kt b/src/A.kt\n--- a/src/A.kt\n+++ b/src/A.kt\n@@ -1 +1,2 @@\n-old\n+new\n+more\ndiff --git a/src/Old.kt b/src/Old.kt\ndeleted file mode 100644\n--- a/src/Old.kt\n+++ /dev/null\n@@ -1 +0,0 @@\n-gone" } } } @@ -705,6 +705,13 @@ class KiloCliDataParserTest { assertEquals(2, result.session.summary?.files) assertEquals("msg_rollback", result.session.revert?.messageID) assertEquals("prt_rollback", result.session.revert?.partID) + assertEquals(2, result.session.revert?.diffs?.size) + assertEquals("src/A.kt", result.session.revert?.diffs?.get(0)?.file) + assertEquals(2, result.session.revert?.diffs?.get(0)?.additions) + assertEquals(1, result.session.revert?.diffs?.get(0)?.deletions) + assertEquals("modified", result.session.revert?.diffs?.get(0)?.status) + assertEquals("src/Old.kt", result.session.revert?.diffs?.get(1)?.file) + assertEquals("deleted", result.session.revert?.diffs?.get(1)?.status) } @Test diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt index 8c489813a65..477f001849f 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt @@ -70,7 +70,7 @@ class KiloCliDownloaderTest { assertEquals(cli.absolutePath, cached.absolutePath) assertEquals(1, server.requestCount) assertTrue(cachedProgress.isEmpty()) - assertContains(log.messages, "INFO: Using cached Kilo CLI 1.2.3 for ${KiloCliPlatform.current()} at ${cli.absolutePath}") + assertContains(log.messages, "INFO: Kilo CLI 1.2.3 (${KiloCliPlatform.current()}) already cached at ${cli.absolutePath}; skipping download and extraction") File(cli.parentFile.parentFile, ".complete").writeText("ok\n") server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes))) diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/diff/DiffFullReconstructTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/diff/DiffFullReconstructTest.kt new file mode 100644 index 00000000000..993ec8928e4 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/diff/DiffFullReconstructTest.kt @@ -0,0 +1,55 @@ +package ai.kilocode.backend.diff + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class DiffFullReconstructTest { + + @Test + fun `reconstructs before across multiple hunks keeping unchanged regions`() { + // Ten-line file; two separated single-line edits. The patch only carries 3-context hunks, so + // the unchanged gap between them must come from the working-tree content. + val after = (1..10).joinToString("\n") { if (it == 2) "TWO" else if (it == 9) "NINE" else "l$it" } + "\n" + val patch = buildString { + append("--- a/f\n+++ b/f\n") + append("@@ -1,4 +1,4 @@\n l1\n-l2\n+TWO\n l3\n l4\n") + append("@@ -7,4 +7,4 @@\n l7\n l8\n-l9\n+NINE\n l10\n") + } + + val before = DiffFullReconstruct.before(after, patch) + + assertEquals((1..10).joinToString("\n") { "l$it" } + "\n", before) + } + + @Test + fun `reconstructs before for a deletion-only hunk`() { + val after = "a\nc\n" + val patch = "--- a/f\n+++ b/f\n@@ -1,3 +1,2 @@\n a\n-b\n c\n" + + assertEquals("a\nb\nc\n", DiffFullReconstruct.before(after, patch)) + } + + @Test + fun `preserves files without a trailing newline`() { + val after = "a\nB" + val patch = "--- a/f\n+++ b/f\n@@ -1,2 +1,2 @@\n a\n-b\n+B\n\\ No newline at end of file\n" + + assertEquals("a\nb", DiffFullReconstruct.before(after, patch)) + } + + @Test + fun `returns null when context does not match the working tree`() { + val patch = "--- a/f\n+++ b/f\n@@ -1,2 +1,2 @@\n a\n-b\n+B\n" + + assertNull(DiffFullReconstruct.before("x\nB\n", patch)) + } + + @Test + fun `returns null for added deleted binary and blank patches`() { + assertNull(DiffFullReconstruct.before("hello\n", "--- /dev/null\n+++ b/f\n@@ -0,0 +1 @@\n+hello\n")) + assertNull(DiffFullReconstruct.before("", "--- a/f\n+++ /dev/null\n@@ -1 +0,0 @@\n-gone\n")) + assertNull(DiffFullReconstruct.before("x", "Binary files a/f and b/f differ\n")) + assertNull(DiffFullReconstruct.before("x", "")) + } +} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImplTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImplTest.kt index c5f1f98158a..a883eebc5b7 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImplTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImplTest.kt @@ -1,19 +1,46 @@ package ai.kilocode.backend.rpc +import ai.kilocode.backend.app.KiloAppState +import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.backend.testing.FakeCliServer +import ai.kilocode.backend.testing.MockCliServer import ai.kilocode.backend.testing.TestLog import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.DiffFileDto +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import java.nio.file.Files +import kotlin.io.path.createTempDirectory +import kotlin.test.AfterTest import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue class KiloSessionRpcApiImplTest { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val apps = mutableListOf() + + @AfterTest + fun tearDown() = runBlocking { + apps.forEach { it.dispose() } + apps.clear() + scope.cancel() + } + @Test fun `events logs normal completion`() = runBlocking(Dispatchers.Default) { @@ -49,4 +76,119 @@ class KiloSessionRpcApiImplTest { assertTrue(log.messages.any { it.contains("route=rpc-events stop=true failed message=stream failed") }, log.messages.joinToString("\n")) } + + @Test + fun `diffSides rebuilds full before by reverse-applying the patch to the working tree`() = runBlocking(Dispatchers.Default) { + val dir = createTempDirectory("kilo-diff") + try { + val file = "src/Main.kt" + Files.createDirectories(dir.resolve("src")) + Files.writeString(dir.resolve(file), "a\nB2\nc\n") + val patch = "--- a/$file\n+++ b/$file\n@@ -1,3 +1,3 @@\n a\n-b2\n+B2\n c\n" + + val diff = KiloSessionRpcApiImpl().diffSides(null, dir.toString(), DiffFileDto(file, 1, 1, patch, "modified"), null) + + assertNotNull(diff) + assertEquals("a\nb2\nc\n", diff.before) + assertEquals("a\nB2\nc\n", diff.after) + } finally { + delete(dir) + } + } + + @Test + fun `diffSides returns null when the working tree drifted from the patch`() = runBlocking(Dispatchers.Default) { + val dir = createTempDirectory("kilo-diff") + try { + val file = "src/Main.kt" + Files.createDirectories(dir.resolve("src")) + Files.writeString(dir.resolve(file), "a\nUNRELATED\nc\n") + val patch = "--- a/$file\n+++ b/$file\n@@ -1,3 +1,3 @@\n a\n-b2\n+B2\n c\n" + + assertNull(KiloSessionRpcApiImpl().diffSides(null, dir.toString(), DiffFileDto(file, 1, 1, patch, "modified"), null)) + } finally { + delete(dir) + } + } + + @Test + fun `diffSides returns null for added files and missing patches`() = runBlocking(Dispatchers.Default) { + val dir = createTempDirectory("kilo-diff") + try { + Files.writeString(dir.resolve("new.kt"), "hello\n") + val added = "--- /dev/null\n+++ b/new.kt\n@@ -0,0 +1 @@\n+hello\n" + + assertNull(KiloSessionRpcApiImpl().diffSides(null, dir.toString(), DiffFileDto("new.kt", 1, 0, added, "added"), null)) + assertNull(KiloSessionRpcApiImpl().diffSides(null, dir.toString(), DiffFileDto("new.kt", 1, 0, null, "added"), null)) + } finally { + delete(dir) + } + } + + @Test + fun `diffSides prefers authoritative CLI content over local reconstruction`() = runBlocking(Dispatchers.Default) { + val mock = MockCliServer() + try { + mock.sessionDiff = + """[{"file":"src/Main.kt","additions":1,"deletions":1,"status":"modified","patch":"p","before":"OLD\n","after":"NEW\n"}]""" + val api = KiloSessionRpcApiImpl(app(mock)) + + // No working-tree file exists here, so a non-null result can only come from the CLI path. + val diff = api.diffSides("ses_test", "/does-not-exist", DiffFileDto("src/Main.kt", 1, 1, "p", "modified"), "msg1") + + assertNotNull(diff) + assertEquals("OLD\n", diff.before) + assertEquals("NEW\n", diff.after) + val path = assertNotNull(mock.lastSessionDiffPath) + assertTrue(path.contains("full=true"), path) + assertTrue(path.contains("file=src%2FMain.kt"), path) + assertTrue(path.contains("messageID=msg1"), path) + } finally { + mock.close() + } + } + + @Test + fun `diffSides falls back to local reconstruction when the CLI omits full content`() = runBlocking(Dispatchers.Default) { + val mock = MockCliServer() + val dir = createTempDirectory("kilo-diff") + try { + // A CLI without full/file support returns the file entry but no before/after. + mock.sessionDiff = """[{"file":"src/Main.kt","additions":1,"deletions":1,"status":"modified","patch":"p"}]""" + Files.createDirectories(dir.resolve("src")) + Files.writeString(dir.resolve("src/Main.kt"), "a\nB2\nc\n") + val patch = "--- a/src/Main.kt\n+++ b/src/Main.kt\n@@ -1,3 +1,3 @@\n a\n-b2\n+B2\n c\n" + val api = KiloSessionRpcApiImpl(app(mock)) + + val diff = api.diffSides("ses_test", dir.toString(), DiffFileDto("src/Main.kt", 1, 1, patch, "modified"), "msg1") + + assertNotNull(diff) + assertEquals("a\nb2\nc\n", diff.before) + assertEquals("a\nB2\nc\n", diff.after) + } finally { + delete(dir) + mock.close() + } + } + + private suspend fun app(mock: MockCliServer): KiloBackendAppService { + val app = KiloBackendAppService.create(scope, FakeCliServer(mock), TestLog()).also { apps.add(it) } + app.connect() + val state = assertNotNull( + withTimeoutOrNull(35_000) { + app.appState.first { + it is KiloAppState.Ready || it is KiloAppState.Error || it is KiloAppState.MigrationRequired + } + }, + "App startup timed out in ${app.appState.value}", + ) + assertIs(state, "App startup failed") + return app + } + + private fun delete(dir: java.nio.file.Path) { + Files.walk(dir).use { paths -> + paths.sorted(Comparator.reverseOrder()).forEach { Files.deleteIfExists(it) } + } + } } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt index d016709a685..910df5eefa4 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt @@ -101,6 +101,8 @@ class MockCliServer : AutoCloseable { @Volatile var recentSessions = "[]" @Volatile var sessionCreate = """{"id":"ses_test","slug":"test","projectID":"prj_test","directory":"/test","title":"New Session","version":"1.0.0","time":{"created":1000,"updated":1000}}""" @Volatile var sessionStatuses = "{}" + @Volatile var sessionDiff = "[]" + @Volatile var lastSessionDiffPath: String? = null @Volatile var summarizeResponse = "true" @Volatile var sessionsStatus = 200 @Volatile var recentSessionsStatus = 200 @@ -431,6 +433,10 @@ class MockCliServer : AutoCloseable { lastSessionRenameMethod = method respond(output, sessionRenameStatus, sessionRenameResponse) } + bare.matches(Regex("/session/ses_[^/]+/diff")) && method == "GET" -> { + lastSessionDiffPath = path + respond(output, 200, sessionDiff) + } bare.matches(Regex("/session/ses_[^/]+/summarize")) && method == "POST" -> { lastSummarizePath = path lastSummarizeBody = body diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt index 5482dfbd492..1c1181c34d1 100644 --- a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt +++ b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt @@ -49,6 +49,9 @@ abstract class GenerateOpenApiSpecTask : DefaultTask() { @get:Internal abstract val cacheDir: DirectoryProperty + @get:Internal + abstract val bunPath: Property + @get:OutputFile abstract val spec: RegularFileProperty @@ -76,7 +79,7 @@ abstract class GenerateOpenApiSpecTask : DefaultTask() { val err = ByteArrayOutputStream() val result = exec.exec { workingDir = root - commandLine("bun", "run", "--conditions=browser", "./src/index.ts", "generate") + commandLine(bunPath.get(), "run", "--conditions=browser", "./src/index.ts", "generate") standardOutput = out errorOutput = err isIgnoreExitValue = true diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/CoreInfoAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/CoreInfoAction.kt index 1bad925ec0c..6ed21bcb695 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/CoreInfoAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/CoreInfoAction.kt @@ -15,8 +15,10 @@ class CoreInfoAction : AnAction(), DumbAware { val app = service() val info = app.core if (info == null) app.fetchCoreInfoAsync() + app.fetchBundledAsync() + val key = if (app.bundled == true) "action.Kilo.CoreInfo.bundled" else "action.Kilo.CoreInfo.text" e.presentation.text = info?.let { - KiloBundle.message("action.Kilo.CoreInfo.text", it.version, it.platform) + KiloBundle.message(key, it.version, it.platform) } ?: KiloBundle.message("action.Kilo.CoreInfo.loading") e.presentation.description = KiloBundle.message("action.Kilo.CoreInfo.description") e.presentation.isEnabled = false diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt index 23443265e1b..1751383899a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt @@ -58,6 +58,18 @@ class KiloAppService internal constructor( val version: String? get() = info?.version + /** + * Whether the running Core is bundled in the plugin (true) or downloaded (false). + * Null until fetched. This is a static property of the plugin build, so it is + * fetched once via RPC independently of the download-progress state. + */ + @Volatile + private var bundledFlag: Boolean? = null + private val bundledLock = Any() + private var bundledJob: Job? = null + + val bundled: Boolean? get() = bundledFlag + /** * App-lifetime scope for fire-and-forget work that must outlive transient UIs such as the * settings dialog (whose own scope is cancelled the moment it closes on OK). @@ -152,6 +164,7 @@ class KiloAppService internal constructor( platform = call { cliPlatform() }, ) info = next + bundledFlag = call { cliBundled() } next } catch (e: Exception) { LOG.warn("core info failed", e) @@ -200,6 +213,26 @@ class KiloAppService internal constructor( fetchCoreInfoAsync { done(it?.version) } } + /** Fetch whether the running Core is bundled and cache it. Deduped and fetched once. */ + fun fetchBundledAsync() { + if (bundledFlag != null) return + synchronized(bundledLock) { + if (bundledFlag != null || bundledJob != null) return + bundledJob = cs.launch { + val value = try { + call { cliBundled() } + } catch (e: Exception) { + LOG.warn("core bundled check failed", e) + null + } + synchronized(bundledLock) { + if (value != null) bundledFlag = value + bundledJob = null + } + } + } + } + fun refreshModelFavoritesAsync() { cs.launch { try { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt index 5a36f3f1905..35df48eaa59 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt @@ -219,6 +219,9 @@ class KiloSessionService internal constructor( suspend fun diff(id: String, dir: String): List = call { diff(id, dir) } + suspend fun diffSides(sessionId: String?, dir: String, file: DiffFileDto, messageId: String?): DiffFileDto? = + call { diffSides(sessionId, dir, file, messageId) } + suspend fun attachmentPart(id: String, dir: String, message: String, part: String, key: String?): PartDto? = call { attachmentPart(id, dir, message, part, key) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffBlocks.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffBlocks.kt index 538a1686ace..6b8b8d0f0f4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffBlocks.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffBlocks.kt @@ -3,12 +3,15 @@ package ai.kilocode.client.diff import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.rpc.dto.DiffFileDto import com.intellij.diff.DiffContentFactory +import com.intellij.diff.contents.DocumentContent import com.intellij.diff.requests.DiffRequest import com.intellij.diff.requests.SimpleDiffRequest import com.intellij.diff.util.DiffUserDataKeys +import com.intellij.diff.util.DiffUserDataKeysEx import com.intellij.openapi.fileTypes.FileTypeManager import com.intellij.openapi.project.Project import com.intellij.openapi.vcs.FileStatus +import java.util.function.IntUnaryOperator internal fun diffRequest( project: Project, @@ -22,15 +25,18 @@ internal fun diffRequest( val status = fileStatus(dto) val patch = dto.patch?.takeIf { it.isNotBlank() } val fallback = patch ?: KiloBundle.message("diff.editor.patch.unavailable") + val full = dto.before != null && dto.after != null val left = when { + full -> factory.create(project, dto.before.orEmpty(), type) DiffPatchReconstruct.added(dto.patch) -> factory.createEmpty() - sides.renderable -> factory.create(project, sides.before, type) + sides.renderable -> factory.create(project, sides.before, type).numbered(sides.leftLines) status == FileStatus.DELETED -> factory.create(project, fallback, type) else -> factory.createEmpty() } val right = when { + full -> factory.create(project, dto.after.orEmpty(), type) DiffPatchReconstruct.deleted(dto.patch) -> factory.createEmpty() - sides.renderable -> factory.create(project, sides.after, type) + sides.renderable -> factory.create(project, sides.after, type).numbered(sides.rightLines) status == FileStatus.DELETED -> factory.createEmpty() else -> factory.create(project, fallback, type) } @@ -43,3 +49,10 @@ internal fun diffTitle(file: String, branch: String?): String { val name = branch.takeIf { !it.isNullOrBlank() } ?: return file return KiloBundle.message("diff.editor.file.title", file, name) } + +// The hunk-fallback content is a concatenation of hunk bodies, so its own document lines restart at 1. +// Remap each document line to its real source-file line (0-based; the platform adds 1) so the gutter +// matches the file instead of showing 1..N. Lines with no mapping return -1 and the platform hides them. +private fun DocumentContent.numbered(lines: List): DocumentContent = apply { + if (lines.isNotEmpty()) putUserData(DiffUserDataKeysEx.LINE_NUMBER_CONVERTOR, IntUnaryOperator { lines.getOrElse(it) { -1 } }) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffPatchReconstruct.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffPatchReconstruct.kt index 654ef86da03..b29812ca12d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffPatchReconstruct.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/DiffPatchReconstruct.kt @@ -6,21 +6,29 @@ internal data class DiffSides( val before: String, val after: String, val renderable: Boolean, + // 0-based source-file line for each reconstructed document line, so the diff editor gutter shows + // real file positions (and jumps across the elided inter-hunk gaps) instead of restarting at 1. + val leftLines: List = emptyList(), + val rightLines: List = emptyList(), ) internal object DiffPatchReconstruct { - private val HUNK = Regex("^@@ -\\d+(?:,(\\d+))? \\+\\d+(?:,(\\d+))? @@") + private val HUNK = Regex("^@@ -(\\d+)(?:,(\\d+))? \\+(\\d+)(?:,(\\d+))? @@") fun sides(dto: DiffFileDto): DiffSides { val patch = dto.patch if (patch.isNullOrBlank() || binary(patch)) return DiffSides("", "", false) val before = StringBuilder() val after = StringBuilder() + val leftLines = mutableListOf() + val rightLines = mutableListOf() var hunks = 0 var oldLen = 0 var newLen = 0 var oldSeen = 0 var newSeen = 0 + var oldLine = 1 + var newLine = 1 // Drop the trailing empty element that split('\n') yields for a newline-terminated patch (the // usual case for git output). Counting it as a body line would inflate oldSeen/newSeen past the // header lengths and wrongly reject every full-context diff. Mirrors DiffLineNumbers' edge trim; @@ -29,8 +37,10 @@ internal object DiffPatchReconstruct { if (line.startsWith("@@")) { hunks += 1 HUNK.find(line)?.let { match -> - oldLen += match.groupValues[1].ifEmpty { "1" }.toInt() - newLen += match.groupValues[2].ifEmpty { "1" }.toInt() + oldLine = match.groupValues[1].toInt() + newLine = match.groupValues[3].toInt() + oldLen += match.groupValues[2].ifEmpty { "1" }.toInt() + newLen += match.groupValues[4].ifEmpty { "1" }.toInt() } continue } @@ -38,29 +48,40 @@ internal object DiffPatchReconstruct { if (line.startsWith("\\")) continue when (line.firstOrNull()) { ' ' -> { - before.appendLine(line.substring(1)) - after.appendLine(line.substring(1)) + before.appendLine(line.substring(1)); leftLines.add(oldLine++ - 1) + after.appendLine(line.substring(1)); rightLines.add(newLine++ - 1) oldSeen += 1 newSeen += 1 } - '-' -> { before.appendLine(line.substring(1)); oldSeen += 1 } - '+' -> { after.appendLine(line.substring(1)); newSeen += 1 } + '-' -> { before.appendLine(line.substring(1)); leftLines.add(oldLine++ - 1); oldSeen += 1 } + '+' -> { after.appendLine(line.substring(1)); rightLines.add(newLine++ - 1); newSeen += 1 } else -> { - before.appendLine("") - after.appendLine("") + before.appendLine(""); leftLines.add(oldLine++ - 1) + after.appendLine(""); rightLines.add(newLine++ - 1) oldSeen += 1 newSeen += 1 } } } - // Both producers (CLI snapshot and branchDiff) emit a single full-context hunk. A patch with - // several hunks, or one whose header lengths don't match the reconstructed body, has elided - // context: reconstructing would place every line at the wrong number, so fall back to the - // raw-patch view (renderable = false) instead of showing a misaligned side-by-side diff. - if (hunks != 1 || oldSeen != oldLen || newSeen != newLen) return DiffSides("", "", false) - val left = if (added(patch)) "" else before.toString().removeSuffix("\n") - val right = if (deleted(patch)) "" else after.toString().removeSuffix("\n") - return DiffSides(left, right, true) + // A patch may carry several hunks (limited-context git output) or a single full-context hunk. + // We concatenate every hunk body into contiguous before/after text: unchanged context lines + // anchor each region so the resulting side-by-side still colors adds/removes correctly. The + // elided gaps between hunks collapse (line numbers restart at 1), which is acceptable for a + // "what changed" view and far better than the all-green raw-patch fallback. We still bail when + // there is no hunk, or when the header lengths don't match the reconstructed body (truncated + // context), because that would place lines against the wrong side. + if (hunks < 1 || oldSeen != oldLen || newSeen != newLen) return DiffSides("", "", false) + val added = added(patch) + val deleted = deleted(patch) + val left = if (added) "" else before.toString().removeSuffix("\n") + val right = if (deleted) "" else after.toString().removeSuffix("\n") + return DiffSides( + left, + right, + true, + if (added) emptyList() else leftLines, + if (deleted) emptyList() else rightLines, + ) } fun added(patch: String?): Boolean = patch?.lineSequence()?.any { it == "--- /dev/null" } == true diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt index f8d61745f8e..e257e62d971 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/diff/KiloDiffEditorKind.kt @@ -150,17 +150,58 @@ internal class KiloDiffEditorService( val dir = params["directory"].takeIfPresent() ?: return DiffEditorData.Empty val workspace = service() val store = project.service() + val session = project.service() val files = when (params["source"]) { // branch is authoritative here (no store seeding): recompute on every load/refresh so a // re-open or Refresh always reflects the current worktree instead of a stale click seed. "branch" -> workspace.branchDiff(dir) "inline" -> store.get(params["token"].orEmpty()).orEmpty() - else -> project.service().diff(params["sessionId"].orEmpty(), dir) + else -> session.diff(params["sessionId"].orEmpty(), dir) } if (files.isEmpty()) return DiffEditorData.Empty val branch = params["branch"].takeIfPresent() ?: if (params["source"] == "branch") workspace.branchName(dir) else null - return DiffEditorData.Files(files, branch) + return DiffEditorData.Files(detail(params, dir, files, session), branch) + } + + // Enrich modified files with full before/after content so the editor shows whole-file diffs. + // Added/deleted/binary files already render fully from their patch, so they skip the round-trip; + // a null result (working tree drifted from the patch) falls back to the hunk view. + private suspend fun detail( + params: Map, + dir: String, + files: List, + session: KiloSessionService, + ): List { + // Revert diffs already carry range-scoped patches from the CLI's `revert.diff`. Whole-file + // enrichment has no per-message scope for a revert here, so the authoritative endpoint would + // return the whole-session before/after and splice in changes from kept turns. Render the + // scoped hunk patches directly instead. + if (params["token"].takeIfPresent()?.startsWith("revert:") == true) return files + val sessionId = params["sessionId"].takeIfPresent() + val message = message(params) + LOG.info("diff editor detail source=${params["source"]} files=${files.size} session=${!sessionId.isNullOrBlank()} message=${!message.isNullOrBlank()}") + return files.map { file -> + val patch = file.patch + if (patch.isNullOrBlank() || DiffPatchReconstruct.added(patch) || DiffPatchReconstruct.deleted(patch)) { + LOG.info("diff editor detail skip file=${file.file} patch=${!patch.isNullOrBlank()} status=${file.status}") + file + } else { + val detail = runCatching { session.diffSides(sessionId, dir, file, message) } + .onFailure { LOG.warn("diff editor detail failed file=${file.file}", it) } + .getOrNull() + LOG.info("diff editor detail file=${file.file} full=${detail?.before != null && detail?.after != null} before=${detail?.before?.length ?: 0} after=${detail?.after?.length ?: 0}") + detail ?: file + } + } + } + + // Turn diffs carry "turn::" and single-edit diffs carry "tool::"; + // the third segment is the message the CLI scopes the authoritative snapshot diff to. Other sources + // (session, branch) have no per-message scope. + private fun message(params: Map): String? { + val parts = params["token"].takeIfPresent()?.split(":", limit = 3) ?: return null + return if (parts.size == 3 && (parts[0] == "turn" || parts[0] == "tool")) parts[2].takeIfPresent() else null } private companion object { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 9b635e1bf4b..a7b30a796c8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -822,15 +822,17 @@ class SessionUi( } private fun openInlineDiff(files: List, title: String, key: String) { + val dir = controller.sessionDirectory cs.launch { - val branch = workspaces.branchName(workspace.directory) + val branch = workspaces.branchName(dir) val label = branch?.let { KiloBundle.message("diff.editor.inline.title.named", title, it) } ?: title + LOG.info("open inline diff session=${controller.id ?: "pending"} dir=${ChatLogSummary.dir(dir)} files=${files.size}") withContext(Dispatchers.Main) { ensureDiffEditorKind() project.service().put(key, files) project.service().open( KiloDiffEditorKind.ID, - diffParams("inline", workspace.directory, controller.id, label, token = key), + diffParams("inline", dir, controller.id, label, token = key), ) Telemetry.send("Diff Editor Opened", mapOf("source" to "inline")) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index 1370c72aa16..a0f0013b98a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -189,6 +189,7 @@ class SessionController( val autoApprove: Boolean get() = KiloPluginSettings.getAutoApprove() internal val blank: Boolean get() = ref == null && model.isEmpty() && !model.showSession internal val id: String? get() = sid + internal val sessionDirectory: String get() = model.session?.directory ?: (ref as? SessionRef.Local)?.session?.directory ?: directory internal val refKey: String? get() = ref?.key internal val refType: SessionRef.Type? get() = ref?.type @@ -1011,6 +1012,7 @@ class SessionController( } } recoverPending(id) + seedRevertDiff(id) runEdt { if (disposed) return@runEdt if (sid != id) return@runEdt @@ -1060,6 +1062,7 @@ class SessionController( } } recoverPending(session.id) + seedRevertDiff(session.id) runEdt { if (disposed) return@runEdt subscribeEvents() @@ -1091,6 +1094,25 @@ class SessionController( } } + /** + * Seed [SessionModel.diff] when opening a reverted session. The rolled-back file list in + * [ai.kilocode.client.session.ui.RevertBanner] falls back to `model.diff` when the CLI does not + * attach a diff to the revert marker. On a live revert a `session.diff` event seeds that; on + * reload nothing does, so fetch the persisted session diff once here. Skipped for sessions + * without a revert or once a diff is already present (e.g. a concurrent `session.diff` event). + */ + private suspend fun seedRevertDiff(id: String) { + var fetch = false + runEdt { fetch = !disposed && sid == id && model.revert() != null && model.diff.isEmpty() } + if (!fetch) return + val diffs = runCatching { sessions.diff(id, directory) }.getOrNull()?.takeIf { it.isNotEmpty() } ?: return + runEdt { + if (disposed || sid != id) return@runEdt + if (model.revert() == null || model.diff.isNotEmpty()) return@runEdt + updateModel { model.setDiff(diffs) } + } + } + private fun startSessionLoading(token: SessionLoadState.Loading) { assertEdt() setSessionLoadState(token) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt index 7eeaa951500..beee188bd70 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt @@ -77,6 +77,8 @@ class FileAttachment(id: String) : Content(id) { /** Tool invocation with lifecycle state. */ class Tool(id: String, val name: String, var kind: ToolKind) : Content(id) { + /** Owning message id. The CLI scopes authoritative snapshot diffs by message, not part, id. */ + var messageID: String? = null var state: ToolExecState = ToolExecState.PENDING var callId: String? = null var title: String? = null diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt index 14f7b8c0271..1d918c70bcd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt @@ -554,6 +554,7 @@ class SessionModel { source = dto.source } "tool" -> Tool(dto.id, dto.tool ?: "unknown", toolKind(dto.tool)).apply { + messageID = dto.messageID state = parseToolState(dto.state) callId = dto.callID title = dto.title diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/RevertBanner.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/RevertBanner.kt index 7eb0106b7c5..e1273bdfc11 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/RevertBanner.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/RevertBanner.kt @@ -1,22 +1,36 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.SessionDiffOpener import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget +import ai.kilocode.client.session.views.SessionViewIcons import ai.kilocode.client.session.views.base.BaseQuestionView +import ai.kilocode.client.session.views.base.PartHeader import ai.kilocode.client.ui.DiffStatBadge +import ai.kilocode.client.ui.ToolbarButtonAction import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.toolbarButton +import ai.kilocode.rpc.dto.DiffFileDto import com.intellij.icons.AllIcons import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBScrollPane import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBFont +import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import com.intellij.util.ui.components.BorderLayoutPanel import java.awt.BorderLayout +import java.awt.Component +import java.awt.Container +import java.awt.Dimension +import java.nio.file.Path +import javax.swing.JComponent import javax.swing.JPanel +import javax.swing.ScrollPaneConstants class RevertBanner( private val model: SessionModel, @@ -24,15 +38,54 @@ class RevertBanner( private val redoAllAction: () -> Unit, private val cancelAction: () -> Unit, focus: (() -> Unit)? = null, + private var openDiff: SessionDiffOpener = { _, _, _ -> }, + private var sessionId: String? = null, ) : BorderLayoutPanel(), SessionView, SessionEditorStyleTarget { override val sessionViewKind = SessionView.Kind.Default + companion object { + /** Cap the reverted-file list to this many rows before scrolling. */ + const val MAX_FILE_ROWS = 10 + } + private val card = BaseQuestionView(focus = focus) + private val title = JBLabel() + + private val diff = toolbarButton( + ToolbarButtonAction(SessionViewIcons.openDiff, KiloBundle.message("session.part.tool.openDiff"), ::openDiffViewer), + ).apply { + isEnabled = false + isVisible = false + } + + private val header = PartHeader().apply { + leading(JBLabel(AllIcons.Actions.Back)) + left(title) + left(PartHeader.centered(diff)) + } + private val body = Stack.vertical(UiStyle.Gap.lg()) private val files = Stack.vertical(UiStyle.Gap.xs()) + private val scroll = object : JBScrollPane( + files, + ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, + ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, + ) { + override fun getPreferredSize(): Dimension { + val size = super.getPreferredSize() + val cap = rowCap() + return Dimension(size.width, if (cap > 0) minOf(size.height, cap) else size.height) + } + }.apply { + border = JBUI.Borders.empty() + viewportBorder = JBUI.Borders.empty() + isOpaque = false + viewport.isOpaque = false + } + private val rows = LinkedHashMap() private var progress: RevertProgress? = null @@ -46,8 +99,8 @@ class RevertBanner( init { isOpaque = false - card.setHeaderIcon(AllIcons.Actions.Back, KiloBundle.message("revert.message.rollback")) - body.next(files).next(hint).next(notice) + card.setTopPanel(header) + body.next(scroll).next(hint).next(notice) card.setContent(body) card.setActions(listOf( BaseQuestionView.Action("redo", KiloBundle.message("revert.banner.redo"), primary = false) { redoAction() }, @@ -58,22 +111,33 @@ class RevertBanner( update() } + @RequiresEdt + fun setDiffOpener(openDiff: SessionDiffOpener, sessionId: String?) { + this.openDiff = openDiff + this.sessionId = sessionId + } + @RequiresEdt fun update() { val revert = model.revert() isVisible = revert != null if (revert == null) return val total = model.revertedCount() - card.setHeader(KiloBundle.message(if (total == 1) "revert.banner.count.one" else "revert.banner.count.other", total)) + title.text = KiloBundle.message(if (total == 1) "revert.banner.count.one" else "revert.banner.count.other", total) card.setActionVisible("all", total > 1) notice.isVisible = revert.snapshot == null - val keep = model.diff.mapTo(LinkedHashSet()) { it.file } + val diffs = resolveDiffs(revert) + val names = disambiguate(diffs.map { it.file }) + diff.isVisible = diffs.isNotEmpty() + diff.isEnabled = diffs.isNotEmpty() + val keep = diffs.mapTo(LinkedHashSet()) { it.file } rows.entries.removeIf { it.key !in keep } - val order = model.diff.map { item -> + scroll.isVisible = diffs.isNotEmpty() + val order = diffs.map { item -> val row = rows.getOrPut(item.file) { - Row(item.file) + Row(item) } - row.update(item.file, item.additions, item.deletions) + row.update(item, names[item.file] ?: item.file, absolute(item.file)) row.panel } if (files.components.toList() != order) { @@ -105,30 +169,92 @@ class RevertBanner( override fun applyStyle(style: SessionEditorStyle) { card.applyStyle(style) + title.font = style.headerFont + title.foreground = UiStyle.Colors.fg() progress?.applyStyle(style) hint.foreground = UIUtil.getLabelForeground() notice.foreground = UIUtil.getContextHelpForeground() rows.values.forEach { it.applyStyle() } } - private class Row(file: String) { - private val label = JBLabel(file) - private val badge = DiffStatBadge(0, 0) + /** + * The rolled-back diff to render. Prefer the snapshot diff the CLI attaches to the revert; fall + * back to the session's current file diff when the pinned CLI doesn't provide it. Empty when no + * snapshot exists (files were not restored, so there is nothing to diff). + */ + private fun resolveDiffs(revert: ai.kilocode.rpc.dto.SessionRevertDto): List = + if (revert.snapshot == null) emptyList() else revert.diffs.ifEmpty { model.diff } + + private fun openDiffViewer() { + val revert = model.revert() ?: return + val diffs = resolveDiffs(revert) + if (diffs.isEmpty()) return + openDiff(diffs, KiloBundle.message("revert.banner.openDiff.title"), "revert:${sessionId ?: "pending"}:${revert.messageID}") + } + + private fun absolute(file: String): String { + val path = runCatching { Path.of(file) }.getOrNull() ?: return file + if (path.isAbsolute) return path.normalize().toString() + val root = model.session?.directory + ?.takeIf { it.isNotBlank() } + ?.let { runCatching { Path.of(it) }.getOrNull() } + return root?.resolve(path)?.normalize()?.toString() ?: file + } + + /** Height that fits at most [MAX_FILE_ROWS] rows, or 0 when the list is short enough to show in full. */ + private fun rowCap(): Int { + val comps = files.components + if (comps.size <= MAX_FILE_ROWS) return 0 + val gap = UiStyle.Gap.xs() + return (0 until MAX_FILE_ROWS).sumOf { comps[it].preferredSize.height } + gap * (MAX_FILE_ROWS - 1) + } + + private class Row(item: DiffFileDto) { + private val label = JBLabel(item.file).apply { + toolTipText = item.file + } + private val badge = DiffStatBadge(item.additions, item.deletions) val panel: JPanel = Stack.horizontal(UiStyle.Gap.sm()) .next(label) .next(badge) init { applyStyle() + tip(item.file) } - fun update(file: String, additions: Int, deletions: Int) { - if (label.text != file) label.text = file - badge.update(additions, deletions) + fun update(item: DiffFileDto, text: String, tip: String) { + if (label.text != text) label.text = text + if (panel.toolTipText != tip) tip(tip) + badge.update(item.additions, item.deletions) } fun applyStyle() { label.foreground = UIUtil.getLabelForeground() } + + private fun tip(text: String) { + tip(panel, text) + } + + private fun tip(node: Component, text: String) { + if (node is JComponent && node.toolTipText != text) node.toolTipText = text + if (node is Container) node.components.forEach { tip(it, text) } + } } } + +internal fun disambiguate(paths: List): Map { + val parts = paths.associateWith { split(it) } + return paths.groupBy { parts[it]?.lastOrNull().orEmpty() }.values.flatMap { group -> + if (group.size == 1) return@flatMap listOf(group.first() to (parts[group.first()]?.lastOrNull() ?: group.first())) + val depth = (1..(group.maxOf { parts[it]?.size ?: 1 })).firstOrNull { count -> + group.map { suffix(parts[it].orEmpty(), count) }.toSet().size == group.size + } ?: group.maxOf { parts[it]?.size ?: 1 } + group.map { it to suffix(parts[it].orEmpty(), depth) } + }.toMap() +} + +private fun split(path: String): List = path.split('/', '\\').filter { it.isNotEmpty() } + +private fun suffix(parts: List, depth: Int): String = parts.takeLast(depth).joinToString("/") diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionLayout.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionLayout.kt index 94d57811264..c4b3d8e6f41 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionLayout.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionLayout.kt @@ -152,6 +152,33 @@ open class SessionLayoutPanel( layout = SessionLayout(gap, pad) } + override fun doLayout() { + super.doLayout() + reconcileValidateRoot() + } + + /** + * Keep the parent [SessionLayout]'s cached height honest across validate-root boundaries. + * + * A validate root, such as a settled [ai.kilocode.client.session.views.TurnView], can be laid out + * independently by `RepaintManager`. Its `isValid` flag can flip back to `true` before the parent + * transcript remeasures it, so [SessionLayout] may otherwise keep stacking it at a stale cached + * height until some unrelated resize changes the cache key. + * + * Once this root has laid out its own content, its rendered height should match its preferred + * height. If it does not, the parent cache is stale: drop this entry and revalidate the parent so + * the outer transcript geometry follows the content. Non-roots are skipped because their + * invalidation already propagates to the parent and keeps `isValid` an honest cache signal. + */ + private fun reconcileValidateRoot() { + if (!isValidateRoot()) return + val host = parent ?: return + val layout = host.layout as? SessionLayout ?: return + if (preferredSize.height == height) return + layout.forget(this) + (host as? javax.swing.JComponent)?.revalidate() + } + override fun getScrollableTracksViewportWidth() = true override fun getScrollableTracksViewportHeight() = false override fun getPreferredScrollableViewportSize(): Dimension = preferredSize diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt index 5f60ef388b3..8e6edd1ed6d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt @@ -214,6 +214,7 @@ class SessionMessageListPanel( fun setDiffOpener(openDiff: SessionDiffOpener, sessionId: String?) { this.openDiff = openDiff this.sessionId = sessionId + banner?.setDiffOpener(openDiff, sessionId) turnViews.values.forEach { it.setDiffOpener(openDiff, sessionId) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt index a2b6331bbba..f7f5f3617d6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt @@ -20,7 +20,10 @@ import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.fileTypes.FileTypeManager import com.intellij.openapi.project.DumbAware import com.intellij.openapi.progress.runBlockingCancellable +import com.intellij.psi.codeStyle.MinusculeMatcher +import com.intellij.psi.codeStyle.NameUtil import com.intellij.util.textCompletion.TextCompletionProvider +import com.intellij.util.text.matching.MatchingMode import java.util.Collections import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -163,12 +166,18 @@ class KiloPromptCompletionProvider( private fun slash(prefix: String, result: CompletionResultSet) { result.restartCompletionOnAnyPrefixChange() val out = result.withPrefixMatcher(PlainPrefixMatcher.ALWAYS_TRUE) + val rank = Ranker(prefix) val names = clientTokens() - val clients = actions.filter { action -> matches(prefix, action.name, action.hints) } - clients.forEach { action -> out.addElement(client(action)) } + val clients = actions.mapNotNull { action -> + rank.score(action.name, action.hints)?.let { Hit(client(action), it) } + } val commands = workspace.state.value.commands - .filter { it.name !in names && matches(prefix, it.name, it.hints) } - commands.forEach { command -> out.addElement(server(command)) } + .mapNotNull { command -> + if (command.name in names) return@mapNotNull null + rank.score(command.name, command.hints)?.let { Hit(server(command), it) } + } + val hits = (clients + commands).sortedByDescending { it.score } + hits.forEach { hit -> out.addElement(weight(hit.item, hit.score)) } if (clients.isNotEmpty() || commands.isNotEmpty()) return result.withPrefixMatcher(PlainPrefixMatcher.ALWAYS_TRUE) .addElement(info(prefix, KiloBundle.message("prompt.completion.noMatches"))) @@ -178,7 +187,8 @@ class KiloPromptCompletionProvider( result.restartCompletionOnAnyPrefixChange() val out = result.withPrefixMatcher(PlainPrefixMatcher.ALWAYS_TRUE) val search = search(prefix) - val known = mentions.filter { action -> matches(prefix, action.name, action.hints) && action.available(search) } + val rank = Ranker(prefix) + val known = mentions.filter { action -> rank.matches(action.name, action.hints) && action.available(search) } known.forEach { action -> out.addElement(prioritize(resource(action))) } if (search.indexing) { val msg = KiloBundle.message("prompt.mention.indexing") @@ -214,8 +224,37 @@ class KiloPromptCompletionProvider( } .withAutoCompletionPolicy(AutoCompletionPolicy.NEVER_AUTOCOMPLETE) - private fun matches(prefix: String, name: String, hints: List): Boolean = - (listOf(name) + hints).any { it.startsWith(prefix, ignoreCase = true) } + private class Ranker(prefix: String) { + private val start = matcher(prefix) + private val middle = if (prefix.any { separator(it) }) null else matcher("*$prefix") + + fun matches(name: String, hints: List): Boolean = score(name, hints) != null + + fun score(name: String, hints: List): Int? = (listOf(name) + hints).maxOfOrNull { value -> + score(value) ?: Int.MIN_VALUE + }?.takeIf { it != Int.MIN_VALUE } + + private fun score(value: String): Int? { + val exact = start.match(value) + if (exact != null) return START + start.matchingDegree(value, true, exact) + val fallback = middle ?: return null + val fuzzy = fallback.match(value) ?: return null + return fallback.matchingDegree(value, false, fuzzy) + } + + private companion object { + const val START = 10_000 + + fun matcher(prefix: String): MinusculeMatcher = NameUtil.buildMatcher(prefix) + .withMatchingMode(MatchingMode.IGNORE_CASE) + .build() + + fun separator(c: Char): Boolean = when (c) { + '_', '-', ':', '+', '.' -> true + else -> c.isWhitespace() + } + } + } private fun commandName(text: String): String? { val raw = text.trimStart() @@ -251,6 +290,11 @@ class KiloPromptCompletionProvider( private fun prioritize(element: LookupElement): LookupElement = PrioritizedLookupElement.withGrouping(PrioritizedLookupElement.withPriority(element, 100.0), 100) + private fun weight(element: LookupElement, score: Int): LookupElement = + PrioritizedLookupElement.withPriority(element, score.toDouble()) + + private data class Hit(val item: LookupElement, val score: Int) + private fun file(file: WorkspaceFileDto): LookupElement = LookupElementBuilder.create(file.path) .withPresentableText("@${file.path}") .withTailText(parent(file.path), true) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt index 5d39e41feb1..113a1c7b924 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt @@ -93,7 +93,9 @@ class BaseQuestionView( } private val headerText: JBTextArea = makeText("", UiStyle.Colors.fg(), bold = true) - private val descriptionText: JBTextArea = makeText("", UiStyle.Colors.weak(), bold = false) + private val descriptionText: JBTextArea = makeText("", UiStyle.Colors.weak(), bold = false).apply { + isVisible = false + } private var top: JComponent? = null private var content: JComponent? = null @@ -131,6 +133,7 @@ class BaseQuestionView( fun setHeader(text: String, description: String? = null) { headerText.text = text setDescription(description) + syncNorth() } /** @@ -141,6 +144,7 @@ class BaseQuestionView( fun setDescription(text: String?) { descriptionText.text = text ?: "" descriptionText.isVisible = !text.isNullOrBlank() + syncNorth() } // ---- public slot API ---- @@ -170,8 +174,7 @@ class BaseQuestionView( if (icon == null && attached) header.remove(this.icon) this.icon.revalidate() this.icon.repaint() - header.revalidate() - header.repaint() + syncNorth() } /** @@ -297,12 +300,14 @@ class BaseQuestionView( private fun syncNorth() { north.removeAll() top?.let { north.next(it) } - north.next(header) + if (hasHeader()) north.next(header) if (content != null) north.fill(gap) north.revalidate() north.repaint() } + private fun hasHeader() = icon.icon != null || headerText.text.isNotBlank() || descriptionText.isVisible + private fun syncFooter() { val layout = footer.layout as BorderLayout val west = layout.getLayoutComponent(BorderLayout.WEST) @@ -331,7 +336,8 @@ class BaseQuestionView( private fun makeText(value: String, color: Color, bold: Boolean): JBTextArea { val area = object : JBTextArea(value) { - override fun getPreferredSize() = withWidth(super.getPreferredSize().height) + override fun getPreferredSize() = + withWidth(super.getPreferredSize().height) override fun getMaximumSize(): Dimension { val size = preferredSize diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt index 77fb712763a..831bfb6ea19 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt @@ -254,7 +254,9 @@ class EditToolView( private fun openDiffViewer() { val files = toDiffFiles(item) if (files.isEmpty()) return - opener(files, diffTitle(item), "tool:${sessionId ?: "pending"}:${item.id}") + // The CLI scopes the authoritative snapshot diff by message id, so carry the owning message id + // (not the tool part id) in the token; otherwise the per-message lookup never matches. + opener(files, diffTitle(item), "tool:${sessionId ?: "pending"}:${item.messageID ?: item.id}") } private fun syncFilesTag(count: Int): Boolean { diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/copy.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/copy.svg index 7ee8dc0f842..f73b6c541c5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/copy.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/copy.svg @@ -1,3 +1,3 @@ - - + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/copy_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/copy_dark.svg index fcbadd38270..b578567ed8a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/copy_dark.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/copy_dark.svg @@ -1,3 +1,3 @@ - - + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain.svg index 304a93ea9c5..72b3551622d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain.svg @@ -1,3 +1,5 @@ - - + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain_dark.svg index 894515662bf..38e3f872ec2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain_dark.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain_dark.svg @@ -1,3 +1,5 @@ - - + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index faf0e7b7828..074f5942042 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -44,6 +44,7 @@ revert.banner.redo=Redo revert.banner.redo.all=Redo All revert.banner.hint=You can redo these changes until you send a new message revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.message.rollback=Rollback to this message session.status.rollingback=Rolling back\u2026 session.status.redoing=Redoing\u2026 @@ -731,6 +732,7 @@ action.Kilo.Reinstall.text=Reinstall action.Kilo.Reinstall.cli.text=Reinstall Core action.Kilo.Reinstall.description=Download a fresh Core binary and restart action.Kilo.CoreInfo.text=Core v{0} • Architecture: {1} +action.Kilo.CoreInfo.bundled=Bundled Core v{0} • Architecture: {1} action.Kilo.CoreInfo.loading=Core details loading... action.Kilo.CoreInfo.description=Kilo Core version and architecture action.Kilo.Session.Open.text=Open diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties index 15944a645df..42610a0aa1d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties @@ -155,6 +155,7 @@ action.Kilo.Restart.description=إنهاء عملية Core وإعادة تشغي action.Kilo.Reinstall.text=إعادة تثبيت Kilo action.Kilo.Reinstall.cli.text=إعادة تثبيت Core action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=‏Core المُضمّن v{0} • البنية: {1} action.Kilo.Session.Open.text=فتح action.Kilo.Session.Open.description=فتح الجلسة المحددة action.Kilo.Session.Rename.text=إعادة تسمية @@ -427,6 +428,7 @@ settings.agentBehavior.skills.sources.editPath.title=تعديل مسار الم settings.agentBehavior.skills.sources.editUrl.title=تعديل URL المهارات session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one=تم التراجع عن رسالة واحدة revert.banner.count.other=تم التراجع عن {0} رسائل revert.banner.redo=إعادة diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties index df5882b5000..91e01f95141 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties @@ -155,6 +155,7 @@ action.Kilo.Restart.description=Ugasite i ponovo pokrenite Core proces action.Kilo.Reinstall.text=Ponovo instalirajte Kilo action.Kilo.Reinstall.cli.text=Ponovo instalirajte Core action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=Ugrađeni Core v{0} • Arhitektura: {1} action.Kilo.Session.Open.text=Otvori action.Kilo.Session.Open.description=Otvorite odabranu sesiju action.Kilo.Session.Rename.text=Preimenuj @@ -427,6 +428,7 @@ settings.agentBehavior.skills.sources.editPath.title=Uredi putanju vještina settings.agentBehavior.skills.sources.editUrl.title=Uredi URL vještina session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one={0} poruka vraćena revert.banner.count.other={0} poruka vraćeno revert.banner.redo=Ponovi diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties index 65d9d0909cd..1383c8f9c44 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties @@ -155,6 +155,7 @@ action.Kilo.Restart.description=Afslut og genstart Core-processen action.Kilo.Reinstall.text=Geninstaller Kilo action.Kilo.Reinstall.cli.text=Geninstaller Core action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=Medfølgende Core v{0} • Arkitektur: {1} action.Kilo.Session.Open.text=Åbn action.Kilo.Session.Open.description=Åbn den valgte session action.Kilo.Session.Rename.text=Omdøb @@ -427,6 +428,7 @@ settings.agentBehavior.skills.sources.editPath.title=Rediger færdighedssti settings.agentBehavior.skills.sources.editUrl.title=Rediger færdigheds-URL session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one={0} besked rullet tilbage revert.banner.count.other={0} beskeder rullet tilbage revert.banner.redo=Gentag diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties index 9b0ca266323..598b2c11b77 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties @@ -155,6 +155,7 @@ action.Kilo.Restart.description=Core-Prozess beenden und neu starten action.Kilo.Reinstall.text=Kilo neu installieren action.Kilo.Reinstall.cli.text=Core neu installieren action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=Gebündelter Core v{0} • Architektur: {1} action.Kilo.Session.Open.text=Öffnen action.Kilo.Session.Open.description=Ausgewählte Sitzung öffnen action.Kilo.Session.Rename.text=Umbenennen @@ -427,6 +428,7 @@ settings.agentBehavior.skills.sources.editPath.title=Skill-Pfad bearbeiten settings.agentBehavior.skills.sources.editUrl.title=Skill-URL bearbeiten session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one={0} Nachricht zurückgesetzt revert.banner.count.other={0} Nachrichten zurückgesetzt revert.banner.redo=Wiederholen diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties index 0d19e12cfd9..344de8e9467 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties @@ -155,6 +155,7 @@ action.Kilo.Restart.description=Terminar y reiniciar el proceso Core action.Kilo.Reinstall.text=Reinstalar Kilo action.Kilo.Reinstall.cli.text=Reinstalar Core action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=Core incluido v{0} • Arquitectura: {1} action.Kilo.Session.Open.text=Abrir action.Kilo.Session.Open.description=Abrir la sesión seleccionada action.Kilo.Session.Rename.text=Renombrar @@ -427,6 +428,7 @@ settings.agentBehavior.skills.sources.editPath.title=Editar ruta de habilidades settings.agentBehavior.skills.sources.editUrl.title=Editar URL de habilidades session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one={0} mensaje revertido revert.banner.count.other={0} mensajes revertidos revert.banner.redo=Rehacer diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties index 683bf55d439..c09dce01edb 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties @@ -155,6 +155,7 @@ action.Kilo.Restart.description=Tuer et redémarrer le processus Core action.Kilo.Reinstall.text=Réinstaller Kilo action.Kilo.Reinstall.cli.text=Réinstaller Core action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=Core intégré v{0} • Architecture : {1} action.Kilo.Session.Open.text=Ouvrir action.Kilo.Session.Open.description=Ouvrir la session sélectionnée action.Kilo.Session.Rename.text=Renommer @@ -427,6 +428,7 @@ settings.agentBehavior.skills.sources.editPath.title=Modifier le chemin des comp settings.agentBehavior.skills.sources.editUrl.title=Modifier l’URL des compétences session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one={0} message annulé revert.banner.count.other={0} messages annulés revert.banner.redo=Rétablir diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties index e9e1c52f3cb..ae011ad81d3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties @@ -155,6 +155,7 @@ action.Kilo.Restart.description=Coreプロセスを終了して再起動 action.Kilo.Reinstall.text=Kiloを再インストール action.Kilo.Reinstall.cli.text=Coreを再インストール action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=バンドルされた Core v{0} • アーキテクチャ: {1} action.Kilo.Session.Open.text=開く action.Kilo.Session.Open.description=選択したセッションを開く action.Kilo.Session.Rename.text=変名 @@ -427,6 +428,7 @@ settings.agentBehavior.skills.sources.editPath.title=スキルパスを編集 settings.agentBehavior.skills.sources.editUrl.title=スキル URL を編集 session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one={0} 件のメッセージをロールバックしました revert.banner.count.other={0} 件のメッセージをロールバックしました revert.banner.redo=やり直し diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties index c2e935a4ed7..0afc7549a47 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties @@ -155,6 +155,7 @@ action.Kilo.Restart.description=Core 프로세스 종료 후 재시작 action.Kilo.Reinstall.text=Kilo 재설치 action.Kilo.Reinstall.cli.text=Core 재설치 action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=번들 Core v{0} • 아키텍처: {1} action.Kilo.Session.Open.text=열기 action.Kilo.Session.Open.description=선택한 세션 열기 action.Kilo.Session.Rename.text=이름 바꾸기 @@ -427,6 +428,7 @@ settings.agentBehavior.skills.sources.editPath.title=스킬 경로 편집 settings.agentBehavior.skills.sources.editUrl.title=스킬 URL 편집 session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one={0}개 메시지가 롤백됨 revert.banner.count.other={0}개 메시지가 롤백됨 revert.banner.redo=다시 실행 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties index e43ba741bd8..ebd3c697179 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties @@ -155,6 +155,7 @@ action.Kilo.Restart.description=Core-proces beëindigen en herstarten action.Kilo.Reinstall.text=Kilo herinstalleren action.Kilo.Reinstall.cli.text=Core herinstalleren action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=Gebundelde Core v{0} • Architectuur: {1} action.Kilo.Session.Open.text=Openen action.Kilo.Session.Open.description=Geselecteerde sessie openen action.Kilo.Session.Rename.text=Hernoemen @@ -427,6 +428,7 @@ settings.agentBehavior.skills.sources.editPath.title=Vaardigheidspad bewerken settings.agentBehavior.skills.sources.editUrl.title=Vaardigheids-URL bewerken session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one={0} bericht teruggedraaid revert.banner.count.other={0} berichten teruggedraaid revert.banner.redo=Opnieuw uitvoeren diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties index d391bcc32a3..6a03c40232a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties @@ -160,6 +160,7 @@ action.Kilo.Restart.description=Avslutt og start Core-prosessen på nytt action.Kilo.Reinstall.text=Installer Kilo på nytt action.Kilo.Reinstall.cli.text=Installer Core på nytt action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=Innebygd Core v{0} • Arkitektur: {1} action.Kilo.Session.Open.text=Åpne action.Kilo.Session.Open.description=Åpne valgt økt action.Kilo.Session.Rename.text=Gi nytt navn @@ -427,6 +428,7 @@ settings.agentBehavior.skills.sources.editPath.title=Rediger ferdighetssti settings.agentBehavior.skills.sources.editUrl.title=Rediger ferdighets-URL session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one={0} melding rullet tilbake revert.banner.count.other={0} meldinger rullet tilbake revert.banner.redo=Gjør om diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties index ef4c5efe137..9495b5a224d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties @@ -160,6 +160,7 @@ action.Kilo.Restart.description=Zakończ i uruchom ponownie proces Core action.Kilo.Reinstall.text=Zainstaluj ponownie Kilo action.Kilo.Reinstall.cli.text=Zainstaluj ponownie Core action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=Wbudowany Core v{0} • Architektura: {1} action.Kilo.Session.Open.text=Otwórz action.Kilo.Session.Open.description=Otwórz wybraną sesję action.Kilo.Session.Rename.text=Zmień nazwę @@ -427,6 +428,7 @@ settings.agentBehavior.skills.sources.editPath.title=Edytuj ścieżkę umiejętn settings.agentBehavior.skills.sources.editUrl.title=Edytuj URL umiejętności session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one=Cofnięto {0} wiadomość revert.banner.count.other=Cofnięto {0} wiadomości revert.banner.redo=Ponów diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties index 2bf9c4c4cce..3b6a69e414d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties @@ -160,6 +160,7 @@ action.Kilo.Restart.description=Encerrar e reiniciar o processo Core action.Kilo.Reinstall.text=Reinstalar Kilo action.Kilo.Reinstall.cli.text=Reinstalar Core action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=Core incluído v{0} • Arquitetura: {1} action.Kilo.Session.Open.text=Abrir action.Kilo.Session.Open.description=Abrir a sessão selecionada action.Kilo.Session.Rename.text=Renomear @@ -427,6 +428,7 @@ settings.agentBehavior.skills.sources.editPath.title=Editar caminho de habilidad settings.agentBehavior.skills.sources.editUrl.title=Editar URL de habilidades session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one={0} mensagem revertida revert.banner.count.other={0} mensagens revertidas revert.banner.redo=Refazer diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties index aef654244b3..50920770978 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties @@ -160,6 +160,7 @@ action.Kilo.Restart.description=Завершить и перезапустить action.Kilo.Reinstall.text=Переустановить Kilo action.Kilo.Reinstall.cli.text=Переустановить Core action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=Встроенный Core v{0} • Архитектура: {1} action.Kilo.Session.Open.text=Открыть action.Kilo.Session.Open.description=Открыть выбранную сессию action.Kilo.Session.Rename.text=Переименовать @@ -427,6 +428,7 @@ settings.agentBehavior.skills.sources.editPath.title=Изменить путь settings.agentBehavior.skills.sources.editUrl.title=Изменить URL навыков session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one=Отменено сообщений: {0} revert.banner.count.other=Отменено сообщений: {0} revert.banner.redo=Повторить diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties index 8959598aa7a..0d5b6c363ff 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties @@ -160,6 +160,7 @@ action.Kilo.Restart.description=สิ้นสุดและรีสตาร action.Kilo.Reinstall.text=ติดตั้ง Kilo ใหม่ action.Kilo.Reinstall.cli.text=ติดตั้ง Core ใหม่ action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=Core แบบรวมมาในตัว v{0} • สถาปัตยกรรม: {1} action.Kilo.Session.Open.text=เปิด action.Kilo.Session.Open.description=เปิดเซสชันที่เลือก action.Kilo.Session.Rename.text=เปลี่ยนชื่อ @@ -427,6 +428,7 @@ settings.agentBehavior.skills.sources.editPath.title=แก้ไขเส้น settings.agentBehavior.skills.sources.editUrl.title=แก้ไข URL ทักษะ session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one=ย้อนกลับข้อความ {0} รายการแล้ว revert.banner.count.other=ย้อนกลับข้อความ {0} รายการแล้ว revert.banner.redo=ทำซ้ำ diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties index b954188a5ca..f3a1c986195 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties @@ -160,6 +160,7 @@ action.Kilo.Restart.description=Core sürecini sonlandır ve yeniden başlat action.Kilo.Reinstall.text=Kilo’yu yeniden yükle action.Kilo.Reinstall.cli.text=Core’yi yeniden yükle action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=Paketlenmiş Core v{0} • Mimari: {1} action.Kilo.Session.Open.text=Aç action.Kilo.Session.Open.description=Seçilen oturumu aç action.Kilo.Session.Rename.text=Yeniden adlandır @@ -427,6 +428,7 @@ settings.agentBehavior.skills.sources.editPath.title=Beceri Yolunu Düzenle settings.agentBehavior.skills.sources.editUrl.title=Beceri URL'sini Düzenle session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one={0} mesaj geri alındı revert.banner.count.other={0} mesaj geri alındı revert.banner.redo=Yinele diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties index 77ea64c7752..7ab35b6970f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties @@ -155,6 +155,7 @@ action.Kilo.Restart.description=Завершити і перезапустити action.Kilo.Reinstall.text=Перевстановити Kilo action.Kilo.Reinstall.cli.text=Перевстановити Core action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=Вбудований Core v{0} • Архітектура: {1} action.Kilo.Session.Open.text=Відкрити action.Kilo.Session.Open.description=Відкрити вибрану сесію action.Kilo.Session.Rename.text=Перейменувати @@ -427,6 +428,7 @@ settings.agentBehavior.skills.sources.editPath.title=Редагувати шля settings.agentBehavior.skills.sources.editUrl.title=Редагувати URL навичок session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one=Відкочено повідомлень: {0} revert.banner.count.other=Відкочено повідомлень: {0} revert.banner.redo=Повторити diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties index 909073cae3e..7905d906475 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties @@ -155,6 +155,7 @@ action.Kilo.Restart.description=终止并重启 Core 进程 action.Kilo.Reinstall.text=重新安装 Kilo action.Kilo.Reinstall.cli.text=重新安装 Core action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=捆绑的 Core v{0} • 架构:{1} action.Kilo.Session.Open.text=打开 action.Kilo.Session.Open.description=打开所选会话 action.Kilo.Session.Rename.text=重命名 @@ -427,6 +428,7 @@ settings.agentBehavior.skills.sources.editPath.title=编辑技能路径 settings.agentBehavior.skills.sources.editUrl.title=编辑技能 URL session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one=已回滚 {0} 条消息 revert.banner.count.other=已回滚 {0} 条消息 revert.banner.redo=重做 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties index bc74d81c93a..1f42649ba93 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties @@ -155,6 +155,7 @@ action.Kilo.Restart.description=終止並重啟 Core 程序 action.Kilo.Reinstall.text=重新安裝 Kilo action.Kilo.Reinstall.cli.text=重新安裝 Core action.Kilo.Reinstall.description=Download a fresh Core binary and restart +action.Kilo.CoreInfo.bundled=捆綁的 Core v{0} • 架構:{1} action.Kilo.Session.Open.text=開啟 action.Kilo.Session.Open.description=開啟所選工作階段 action.Kilo.Session.Rename.text=重命名 @@ -427,6 +428,7 @@ settings.agentBehavior.skills.sources.editPath.title=編輯技能路徑 settings.agentBehavior.skills.sources.editUrl.title=編輯技能 URL session.file.missing=Couldn''t find ''{0}'' in this repository. revert.banner.filesNotRestored=Snapshots are off - only the conversation was reverted; your files were not changed. +revert.banner.openDiff.title=Rolled back changes revert.banner.count.one=已回復 {0} 則訊息 revert.banner.count.other=已回復 {0} 則訊息 revert.banner.redo=重做 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt index 477a78333ca..9d4689d514b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/KiloRecoveryActionsTest.kt @@ -126,6 +126,21 @@ class KiloRecoveryActionsTest : BasePlatformTestCase() { assertEquals("Core v1.2.3 • Architecture: darwin-arm64", event.presentation.text) } + fun `test core info action marks bundled core`() { + appRpc.cliVersion = "1.2.3" + appRpc.cliPlatform = "darwin-arm64" + appRpc.cliBundled = true + ApplicationManager.getApplication().executeOnPooledThread { + runBlocking { app().coreInfo() } + }.get() + val action = CoreInfoAction() + val event = event(action) + + update(action, event) + + assertEquals("Bundled Core v1.2.3 • Architecture: darwin-arm64", event.presentation.text) + } + fun `test local config action says open when target exists`() { rpc.localConfigPath = "/test/.kilo/kilo.jsonc" rpc.localConfigDisplayPath = "~/.kilo/kilo.jsonc" diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffBlocksTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffBlocksTest.kt new file mode 100644 index 00000000000..2d68628ea9f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffBlocksTest.kt @@ -0,0 +1,53 @@ +package ai.kilocode.client.diff + +import ai.kilocode.rpc.dto.DiffFileDto +import com.intellij.diff.contents.DocumentContent +import com.intellij.diff.requests.SimpleDiffRequest +import com.intellij.diff.util.DiffUserDataKeysEx +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class DiffBlocksTest : BasePlatformTestCase() { + fun `test diffRequest uses full sides when available`() { + val request = diffRequest( + project, + DiffFileDto( + file = "src/Main.kt", + additions = 1, + deletions = 1, + patch = "@@ -1 +1 @@\n-old\n+new\n", + status = "modified", + before = "old\nkeep\n", + after = "new\nkeep\n", + ), + ) as SimpleDiffRequest + + assertEquals("old\nkeep\n", (request.contents[0] as DocumentContent).document.text) + assertEquals("new\nkeep\n", (request.contents[1] as DocumentContent).document.text) + // Full content already starts at line 1, so no gutter remap is installed. + assertNull((request.contents[0] as DocumentContent).getUserData(DiffUserDataKeysEx.LINE_NUMBER_CONVERTOR)) + assertNull((request.contents[1] as DocumentContent).getUserData(DiffUserDataKeysEx.LINE_NUMBER_CONVERTOR)) + } + + fun `test diffRequest remaps hunk fallback gutter to real file lines`() { + val request = diffRequest( + project, + DiffFileDto( + file = "src/Main.kt", + additions = 1, + deletions = 1, + patch = "--- a/src/Main.kt\n+++ b/src/Main.kt\n@@ -126,3 +126,3 @@\n one\n-two\n+TWO\n three\n", + status = "modified", + ), + ) as SimpleDiffRequest + + val left = (request.contents[0] as DocumentContent).getUserData(DiffUserDataKeysEx.LINE_NUMBER_CONVERTOR)!! + val right = (request.contents[1] as DocumentContent).getUserData(DiffUserDataKeysEx.LINE_NUMBER_CONVERTOR)!! + // Document line 0 (0-based) maps to file line 125 (0-based); the platform renders it as 126. + assertEquals(125, left.applyAsInt(0)) + assertEquals(127, left.applyAsInt(2)) + assertEquals(125, right.applyAsInt(0)) + assertEquals(127, right.applyAsInt(2)) + // Out-of-range document lines are hidden. + assertEquals(-1, left.applyAsInt(9)) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffPatchReconstructTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffPatchReconstructTest.kt index 24ab5b67c5a..b55ef75ae72 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffPatchReconstructTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/DiffPatchReconstructTest.kt @@ -133,7 +133,98 @@ class DiffPatchReconstructTest { } @Test - fun `multi hunk partial context patch is not renderable`() { + fun `multi hunk patch reconstructs concatenated changed regions`() { + // Turn/tool diffs are ordinary limited-context git output with several hunks. The reconstruction + // stitches each hunk body into contiguous before/after text so the diff editor colors the + // changes instead of dumping the raw patch as all-added lines. Inter-hunk gaps collapse. + val dto = DiffFileDto( + file = "src/A.kt", + additions = 2, + deletions = 2, + patch = """ + diff --git a/src/A.kt b/src/A.kt + --- a/src/A.kt + +++ b/src/A.kt + @@ -1,3 +1,3 @@ + one + -two + +TWO + three + @@ -20,3 +20,3 @@ + twenty + -x + +X + z + """.trimIndent(), + ) + + val sides = DiffPatchReconstruct.sides(dto) + + assertTrue(sides.renderable) + assertEquals("one\ntwo\nthree\ntwenty\nx\nz", sides.before) + assertEquals("one\nTWO\nthree\ntwenty\nX\nz", sides.after) + } + + @Test + fun `line maps carry real file positions across hunk gaps`() { + // Regression: the fallback content restarts at line 1, so the diff editor gutter showed 1..N + // instead of real file positions. The per-side maps (0-based; platform adds 1) must follow the + // @@ headers and jump across the elided gap between the two hunks. + val dto = DiffFileDto( + file = "src/A.kt", + additions = 2, + deletions = 2, + patch = """ + diff --git a/src/A.kt b/src/A.kt + --- a/src/A.kt + +++ b/src/A.kt + @@ -126,3 +126,3 @@ + one + -two + +TWO + three + @@ -220,3 +221,3 @@ + twenty + -x + +X + z + """.trimIndent(), + ) + + val sides = DiffPatchReconstruct.sides(dto) + + // before doc lines: one/two/three (126,127,128) then twenty/x/z (220,221,222) -> 0-based + assertEquals(listOf(125, 126, 127, 219, 220, 221), sides.leftLines) + // after doc lines: one/TWO/three (126,127,128) then twenty/X/z (221,222,223) -> 0-based + assertEquals(listOf(125, 126, 127, 220, 221, 222), sides.rightLines) + } + + @Test + fun `added file has no left line map and after side maps from one`() { + val dto = DiffFileDto( + file = "src/A.kt", + additions = 2, + deletions = 0, + patch = """ + diff --git a/src/A.kt b/src/A.kt + --- /dev/null + +++ b/src/A.kt + @@ -0,0 +1,2 @@ + +one + +two + """.trimIndent(), + ) + + val sides = DiffPatchReconstruct.sides(dto) + + assertEquals(emptyList(), sides.leftLines) + assertEquals(listOf(0, 1), sides.rightLines) + } + + @Test + fun `multi hunk patch with truncated context is not renderable`() { + // header claims 3 old / 3 new lines per hunk but the body carries only 2 of each: reconstructing + // would misalign the sides, so fall back to the raw-patch view. val dto = DiffFileDto( file = "src/A.kt", additions = 2, diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt index 7bf4d3c12bd..de63af8b033 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/diff/KiloDiffEditorContentTest.kt @@ -291,6 +291,31 @@ class KiloDiffEditorContentTest : BasePlatformTestCase() { assertEquals("hello\nworld", contents[1]) } + fun `test diff request reconstructs multi hunk modified patch`() { + // Regression: multi-hunk modified diffs used to fall back to an empty original + raw patch on + // the right, rendering every line as added. They now reconstruct into a real side-by-side diff. + val patch = """ + diff --git a/src/App.kt b/src/App.kt + --- a/src/App.kt + +++ b/src/App.kt + @@ -1,3 +1,3 @@ + one + -two + +TWO + three + @@ -20,3 +20,3 @@ + twenty + -x + +X + z + """.trimIndent() + val request = diffRequest(project, file("src/App.kt", 2, 2, patch = patch)) as SimpleDiffRequest + val contents = request.contents.map(::content) + + assertEquals("one\ntwo\nthree\ntwenty\nx\nz", contents[0]) + assertEquals("one\nTWO\nthree\ntwenty\nX\nz", contents[1]) + } + fun `test tree displays absolute files relative to workspace`() { val parent = Disposer.newDisposable() try { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/RevertDiffLoadingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/RevertDiffLoadingTest.kt new file mode 100644 index 00000000000..e2d831baa19 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/RevertDiffLoadingTest.kt @@ -0,0 +1,38 @@ +package ai.kilocode.client.session.controller + +import ai.kilocode.rpc.dto.DiffFileDto +import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.SessionRevertDto + +/** + * A session reverted in a previous run has no `session.diff` event to replay on open, and the + * pinned CLI may not attach a diff to the revert marker. [SessionController.seedRevertDiff] fetches + * the persisted session diff so the reverted-files banner has something to render. + */ +class RevertDiffLoadingTest : SessionControllerTestBase() { + + fun `test opening a reverted session seeds model diff from the diff rpc`() { + rpc.session = session("ses_test").copy(revert = SessionRevertDto(messageID = "msg1", snapshot = "snap")) + rpc.history.add(MessageWithPartsDto(msg("msg1", "ses_test", "user"), emptyList())) + rpc.history.add(MessageWithPartsDto(msg("msg2", "ses_test", "assistant"), emptyList())) + rpc.diffs["ses_test"] = mutableListOf(DiffFileDto("src/A.kt", 2, 1, "@@ patch")) + + val c = controller("ses_test") + flush() + + assertEquals("msg1", c.model.revert()?.messageID) + assertEquals(listOf("src/A.kt"), c.model.diff.map { it.file }) + } + + fun `test opening a session without a revert does not fetch the diff`() { + rpc.session = session("ses_test") + rpc.history.add(MessageWithPartsDto(msg("msg1", "ses_test", "user"), emptyList())) + rpc.diffs["ses_test"] = mutableListOf(DiffFileDto("src/A.kt", 2, 1, "@@ patch")) + + val c = controller("ses_test") + flush() + + assertNull(c.model.revert()) + assertTrue(c.model.diff.isEmpty()) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt index a210429af0d..2479977f632 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt @@ -375,6 +375,7 @@ class SessionModelTest : BasePlatformTestCase() { ) val p = model.message("m1")!!.parts["p1"] as Tool + assertEquals("m1", p.messageID) assertEquals("git log", p.input["command"]) assertEquals("Show history", p.input["description"]) assertEquals("state", p.metadata["source"]) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionLayoutTest.kt index 6e39e42c605..ced62e863ba 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionLayoutTest.kt @@ -361,6 +361,34 @@ class SessionLayoutTest : BasePlatformTestCase() { assertEquals(sCount + 1, second.count) } + fun `test validate root child reconciles stale parent cache after self layout`() { + val p = panel(width = 300) + val child = rootProbe(root = true) + p.add(child) + p.doLayout() + child.markValid() + child.preferred = 80 + + child.doLayout() + p.doLayout() + + assertEquals(80, child.height) + } + + fun `test non validate root child keeps parent cache until it invalidates upward`() { + val p = panel(width = 300) + val child = rootProbe(root = false) + p.add(child) + p.doLayout() + child.markValid() + child.preferred = 80 + + child.doLayout() + p.doLayout() + + assertEquals(20, child.height) + } + // ---- helpers ------ /** A fixed-height JLabel. The width is reported as 0 until layout sets it. */ @@ -394,4 +422,24 @@ class SessionLayoutTest : BasePlatformTestCase() { return Dimension(0, height) } } + + private fun rootProbe(root: Boolean) = object : SessionLayoutPanel() { + var preferred = 20 + private var valid = false + + override fun isValid() = valid + + override fun invalidate() { + valid = false + super.invalidate() + } + + fun markValid() { + valid = true + } + + override fun isValidateRoot() = root + + override fun getPreferredSize(): Dimension = Dimension(0, preferred) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index ab7f800934b..95ff55bf948 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -15,6 +15,7 @@ import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.LoginRequiredView import ai.kilocode.client.session.views.PlanExitView import ai.kilocode.client.session.views.base.BaseQuestionView +import ai.kilocode.client.session.views.base.PartHeader import ai.kilocode.client.session.views.permission.PermissionView import ai.kilocode.client.session.views.question.QuestionResultView import ai.kilocode.client.session.views.question.QuestionView @@ -29,6 +30,7 @@ import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.client.session.views.todo.TodoWriteView import ai.kilocode.client.ui.DiffStatBadge import ai.kilocode.client.ui.HoverIcon +import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.MessageDto @@ -37,6 +39,8 @@ import ai.kilocode.rpc.dto.MessageTimeDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.SessionRevertDto +import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionTimeDto import ai.kilocode.rpc.dto.TodoDto import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI import com.intellij.openapi.Disposable @@ -60,6 +64,7 @@ import javax.swing.JButton import javax.swing.JComponent import javax.swing.JPanel import javax.swing.RepaintManager +import javax.swing.ScrollPaneConstants import javax.swing.SwingUtilities import javax.swing.border.Border @@ -1080,8 +1085,9 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { banner.update() assertNotNull(find(banner)) + assertNotNull(components(banner).filterIsInstance().singleOrNull()) - val buttons = components(banner).filterIsInstance() + val buttons = components(banner).filterIsInstance().filter { it.text.isNotEmpty() } assertEquals( listOf(KiloBundle.message("revert.banner.redo"), KiloBundle.message("revert.banner.redo.all")), buttons.map { it.text }, @@ -1098,7 +1104,7 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { fun `test rollback banner reuses file rows across updates`() { val banner = RevertBanner(model, {}, {}, {}) model.upsertMessage(msg("u1", "user")) - model.setRevert(SessionRevertDto("u1")) + model.setRevert(SessionRevertDto("u1", snapshot = "snap1")) model.setDiff(listOf(DiffFileDto("src/A.kt", 1, 0), DiffFileDto("src/B.kt", 2, 1))) banner.update() val rows = components(banner).filterIsInstance().filter { stack -> @@ -1122,6 +1128,127 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { assertEquals("-2", badges[1].removedLabelForTest().text) } + fun `test rollback banner caps file list with scroll pane`() { + val banner = RevertBanner(model, {}, {}, {}) + model.upsertMessage(msg("u1", "user")) + model.setRevert(SessionRevertDto("u1", snapshot = "snap1")) + model.setDiff((1..80).map { DiffFileDto("src/file-$it.kt", it, 0) }) + + banner.update() + + val scroll = components(banner).filterIsInstance().single() + assertTrue(scroll.verticalScrollBarPolicy == ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED) + assertTrue(scroll.horizontalScrollBarPolicy == ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED) + val rows = rowLabels(banner).mapNotNull { it.parent } + val rowHeight = rows.first().preferredSize.height + val cap = rowHeight * RevertBanner.MAX_FILE_ROWS + UiStyle.Gap.xs() * (RevertBanner.MAX_FILE_ROWS - 1) + assertEquals(cap, scroll.preferredSize.height) + } + + fun `test rollback banner shortens duplicate file names with parents`() { + val banner = RevertBanner(model, {}, {}, {}) + model.upsertMessage(msg("u1", "user")) + model.setRevert(SessionRevertDto("u1", snapshot = "snap1")) + model.setDiff(listOf( + DiffFileDto("apps/main/src/App.kt", 1, 0), + DiffFileDto("packages/ui/src/App.kt", 2, 1), + DiffFileDto("packages/ui/src/Button.kt", 3, 0), + )) + + banner.update() + + val labels = rowLabels(banner).map { it.text to it.toolTipText } + assertTrue(labels.contains("main/src/App.kt" to "apps/main/src/App.kt")) + assertTrue(labels.contains("ui/src/App.kt" to "packages/ui/src/App.kt")) + assertTrue(labels.contains("Button.kt" to "packages/ui/src/Button.kt")) + } + + fun `test rollback banner uses full path tooltip for entire file row`() { + val banner = RevertBanner(model, {}, {}, {}) + model.setSession(SessionDto( + id = "ses", + projectID = "proj", + directory = "/workspace/root", + title = "Session", + version = "1", + time = SessionTimeDto(0.0, 0.0), + )) + model.upsertMessage(msg("u1", "user")) + model.setRevert(SessionRevertDto("u1", snapshot = "snap1")) + model.setDiff(listOf( + DiffFileDto("project/dir1/shared-alpha.txt", 0, 4), + DiffFileDto("project/dir2/shared-alpha.txt", 0, 4), + )) + + banner.update() + + val label = rowLabels(banner).first { it.text == "dir1/shared-alpha.txt" } + val row = label.parent as JComponent + assertEquals("/workspace/root/project/dir1/shared-alpha.txt", row.toolTipText) + assertTrue(components(row).filterIsInstance().all { it.toolTipText == "/workspace/root/project/dir1/shared-alpha.txt" }) + } + + fun `test rollback banner opens rolled back diff`() { + val diff = DiffFileDto("src/A.kt", 1, 0, PATCH, "modified") + val opened = mutableListOf>() + val titles = mutableListOf() + val keys = mutableListOf() + val banner = RevertBanner(model, {}, {}, {}) + banner.setDiffOpener({ files, title, key -> + opened.add(files) + titles.add(title) + keys.add(key) + }, "ses_1") + model.upsertMessage(msg("u1", "user")) + model.setRevert(SessionRevertDto("u1", snapshot = "snap1", diffs = listOf(diff))) + + banner.update() + + val button = components(banner).filterIsInstance() + .first { it.toolTipText == KiloBundle.message("session.part.tool.openDiff") } + assertTrue(button.isVisible) + assertTrue(button.isEnabled) + button.doClick() + + assertEquals(listOf(diff), opened.single()) + assertEquals(KiloBundle.message("revert.banner.openDiff.title"), titles.single()) + assertEquals("revert:ses_1:u1", keys.single()) + } + + fun `test rollback banner hides open diff without a snapshot`() { + val banner = RevertBanner(model, {}, {}, {}) + model.upsertMessage(msg("u1", "user")) + model.setRevert(SessionRevertDto("u1", snapshot = null)) + model.setDiff(listOf(DiffFileDto("src/A.kt", 1, 0, PATCH))) + + banner.update() + + val button = components(banner).filterIsInstance() + .first { it.toolTipText == KiloBundle.message("session.part.tool.openDiff") } + assertFalse(button.isVisible) + assertFalse(button.isEnabled) + } + + fun `test rollback banner opens session diff when revert diff is absent`() { + val diff = DiffFileDto("src/A.kt", 1, 0, PATCH, "modified") + val opened = mutableListOf>() + val banner = RevertBanner(model, {}, {}, {}) + banner.setDiffOpener({ files, _, _ -> opened.add(files) }, "ses_1") + model.upsertMessage(msg("u1", "user")) + model.setRevert(SessionRevertDto("u1", snapshot = "snap1")) + model.setDiff(listOf(diff)) + + banner.update() + + val button = components(banner).filterIsInstance() + .first { it.toolTipText == KiloBundle.message("session.part.tool.openDiff") } + assertTrue(button.isVisible) + assertTrue(button.isEnabled) + button.doClick() + + assertEquals(listOf(diff), opened.single()) + } + fun `test rollback banner shows redo all only for multiple reverted messages`() { val banner = RevertBanner(model, {}, {}, {}) model.upsertMessage(msg("u1", "user")) @@ -1168,7 +1295,7 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { banner.setReverting(SessionState.Reverting("Rolling back...", SessionState.Reverting.Kind.ROLLBACK, "u1")) - val buttons = components(banner).filterIsInstance() + val buttons = components(banner).filterIsInstance().filter { it.text.isNotEmpty() } assertTrue(buttons.filter { it.text == KiloBundle.message("revert.banner.redo") }.all { !it.isEnabled }) assertTrue(buttons.filter { it.text == KiloBundle.message("revert.banner.redo.all") }.all { !it.isEnabled }) val progress = components(banner).filterIsInstance().single() @@ -1521,6 +1648,11 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { return out } + private fun rowLabels(root: Component): List = components(root) + .filterIsInstance() + .filter { stack -> stack.components.any { it is DiffStatBadge } } + .mapNotNull { stack -> components(stack).filterIsInstance().firstOrNull() } + private fun taskText(view: TaskToolView): List { val scroll = components(view).filterIsInstance().single() val stack = components(scroll.viewport.view).filterIsInstance().single() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProviderTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProviderTest.kt index 76077daeabb..b61398ca28b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProviderTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProviderTest.kt @@ -174,6 +174,69 @@ class KiloPromptCompletionProviderTest : BasePlatformTestCase() { assertFalse(myFixture.lookupElementStrings.orEmpty().contains(noMatches())) } + fun `test slash completion ranks prefix matches before contains matches`() { + rpc.state.value = KiloWorkspaceStateDto( + KiloWorkspaceStatusDto.READY, + commands = listOf( + CommandDto("three-jetbrains"), + CommandDto("jetbrains-one"), + CommandDto("jetbrains-two"), + ), + ) + waitFor { provider.serverCommand("/jetbrains-one") != null } + + complete("/jet") + + assertOrder("jetbrains-one", "jetbrains-two", "three-jetbrains") + } + + fun `test slash completion matches prefix across separators`() { + rpc.state.value = KiloWorkspaceStateDto( + KiloWorkspaceStatusDto.READY, + commands = listOf( + CommandDto("three-jetbrains"), + CommandDto("jetbrains-one"), + CommandDto("jetbrains-two"), + CommandDto("jetbrains_two"), + CommandDto("jetbrains.three"), + CommandDto("jetbrains:four"), + CommandDto("jetbrains+five"), + ), + ) + waitFor { provider.serverCommand("/jetbrains-one") != null } + + complete("/j-") + assertEquals( + listOf("jetbrains-one", "jetbrains-two"), + matches("jetbrains-one", "jetbrains-two", "jetbrains_two", "three-jetbrains"), + ) + + complete("/j_") + assertEquals(listOf("jetbrains_two"), matches("jetbrains-one", "jetbrains_two", "three-jetbrains")) + + complete("/j.") + assertEquals(listOf("jetbrains.three"), matches("jetbrains.three", "jetbrains:four", "jetbrains+five")) + + complete("/j:") + assertEquals(listOf("jetbrains:four"), matches("jetbrains.three", "jetbrains:four", "jetbrains+five")) + + complete("/j+") + assertEquals(listOf("jetbrains+five"), matches("jetbrains.three", "jetbrains:four", "jetbrains+five")) + } + + fun `test slash completion matches camel humps with capitals`() { + rpc.state.value = KiloWorkspaceStateDto( + KiloWorkspaceStatusDto.READY, + commands = listOf(CommandDto("jetBrainsSkill"), CommandDto("jetbrains-skill")), + ) + waitFor { provider.serverCommand("/jetBrainsSkill") != null } + + complete("/JBS") + + assertContainsElements(myFixture.lookupElementStrings.orEmpty(), "jetBrainsSkill") + assertFalse(myFixture.lookupElementStrings.orEmpty().contains(noMatches())) + } + fun `test blank mention completion includes special and root entries`() { rpc.searchResult = FileSearchResultDto( files = listOf(file("src", directory = true), file("README.md")), @@ -380,6 +443,19 @@ class KiloPromptCompletionProviderTest : BasePlatformTestCase() { private fun item(value: String) = myFixture.lookupElements.orEmpty().first { it.lookupString == value } + private fun assertOrder(vararg values: String) { + val items = myFixture.lookupElementStrings.orEmpty() + assertContainsElements(items, *values) + values.toList().zipWithNext().forEach { (left, right) -> + assertTrue("Expected $left before $right in $items", items.indexOf(left) < items.indexOf(right)) + } + } + + private fun matches(vararg values: String): List { + val items = myFixture.lookupElementStrings.orEmpty() + return values.filter { it in items } + } + private fun file(path: String, directory: Boolean = false) = WorkspaceFileDto( path = path, name = path.substringAfterLast('/'), diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt index 6018178065f..9f38bca3202 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt @@ -21,12 +21,11 @@ class BaseQuestionViewTest : BasePlatformTestCase() { // ------ initial state ------ - fun `test header and description text areas are in the component tree by default`() { + fun `test empty card does not render a header row by default`() { edt { val panel = BaseQuestionView() assertTrue("Root layout should be BorderLayout", panel.layout is BorderLayout) - val areas = findAll(panel) - assertTrue("Should have at least 2 text areas (header + description)", areas.size >= 2) + assertNull("Header row should be omitted until header content exists", headerRow(panel)) } } @@ -87,6 +86,7 @@ class BaseQuestionViewTest : BasePlatformTestCase() { fun `test setTopPanel adds component before header`() { edt { val panel = BaseQuestionView() + panel.setHeader("Title") val top = JLabel("top") panel.setTopPanel(top) @@ -280,9 +280,10 @@ class BaseQuestionViewTest : BasePlatformTestCase() { } } - fun `test header row has no west icon gap by default`() { + fun `test header row has no west icon gap without icon`() { edt { val panel = BaseQuestionView() + panel.setHeader("Title") val header = headerRow(panel)!! val west = (header.layout as BorderLayout).getLayoutComponent(BorderLayout.WEST) assertNull("header should not reserve icon space when icon is absent", west) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt index 7c7d4a9affe..ba9ce1d3bca 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt @@ -37,6 +37,7 @@ class FakeAppRpcApi : KiloAppRpcApi { var health = HealthDto(healthy = true, version = "1.0.0") var cliVersion = "1.0.0" var cliPlatform = "darwin-arm64" + var cliBundled = false var cliInfoGate: CompletableDeferred? = null var cliInfoError: Exception? = null var cliVersionCalls = 0 @@ -94,6 +95,11 @@ class FakeAppRpcApi : KiloAppRpcApi { return cliPlatform } + override suspend fun cliBundled(): Boolean { + assertNotEdt("cliBundled") + return cliBundled + } + override suspend fun retry() { assertNotEdt("retry") retries += 1 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt index ce540a25d48..b3951bfab33 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt @@ -49,6 +49,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi { val history = mutableListOf() val histories = mutableMapOf>() val diffs = mutableMapOf>() + val diffSides = mutableMapOf() var historyGate: CompletableDeferred? = null var historyCalls = 0 private set @@ -259,6 +260,11 @@ class FakeSessionRpcApi : KiloSessionRpcApi { return diffs[id]?.toList().orEmpty() } + override suspend fun diffSides(sessionId: String?, directory: String, file: DiffFileDto, messageId: String?): DiffFileDto? { + assertNotEdt("diffSides") + return diffSides[file.file] + } + override suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto? { assertNotEdt("attachmentPart") attachmentParts.add(AttachmentCall(id, directory, messageId, partId, attachmentKey)) diff --git a/packages/kilo-jetbrains/gradle.properties b/packages/kilo-jetbrains/gradle.properties index 22bcfa17240..7db9a13fbb7 100644 --- a/packages/kilo-jetbrains/gradle.properties +++ b/packages/kilo-jetbrains/gradle.properties @@ -1,5 +1,5 @@ kotlin.stdlib.default.dependency=false -kilo.jetbrains.version=7.0.12 +kilo.jetbrains.version=7.0.13 # When true (default) the JetBrains plugin uses the pinned CLI release from package.json. # Set to false ONLY for local dev: generate the client from local source + bundle the local binary. # false is NOT releasable -- production builds fail unless this is true. diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt index e05b4c3bdf1..2322ce61640 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt @@ -45,6 +45,9 @@ interface KiloAppRpcApi : RemoteApi { /** Core platform downloaded by the backend process. */ suspend fun cliPlatform(): String + /** Whether the running Core is bundled in the plugin (true) or downloaded (false). */ + suspend fun cliBundled(): Boolean + /** Retry app connection or loading after a failure. */ suspend fun retry() diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt index 0eaa3c5c01c..9c5773a8961 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt @@ -103,6 +103,15 @@ interface KiloSessionRpcApi : RemoteApi { /** Load cumulative file changes for a session. */ suspend fun diff(id: String, directory: String): List + /** + * Full before/after content for one changed file so the diff editor can show a whole-file diff. + * Prefers authoritative snapshot content from a CLI that supports it (correct even for historical + * turns); falls back to rebuilding locally from the working tree + hunk patch against any pinned + * CLI. Returns null when neither is available (fall back to the hunk view). Added/deleted files + * return null because the frontend reconstructs those directly. + */ + suspend fun diffSides(sessionId: String?, directory: String, file: DiffFileDto, messageId: String?): DiffFileDto? + /** Load one attachment part from a session without returning full history to the frontend. */ suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto? diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt index 4d31c3a5517..ecf0da6a410 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt @@ -410,6 +410,8 @@ data class DiffFileDto( val deletions: Int, val patch: String? = null, val status: String? = null, + val before: String? = null, + val after: String? = null, ) // --- Config Update --- diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SessionDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SessionDto.kt index 82818fccb49..26c54b7d080 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SessionDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/SessionDto.kt @@ -21,6 +21,7 @@ data class SessionRevertDto( val partID: String? = null, val snapshot: String? = null, val diff: String? = null, + val diffs: List = emptyList(), ) @Serializable diff --git a/packages/kilo-telemetry/src/__tests__/telemetry.test.ts b/packages/kilo-telemetry/src/__tests__/telemetry.test.ts index c86a249f51f..9246facb48c 100644 --- a/packages/kilo-telemetry/src/__tests__/telemetry.test.ts +++ b/packages/kilo-telemetry/src/__tests__/telemetry.test.ts @@ -126,4 +126,15 @@ describe("Telemetry", () => { expect(typeof Telemetry.trackSuggestionShown).toBe("function") expect(typeof Telemetry.trackSuggestionAccepted).toBe("function") }) + + test("trackToolUsed sends Tool Used event with tool name and sessionId", () => { + const capture = spyOn(Client, "capture").mockImplementation(() => {}) + + try { + Telemetry.trackToolUsed("chart", "session-123") + expect(capture).toHaveBeenCalledWith(TelemetryEvent.TOOL_USED, expect.objectContaining({ tool: "chart", sessionId: "session-123" })) + } finally { + capture.mockRestore() + } + }) }) diff --git a/packages/kilo-vscode/AGENTS.md b/packages/kilo-vscode/AGENTS.md index 15fac1d6be2..64fe5fc2214 100644 --- a/packages/kilo-vscode/AGENTS.md +++ b/packages/kilo-vscode/AGENTS.md @@ -221,6 +221,8 @@ import { spawn, exec } from "../util/process" The `spawn` wrapper covers long-lived processes (e.g. `kilo serve`). The `exec` wrapper covers short commands (e.g. `git`, `tar`). If you need the raw callback form of `execFile` for some reason, pass `windowsHide: true` explicitly in the options object. +Agent Manager uses read-only `gh` commands for PR status and PR import. Call `execGhRead` from `src/agent-manager/gh.ts` for those commands; on Windows it supplies `TZ=UTC` when no timezone is configured, preventing older `gh` releases from launching `tzutil.exe` in a visible console. + ## Style Follow monorepo root AGENTS.md style guide: diff --git a/packages/kilo-vscode/script/dev-snapshot.ts b/packages/kilo-vscode/script/dev-snapshot.ts index edbe590573e..14ed0ee64c3 100755 --- a/packages/kilo-vscode/script/dev-snapshot.ts +++ b/packages/kilo-vscode/script/dev-snapshot.ts @@ -1,5 +1,6 @@ #!/usr/bin/env bun import { $ } from "bun" +import { createRequire } from "node:module" import { join, dirname } from "node:path" import { tmpdir } from "node:os" import { rmSync, mkdirSync, existsSync } from "node:fs" @@ -26,37 +27,53 @@ console.log(`Commit: ${sha}`) console.log(`Mode: ${mode}\n`) console.log("🧹 Cleaning build directories...") -for (const dir of ["bin", "dist"]) { - const dirPath = join(root, dir) - if (existsSync(dirPath)) { - rmSync(dirPath, { recursive: true, force: true }) - console.log(` ✓ Cleaned ${dir}/`) - } +const dist = join(root, "dist") +if (existsSync(dist)) { + rmSync(dist, { recursive: true, force: true }) + console.log(" ✓ Cleaned dist/") } const outDir = join(tmpdir(), "kilo-vscode-snapshots") mkdirSync(outDir, { recursive: true }) -console.log("\n📦 Rebuilding SDK...") -await $`bun run --cwd ../sdk/js build`.cwd(root) +console.log("\n📦 Preparing SDK...") +await $`bun run prepare:sdk`.cwd(root) -console.log("\n🔧 Preparing CLI binary...") -await $`bun script/local-bin.ts --force`.cwd(root) - -console.log("\n✅ Type-checking...") -await $`bun run typecheck`.cwd(root) - -console.log("\n🔍 Linting...") -await $`bun run lint`.cwd(root) - -console.log("\n🏗️ Building extension...") -await $`node ${join(root, "esbuild.js")} --production`.cwd(root) +console.log("\n🔧 Preparing CLI binary and validating extension...") +await $`bun script/local-bin.ts --compiled`.cwd(root) +await $`bun run build:check:production`.cwd(root) console.log("\n📦 Packaging VSIX...") const vsixPath = join(outDir, `kilo-vscode-snapshot-${sha}-${user}.vsix`) -await $`bunx vsce package ${snapshotVersion} --no-update-package-json --no-dependencies --skip-license -o ${vsixPath}`.cwd( - root, -) +const require = createRequire(import.meta.url) +const vsceRequire = createRequire(require.resolve("@vscode/vsce")) +if (shouldInstall) { + // Local installs favor fast packaging and extraction over archive size. + type Options = Record + type Zip = { + addFile(path: string, name: string, options?: Options): void + addBuffer(data: Uint8Array, name: string, options?: Options): void + } + const yazl = vsceRequire("yazl") as { ZipFile: { prototype: Zip } } + const zip = yazl.ZipFile.prototype + const file = zip.addFile + const buffer = zip.addBuffer + zip.addFile = function (this: Zip, path, name, options) { + return file.call(this, path, name, { ...options, compress: false }) + } + zip.addBuffer = function (this: Zip, data, name, options) { + return buffer.call(this, data, name, { ...options, compress: false }) + } +} +const { createVSIX } = await import("@vscode/vsce") +await createVSIX({ + cwd: root, + packagePath: vsixPath, + version: snapshotVersion, + updatePackageJson: false, + dependencies: false, + skipLicense: true, +}) if (shouldInstall) { const execPath = process.env.VSCODE_EXEC_PATH ?? "" diff --git a/packages/kilo-vscode/script/ffmpeg-helper.ts b/packages/kilo-vscode/script/ffmpeg-helper.ts index c0b5e440ef4..eaf604a9d0e 100644 --- a/packages/kilo-vscode/script/ffmpeg-helper.ts +++ b/packages/kilo-vscode/script/ffmpeg-helper.ts @@ -23,7 +23,8 @@ export async function ensureFfmpegForTarget(target: string, bin: string): Promis const exe = target.startsWith("win32") ? "ffmpeg.exe" : "ffmpeg" const dest = join(bin, exe) - if (existsSync(dest)) return + const marker = join(bin, "..", "node_modules", ".kilo-ffmpeg-target") + if (existsSync(dest) && existsSync(marker) && (await Bun.file(marker).text()).trim() === target) return const tmp = join(bin, ".ffmpeg-tmp") rmSync(tmp, { recursive: true, force: true }) @@ -37,6 +38,7 @@ export async function ensureFfmpegForTarget(target: string, bin: string): Promis await $`tar -xzf ${join(tmp, name)} -C ${tmp}`.quiet() copyFileSync(join(tmp, "package", exe), dest) if (!target.startsWith("win32")) chmodSync(dest, 0o755) + await Bun.write(marker, `${target}\n`) } finally { rmSync(tmp, { recursive: true, force: true }) } diff --git a/packages/kilo-vscode/script/local-bin.ts b/packages/kilo-vscode/script/local-bin.ts index 9a2f11f550b..4b3c78e7498 100644 --- a/packages/kilo-vscode/script/local-bin.ts +++ b/packages/kilo-vscode/script/local-bin.ts @@ -1,5 +1,6 @@ #!/usr/bin/env bun import { $ } from "bun" +import { createHash } from "node:crypto" import { join, relative, dirname, basename } from "node:path" import { chmodSync, statSync, rmSync, readdirSync, existsSync } from "node:fs" import { @@ -15,6 +16,7 @@ import { currentBwrapTarget, ensureBwrapForTarget } from "./bwrap-helper" import { currentFfmpegTarget, ensureFfmpegForTarget } from "./ffmpeg-helper" const forceRebuild = process.argv.includes("--force") +const compiledOnly = process.argv.includes("--compiled") /** * Ensures the VS Code extension has a CLI binary at `packages/kilo-vscode/bin/kilo`. @@ -32,64 +34,154 @@ const kiloVscodeDir = join(import.meta.dir, "..") const packagesDir = join(kiloVscodeDir, "..") const repoDir = join(packagesDir, "..") const opencodeDir = join(packagesDir, "opencode") -const coreDir = join(packagesDir, "core") -const gatewayDir = join(packagesDir, "kilo-gateway") -const indexingDir = join(packagesDir, "kilo-indexing") const sandboxDir = join(packagesDir, "kilo-sandbox") +const rootFile = join(repoDir, "package.json") const targetBinDir = join(kiloVscodeDir, "bin") const binName = process.platform === "win32" ? "kilo.exe" : "kilo" const targetBinPath = join(targetBinDir, binName) -const versionFile = join(targetBinDir, ".cli-version") +const versionFile = join(kiloVscodeDir, "node_modules", ".kilo-cli-version") function log(msg: string) { console.log(`[local-bin] ${msg}`) } -async function cliSourceHash(): Promise { +type Package = { + name?: string + workspaces?: string[] | { packages?: string[] } + dependencies?: Record + devDependencies?: Record + optionalDependencies?: Record + peerDependencies?: Record +} + +async function cliInputs() { + const root: Package = await Bun.file(rootFile).json() + const workspaces = Array.isArray(root.workspaces) ? root.workspaces : (root.workspaces?.packages ?? []) + const files = ( + await Promise.all( + workspaces.map((pattern) => + Array.fromAsync(new Bun.Glob(`${pattern}/package.json`).scan({ cwd: repoDir, onlyFiles: true })), + ), + ) + ).flat() + const entries = await Promise.all( + files.map(async (file) => ({ file, pkg: (await Bun.file(join(repoDir, file)).json()) as Package })), + ) + const packages = new Map(entries.flatMap((entry) => (entry.pkg.name ? [[entry.pkg.name, entry] as const] : []))) + const dirs = new Set() + + function visit(name: string) { + const entry = packages.get(name) + if (!entry) return + const dir = dirname(entry.file) + if (dirs.has(dir)) return + dirs.add(dir) + const deps = { + ...entry.pkg.dependencies, + ...entry.pkg.devDependencies, + ...entry.pkg.optionalDependencies, + ...entry.pkg.peerDependencies, + } + for (const dep of Object.keys(deps)) visit(dep) + } + + // The CLI build embeds the console even though it is not a package dependency. + for (const dir of [opencodeDir, join(packagesDir, "kilo-console")]) { + const pkg: Package = await Bun.file(join(dir, "package.json")).json() + if (!pkg.name) throw new Error(`Workspace package at ${dir} has no name`) + visit(pkg.name) + } + + return [ + relative(repoDir, rootFile), + "bun.lock", + "patches", + ...[...dirs].sort(), + "packages/kilo-vscode/script/bwrap-helper.ts", + "packages/kilo-vscode/script/ffmpeg-helper.ts", + "packages/kilo-vscode/script/local-bin.ts", + "packages/kilo-vscode/src/services/cli-backend/cli-resources.ts", + ] +} + +async function cliSourceHash() { try { - const opencodeResult = await $`git log -1 --format=%H -- .`.cwd(opencodeDir).quiet() - const coreResult = await $`git log -1 --format=%H -- .`.cwd(coreDir).quiet() - const gatewayResult = await $`git log -1 --format=%H -- .`.cwd(gatewayDir).quiet() - const indexingResult = await $`git log -1 --format=%H -- .`.cwd(indexingDir).quiet() - const sandboxResult = await $`git log -1 --format=%H -- .`.cwd(sandboxDir).quiet() - return `${opencodeResult.text().trim()}-${coreResult.text().trim()}-${gatewayResult.text().trim()}-${indexingResult.text().trim()}-${sandboxResult.text().trim()}` - } catch { + const inputs = await cliInputs() + const [tree, diff, extra, branch] = await Promise.all([ + $`git ls-tree -r HEAD -- ${inputs}`.cwd(repoDir).quiet(), + $`git diff --binary HEAD -- ${inputs}`.cwd(repoDir).quiet(), + $`git ls-files --others --exclude-standard -z -- ${inputs}`.cwd(repoDir).quiet(), + $`git branch --show-current`.cwd(repoDir).quiet(), + ]) + const env = Object.fromEntries( + [ + "GH_REPO", + "KILO_BUMP", + "KILO_BWRAP_CACHE", + "KILO_CHANNEL", + "KILO_MODELS_URL", + "KILO_PRE_RELEASE", + "KILO_RELEASE", + "KILO_SKIP_BUNDLED_BWRAP", + "KILO_VERSION", + "MODELS_DEV_API_JSON", + "ZIG", + ].map((key) => [key, process.env[key] ?? ""]), + ) + const hash = createHash("sha256") + .update(tree.text()) + .update(diff.text()) + .update(branch.text()) + .update(JSON.stringify(env)) + const files = extra.text().split("\0").filter(Boolean).sort() + + for (const file of files) { + hash.update(file) + hash.update(new Uint8Array(await Bun.file(join(repoDir, file)).arrayBuffer())) + } + + const models = process.env.MODELS_DEV_API_JSON + if (models) hash.update(new Uint8Array(await Bun.file(models).arrayBuffer())) + return hash.digest("hex") + } catch (err) { + log(`Could not determine CLI source hash: ${err instanceof Error ? err.message : String(err)}`) return null } } -async function isDirty(): Promise { - try { - const opencodeResult = await $`git status --porcelain -- .`.cwd(opencodeDir).quiet() - const coreResult = await $`git status --porcelain -- .`.cwd(coreDir).quiet() - const gatewayResult = await $`git status --porcelain -- .`.cwd(gatewayDir).quiet() - const indexingResult = await $`git status --porcelain -- .`.cwd(indexingDir).quiet() - const sandboxResult = await $`git status --porcelain -- .`.cwd(sandboxDir).quiet() - return ( - opencodeResult.text().trim().length > 0 || - coreResult.text().trim().length > 0 || - gatewayResult.text().trim().length > 0 || - indexingResult.text().trim().length > 0 || - sandboxResult.text().trim().length > 0 - ) - } catch { - return false - } -} - -async function isStale(): Promise { - if (await isDirty()) return true +async function isStale() { const hash = await cliSourceHash() - if (!hash) return false // can't determine — assume fresh + if (!hash) { + if (!compiledOnly) return false + try { + const stored: unknown = await Bun.file(versionFile).json() + return Reflect.get(stored, "kind") !== "compiled" + } catch { + return true + } + } try { - const stored = (await Bun.file(versionFile).text()).trim() - return stored !== hash + const stored: unknown = await Bun.file(versionFile).json() + if (!stored || typeof stored !== "object") return true + const input = Reflect.get(stored, "input") + const target = Reflect.get(stored, "target") + const kind = Reflect.get(stored, "kind") + return input !== hash || target !== platformTag() || (compiledOnly && kind !== "compiled") } catch { return true // no version file — treat as stale } } +async function writeVersion(kind: "compiled" | "wrapper") { + const input = await cliSourceHash() + if (!input) { + rmSync(versionFile, { force: true }) + return + } + await Bun.write(versionFile, JSON.stringify({ input, target: platformTag(), kind }) + "\n") +} + function platformTag(): string { const os = process.platform === "win32" ? "windows" : process.platform return `cli-${os}-${process.arch}` @@ -115,6 +207,8 @@ async function findKiloBinaryInOpencodeDist(): Promise { // fall through to generic search } + if (compiledOnly) return null + // Fallback: find any dist/**/bin/kilo or kilo.exe const queue = [distDir] while (queue.length) { @@ -214,14 +308,16 @@ async function writeSourceWrapper() { await bundleKiloSandboxWorker() await ensureLocalHelpers() - const hash = await cliSourceHash() - if (hash) await Bun.write(versionFile, hash + "\n") + await writeVersion("wrapper") log( `Compiled CLI build failed; wrote source wrapper at ${relative(kiloVscodeDir, targetBinPath)} for local development.`, ) } async function main() { + for (const file of [join(targetBinDir, ".cli-version"), join(targetBinDir, ".ffmpeg-target")]) { + rmSync(file, { force: true }) + } const targetFile = Bun.file(targetBinPath) const exists = await targetFile.exists() const ready = exists && hasTreeSitterResources(targetBinPath) && hasKiloSandboxWorker(targetBinPath) @@ -238,7 +334,7 @@ async function main() { return } - if (forceRebuild && !exists) { + if ((forceRebuild || compiledOnly) && !ready) { removeDist() } @@ -256,7 +352,7 @@ async function main() { } const sourceBinPath = await ensureBuiltBinary().catch(async (err) => { - if (forceRebuild) throw err + if (forceRebuild || compiledOnly) throw err await writeSourceWrapper() log(`Wrapper fallback reason: ${err instanceof Error ? err.message : String(err)}`) return null @@ -270,8 +366,7 @@ async function main() { chmodSync(targetBinPath, 0o755) await ensureLocalHelpers() - const hash = await cliSourceHash() - if (hash) await Bun.write(versionFile, hash + "\n") + await writeVersion("compiled") log(`Copied CLI binary from ${relative(packagesDir, sourceBinPath)} -> ${relative(kiloVscodeDir, targetBinPath)}`) } diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index c04d818b4b8..1b9a9a5244e 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -49,7 +49,6 @@ import { } from "./kilo-provider-utils" import { GitOps } from "./agent-manager/GitOps" import { GitStatsPoller, type LocalStats } from "./agent-manager/GitStatsPoller" -import { diffSummary as localDiffSummary } from "./agent-manager/local-diff" import { createMarketplaceRemover, removeMcp } from "./kilo-provider/remove-config-item" import { AgentRequirementsController } from "./kilo-provider/agent-requirements-controller" import type { RemoteStatusService } from "./services/RemoteStatusService" @@ -90,6 +89,7 @@ import { } from "./services/autocomplete/settings" import { routeEarlyMessage } from "./kilo-provider/early-message" import * as ModelState from "./kilo-provider/model-state" +import { handleModelUsageMessage } from "./kilo-provider/model-usage" import { handleForkSession } from "./kilo-provider/fork-session" import { openConfig } from "./kilo-provider/open-config" import { @@ -442,6 +442,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private cachedGitRepo = false private cachedGitDirectory: string | undefined private gitStatusRevision = 0 + private sessionRefreshRevision = 0 private onBeforeMessage: ((msg: Record) => Promise | null>) | null = null @@ -1025,6 +1026,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper copy: (text) => vscode.env.clipboard.writeText(text), openSessions: (ids) => this.trackOpenSessions(ids), speechToTextModels: () => this.fetchAndSendSpeechToTextModels(), + modelUsage: (msg) => handleModelUsageMessage(msg, this.extensionContext, (value) => this.postMessage(value)), }) ) { return @@ -1665,7 +1667,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Subscribe to SSE events for this webview (filtered by tracked sessions) this.unsubscribeEvent = this.connectionService.onEventFiltered( (payload, directory) => { - if (directory && !this.isCurrentProjectDirectory(directory)) return false + if (directory && directory !== "global" && !this.isCurrentProjectDirectory(directory)) return false if (!directory && isEventFromForeignProject(payload, this.projectID)) return false const event = unwrapSyncEvent(payload) if (!event) return false @@ -2085,7 +2087,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper /** * Build the context object used by the extracted session-refresh helpers. */ - private get sessionRefreshContext(): SessionRefreshContext { + private getSessionRefreshContext(revision: number): SessionRefreshContext { const client = this.client return { pendingSessionRefresh: this.pendingSessionRefresh, @@ -2097,6 +2099,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper sessionDirectories: this.sessionDirectories, worktreeDirectories: this.opts.worktreeDirectories, workspaceDirectory: this.getWorkspaceDirectory(), + isCurrent: () => revision === this.sessionRefreshRevision, postMessage: (msg: unknown) => this.postMessage(msg), } } @@ -2107,9 +2110,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private async flushPendingSessionRefresh(reason: string): Promise { if (!this.pendingSessionRefresh) return console.log("[Kilo New] KiloProvider: 🔄 Flushing deferred sessions refresh", { reason }) + const revision = ++this.sessionRefreshRevision const scope = this.opts.projectQualifier?.()?.projectId if (scope !== undefined) this.projectID = undefined - const ctx = this.sessionRefreshContext + const ctx = this.getSessionRefreshContext(revision) try { const resolved = await flushPendingSessionRefreshUtil(ctx) if (resolved && scope === this.opts.projectQualifier?.()?.projectId) this.projectID = resolved @@ -2123,9 +2127,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper * Handle loading all sessions. */ private async handleLoadSessions(): Promise { + const revision = ++this.sessionRefreshRevision const scope = this.opts.projectQualifier?.()?.projectId if (scope !== undefined) this.projectID = undefined - const ctx = this.sessionRefreshContext + const ctx = this.getSessionRefreshContext(revision) try { const resolved = await loadSessionsUtil(ctx) if (resolved && scope === this.opts.projectQualifier?.()?.projectId) this.projectID = resolved @@ -4054,6 +4059,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Clear globalState items that are not part of the configuration await this.extensionContext?.globalState.update("variantSelections", undefined) await this.extensionContext?.globalState.update("recentModels", undefined) + await this.extensionContext?.globalState.update("modelUsage", undefined) await this.extensionContext?.globalState.update("kilo.dismissedNotificationIds", undefined) await this.extensionContext?.globalState.update("kilo.agentMigrationBannerDismissed", undefined) await this.extensionContext?.globalState.update("kilo.marketplace.dismissedSuggestions", undefined) @@ -4071,6 +4077,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Re-send globalState items to the webview this.postMessage({ type: "variantsLoaded", variants: {} }) this.postMessage({ type: "recentsLoaded", recents: [] }) + this.postMessage({ type: "modelUsageLoaded", usage: {} }) // Re-fetch notifications to reflect cleared dismissed IDs await this.fetchAndSendNotifications() @@ -4312,7 +4319,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Drop session events from other projects before any tracking logic. // This must come first: the trackedSessionIds guard below would otherwise // let a foreign session through if it was accidentally tracked. - if (directory && !this.isCurrentProjectDirectory(directory)) return + if (directory && directory !== "global" && !this.isCurrentProjectDirectory(directory)) return if ( this.projectID && (!this.opts.projectQualifier || !directory) && @@ -4754,6 +4761,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private isCurrentProjectSession(sessionID: string): boolean { if (!this.opts.projectQualifier || !this.opts.routeService) return true + if (this.isSessionRouteAmbiguous(sessionID)) return false const directory = this.opts.routeService.trySessionDirectory(sessionID) return !directory || this.isCurrentProjectDirectory(directory) } @@ -4798,7 +4806,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (revision !== this.gitStatusRevision) return const found = repo || root !== undefined const target = root ?? directory - if (!this.cachedGitDirectory || !sameDirectory(this.cachedGitDirectory, target)) this.cachedStats = null + const changed = !this.cachedGitDirectory || !sameDirectory(this.cachedGitDirectory, target) + if (changed) { + this.cachedStats = null + this.statsPoller?.stop() + this.statsPoller = null + this.statsGitOps?.dispose() + this.statsGitOps = null + } this.cachedGitDirectory = target this.cachedGitRepo = found this.postMessage({ type: "gitStatus", repo: found }) @@ -4929,7 +4944,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.statsPoller = new GitStatsPoller({ getWorktrees: () => [], getWorkspaceRoot: () => this.cachedGitDirectory ?? this.getWorkspaceDirectory(this.currentSession?.id), - localDiff: (dir, base) => localDiffSummary(git, dir, base), git, onStats: () => {}, onLocalStats: (stats: LocalStats) => { diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index ebfe467e076..490fb674ff1 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -27,6 +27,7 @@ import { GitStatsPoller, type LocalStats, type WorktreePresenceResult, type Work import { PRStatusBridge } from "./pr-status-bridge" import { createPollers, type ProjectPollers } from "./project/pollers" import { GitOps } from "./GitOps" +import type { GitExecutable } from "../util/git-executable" import { versionedName } from "./branch-name" import { BranchNamingController } from "./branch-naming" import { SetupScriptService } from "./SetupScriptService" @@ -111,6 +112,7 @@ export class AgentManagerProvider implements Disposable { private onVisibilityChange: ((visible: boolean) => void) | undefined // Tracks sessions owned by this panel until they are explicitly closed. private panelSessions = new Set() + private busySessions = new Set() /** Session ID most recently loaded via `loadMessages`; updated synchronously. */ private activeSessionId: string | undefined @@ -122,6 +124,7 @@ export class AgentManagerProvider implements Disposable { constructor( private readonly host: Host, private readonly connectionService: KiloConnectionService, + binary: GitExecutable = () => Promise.resolve("git"), ) { this.outputChannel = host.createOutput("Kilo Agent Manager") this.terminalManager = new SessionTerminalManager( @@ -175,7 +178,7 @@ export class AgentManagerProvider implements Disposable { log: (...args) => this.log(...args), }) const semaphore = new Semaphore(3) - this.gitOps = new GitOps({ log: (...args) => this.log(...args), semaphore }) + this.gitOps = new GitOps({ log: (...args) => this.log(...args), semaphore, binary }) const wiring = createProjectWiring({ host: this.host, git: this.gitOps, @@ -219,6 +222,24 @@ export class AgentManagerProvider implements Disposable { state: () => this.state, root: () => this.getRoot(), activeId: () => this.contexts.active()?.id, + hot: () => { + const ids = new Set() + const target = this.state?.getActiveTarget() + if (target?.kind === "worktree") ids.add(target.worktreeId) + if (target?.kind === "session") { + const id = this.state?.getSession(target.sessionId)?.worktreeId + if (id) ids.add(id) + } + for (const status of this.run.state().runStatuses) { + if (status.state === "running" || status.state === "stopping") ids.add(status.worktreeId) + } + for (const sid of this.busySessions) { + const owner = this.contexts.byLiveSession(sid) + const id = owner?.peekState()?.getSession(sid)?.worktreeId ?? this.state?.getSession(sid)?.worktreeId + if (id) ids.add(id) + } + return ids + }, visible: () => this.panel?.visible ?? false, post: (msg) => this.postToWebview(msg), cache: (msg) => { @@ -261,7 +282,12 @@ export class AgentManagerProvider implements Disposable { this.unsubSessions = this.connectionService.onEventFiltered( (event) => { const type = (event as { type?: string }).type - return type === "session.created" || type === "session.updated" || type === "session.deleted" + return ( + type === "session.created" || + type === "session.updated" || + type === "session.deleted" || + type === "session.error" + ) }, (event) => this.onSessionLifecycle(event), ) @@ -274,9 +300,14 @@ export class AgentManagerProvider implements Disposable { */ private onSessionLifecycle(event: unknown): void { const ev = event as { type?: string; properties?: { info?: Session; sessionID?: string } } + if (ev.type === "session.error") { + if (ev.properties?.sessionID) this.busySessions.delete(ev.properties.sessionID) + return + } if (ev.type === "session.deleted") { const id = ev.properties?.sessionID if (!id) return + this.busySessions.delete(id) const ctx = this.contexts.byLiveSession(id) if (!ctx) return ctx.removeLiveSession(id) @@ -307,8 +338,13 @@ export class AgentManagerProvider implements Disposable { const sid = props?.sessionID const type = props?.status?.type if (!sid || !type) return - if (type === "idle") this.naming.idle(sid) - else this.naming.busy(sid) + if (type === "idle") { + this.busySessions.delete(sid) + this.naming.idle(sid) + return + } + this.busySessions.add(sid) + this.naming.busy(sid) } private log(...args: unknown[]) { @@ -393,6 +429,7 @@ export class AgentManagerProvider implements Disposable { const ids = [...this.panelSessions] if (this.activeSessionId) ids.push(this.activeSessionId) this.panelSessions.clear() + this.busySessions.clear() void ctx.sessions.abortSessions(ids).catch((err) => this.log("Failed to abort sessions on panel close:", err)) this.statsPoller.stop() this.projectPollers.dispose() @@ -774,15 +811,15 @@ export class AgentManagerProvider implements Disposable { private onImportMessage(m: AgentManagerInMessage): Record | null | undefined { if (m.type === "agentManager.requestBranches") { - void this.importer.branches() + void this.importer.branches(m.projectId) return null } if (m.type === "agentManager.importFromBranch") { - void this.importer.branch(m.branch) + void this.importer.branch(m.branch, m.projectId) return null } if (m.type === "agentManager.importFromPR") { - void this.importer.pr(m.url) + void this.importer.pr(m.url, m.projectId) return null } } @@ -1018,6 +1055,7 @@ export class AgentManagerProvider implements Disposable { this.pushState() this.postToWebview({ type: "agentManager.worktreeSetup", + projectId: this.host.multiProject() ? this.context?.id : undefined, status: "ready", message: "Worktree ready", sessionId, @@ -1557,6 +1595,7 @@ export class AgentManagerProvider implements Disposable { this.statsPoller.stop() this.prBridge.reset() this.activeSessionId = undefined + this.busySessions.clear() this.cachedWorktreeStats = this.cachedLocalStats = undefined void this.sendRepoInfo() if (!reactivateProject(ctx, this.panel?.sessions, (c) => this.pushState(c))) diff --git a/packages/kilo-vscode/src/agent-manager/GitOps.ts b/packages/kilo-vscode/src/agent-manager/GitOps.ts index ea26906b893..d33799e0c06 100644 --- a/packages/kilo-vscode/src/agent-manager/GitOps.ts +++ b/packages/kilo-vscode/src/agent-manager/GitOps.ts @@ -2,6 +2,7 @@ import * as nodePath from "path" import * as os from "os" import * as fs from "fs/promises" import { spawn } from "../util/process" +import type { GitExecutable } from "../util/git-executable" import simpleGit from "simple-git" import { parseWorktreeList, @@ -18,6 +19,8 @@ interface GitOpsOptions { runGit?: (args: string[], cwd: string) => Promise /** Shared concurrency gate for child process spawning. */ semaphore?: Semaphore + /** Validated Git executable shared by Agent Manager operations. */ + binary?: GitExecutable } export interface ApplyConflict { @@ -96,6 +99,9 @@ export class GitOps { private readonly runGit: (args: string[], cwd: string) => Promise private readonly controller = new AbortController() private readonly semaphore: Semaphore | undefined + private readonly binary: GitExecutable + private readonly injected: boolean + private executableCache: Promise | undefined private readonly resolutionCache = new Map() private static readonly CACHE_TTL_MS = 60000 private static readonly MAX_CACHE_SIZE = 100 @@ -107,12 +113,19 @@ export class GitOps { constructor(options: GitOpsOptions) { this.log = options.log this.semaphore = options.semaphore + this.binary = options.binary ?? (() => Promise.resolve("git")) + this.injected = options.runGit !== undefined this.runGit = options.runGit ?? - ((args, cwd) => - simpleGit(cwd, { abort: this.controller.signal }) + (async (args, cwd) => { + const binary = await this.executable() + return simpleGit(cwd, { + abort: this.controller.signal, + binary, + }) .raw(args) - .then((out) => out.trim())) + .then((out) => out.trim()) + }) } dispose(): void { @@ -148,22 +161,28 @@ export class GitOps { private raw(args: string[], cwd: string): Promise { const signal = this.controller.signal if (signal.aborted) return Promise.reject(new Error("GitOps disposed")) - const invoke = () => - new Promise((resolve, reject) => { - const onAbort = () => reject(new Error("GitOps disposed")) - signal.addEventListener("abort", onAbort, { once: true }) - this.runGit(args, cwd).then( - (value) => { - signal.removeEventListener("abort", onAbort) - resolve(value) - }, - (err) => { - signal.removeEventListener("abort", onAbort) - reject(err) - }, - ) - }) - return this.semaphore ? this.semaphore.run(invoke) : invoke() + return this.executable().then(() => { + if (signal.aborted) throw new Error("GitOps disposed") + const invoke = () => { + const pending = this.runGit(args, cwd) + if (!this.injected) return pending + return new Promise((resolve, reject) => { + const onAbort = () => reject(new Error("GitOps disposed")) + signal.addEventListener("abort", onAbort, { once: true }) + pending.then( + (value) => { + signal.removeEventListener("abort", onAbort) + resolve(value) + }, + (err) => { + signal.removeEventListener("abort", onAbort) + reject(err) + }, + ) + }) + } + return this.semaphore ? this.semaphore.run(invoke) : invoke() + }) } /** Return the name of the currently checked-out branch, or `"HEAD"` if detached. */ @@ -551,43 +570,79 @@ export class GitOps { return { code: result.code, stdout: result.stdout.toString("utf8"), stderr: result.stderr } } - private execBuffer(args: string[], cwd: string, options?: ExecOptions): Promise { + private async execBuffer(args: string[], cwd: string, options?: ExecOptions): Promise { + if (this.controller.signal.aborted) { + return { code: 1, stdout: Buffer.alloc(0), stderr: "GitOps disposed" } + } + const cmd = await this.executable().catch(() => undefined) + if (!cmd || this.controller.signal.aborted) { + return { code: 1, stdout: Buffer.alloc(0), stderr: "GitOps disposed" } + } + const invoke = () => this.invoke(cmd, args, cwd, options) + return this.semaphore ? this.semaphore.run(invoke) : invoke() + } + + private executable(): Promise { + const signal = this.controller.signal + if (signal.aborted) return Promise.reject(new Error("GitOps disposed")) + + return new Promise((resolve, reject) => { + const onAbort = () => reject(new Error("GitOps disposed")) + signal.addEventListener("abort", onAbort, { once: true }) + const cache = (this.executableCache ??= Promise.resolve().then(() => this.binary())) + cache.then( + (value) => { + signal.removeEventListener("abort", onAbort) + resolve(value) + }, + (err) => { + signal.removeEventListener("abort", onAbort) + if (this.executableCache === cache) this.executableCache = undefined + reject(err) + }, + ) + }) + } + + private invoke(cmd: string, args: string[], cwd: string, options?: ExecOptions): Promise { if (this.controller.signal.aborted) { return Promise.resolve({ code: 1, stdout: Buffer.alloc(0), stderr: "GitOps disposed" }) } - const invoke = () => - new Promise((resolve) => { - const child = spawn("git", args, { - cwd, - env: options?.env, - signal: this.controller.signal, - stdio: ["pipe", "pipe", "pipe"], - }) - if (options?.stdin !== undefined) { - if (!child.stdin) { - resolve({ code: 1, stdout: Buffer.alloc(0), stderr: "stdin not available for git process" }) - return - } - child.stdin.end(options.stdin) - } + return new Promise((resolve) => { + const child = spawn(cmd, args, { + cwd, + env: options?.env, + stdio: ["pipe", "pipe", "pipe"], + }) + const out: Buffer[] = [] + const err: Buffer[] = [] + let failure: string | undefined + const abort = () => child.kill("SIGTERM") - const out: Buffer[] = [] - const err: Buffer[] = [] - child.stdout?.on("data", (chunk: Buffer) => out.push(chunk)) - child.stderr?.on("data", (chunk: Buffer) => err.push(chunk)) + this.controller.signal.addEventListener("abort", abort, { once: true }) + child.stdout?.on("data", (chunk: Buffer) => out.push(chunk)) + child.stderr?.on("data", (chunk: Buffer) => err.push(chunk)) - child.on("error", (error) => { - resolve({ code: 1, stdout: Buffer.alloc(0), stderr: error.message }) - }) - child.on("close", (code) => { - resolve({ - code: code ?? 1, - stdout: Buffer.concat(out), - stderr: Buffer.concat(err).toString("utf8"), - }) + child.on("error", (error) => { + failure = error.message + }) + child.on("close", (code) => { + this.controller.signal.removeEventListener("abort", abort) + resolve({ + code: code ?? 1, + stdout: Buffer.concat(out), + stderr: failure ?? Buffer.concat(err).toString("utf8"), }) }) - return this.semaphore ? this.semaphore.run(invoke) : invoke() + + if (options?.stdin === undefined) return + if (child.stdin) { + child.stdin.end(options.stdin) + return + } + failure = "stdin not available for git process" + child.kill("SIGTERM") + }) } } diff --git a/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts b/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts index 188885497f0..11e513f32c5 100644 --- a/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts +++ b/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts @@ -4,7 +4,14 @@ import { remoteRef, type Worktree } from "./WorktreeStateManager" import type { GitOps } from "./GitOps" import type { Semaphore } from "./semaphore" import { findTrackedBranch } from "./project/paths" -import type { WorktreeDiffEntry } from "./types" +import { + GitStatsSnapshot, + refOID, + shortRef, + type DiffStats, + type RefSnapshot, + type GitStatsSource, +} from "./git-stats-snapshot" export interface WorktreeStats { worktreeId: string @@ -39,12 +46,9 @@ export interface WorktreePresenceResult { interface GitStatsPollerOptions { getWorktrees: () => Worktree[] getWorkspaceRoot: () => string | undefined - /** - * Compute diff summaries locally (in the extension host) rather than over - * HTTP to `kilo serve`. Keeps git spawning out of the Bun process, which - * leaks native memory on Windows (oven-sh/bun#18265). - */ - localDiff: (dir: string, base: string) => Promise + getHotWorktreeIds?: () => Set + /** Override the real Git source in scheduler and failure-path tests. */ + source?: GitStatsSource git: GitOps onStats: (stats: WorktreeStats[]) => void onLocalStats: (stats: LocalStats) => void @@ -54,6 +58,7 @@ interface GitStatsPollerOptions { /** Shared concurrency gate for child process spawning. */ semaphore?: Semaphore hiddenIntervalMs?: number + dormantIntervalMs?: number } export class GitStatsPoller { @@ -66,15 +71,22 @@ export class GitStatsPoller { private lastStats: Record = {} private readonly intervalMs: number private readonly hiddenIntervalMs: number + private readonly dormantIntervalMs: number private readonly git: GitOps + private readonly snapshots: GitStatsSource + private readonly cache = new Map() + private localCache: CachedStats | undefined private skipWorktreeIds = new Set() private visible = true private generation = 0 + private cursor = 0 constructor(private readonly options: GitStatsPollerOptions) { this.intervalMs = options.intervalMs ?? 5000 this.hiddenIntervalMs = options.hiddenIntervalMs ?? 60000 + this.dormantIntervalMs = options.dormantIntervalMs ?? 30000 this.git = options.git + this.snapshots = options.source ?? new GitStatsSnapshot(options.git) } setVisible(visible: boolean): void { @@ -129,9 +141,22 @@ export class GitStatsPoller { this.lastLocalHash = undefined this.lastLocalStats = undefined this.lastStats = {} + this.cache.clear() + this.localCache = undefined + this.cursor = 0 } - async snapshot(): Promise<{ worktrees: WorktreeStats[]; local?: LocalStats }> { + async snapshot(refresh = false): Promise<{ worktrees: WorktreeStats[]; local?: LocalStats }> { + if (refresh && !this.busy) { + this.busy = true + const refs = await this.fetchRefs() + await Promise.all([ + this.fetchWorktreeStats(true, this.generation, refs), + this.fetchLocalStats(this.generation, refs, true), + ]).finally(() => { + this.busy = false + }) + } return { worktrees: Object.values(this.lastStats), ...(this.lastLocalStats ? { local: this.lastLocalStats } : {}), @@ -164,10 +189,15 @@ export class GitStatsPoller { } private async fetch(generation = this.generation): Promise { - await Promise.all([this.fetchWorktreeStats(generation), this.fetchLocalStats(generation)]) + const refs = await this.fetchRefs() + await Promise.all([this.fetchWorktreeStats(false, generation, refs), this.fetchLocalStats(generation, refs)]) } - private async fetchWorktreeStats(generation = this.generation): Promise { + private async fetchWorktreeStats( + includeSkipped = false, + generation = this.generation, + refs?: RefSnapshot, + ): Promise { const worktrees = this.options.getWorktrees() if (worktrees.length === 0) return @@ -181,9 +211,13 @@ export class GitStatsPoller { const available = worktrees.filter((wt) => !missing.has(wt.id)) const ids = new Set(available.map((wt) => wt.id)) for (const id of Object.keys(this.lastStats)) { - if (!ids.has(id)) delete this.lastStats[id] + if (!ids.has(id)) { + delete this.lastStats[id] + this.cache.delete(id) + } } - const active = available.filter((wt) => !this.skipWorktreeIds.has(wt.id)) + const candidates = includeSkipped ? available : available.filter((wt) => !this.skipWorktreeIds.has(wt.id)) + const active = includeSkipped ? candidates : this.select(candidates) if (active.length === 0) { if (available.length > 0) return if (this.lastHash === "") return @@ -193,29 +227,7 @@ export class GitStatsPoller { return } - // localDiff runs in-process via GitOps.execGit() which already acquires - // the shared semaphore internally; same goes for aheadBehind via - // GitOps.raw(). Wrapping either again here would deadlock. - const stats = ( - await Promise.all( - active.map(async (wt) => { - try { - const base = remoteRef(wt) - const [diffs, ab] = await Promise.all([ - this.options.localDiff(wt.path, base), - this.git.aheadBehind(wt.path, base), - ]) - const files = diffs.length - const additions = diffs.reduce((sum, diff) => sum + diff.additions, 0) - const deletions = diffs.reduce((sum, diff) => sum + diff.deletions, 0) - return { worktreeId: wt.id, files, additions, deletions, ahead: ab.ahead, behind: ab.behind } - } catch (err) { - this.options.log(`Failed to fetch worktree stats for ${wt.branch} (${wt.path}):`, err) - return this.lastStats[wt.id] - } - }), - ) - ).filter((item): item is WorktreeStats => !!item) + const stats = await this.fetchOptimized(active, refs, includeSkipped) if (generation !== this.generation) return for (const item of stats) this.lastStats[item.worktreeId] = item @@ -229,6 +241,70 @@ export class GitStatsPoller { this.options.onStats(visible) } + private async fetchOptimized(worktrees: Worktree[], refs: RefSnapshot | undefined, refresh: boolean) { + const rows = await Promise.all( + worktrees.map(async (wt) => { + const base = remoteRef(wt) + const baseOID = refOID(refs, base) + try { + const status = await this.snapshots.status(wt.path) + const cached = this.cache.get(wt.id) + const same = + !refresh && + !!baseOID && + cached?.base === base && + cached.baseOID === baseOID && + cached.fingerprint === status.fingerprint + const diff = same ? cached.diff : await this.snapshots.diff(wt.path, base, status.untracked) + const ahead = + !refresh && baseOID && cached?.head === status.head && cached.baseOID === baseOID + ? cached.ahead + : await this.git.aheadBehind(wt.path, base) + return { wt, base, baseOID, status, diff, ahead } + } catch (err) { + this.options.log(`Failed to fetch worktree stats for ${wt.branch} (${wt.path}):`, err) + return { wt, prior: this.lastStats[wt.id] } + } + }), + ) + return rows + .map((row) => { + if ("prior" in row) return row.prior + const stats = { worktreeId: row.wt.id, ...row.diff, ...row.ahead } + this.cache.set(row.wt.id, { + base: row.base, + baseOID: row.baseOID, + head: row.status.head, + fingerprint: row.status.fingerprint, + diff: row.diff, + dirty: row.status.dirty, + clean: row.status.dirty ? 0 : (this.cache.get(row.wt.id)?.clean ?? 0) + 1, + ahead: row.ahead, + }) + return stats + }) + .filter((item): item is WorktreeStats => !!item) + } + + private select(worktrees: Worktree[]): Worktree[] { + if (!this.visible || worktrees.length === 0) return worktrees + const hot = this.options.getHotWorktreeIds?.() ?? new Set() + const selected = worktrees.filter((wt) => { + const cached = this.cache.get(wt.id) + return hot.has(wt.id) || !cached || cached.dirty || cached.clean < 2 + }) + const ids = new Set(selected.map((wt) => wt.id)) + const dormant = worktrees.filter((wt) => !ids.has(wt.id)) + if (dormant.length === 0) return selected + const ticks = Math.max(1, Math.ceil(this.dormantIntervalMs / this.intervalMs)) + const count = Math.max(1, Math.ceil(dormant.length / ticks)) + for (let i = 0; i < count; i++) { + selected.push(dormant[(this.cursor + i) % dormant.length]!) + } + this.cursor = (this.cursor + count) % dormant.length + return selected + } + private hash(stats: WorktreeStats[]): string { return stats .map( @@ -267,60 +343,95 @@ export class GitStatsPoller { return { worktrees: worktreeStatuses, degraded: false } } - private async fetchLocalStats(generation = this.generation): Promise { + private async fetchLocalStats(generation = this.generation, refs?: RefSnapshot, refresh = false): Promise { const root = this.options.getWorkspaceRoot() if (!root) return try { - const branch = await this.git.currentBranch(root) + const status = await this.snapshots.status(root) + const branch = status.branch if (!branch || branch === "HEAD") return - - const tracking = await this.git.resolveTrackingBranch(root, branch) - const base = tracking ?? (await this.git.resolveDefaultBranch(root, branch)) - - let files: number - let additions: number - let deletions: number - let ahead: number - let behind: number - try { - if (base) { - this.options.log(`Local stats: using localDiff with base=${base}`) - const [diffs, ab] = await Promise.all([this.options.localDiff(root, base), this.git.aheadBehind(root, base)]) - files = diffs.length - additions = diffs.reduce((sum, d) => sum + d.additions, 0) - deletions = diffs.reduce((sum, d) => sum + d.deletions, 0) - ahead = ab.ahead - behind = ab.behind - } else { - this.options.log(`Local stats: fallback to workingTreeStats (no base branch)`) - const wt = await this.git.workingTreeStats(root) - files = wt.files - additions = wt.additions - deletions = wt.deletions - ahead = 0 - behind = 0 - } - } catch (err) { + const stats = await this.local(root, branch, status, refs, refresh).catch((err) => { this.options.log("Failed to fetch local diff stats:", err) - if (this.lastLocalStats && this.lastLocalStats.branch === branch) return - return - } + return undefined + }) + if (!stats) return if (generation !== this.generation) return - const hash = `local:${branch}:${files}:${additions}:${deletions}:${ahead}:${behind}` + const hash = `local:${branch}:${stats.files}:${stats.additions}:${stats.deletions}:${stats.ahead}:${stats.behind}` if (hash === this.lastLocalHash) { this.options.log(`Local stats: unchanged (${hash})`) return } this.lastLocalHash = hash - this.options.log(`Local stats: emitting files=${files} +${additions} -${deletions} ↑${ahead} ↓${behind}`) - const stats: LocalStats = { branch, files, additions, deletions, ahead, behind } + this.options.log( + `Local stats: emitting files=${stats.files} +${stats.additions} -${stats.deletions} ↑${stats.ahead} ↓${stats.behind}`, + ) this.lastLocalStats = stats this.options.onLocalStats(stats) } catch (err) { this.options.log("Failed to fetch local stats:", err) } } + + private async local( + root: string, + branch: string, + status: Awaited>, + refs: RefSnapshot | undefined, + refresh: boolean, + ): Promise { + const trackingRef = refs?.upstreams.get(`refs/heads/${branch}`) + const tracking = trackingRef ? shortRef(trackingRef) : await this.git.resolveTrackingBranch(root, branch) + const base = tracking ?? (await this.git.resolveDefaultBranch(root, branch)) + if (!base) { + const stats = await this.git.workingTreeStats(root) + return { branch, ...stats, ahead: 0, behind: 0 } + } + const baseOID = refOID(refs, base) + const cached = this.localCache + const same = + !refresh && + !!baseOID && + cached?.base === base && + cached.baseOID === baseOID && + cached.fingerprint === status.fingerprint + const diff = same ? cached.diff : await this.snapshots.diff(root, base, status.untracked) + const ahead = + !refresh && baseOID && cached?.head === status.head && cached.baseOID === baseOID + ? cached.ahead + : await this.git.aheadBehind(root, base) + this.localCache = { + base, + baseOID, + head: status.head, + fingerprint: status.fingerprint, + diff, + dirty: status.dirty, + clean: status.dirty ? 0 : (cached?.clean ?? 0) + 1, + ahead, + } + return { branch, ...diff, ...ahead } + } + + private async fetchRefs(): Promise { + const root = this.options.getWorkspaceRoot() + if (!root) return undefined + return this.snapshots.refs(root).catch((err) => { + this.options.log("Failed to read project refs:", err) + return undefined + }) + } +} + +interface CachedStats { + base: string + baseOID?: string + head: string + fingerprint: string + diff: DiffStats + dirty: boolean + clean: number + ahead: { ahead: number; behind: number } } diff --git a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts index 155bd410bba..56ced904d57 100644 --- a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts +++ b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts @@ -2,6 +2,7 @@ import type { ExecFileOptionsWithStringEncoding } from "child_process" import type { Worktree } from "./WorktreeStateManager" import type { PRStatus, PRCheck, PRComment, CheckStatus, AggregateCheckStatus, PRState, ReviewDecision } from "./types" import { execWithShellEnv } from "./shell-env" +import { execGhRead } from "./gh" import { classifyPRError } from "./git-import" import type { Semaphore } from "./semaphore" @@ -59,6 +60,14 @@ export class PRStatusPoller { return this.semaphore ? this.semaphore.run(invoke) : invoke() } + private gh( + args: string[], + options?: Omit, + ): Promise<{ stdout: string; stderr: string }> { + const invoke = () => execGhRead(args, options) + return this.semaphore ? this.semaphore.run(invoke) : invoke() + } + setEnabled(enabled: boolean): void { if (enabled) { if (this.active) return @@ -166,7 +175,7 @@ export class PRStatusPoller { return this.ghAvailable } try { - await this.shell("gh", ["--version"], { timeout: 5_000 }) + await this.gh(["--version"], { timeout: 5_000 }) this.ghAvailable = true } catch { this.ghAvailable = false @@ -311,7 +320,7 @@ export class PRStatusPoller { if (branch) args.push(branch) args.push("--json", PRStatusPoller.PR_JSON_FIELDS) - const { stdout } = await this.shell("gh", args, { cwd, timeout: 15_000 }) + const { stdout } = await this.gh(args, { cwd, timeout: 15_000 }) return parsePRResult(stdout) } catch (err) { const msg = err instanceof Error ? err.message : String(err) @@ -327,8 +336,7 @@ export class PRStatusPoller { const head = sha.trim() if (!head) return null - const { stdout } = await this.shell( - "gh", + const { stdout } = await this.gh( [ "pr", "list", @@ -369,8 +377,7 @@ export class PRStatusPoller { items: PRCheck[] }> { try { - const { stdout } = await this.shell( - "gh", + const { stdout } = await this.gh( ["pr", "checks", String(prNumber), "--json", "name,state,link,startedAt,completedAt"], { cwd, timeout: 15_000 }, ) @@ -407,7 +414,7 @@ export class PRStatusPoller { if (this.cachedRepo && this.cachedRepo.cwd === cwd) { return this.cachedRepo } - const { stdout } = await this.shell("gh", ["repo", "view", "--json", "owner,name"], { + const { stdout } = await this.gh(["repo", "view", "--json", "owner,name"], { cwd, timeout: 10_000, }) @@ -447,8 +454,7 @@ export class PRStatusPoller { } }` - const { stdout } = await this.shell( - "gh", + const { stdout } = await this.gh( [ "api", "graphql", diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts index 32f2fb1b9f9..4879b32af35 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts @@ -13,6 +13,7 @@ import simpleGit, { type SimpleGit } from "simple-git" import { generateBranchName, sanitizeBranchName } from "./branch-name" import { type GitOps, isKiloOwnedSshCommand, nonInteractiveEnv } from "./GitOps" import { execWithShellEnv } from "./shell-env" +import { execGhRead } from "./gh" import { markNoIndex } from "../util/spotlight" import { parsePRUrl, @@ -1043,8 +1044,7 @@ export class WorktreeManager { private async fetchPRInfo(parsed: { owner: string; repo: string; number: number }): Promise { try { - const json = await this.exec( - "gh", + const json = await this.gh( [ "pr", "view", @@ -1100,6 +1100,11 @@ export class WorktreeManager { return stdout } + private async gh(args: string[], timeout = 120000): Promise { + const { stdout } = await execGhRead(args, { cwd: this.root, timeout }) + return stdout + } + private async gitExec(args: string[]): Promise { await this.exec("git", args) } diff --git a/packages/kilo-vscode/src/agent-manager/gh.ts b/packages/kilo-vscode/src/agent-manager/gh.ts new file mode 100644 index 00000000000..5794f2f2b91 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/gh.ts @@ -0,0 +1,18 @@ +import type { ExecFileOptionsWithStringEncoding } from "child_process" +import { execWithShellEnv } from "./shell-env" + +function env(options?: Omit): NodeJS.ProcessEnv { + const result = options?.env ? { ...options.env } : { ...process.env } + const tz = Object.keys(result).find((key) => key.toLowerCase() === "tz") + if (!tz) result.TZ = "UTC" + return result +} + +/** Run read-only gh queries without tzutil console windows flashing on Windows. */ +export function execGhRead( + args: string[], + options?: Omit, +): Promise<{ stdout: string; stderr: string }> { + if (process.platform !== "win32") return execWithShellEnv("gh", args, options) + return execWithShellEnv("gh", args, { ...options, env: env(options) }) +} diff --git a/packages/kilo-vscode/src/agent-manager/git-stats-snapshot.ts b/packages/kilo-vscode/src/agent-manager/git-stats-snapshot.ts new file mode 100644 index 00000000000..bfe2f02b8ed --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/git-stats-snapshot.ts @@ -0,0 +1,222 @@ +import { createHash } from "crypto" +import * as fs from "fs/promises" +import { binaryFile } from "../diff/shared/binary" +import { resolveInside } from "../diff/shared/path" +import type { GitOps } from "./GitOps" + +const MAX_BYTES = 1_000_000 + +export interface DiffStats { + files: number + additions: number + deletions: number +} + +export interface StatusSnapshot { + branch: string + dirty: boolean + head: string + fingerprint: string + untracked: string[] +} + +export interface RefSnapshot { + oids: Map + upstreams: Map +} + +export interface GitStatsSource { + status(dir: string): Promise + refs(root: string): Promise + diff(dir: string, base: string, untracked: string[]): Promise +} + +interface PathState { + file: string + missing: boolean +} + +function tail(record: string, fields: number): string | undefined { + let offset = 0 + for (let i = 0; i < fields; i++) { + const next = record.indexOf(" ", offset) + if (next === -1) return undefined + offset = next + 1 + } + return record.slice(offset) +} + +function records(raw: Buffer): { branch: string; head: string; paths: PathState[]; untracked: string[] } { + const items = raw.toString("utf8").split("\0") + const paths: PathState[] = [] + const untracked: string[] = [] + let branch = "" + let head = "" + + for (let i = 0; i < items.length; i++) { + const item = items[i] + if (!item) continue + if (item.startsWith("# branch.oid ")) { + head = item.slice(13) + continue + } + if (item.startsWith("# branch.head ")) { + const value = item.slice(14) + branch = value === "(detached)" ? "HEAD" : value + continue + } + if (item.startsWith("? ")) { + const file = item.slice(2) + untracked.push(file) + paths.push({ file, missing: false }) + continue + } + if (item.startsWith("1 ")) { + const file = tail(item, 8) + if (file) paths.push({ file, missing: item.slice(2, 4).includes("D") }) + continue + } + if (item.startsWith("2 ")) { + const file = tail(item, 9) + if (file) paths.push({ file, missing: false }) + i++ + continue + } + if (item.startsWith("u ")) { + const file = tail(item, 10) + if (file) paths.push({ file, missing: true }) + } + } + + return { branch, head, paths, untracked } +} + +async function fingerprint(dir: string, raw: Buffer, paths: PathState[]): Promise { + const hash = createHash("sha256").update(raw) + const unique = new Map(paths.map((item) => [item.file, item])) + const files = [...unique.values()].sort((a, b) => a.file.localeCompare(b.file)) + + for (const item of files) { + const full = resolveInside(dir, item.file) + if (!full) return undefined + const stat = await fs.lstat(full, { bigint: true }).catch(() => undefined) + if (!stat) { + if (!item.missing) return undefined + hash.update(`\0${item.file}\0missing`) + continue + } + hash.update(`\0${item.file}\0${stat.dev}:${stat.ino}:${stat.mode}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`) + } + return hash.digest("hex") +} + +function numstat(raw: Buffer): DiffStats { + const result = { files: 0, additions: 0, deletions: 0 } + for (const item of raw.toString("utf8").split("\0")) { + if (!item) continue + const first = item.indexOf("\t") + const second = item.indexOf("\t", first + 1) + if (first === -1 || second === -1) continue + result.files++ + const additions = item.slice(0, first) + const deletions = item.slice(first + 1, second) + if (additions !== "-") result.additions += parseInt(additions, 10) || 0 + if (deletions !== "-") result.deletions += parseInt(deletions, 10) || 0 + } + return result +} + +async function lines(file: string): Promise { + const stat = await fs.lstat(file).catch(() => undefined) + if (!stat || stat.size === 0 || stat.size > MAX_BYTES) return 0 + if (await binaryFile(file)) return 0 + const content = stat.isSymbolicLink() + ? await fs.readlink(file).catch(() => "") + : await fs.readFile(file, "utf8").catch(() => "") + if (!content) return 0 + return content.endsWith("\n") ? content.split("\n").length - 1 : content.split("\n").length +} + +export function refOID(refs: RefSnapshot | undefined, ref: string): string | undefined { + if (!refs) return undefined + if (ref.startsWith("refs/")) return refs.oids.get(ref) + return refs.oids.get(`refs/remotes/${ref}`) ?? refs.oids.get(`refs/heads/${ref}`) ?? refs.oids.get(ref) +} + +export function shortRef(ref: string): string { + if (ref.startsWith("refs/remotes/")) return ref.slice(13) + if (ref.startsWith("refs/heads/")) return ref.slice(11) + return ref +} + +export class GitStatsSnapshot implements GitStatsSource { + constructor(private readonly git: GitOps) {} + + async status(dir: string): Promise { + const result = await this.git.execGitBuffer( + [ + "--no-optional-locks", + "status", + "--porcelain=v2", + "--branch", + "-z", + "--no-ahead-behind", + "--untracked-files=all", + "--no-renames", + ], + dir, + ) + if (result.code !== 0) throw new Error(result.stderr.trim() || "git status failed") + const parsed = records(result.stdout) + if (!parsed.head || !parsed.branch) throw new Error("git status returned incomplete branch data") + const stamp = await fingerprint(dir, result.stdout, parsed.paths) + if (!stamp) throw new Error("worktree changed while status was being sampled") + return { + branch: parsed.branch, + dirty: parsed.paths.length > 0, + head: parsed.head, + fingerprint: stamp, + untracked: parsed.untracked, + } + } + + async refs(root: string): Promise { + const result = await this.git.execGitBuffer( + ["for-each-ref", "--format=%(refname)%00%(objectname)%00%(upstream)%00", "refs/heads", "refs/remotes"], + root, + ) + if (result.code !== 0) throw new Error(result.stderr.trim() || "git for-each-ref failed") + const oids = new Map() + const upstreams = new Map() + for (const line of result.stdout.toString("utf8").split("\n")) { + if (!line) continue + const [ref, oid, upstream] = line.split("\0") + if (!ref || !oid) continue + oids.set(ref, oid) + if (upstream) upstreams.set(ref, upstream) + } + return { oids, upstreams } + } + + async diff(dir: string, base: string, untracked: string[]): Promise { + const ancestor = await this.git.execGit(["merge-base", "HEAD", base], dir) + if (ancestor.code !== 0) throw new Error(ancestor.stderr.trim() || "git merge-base failed") + const result = await this.git.execGitBuffer( + ["-c", "core.quotepath=false", "diff", "--numstat", "-z", "--no-renames", ancestor.stdout.trim()], + dir, + ) + if (result.code !== 0) throw new Error(result.stderr.trim() || "git diff failed") + const stats = numstat(result.stdout) + const counts = await Promise.all( + untracked.map(async (file) => { + const full = resolveInside(dir, file) + return full ? lines(full) : 0 + }), + ) + return { + files: stats.files + untracked.length, + additions: stats.additions + counts.reduce((sum, count) => sum + count, 0), + deletions: stats.deletions, + } + } +} diff --git a/packages/kilo-vscode/src/agent-manager/project/pollers.ts b/packages/kilo-vscode/src/agent-manager/project/pollers.ts index 04bc8f023b6..56384b72063 100644 --- a/packages/kilo-vscode/src/agent-manager/project/pollers.ts +++ b/packages/kilo-vscode/src/agent-manager/project/pollers.ts @@ -13,13 +13,12 @@ import type { GitOps } from "../GitOps" import { GitStatsPoller, type LocalStats, type WorktreePresenceResult, type WorktreeStats } from "../GitStatsPoller" -import { diffSummary } from "../local-diff" import { PRStatusBridge } from "../pr-status-bridge" import type { PRStatus } from "../types" import type { ProjectContext } from "./context" import type { ProjectContexts } from "./contexts" import type { Semaphore } from "../semaphore" -import type { AgentManagerOutMessage, WorktreeDiffEntry } from "../types" +import type { AgentManagerOutMessage } from "../types" import type { WorktreeStateManager } from "../WorktreeStateManager" export interface PollerPair { @@ -37,20 +36,31 @@ type StatsMessage = Extract Promise + hot?: () => Set post: (msg: StatsOutMessage) => void openExternal: (url: string) => void visible: () => boolean log: (...args: unknown[]) => void } +function hot(state: WorktreeStateManager | undefined): Set { + const result = new Set() + const target = state?.getActiveTarget() + if (target?.kind === "worktree") result.add(target.worktreeId) + if (target?.kind === "session") { + const id = state?.getSession(target.sessionId)?.worktreeId + if (id) result.add(id) + } + return result +} + /** Create the real poller pair for one project context. */ function createPollerPair(ctx: ProjectContext, deps: PollerDeps): PollerPair { const state = () => ctx.peekState() const stats = new GitStatsPoller({ getWorktrees: () => state()?.getWorktrees() ?? [], getWorkspaceRoot: () => ctx.root, - localDiff: deps.localDiff, + getHotWorktreeIds: deps.hot ?? (() => hot(state())), git: deps.git, semaphore: deps.semaphore, log: deps.log, @@ -135,12 +145,12 @@ export function createPollers(opts: { presence: (result: WorktreePresenceResult) => void openExternal: (url: string) => void log: (...args: unknown[]) => void + hot?: () => Set }): { stats: GitStatsPoller; pr: PRStatusBridge; projects: ProjectPollers } { - const localDiff = (dir: string, base: string) => diffSummary(opts.git, dir, base, opts.log) const stats = new GitStatsPoller({ getWorktrees: () => opts.state()?.getWorktrees() ?? [], getWorkspaceRoot: opts.root, - localDiff, + getHotWorktreeIds: opts.hot ?? (() => hot(opts.state())), semaphore: opts.semaphore, onStats: (stats) => { const msg = { type: "agentManager.worktreeStats" as const, projectId: opts.activeId(), stats } @@ -169,7 +179,7 @@ export function createPollers(opts: { const projects = new ProjectPollers({ git: opts.git, semaphore: opts.semaphore, - localDiff, + hot: opts.hot, post: opts.post, openExternal: opts.openExternal, visible: opts.visible, diff --git a/packages/kilo-vscode/src/agent-manager/terminal-manager.ts b/packages/kilo-vscode/src/agent-manager/terminal-manager.ts index 24f916542e5..40b98a58e14 100644 --- a/packages/kilo-vscode/src/agent-manager/terminal-manager.ts +++ b/packages/kilo-vscode/src/agent-manager/terminal-manager.ts @@ -15,6 +15,8 @@ import type { KiloClient } from "@kilocode/sdk/v2/client" +const env = { KILO_UNICODE_LOGO: "0" } + /** * Everything the manager needs from the surrounding AgentManagerProvider. * @@ -71,6 +73,9 @@ export class TerminalManager { directory: params.cwd, cwd: params.cwd, title: params.title, + // xterm's DOM renderer cannot draw the Unicode sextant glyphs used by + // Kilo's modern wordmark, so use the compatible logo in embedded tabs. + env, }) if (error || !data) { const err = error instanceof Error ? error.message : String(error ?? "unknown error") @@ -237,6 +242,7 @@ export class TerminalManager { directory: entry.cwd, cwd: entry.cwd, title: entry.title, + env, }) const info = created.data if (created.error || !info) diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 76389d4f288..7064f16d224 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -299,6 +299,7 @@ interface BranchesMessage { interface ImportResultMessage { type: "agentManager.importResult" + projectId?: string success: boolean message: string errorCode?: WorktreeSetupErrorCode diff --git a/packages/kilo-vscode/src/agent-manager/worktree-importer.ts b/packages/kilo-vscode/src/agent-manager/worktree-importer.ts index f589a7c4ebe..c44d7d9cf95 100644 --- a/packages/kilo-vscode/src/agent-manager/worktree-importer.ts +++ b/packages/kilo-vscode/src/agent-manager/worktree-importer.ts @@ -21,10 +21,10 @@ export class WorktreeImporter { constructor(private readonly host: WorktreeImporterHost) {} - async branches(): Promise { + async branches(projectId?: string): Promise { const manager = this.host.manager() if (!manager) { - this.host.post({ type: "agentManager.branches", branches: [], defaultBranch: "main" }) + this.host.post({ type: "agentManager.branches", projectId, branches: [], defaultBranch: "main" }) return } @@ -46,31 +46,32 @@ export class WorktreeImporter { this.host.post({ type: "agentManager.branches", + projectId, branches, defaultBranch: result.defaultBranch, }) } catch (error) { this.host.log(`Failed to list branches: ${error}`) - this.host.post({ type: "agentManager.branches", branches: [], defaultBranch: "main" }) + this.host.post({ type: "agentManager.branches", projectId, branches: [], defaultBranch: "main" }) } } - async branch(branch: string): Promise { - await this.run({ branch }) + async branch(branch: string, projectId?: string): Promise { + await this.run({ branch }, projectId) } - async pr(url: string): Promise { - await this.run({ url }) + async pr(url: string, projectId?: string): Promise { + await this.run({ url }, projectId) } - private async run(target: { branch: string } | { url: string }): Promise { + private async run(target: { branch: string } | { url: string }, projectId?: string): Promise { const manager = this.host.manager() const state = this.host.state() if (!manager || !state) { - this.host.post({ type: "agentManager.importResult", success: false, message: "Not a git repository" }) + this.host.post({ type: "agentManager.importResult", projectId, success: false, message: "Not a git repository" }) return } - if (this.busy()) return + if (this.busy(projectId)) return this.importing = true const branch = "branch" in target const creating = branch ? "Creating worktree from branch..." : "Resolving PR..." @@ -79,7 +80,7 @@ export class WorktreeImporter { ? `Branch "${target.branch}" is already checked out in another worktree` : "This PR's branch is already checked out in another worktree" try { - const progress = { type: "agentManager.worktreeSetup", status: "creating" } as const + const progress = { type: "agentManager.worktreeSetup", projectId, status: "creating" } as const this.host.post({ ...progress, message: creating }) const result = branch ? await manager.createWorktree({ existingBranch: target.branch }) @@ -102,7 +103,7 @@ export class WorktreeImporter { state.addSession(session.id, worktree.id) this.host.register(session.id, result.path) this.host.ready(session.id, result, worktree.id) - this.host.post({ type: "agentManager.importResult", success: true, message: success }) + this.host.post({ type: "agentManager.importResult", projectId, success: true, message: success }) this.host.log(`${log} as worktree ${worktree.id}`) } catch (error) { state.removeWorktree(worktree.id) @@ -111,27 +112,28 @@ export class WorktreeImporter { throw error } } catch (error) { - this.importError(error, duplicate) + this.importError(error, duplicate, projectId) } finally { this.importing = false } } - private busy(): boolean { + private busy(projectId?: string): boolean { if (!this.importing) return false this.host.post({ type: "agentManager.importResult", + projectId, success: false, message: "Another import is already in progress", }) return true } - private importError(error: unknown, duplicate: string): void { + private importError(error: unknown, duplicate: string, projectId?: string): void { const raw = error instanceof Error ? error.message : String(error) const message = raw.includes("already used by worktree") || raw.includes("already checked out") ? duplicate : raw const code = classifyWorktreeError(message) - this.host.post({ type: "agentManager.worktreeSetup", status: "error", message, errorCode: code }) - this.host.post({ type: "agentManager.importResult", success: false, message, errorCode: code }) + this.host.post({ type: "agentManager.worktreeSetup", projectId, status: "error", message, errorCode: code }) + this.host.post({ type: "agentManager.importResult", projectId, success: false, message, errorCode: code }) } } diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index b1549e3c479..20f842e324b 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -25,6 +25,7 @@ import { registerHeapSnapshot } from "./commands/heap-snapshot" import { RemoteStatusService } from "./services/RemoteStatusService" import { markWorkspace } from "./util/spotlight" import { createNotebookBridge } from "./services/notebook" +import { createGitExecutable } from "./util/git-executable" let agentManager: AgentManagerProvider | undefined let shuttingDown = false @@ -149,7 +150,10 @@ export function activate(context: vscode.ExtensionContext) { // Create Agent Manager provider for editor panel const agentManagerHost = new VscodeHost(context.extensionUri, connectionService, context, remoteService) - const agentManagerProvider = new AgentManagerProvider(agentManagerHost, connectionService) + const git = createGitExecutable({ + log: (message) => console.warn(`[Kilo New] ${message}`), + }) + const agentManagerProvider = new AgentManagerProvider(agentManagerHost, connectionService, git) agentManagerProvider.onPanelVisibilityChange((visible) => remember({ agentManager: visible })) agentManager = agentManagerProvider context.subscriptions.push(agentManagerProvider) diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts index 41ea85380de..c4bd2f9f74e 100644 --- a/packages/kilo-vscode/src/kilo-provider-utils.ts +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -234,6 +234,7 @@ export interface SessionRefreshContext { sessionDirectories: Map worktreeDirectories?: () => string[] workspaceDirectory: string + isCurrent?: () => boolean postMessage(message: unknown): void } @@ -256,7 +257,7 @@ export async function loadSessions(ctx: SessionRefreshContext): Promise() const extra = await Promise.all( [...worktreeDirs].map((dir) => @@ -276,6 +277,8 @@ export async function loadSessions(ctx: SessionRefreshContext): Promise PromiseLike openSessions: (ids: string[]) => void speechToTextModels: () => Promise + modelUsage: (message: ModelUsageMessage) => Promise } export async function routeEarlyMessage( @@ -42,6 +44,10 @@ export async function routeEarlyMessage( ) return true } + if (message.type === "recordModelUsage" || message.type === "requestModelUsage") { + await ctx.modelUsage(message as ModelUsageMessage) + return true + } await routeSuggestionWebviewMessage(ctx.question, message) if (await ModelState.handleMessage(message.type, message, ctx.client, ctx.post)) return true if (message.type === "exportSessionTranscript") { diff --git a/packages/kilo-vscode/src/kilo-provider/model-usage.ts b/packages/kilo-vscode/src/kilo-provider/model-usage.ts new file mode 100644 index 00000000000..e27d6c735d0 --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/model-usage.ts @@ -0,0 +1,65 @@ +const LIMIT = 200 +export type ModelUsageMap = Record +export type ModelUsageMessage = + | { type: "recordModelUsage"; providerID: string; modelID: string } + | { type: "requestModelUsage" } + +function valid(value: unknown): value is { count: number; lastUsed: number } { + if (!value || typeof value !== "object" || Array.isArray(value)) return false + const item = value as Record + const count = item.count + const lastUsed = item.lastUsed + return ( + typeof count === "number" && + Number.isFinite(count) && + count > 0 && + typeof lastUsed === "number" && + Number.isFinite(lastUsed) + ) +} + +export function validateModelUsage(raw: unknown): ModelUsageMap { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {} + const entries = Object.entries(raw as Record).flatMap(([key, value]) => + valid(value) ? [[key, value] as const] : [], + ) + return Object.fromEntries( + entries + .sort(([, a], [, b]) => b.lastUsed - a.lastUsed) + .slice(0, LIMIT) + .map(([key, value]) => [ + key, + { + count: Math.floor(value.count), + lastUsed: value.lastUsed, + }, + ]), + ) +} + +export function recordModelUsage(raw: unknown, providerID: unknown, modelID: unknown, now = Date.now()): ModelUsageMap { + if (typeof providerID !== "string" || !providerID || typeof modelID !== "string" || !modelID) { + return validateModelUsage(raw) + } + const usage = validateModelUsage(raw) + const key = `${providerID}/${modelID}` + const current = usage[key] ?? { count: 0, lastUsed: 0 } + usage[key] = { count: current.count + 1, lastUsed: now } + return validateModelUsage(usage) +} + +export async function handleModelUsageMessage( + message: ModelUsageMessage, + context: + | { globalState: { get: (key: string) => unknown; update: (key: string, value: unknown) => Thenable } } + | undefined, + post: (message: unknown) => void, +): Promise { + const current = context?.globalState.get("modelUsage") + const usage = + message.type === "recordModelUsage" + ? recordModelUsage(current, message.providerID, message.modelID) + : validateModelUsage(current) + if (message.type === "recordModelUsage") await context?.globalState.update("modelUsage", usage) + post({ type: "modelUsageLoaded", usage }) +} diff --git a/packages/kilo-vscode/src/util/git-executable.ts b/packages/kilo-vscode/src/util/git-executable.ts new file mode 100644 index 00000000000..a4fa64f377b --- /dev/null +++ b/packages/kilo-vscode/src/util/git-executable.ts @@ -0,0 +1,70 @@ +import { constants } from "fs" +import * as fs from "fs/promises" +import * as path from "path" +import { exec } from "./process" + +export type GitExecutable = () => Promise + +interface GitExecutableOptions { + platform?: NodeJS.Platform + path?: string + run?: (cmd: string, args: string[]) => Promise<{ stdout: string }> + access?: (file: string, mode: number) => Promise + realpath?: (file: string) => Promise + log?: (message: string) => void +} + +/** + * Preserve normal PATH lookup on every platform. On macOS only, bypass Apple's + * /usr/bin/git launcher after confirming it is the command PATH would select and + * xcrun identifies a valid executable for the active developer directory. + */ +export function createGitExecutable(options: GitExecutableOptions = {}): GitExecutable { + const platform = options.platform ?? process.platform + const run = options.run ?? ((cmd, args) => exec(cmd, args, { timeout: 15_000 })) + const access = options.access ?? fs.access + const realpath = options.realpath ?? fs.realpath + const log = options.log ?? (() => undefined) + let cached: Promise | undefined + + return (): Promise => { + cached ??= (async () => { + if (platform !== "darwin") return "git" + + try { + const env = options.path ?? process.env.PATH ?? "/usr/bin:/bin" + const selected = await (async () => { + for (const dir of env.split(path.posix.delimiter)) { + // Relative and empty PATH entries depend on each command's cwd, so + // they cannot be resolved once without changing lookup semantics. + if (!dir || !path.posix.isAbsolute(dir)) return undefined + const file = path.posix.join(dir, "git") + const resolved = await access(file, constants.X_OK) + .then(() => realpath(file)) + .catch(() => undefined) + if (resolved) return resolved + } + return undefined + })() + if (selected !== "/usr/bin/git") return "git" + + const result = await run("/usr/bin/xcrun", ["--find", "git"]) + const candidate = result.stdout.trim() + if (!candidate || candidate === selected || !path.posix.isAbsolute(candidate)) return "git" + if (!/^[/a-zA-Z0-9._~-]+$/.test(candidate)) return "git" + + await access(candidate, constants.X_OK) + const version = await run(candidate, ["--version"]) + if (!version.stdout.trim().startsWith("git version ")) return "git" + + log(`Using ${candidate} directly instead of the macOS Git launcher`) + return candidate + } catch (err) { + log(`Unable to bypass the macOS Git launcher, using PATH: ${err}`) + return "git" + } + })() + + return cached + } +} diff --git a/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts b/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts index 6fda7cdbb95..785bf9faf2f 100644 --- a/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts +++ b/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts @@ -80,17 +80,18 @@ test("auto efficient details show server description and model choices", async ( await expect(preview).not.toContainText("openai/gpt-5.5") }) -test("typing a provider initial moves the active descendant to matching results", async ({ page }) => { +test("search uses a flat relevance-ranked result list with provider labels", async ({ page }) => { await load(page, "shared--model-selector-accessible") await page.getByRole("button", { name: "Review model: Alpha" }).click() const combobox = page.getByRole("combobox", { name: "Review model: Alpha. Search models" }) - await combobox.fill("N") + await combobox.fill("nov") const nova = page.getByRole("treeitem", { name: "Nova" }) await expect(nova).toBeVisible() await expect(combobox).toHaveAttribute("aria-activedescendant", await nova.getAttribute("id")) - await expect(page.getByRole("treeitem", { name: "NVIDIA" })).toHaveAttribute("aria-expanded", "true") + await expect(page.locator(".model-selector-group-label").filter({ hasText: "NVIDIA" })).toHaveCount(0) + await expect(nova).toContainText("NVIDIA") }) test("provider groups collapse, expand, and skip their model rows", async ({ page }) => { @@ -141,12 +142,13 @@ test("active descendant always identifies a visible tree item", async ({ page }) await active() await combobox.fill("N") await active() - await combobox.press("ArrowLeft") await combobox.press("ArrowDown") - await combobox.press("ArrowLeft") await active() await combobox.fill("no matching model") - await active() + await expect(combobox).toHaveAttribute( + "aria-activedescendant", + await page.getByRole("treeitem", { name: "Use default model" }).getAttribute("id"), + ) }) test("expanded preview waits for explicit pointer selection", async ({ page }) => { diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index b4a24df88a7..3d41e4e7dcd 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -23,6 +23,7 @@ const TSX_FILES = [ path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx"), path.join(ROOT, "webview-ui/agent-manager/UnassignedSessionsSection.tsx"), path.join(ROOT, "webview-ui/agent-manager/NewWorktreeDialog.tsx"), + path.join(ROOT, "webview-ui/agent-manager/ProjectSelect.tsx"), path.join(ROOT, "webview-ui/agent-manager/sortable-tab.tsx"), path.join(ROOT, "webview-ui/agent-manager/DiffPanel.tsx"), path.join(ROOT, "webview-ui/diff-viewer/FullScreenDiffView.tsx"), @@ -690,7 +691,7 @@ describe("Agent Manager Provider — onMessage routing", () => { it("worktree import behavior lives in the cohesive importer", () => { const text = importer() - for (const value of ["createFromPR", "createWorktree", "this.busy()"]) expect(text).toContain(value) + for (const value of ["createFromPR", "createWorktree", "this.busy(projectId)"]) expect(text).toContain(value) expect(body("onImportMessage")).toContain("this.importer") }) @@ -993,6 +994,16 @@ function agentManagerSourceFiles(): string[] { } describe("Agent Manager — VS Code import boundary", () => { + it("routes GitHub CLI execution through execGhRead", () => { + const gh = path.join(AGENT_MANAGER_DIR, "gh.ts") + const violations = agentManagerSourceFiles() + .map((file) => path.join(AGENT_MANAGER_DIR, file)) + .filter((file) => file !== gh) + .filter((file) => /(["'])gh(?:\.exe)?\1/.test(fs.readFileSync(file, "utf8"))) + .map((file) => path.basename(file)) + expect(violations).toEqual([]) + }) + it("only allowlisted files may import vscode", () => { const violations: string[] = [] for (const file of agentManagerSourceFiles()) { diff --git a/packages/kilo-vscode/tests/unit/agent-manager-new-worktree-project.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-new-worktree-project.test.ts new file mode 100644 index 00000000000..35e439bc876 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-new-worktree-project.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "bun:test" +import { readdirSync, readFileSync } from "node:fs" +import { join } from "node:path" + +const root = join(__dirname, "..", "..") +const dialog = readFileSync(join(root, "webview-ui", "agent-manager", "NewWorktreeDialog.tsx"), "utf8") +const app = readFileSync(join(root, "webview-ui", "agent-manager", "AgentManagerApp.tsx"), "utf8") +const importer = readFileSync(join(root, "src", "agent-manager", "worktree-importer.ts"), "utf8") +const css = readFileSync(join(root, "webview-ui", "agent-manager", "agent-manager.css"), "utf8") + +describe("Agent Manager New Worktree project targeting", () => { + it("routes dialog operations through the selected project and rejects stale responses", () => { + expect(dialog).toContain("const [project, setProject]") + expect(dialog).toContain("if (ev.projectId !== project()) return") + expect(dialog).toContain('type: "agentManager.requestBranches", projectId: id') + expect(dialog).toContain('type: "agentManager.createMultiVersion"') + expect(dialog).toContain("projectId: target") + expect(dialog).toContain('type: "agentManager.importFromPR"') + expect(dialog).toContain('type: "agentManager.importFromBranch"') + }) + + it("does not replace a pending cross-project activation", () => { + expect(app).toContain("if (pendingCreate()) return") + expect(app).toContain('msg.type === "agentManager.importResult"') + expect(app).toContain("!msg.success && pendingCreate()?.projectId === msg.projectId") + }) + + it("tags branch and import responses with their owning project", () => { + expect(importer).toContain("async branches(projectId?: string)") + expect(importer).toContain('type: "agentManager.branches", projectId') + expect(importer).toContain('type: "agentManager.importResult", projectId') + expect(importer).toContain('type: "agentManager.worktreeSetup", projectId') + }) + + it("keeps the project picker aligned with the dialog selector system", () => { + expect(css).toContain(".am-nv-project-inline") + expect(css).toContain(".am-project-option") + expect(css).toContain('[data-component="dialog"]:has(.am-nv-project-inline [data-component="popover-content"])') + }) + + it("defines project labels in every Agent Manager locale", () => { + const keys = [ + "agentManager.dialog.project.select", + "agentManager.dialog.project.untrusted", + "agentManager.dialog.project.missing", + ] + const locales = readdirSync(join(root, "webview-ui", "agent-manager", "i18n")).filter((file) => + file.endsWith(".ts"), + ) + + for (const file of locales) { + const source = readFileSync(join(root, "webview-ui", "agent-manager", "i18n", file), "utf8") + for (const key of keys) expect(source, `${file} is missing ${key}`).toContain(`"${key}"`) + } + }) +}) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts index a38ffbb7b99..185e7770c80 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts @@ -5,6 +5,10 @@ import { clampPanelWidth, maxPanelWidth, minPanelWidth } from "../../webview-ui/ const css = readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/agent-manager.css"), "utf8") const app = readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/AgentManagerApp.tsx"), "utf8") +const terminal = readFileSync( + resolve(import.meta.dir, "../../webview-ui/agent-manager/terminal/TerminalTab.tsx"), + "utf8", +) test("xterm owns the padding used by FitAddon", () => { const host = css.match(/\.am-terminal-host\s*\{([^}]*)\}/)?.[1] @@ -23,6 +27,18 @@ test("uses one persisted width for the diff and terminal inspector", () => { expect(app).not.toContain("terminalWidth") }) +test("limits inspector layout updates during resize", () => { + expect(app).toContain("SIDE_RESIZE_INTERVAL_MS = 32") + expect(app).toContain("time - sideResizeTime < SIDE_RESIZE_INTERVAL_MS") +}) + +test("does not refit hidden terminal buffers during resize", () => { + const callback = terminal.match(/const ro = new ResizeObserver\(\(\) => \{([\s\S]*?)\n \}\)/)?.[1] + expect(callback).toBeDefined() + expect(callback).toContain("if (!props.active) return") + expect(callback!.indexOf("if (!props.active) return")).toBeLessThan(callback!.indexOf("fit.fit()")) +}) + test("clamps the restored inspector width to the shared layout bounds", () => { expect(clampPanelWidth(undefined, 1200)).toBe(600) expect(clampPanelWidth(500, 1200)).toBe(500) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts index 348a632a48e..709a704b8f6 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-routing.test.ts @@ -12,9 +12,13 @@ function wait() { describe("Agent Manager terminal routing", () => { it("round-trips side placement and rejects missing worktrees", async () => { const messages: AgentManagerOutMessage[] = [] + const envs: Array | undefined> = [] const client = { pty: { - create: async () => ({ data: { id: "pty-1", title: "Terminal 1" } }), + create: async ({ env }: { env?: Record }) => { + envs.push(env) + return { data: { id: "pty-1", title: "Terminal 1" } } + }, remove: async () => ({ data: true }), update: async () => ({ data: true }), }, @@ -46,6 +50,10 @@ describe("Agent Manager terminal routing", () => { worktreeId: "wt-1", projectId: "prj-1", }) + expect(envs[0]).toEqual({ KILO_UNICODE_LOGO: "0" }) + router.handle({ type: "agentManager.terminal.restart", terminalId: "side-1" }) + await wait() + expect(envs[1]).toEqual({ KILO_UNICODE_LOGO: "0" }) router.handle({ type: "agentManager.terminal.create", @@ -53,7 +61,9 @@ describe("Agent Manager terminal routing", () => { placement: "side", worktreeId: "missing", }) - expect(messages[1]).toMatchObject({ + expect( + messages.find((message) => message.type === "agentManager.terminal.error" && message.createId === "side-missing"), + ).toMatchObject({ type: "agentManager.terminal.error", createId: "side-missing", }) diff --git a/packages/kilo-vscode/tests/unit/gh.test.ts b/packages/kilo-vscode/tests/unit/gh.test.ts new file mode 100644 index 00000000000..3e61071f4ec --- /dev/null +++ b/packages/kilo-vscode/tests/unit/gh.test.ts @@ -0,0 +1,95 @@ +import { afterEach, describe, expect, it } from "bun:test" +import * as fs from "fs" +import * as os from "os" +import * as path from "path" +import { execGhRead } from "../../src/agent-manager/gh" + +const host = process.platform +const platform = Object.getOwnPropertyDescriptor(process, "platform") + +function setPlatform(value: string): void { + Object.defineProperty(process, "platform", { value, configurable: true }) +} + +function link(src: string, dest: string): void { + try { + fs.linkSync(src, dest) + } catch { + fs.copyFileSync(src, dest) + } + if (host !== "win32") fs.chmodSync(dest, 0o755) +} + +function fakeBin(): { dir: string; cleanup: () => void } { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gh-process-")) + const name = host === "win32" ? "gh.exe" : "gh" + try { + link(process.execPath, path.join(dir, name)) + return { dir, cleanup: () => fs.rmSync(dir, { recursive: true, force: true }) } + } catch (error) { + fs.rmSync(dir, { recursive: true, force: true }) + throw error + } +} + +function env(dir: string): Record { + const result: Record = {} + for (const [key, value] of Object.entries(process.env)) { + if (typeof value === "string") result[key] = value + } + const key = Object.keys(result).find((key) => key.toLowerCase() === "path") ?? "PATH" + result[key] = dir + result.PATHEXT = ".COM;.EXE;.BAT;.CMD" + return result +} + +function unset(env: Record, name: string): void { + for (const key of Object.keys(env)) { + if (key.toLowerCase() === name.toLowerCase()) delete env[key] + } +} + +afterEach(() => { + if (platform) Object.defineProperty(process, "platform", platform) +}) + +describe("execGhRead", () => { + it("uses UTC when TZ is unset on Windows", async () => { + setPlatform("win32") + const bin = fakeBin() + try { + const child = env(bin.dir) + unset(child, "TZ") + const { stdout } = await execGhRead(["-e", "console.log(process.env.TZ)"], { env: child }) + expect(stdout.trim()).toBe("UTC") + } finally { + bin.cleanup() + } + }) + + it("preserves an existing TZ on Windows", async () => { + setPlatform("win32") + const bin = fakeBin() + try { + const child = env(bin.dir) + child.TZ = "Europe/London" + const { stdout } = await execGhRead(["-e", "console.log(process.env.TZ)"], { env: child }) + expect(stdout.trim()).toBe("Europe/London") + } finally { + bin.cleanup() + } + }) + + it("does not add TZ on non-Windows platforms", async () => { + setPlatform("linux") + const bin = fakeBin() + try { + const child = env(bin.dir) + unset(child, "TZ") + const { stdout } = await execGhRead(["-e", "console.log(process.env.TZ)"], { env: child }) + expect(stdout.trim()).toBe("undefined") + } finally { + bin.cleanup() + } + }) +}) diff --git a/packages/kilo-vscode/tests/unit/git-executable.test.ts b/packages/kilo-vscode/tests/unit/git-executable.test.ts new file mode 100644 index 00000000000..f6850a391db --- /dev/null +++ b/packages/kilo-vscode/tests/unit/git-executable.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "bun:test" +import { createGitExecutable } from "../../src/util/git-executable" + +describe("createGitExecutable", () => { + it("preserves PATH lookup on other platforms", async () => { + const git = createGitExecutable({ + platform: "linux", + run: async () => { + throw new Error("should not run") + }, + }) + + expect(await git()).toBe("git") + }) + + it("resolves and validates the real macOS Git executable", async () => { + const calls: string[] = [] + const git = createGitExecutable({ + platform: "darwin", + path: "/usr/bin:/bin", + access: async () => undefined, + realpath: async (file) => file, + run: async (cmd, args) => { + calls.push([cmd, ...args].join(" ")) + if (cmd === "/usr/bin/xcrun") return { stdout: "/Library/Developer/CommandLineTools/usr/bin/git\n" } + return { stdout: "git version 2.50.1\n" } + }, + }) + + expect(await git()).toBe("/Library/Developer/CommandLineTools/usr/bin/git") + expect(calls).toEqual(["/usr/bin/xcrun --find git", "/Library/Developer/CommandLineTools/usr/bin/git --version"]) + }) + + it("falls back to the macOS launcher when resolution fails", async () => { + const git = createGitExecutable({ + platform: "darwin", + path: "/usr/bin", + access: async () => undefined, + realpath: async (file) => file, + run: async () => { + throw new Error("xcrun failed") + }, + }) + + expect(await git()).toBe("git") + }) + + it("rejects a resolved command that is not Git", async () => { + const git = createGitExecutable({ + platform: "darwin", + path: "/usr/bin", + access: async () => undefined, + realpath: async (file) => file, + run: async (cmd) => + cmd === "/usr/bin/xcrun" ? { stdout: "/tmp/not-git\n" } : { stdout: "unexpected command\n" }, + }) + + expect(await git()).toBe("git") + }) + + it("does not override a non-Apple Git selected by PATH", async () => { + let calls = 0 + const git = createGitExecutable({ + platform: "darwin", + path: "/opt/homebrew/bin:/usr/bin", + access: async () => undefined, + realpath: async (file) => file, + run: async () => { + calls++ + return { stdout: "" } + }, + }) + + expect(await git()).toBe("git") + expect(calls).toBe(0) + }) + + it("keeps per-command lookup for relative PATH entries", async () => { + let calls = 0 + const git = createGitExecutable({ + platform: "darwin", + path: "./bin:/usr/bin", + run: async () => { + calls++ + return { stdout: "" } + }, + }) + + expect(await git()).toBe("git") + expect(calls).toBe(0) + }) + + it("keeps per-command lookup for empty PATH entries", async () => { + let calls = 0 + const git = createGitExecutable({ + platform: "darwin", + path: ":/usr/bin", + run: async () => { + calls++ + return { stdout: "" } + }, + }) + + expect(await git()).toBe("git") + expect(calls).toBe(0) + }) + + it("keeps PATH lookup when the developer directory contains unsafe path characters", async () => { + const git = createGitExecutable({ + platform: "darwin", + path: "/usr/bin", + access: async () => undefined, + realpath: async (file) => file, + run: async () => ({ stdout: "/Applications/Xcode Beta.app/Contents/Developer/usr/bin/git\n" }), + }) + + expect(await git()).toBe("git") + }) + + it("shares one resolution across concurrent callers", async () => { + let calls = 0 + const git = createGitExecutable({ + platform: "darwin", + path: "/usr/bin", + access: async () => undefined, + realpath: async (file) => file, + run: async (cmd) => { + calls++ + return cmd === "/usr/bin/xcrun" + ? { stdout: "/Library/Developer/CommandLineTools/usr/bin/git\n" } + : { stdout: "git version 2.50.1\n" } + }, + }) + + expect(await Promise.all([git(), git(), git()])).toEqual([ + "/Library/Developer/CommandLineTools/usr/bin/git", + "/Library/Developer/CommandLineTools/usr/bin/git", + "/Library/Developer/CommandLineTools/usr/bin/git", + ]) + expect(calls).toBe(2) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/git-ops.test.ts b/packages/kilo-vscode/tests/unit/git-ops.test.ts index 563620705d5..efb1fd98798 100644 --- a/packages/kilo-vscode/tests/unit/git-ops.test.ts +++ b/packages/kilo-vscode/tests/unit/git-ops.test.ts @@ -41,6 +41,71 @@ async function withRepo(run: (cwd: string) => Promise): Promise { } describe("GitOps", () => { + it("uses the configured Git executable for raw commands", async () => { + await withRepo(async (cwd) => { + let calls = 0 + const git = new GitOps({ + log: () => undefined, + binary: async () => { + calls++ + return "git" + }, + }) + + expect(await fs.realpath(await git.root(cwd))).toBe(await fs.realpath(cwd)) + expect(calls).toBe(1) + }) + }) + + it("does not hold a semaphore slot while resolving Git", async () => { + const semaphore = new Semaphore(1) + let resolve!: (value: string) => void + const binary = new Promise((done) => { + resolve = done + }) + const git = new GitOps({ log: () => undefined, semaphore, binary: () => binary }) + const pending = git.currentBranch("/repo") + let entered = false + + await semaphore.run(async () => { + entered = true + }) + resolve("git") + await pending + + expect(entered).toBe(true) + }) + + it("stops waiting for Git resolution when disposed", async () => { + const git = new GitOps({ + log: () => undefined, + binary: () => new Promise(() => undefined), + }) + const pending = git.currentBranch("/repo") + + git.dispose() + + expect(await pending).toBe("") + }) + + it("retries executable resolution after a transient failure", async () => { + await withRepo(async (cwd) => { + let calls = 0 + const git = new GitOps({ + log: () => undefined, + binary: async () => { + calls++ + if (calls === 1) throw new Error("transient resolution failure") + return "git" + }, + }) + + expect(await git.root(cwd)).toBeUndefined() + expect(await fs.realpath(await git.root(cwd))).toBe(await fs.realpath(cwd)) + expect(calls).toBe(2) + }) + }) + describe("currentBranch", () => { it("returns the current branch name", async () => { const git = ops(async (args) => { diff --git a/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts b/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts index c62663414fe..cf161d03150 100644 --- a/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts +++ b/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts @@ -3,7 +3,8 @@ import * as fs from "fs" import * as os from "os" import * as path from "path" import { GitStatsPoller, type WorktreePresenceResult } from "../../src/agent-manager/GitStatsPoller" -import { GitOps } from "../../src/agent-manager/GitOps" +import { GitOps, type ExecBufferResult } from "../../src/agent-manager/GitOps" +import type { GitStatsSource } from "../../src/agent-manager/git-stats-snapshot" import { Semaphore } from "../../src/agent-manager/semaphore" import type { Worktree } from "../../src/agent-manager/WorktreeStateManager" import type { WorktreeDiffEntry } from "../../src/agent-manager/types" @@ -53,6 +54,47 @@ function gitOps(handler: (args: string[], cwd: string) => Promise): GitO return new GitOps({ log: () => undefined, runGit: handler }) } +function source( + localDiff: (dir: string, base: string) => Promise, + branch = "HEAD", +): GitStatsSource { + let sequence = 0 + return { + status: async () => { + const fingerprint = String(++sequence) + return { branch, dirty: true, head: fingerprint, fingerprint, untracked: [] } + }, + refs: async () => ({ oids: new Map(), upstreams: new Map() }), + diff: async (dir, base) => { + const entries = await localDiff(dir, base) + return { + files: entries.length, + additions: entries.reduce((sum, item) => sum + item.additions, 0), + deletions: entries.reduce((sum, item) => sum + item.deletions, 0), + } + }, + } +} + +class RecordingGitOps extends GitOps { + readonly commands: Array<{ args: string[]; cwd: string }> = [] + aheadCalls = 0 + + constructor() { + super({ log: () => undefined }) + } + + override execGitBuffer(args: string[], cwd: string): Promise { + this.commands.push({ args, cwd }) + return super.execGitBuffer(args, cwd) + } + + override aheadBehind(cwd: string, base: string): Promise<{ ahead: number; behind: number }> { + this.aheadCalls++ + return super.aheadBehind(cwd, base) + } +} + describe("GitOps", () => { it("resolveDefaultBranch returns undefined on cache hit when there is no remote HEAD", async () => { let calls = 0 @@ -83,6 +125,132 @@ describe("GitOps", () => { }) describe("GitStatsPoller", () => { + it("uses only status and shared snapshots on an unchanged second poll", async () => { + const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gsp-optimized-")) + try { + const run = (args: string[]) => { + const result = Bun.spawnSync({ + cmd: ["git", ...args], + cwd: root, + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + GIT_AUTHOR_NAME: "Test", + GIT_AUTHOR_EMAIL: "test@example.com", + GIT_COMMITTER_NAME: "Test", + GIT_COMMITTER_EMAIL: "test@example.com", + }, + }) + if (result.exitCode !== 0) throw new Error(Buffer.from(result.stderr).toString("utf8")) + } + run(["init", "-b", "main"]) + await fs.promises.writeFile(path.join(root, "file.txt"), "one\n") + run(["add", "."]) + run(["commit", "-m", "base"]) + run(["remote", "add", "origin", "."]) + run(["update-ref", "refs/remotes/origin/main", "HEAD"]) + run(["branch", "--set-upstream-to=origin/main", "main"]) + + const git = new RecordingGitOps() + const poller = new GitStatsPoller({ + getWorktrees: () => [], + getWorkspaceRoot: () => root, + onStats: () => undefined, + onLocalStats: () => undefined, + log: () => undefined, + intervalMs: 10, + git, + }) + + poller.setEnabled(true) + await waitFor(() => git.commands.filter((item) => item.args.includes("--porcelain=v2")).length >= 2, 2_000) + const statuses = git.commands + .map((item, index) => ({ ...item, index })) + .filter((item) => item.args.includes("--porcelain=v2")) + const second = git.commands.slice(statuses[1]!.index - 1) + expect(second.filter((item) => item.args.includes("diff"))).toHaveLength(0) + expect(git.aheadCalls).toBe(1) + + const diffs = git.commands.filter((item) => item.args.includes("diff")).length + await fs.promises.writeFile(path.join(root, "file.txt"), "changed and larger\n") + await waitFor(() => git.commands.filter((item) => item.args.includes("diff")).length > diffs, 2_000) + + const ahead = git.aheadCalls + poller.stop() + await poller.snapshot(true) + expect(git.aheadCalls).toBe(ahead + 1) + } finally { + await fs.promises.rm(root, { recursive: true, force: true }) + } + }) + + it("keeps hot worktrees on every tick and rotates clean dormant worktrees", async () => { + const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gsp-shard-")) + const dirs = ["a", "b", "c"].map((id) => path.join(root, id)) + try { + const run = (cwd: string, args: string[]) => { + const result = Bun.spawnSync({ + cmd: ["git", ...args], + cwd, + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + GIT_AUTHOR_NAME: "Test", + GIT_AUTHOR_EMAIL: "test@example.com", + GIT_COMMITTER_NAME: "Test", + GIT_COMMITTER_EMAIL: "test@example.com", + }, + }) + if (result.exitCode !== 0) throw new Error(Buffer.from(result.stderr).toString("utf8")) + } + await fs.promises.mkdir(dirs[0]!) + run(dirs[0]!, ["init", "-b", "main"]) + await fs.promises.writeFile(path.join(dirs[0]!, "file.txt"), "one\n") + run(dirs[0]!, ["add", "."]) + run(dirs[0]!, ["commit", "-m", "base"]) + run(dirs[0]!, ["remote", "add", "origin", "."]) + run(dirs[0]!, ["update-ref", "refs/remotes/origin/main", "HEAD"]) + run(dirs[0]!, ["branch", "--set-upstream-to=origin/main", "main"]) + run(dirs[0]!, ["worktree", "add", "-b", "branch-b", dirs[1]!, "main"]) + run(dirs[0]!, ["worktree", "add", "-b", "branch-c", dirs[2]!, "main"]) + + const git = new RecordingGitOps() + const hot = new Set(["a"]) + const poller = new GitStatsPoller({ + getWorktrees: () => + dirs.map((dir, index) => ({ + ...worktree(String.fromCharCode(97 + index)), + path: dir, + branch: index === 0 ? "main" : `branch-${String.fromCharCode(97 + index)}`, + })), + getWorkspaceRoot: () => dirs[0], + getHotWorktreeIds: () => hot, + onStats: () => undefined, + onLocalStats: () => undefined, + log: () => undefined, + intervalMs: 10, + dormantIntervalMs: 30, + git, + }) + + poller.setEnabled(true) + await waitFor(() => git.commands.filter((item) => item.args.includes("--porcelain=v2")).length >= 15, 3_000) + poller.stop() + const counts = new Map() + for (const item of git.commands) { + if (!item.args.includes("--porcelain=v2")) continue + counts.set(item.cwd, (counts.get(item.cwd) ?? 0) + 1) + } + expect(counts.get(dirs[0]!)).toBeGreaterThan(counts.get(dirs[1]!) ?? 0) + expect(counts.get(dirs[1]!)).toBeGreaterThan(2) + expect(counts.get(dirs[2]!)).toBeGreaterThan(2) + } finally { + await fs.promises.rm(root, { recursive: true, force: true }) + } + }) + it("keeps mutual exclusion when a stale fetch finishes after a restart", async () => { let calls = 0 let running = 0 @@ -102,7 +270,7 @@ describe("GitStatsPoller", () => { const poller = new GitStatsPoller({ getWorktrees: () => [], getWorkspaceRoot: () => "/tmp", - localDiff, + source: source(localDiff, "main"), onStats: () => undefined, onLocalStats: () => undefined, log: () => undefined, @@ -148,7 +316,7 @@ describe("GitStatsPoller", () => { const poller = new GitStatsPoller({ getWorktrees: () => [worktree("a")], getWorkspaceRoot: () => undefined, - localDiff, + source: source(localDiff), onStats: () => undefined, onLocalStats: () => undefined, log: () => undefined, @@ -181,7 +349,7 @@ describe("GitStatsPoller", () => { const poller = new GitStatsPoller({ getWorktrees: () => [worktree("a")], getWorkspaceRoot: () => undefined, - localDiff, + source: source(localDiff), onStats: (stats) => emitted.push(stats), onLocalStats: () => undefined, log: () => undefined, @@ -217,9 +385,9 @@ describe("GitStatsPoller", () => { const poller = new GitStatsPoller({ getWorktrees: () => [{ ...worktree("a"), path: wtPath }], getWorkspaceRoot: () => root, - localDiff: async () => { + source: source(async () => { throw new Error("should not be called when backend unavailable path") - }, + }), onStats: () => undefined, onLocalStats: () => undefined, onWorktreePresence: (result) => presence.push(result), @@ -254,7 +422,7 @@ describe("GitStatsPoller", () => { const poller = new GitStatsPoller({ getWorktrees: () => [{ ...worktree("a"), path: wtPath }], getWorkspaceRoot: () => root, - localDiff: async () => diff(0, 0), + source: source(async () => diff(0, 0)), onStats: () => undefined, onLocalStats: () => undefined, onWorktreePresence: (result) => presence.push(result), @@ -292,10 +460,10 @@ describe("GitStatsPoller", () => { { ...worktree("b"), path: wtBPath }, ], getWorkspaceRoot: () => root, - localDiff: async (dir) => { + source: source(async (dir) => { calls.push(dir) return diff(1, 1) - }, + }), onStats: (stats) => emitted.push(stats), onLocalStats: () => undefined, onWorktreePresence: (result) => presence.push(result), @@ -345,10 +513,10 @@ describe("GitStatsPoller", () => { const poller = new GitStatsPoller({ getWorktrees: () => [{ ...worktree("a"), path: alias }], getWorkspaceRoot: () => root, - localDiff: async (dir) => { + source: source(async (dir) => { calls.push(dir) return diff(3, 2) - }, + }), onStats: (stats) => emitted.push(stats), onLocalStats: () => undefined, onWorktreePresence: (result) => presence.push(result), @@ -398,7 +566,7 @@ describe("GitStatsPoller", () => { const poller = new GitStatsPoller({ getWorktrees: () => [], getWorkspaceRoot: () => "/workspace", - localDiff, + source: source(localDiff, "feature"), onStats: () => undefined, onLocalStats: (stats) => emitted.push(stats), log: () => undefined, @@ -435,7 +603,7 @@ describe("GitStatsPoller", () => { const poller = new GitStatsPoller({ getWorktrees: () => [], getWorkspaceRoot: () => "/workspace", - localDiff: async () => diff(10, 4), + source: source(async () => diff(10, 4), "my-feature"), onStats: () => undefined, onLocalStats: (stats) => emitted.push(stats), log: () => undefined, @@ -479,7 +647,7 @@ describe("GitStatsPoller", () => { const poller = new GitStatsPoller({ getWorktrees: () => [], getWorkspaceRoot: () => "/workspace", - localDiff: async () => diff(0, 0), + source: source(async () => diff(0, 0), "orphan-branch"), onStats: () => undefined, onLocalStats: (stats) => emitted.push(stats), log: () => undefined, @@ -522,7 +690,7 @@ describe("GitStatsPoller", () => { const poller = new GitStatsPoller({ getWorktrees: () => [worktree("a", "upstream"), worktree("b", "upstream")], getWorkspaceRoot: () => undefined, - localDiff: async () => diff(0, 0), + source: source(async () => diff(0, 0)), onStats: (stats) => emitted.push(stats), onLocalStats: () => undefined, log: () => undefined, @@ -543,8 +711,8 @@ describe("GitStatsPoller", () => { }) it("runs diffs in parallel without stalling (no extra semaphore layer)", async () => { - // localDiff is a synchronous promise — since the poller no longer wraps - // it in a semaphore (GitOps.execGit() gates at the child-process layer), + // The injected diff source is a synchronous promise. The poller does not + // wrap it in a semaphore because GitOps gates at the child-process layer, // many worktrees can have their diffs computed concurrently without // contending for a dedicated outer gate. let running = 0 @@ -555,13 +723,13 @@ describe("GitStatsPoller", () => { const poller = new GitStatsPoller({ getWorktrees: () => wts, getWorkspaceRoot: () => undefined, - localDiff: async () => { + source: source(async () => { running++ peak = Math.max(peak, running) await sleep(20) running-- return diff(1, 0) - }, + }), onStats: () => { ticks++ }, @@ -592,7 +760,7 @@ describe("GitStatsPoller", () => { const poller = new GitStatsPoller({ getWorktrees: () => wts, getWorkspaceRoot: () => undefined, - localDiff: async () => diff(1, 0), + source: source(async () => diff(1, 0)), onStats: () => { ticks++ }, diff --git a/packages/kilo-vscode/tests/unit/git-stats-snapshot.test.ts b/packages/kilo-vscode/tests/unit/git-stats-snapshot.test.ts new file mode 100644 index 00000000000..ef53d5300ed --- /dev/null +++ b/packages/kilo-vscode/tests/unit/git-stats-snapshot.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "bun:test" +import * as fs from "fs/promises" +import * as os from "os" +import * as path from "path" +import { GitOps, type ExecBufferResult } from "../../src/agent-manager/GitOps" +import { GitStatsSnapshot, refOID } from "../../src/agent-manager/git-stats-snapshot" +import { diffSummary } from "../../src/agent-manager/local-diff" + +function run(dir: string, args: string[]): string { + const result = Bun.spawnSync({ + cmd: ["git", ...args], + cwd: dir, + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + GIT_AUTHOR_NAME: "Test", + GIT_AUTHOR_EMAIL: "test@example.com", + GIT_COMMITTER_NAME: "Test", + GIT_COMMITTER_EMAIL: "test@example.com", + }, + }) + if (result.exitCode !== 0) throw new Error(Buffer.from(result.stderr).toString("utf8")) + return Buffer.from(result.stdout).toString("utf8").trim() +} + +async function repo(test: (dir: string, base: string) => Promise) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "git-stats-snapshot-")) + try { + run(dir, ["init", "-b", "main"]) + run(dir, ["config", "commit.gpgsign", "false"]) + await fs.writeFile(path.join(dir, "tracked.txt"), "one\ntwo\n") + run(dir, ["add", "."]) + run(dir, ["commit", "-m", "base"]) + run(dir, ["branch", "base"]) + await test(dir, "base") + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } +} + +describe("GitStatsSnapshot", () => { + it("accepts an absent path in an unmerged status record", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "git-stats-conflict-")) + try { + const raw = Buffer.from( + "# branch.oid abc\0# branch.head main\0u UU N... 100644 100644 100644 100644 abc abc abc missing.txt\0", + ) + const git = new GitOps({ log: () => undefined }) + git.execGitBuffer = async (): Promise => ({ code: 0, stdout: raw, stderr: "" }) + const status = await new GitStatsSnapshot(git).status(dir) + expect(status.dirty).toBe(true) + expect(status.fingerprint).toBeTruthy() + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) + + it("matches legacy aggregate stats with tracked and untracked changes", async () => { + await repo(async (dir, base) => { + await fs.writeFile(path.join(dir, "tracked.txt"), "one\nchanged\nthree\n") + await fs.writeFile(path.join(dir, "new.txt"), "a\nb\nc\n") + const git = new GitOps({ log: () => undefined }) + const snapshots = new GitStatsSnapshot(git) + + const status = await snapshots.status(dir) + const actual = await snapshots.diff(dir, base, status.untracked) + const legacy = await diffSummary(git, dir, base) + + expect(actual).toEqual({ + files: legacy.length, + additions: legacy.reduce((sum, item) => sum + item.additions, 0), + deletions: legacy.reduce((sum, item) => sum + item.deletions, 0), + }) + expect(status.untracked).toEqual(["new.txt"]) + }) + }) + + it("changes its fingerprint when an already-modified file changes", async () => { + await repo(async (dir) => { + const snapshots = new GitStatsSnapshot(new GitOps({ log: () => undefined })) + await fs.writeFile(path.join(dir, "tracked.txt"), "modified once\n") + const first = await snapshots.status(dir) + await fs.writeFile(path.join(dir, "tracked.txt"), "modified twice and larger\n") + const second = await snapshots.status(dir) + expect(second.fingerprint).not.toBe(first.fingerprint) + }) + }) + + it("reads ref OIDs and upstreams", async () => { + await repo(async (dir) => { + run(dir, ["remote", "add", "origin", "."]) + run(dir, ["update-ref", "refs/remotes/origin/main", "HEAD"]) + run(dir, ["branch", "--set-upstream-to=origin/main", "main"]) + const snapshots = new GitStatsSnapshot(new GitOps({ log: () => undefined })) + const refs = await snapshots.refs(dir) + expect(refOID(refs, "origin/main")).toBe(run(dir, ["rev-parse", "HEAD"])) + expect(refs.upstreams.get("refs/heads/main")).toBe("refs/remotes/origin/main") + }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/model-selector-utils.test.ts b/packages/kilo-vscode/tests/unit/model-selector-utils.test.ts index f58b20349bc..d1c681f89fc 100644 --- a/packages/kilo-vscode/tests/unit/model-selector-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/model-selector-utils.test.ts @@ -13,7 +13,10 @@ import { isAuto, autoSummary, autoChoices, + rankModelSearch, + mostUsedModels, } from "../../webview-ui/src/components/shared/model-selector-utils" +import type { EnrichedModel } from "../../webview-ui/src/context/provider" const labels = { select: "Select model", noProviders: "No providers", notSet: "Not set" } @@ -169,6 +172,51 @@ describe("autoSummary", () => { }) }) +const SEARCH_MODELS: EnrichedModel[] = [ + { id: "solar-pro", name: "Solar Pro", providerID: "nvidia", providerName: "NVIDIA" }, + { id: "gpt-5.6-sol", name: "GPT-5.6 Sol", providerID: "openai", providerName: "OpenAI" }, + { id: "gpt-5.6-sol", name: "GPT-5.6 Sol", providerID: "kilo", providerName: "Kilo" }, + { id: "gpt-5.6", name: "GPT-5.6", providerID: "anthropic", providerName: "Anthropic" }, +] + +describe("rankModelSearch", () => { + it("prefers an exact model token over a longer prefix match", () => { + expect( + rankModelSearch(SEARCH_MODELS, "sol") + .slice(0, 2) + .map((model) => model.name), + ).toEqual(["GPT-5.6 Sol", "GPT-5.6 Sol"]) + }) + + it("keeps provider variants together and uses usage to order equivalent variants", () => { + const result = rankModelSearch(SEARCH_MODELS, "sol", { + usage: { "kilo/gpt-5.6-sol": { count: 4, lastUsed: 10 }, "openai/gpt-5.6-sol": { count: 1, lastUsed: 20 } }, + }) + expect(result.slice(0, 2).map((model) => model.providerID)).toEqual(["kilo", "openai"]) + }) + + it("does not let usage make a weaker model beat an exact match", () => { + const result = rankModelSearch(SEARCH_MODELS, "sol", { + usage: { "nvidia/solar-pro": { count: 1000, lastUsed: 100 } }, + }) + expect(result[0]?.name).toBe("GPT-5.6 Sol") + }) +}) + +describe("mostUsedModels", () => { + it("orders suggestions by personal count and excludes favorites", () => { + const result = mostUsedModels( + SEARCH_MODELS, + { + "nvidia/solar-pro": { count: 2, lastUsed: 20 }, + "openai/gpt-5.6-sol": { count: 5, lastUsed: 10 }, + }, + new Set(["openai/gpt-5.6-sol"]), + ) + expect(result.map((model) => model.providerID)).toEqual(["nvidia"]) + }) +}) + describe("isDataCollectedModel", () => { it("uses only explicit prompt training metadata", () => { expect(isDataCollectedModel({ mayTrainOnYourPrompts: true })).toBe(true) diff --git a/packages/kilo-vscode/tests/unit/model-usage-history.test.ts b/packages/kilo-vscode/tests/unit/model-usage-history.test.ts new file mode 100644 index 00000000000..70bf6535620 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/model-usage-history.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "bun:test" +import { recordModelUsage, validateModelUsage } from "../../src/kilo-provider/model-usage" + +describe("model usage history", () => { + it("increments a model and updates its last-used timestamp", () => { + expect(recordModelUsage({ "openai/gpt": { count: 2, lastUsed: 10 } }, "openai", "gpt", 20)).toEqual({ + "openai/gpt": { count: 3, lastUsed: 20 }, + }) + }) + + it("drops malformed entries and caps persisted history", () => { + const raw = Object.fromEntries( + Array.from({ length: 205 }, (_, index) => [`provider/model-${index}`, { count: 1, lastUsed: index }]), + ) + const result = validateModelUsage({ ...raw, invalid: { count: 0, lastUsed: 1 } }) + expect(Object.keys(result)).toHaveLength(200) + expect(result["provider/model-204"]).toEqual({ count: 1, lastUsed: 204 }) + expect(result.invalid).toBeUndefined() + }) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index a68c1099f47..615ebb350d0 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -27,6 +27,7 @@ import type { AgentManagerWorktreeDiffLoadingMessage, AgentManagerWorktreeDiffNoticeMessage, AgentManagerDiffBranchesMessage, + AgentManagerImportResultMessage, AgentManagerApplyWorktreeDiffResultMessage, AgentManagerWorktreeStatsMessage, AgentManagerLocalStatsMessage, @@ -200,6 +201,7 @@ type SidePanel = "diff" | "pr" | "terminal" | null const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent) // Fallback keybindings before extension sends resolved ones const MAX_JUMP_INDEX = 9 +const SIDE_RESIZE_INTERVAL_MS = 32 const defaultBindings: Record = { previousSession: isMac ? "⌘⌥↑" : "Ctrl+Alt+↑", @@ -266,6 +268,12 @@ const AgentManagerContent: Component = () => { const [currentProjectId, setCurrentProjectId] = createSignal() const [projectStates, setProjectStates] = createSignal>({}) const activeProjectId = () => projectList().find((p) => p.active)?.id ?? currentProjectId() + const [pendingCreate, setPendingCreate] = createSignal<{ projectId: string }>() + const scheduleCreate = (projectId: string) => { + if (projectId === activeProjectId()) return + if (pendingCreate()) return + setPendingCreate({ projectId }) + } const isActivePayload = (pid: string | undefined) => projectList().length === 0 || pid === undefined || pid === activeProjectId() @@ -282,6 +290,14 @@ const AgentManagerContent: Component = () => { persisted: persisted ?? {}, activeId: () => currentProjectId() ?? "single", }) + const defaultBase = (id: string) => { + const store = registry.ensure(id) + return ( + store.defaultBaseBranch() ?? + store.localStats()?.branch ?? + (id === activeProjectId() ? repoDetectedBranch() : undefined) + ) + } const localSessionIDs = () => registry.active().tabs.ids() const setLocalSessionIDs = (next: string[] | ((prev: string[]) => string[])) => registry.active().tabs.set(next) /** Remove a session ID from the local tab (no-op if absent). */ @@ -308,6 +324,7 @@ const AgentManagerContent: Component = () => { let pendingSidebarWidth: number | undefined let sideRaf: number | undefined let pendingSideWidth: number | undefined + let sideResizeTime = 0 const [history, setHistory] = createSignal(false) const [sidePanel, setSidePanel] = createSignal(null) @@ -323,10 +340,16 @@ const AgentManagerContent: Component = () => { const resizeSide = (width: number) => { pendingSideWidth = clampPanelWidth(width, window.innerWidth) if (sideRaf !== undefined) return - sideRaf = requestAnimationFrame(() => { + const flush = (time: number) => { + if (time - sideResizeTime < SIDE_RESIZE_INTERVAL_MS) { + sideRaf = requestAnimationFrame(flush) + return + } sideRaf = undefined + sideResizeTime = time setPanelWidth(pendingSideWidth!) - }) + } + sideRaf = requestAnimationFrame(flush) } const showSideTerminal = () => { setHistory(false) @@ -1371,6 +1394,15 @@ const AgentManagerContent: Component = () => { if (msg.type === "agentManager.worktreeSetup") { const ev = msg as AgentManagerWorktreeSetupMessage + const pending = pendingCreate() + if (ev.status === "ready" && ev.projectId && pending?.projectId === ev.projectId && ev.worktreeId) { + setPendingCreate(undefined) + vscode.postMessage({ + type: "agentManager.activateSelection", + target: { projectId: ev.projectId, kind: "worktree", worktreeId: ev.worktreeId }, + }) + } + if (ev.status === "error" && pending?.projectId === ev.projectId) setPendingCreate(undefined) const store = ev.projectId ? registry.ensure(ev.projectId) : registry.active() const updateBusy: Setter> = (value) => store.setBusy(value) if (ev.status === "ready" || ev.status === "error") { @@ -1412,6 +1444,9 @@ const AgentManagerContent: Component = () => { } } + if (msg.type === "agentManager.importResult" && !msg.success && pendingCreate()?.projectId === msg.projectId) + setPendingCreate(undefined) + if (msg.type === "agentManager.sessionAdded") { const ev = msg as { type: string; sessionId: string; worktreeId: string } saveTabMemory() @@ -1453,6 +1488,7 @@ const AgentManagerContent: Component = () => { // When a multi-version progress update arrives, mark newly created worktrees as loading if ((msg as { type: string }).type === "agentManager.multiVersionProgress") { const ev = msg as unknown as AgentManagerMultiVersionProgressMessage + if (ev.status === "done" && pendingCreate()?.projectId === ev.projectId) setPendingCreate(undefined) if (ev.status === "done" && ev.groupId) { // Clear busy state for all worktrees in this group const store = ev.projectId ? registry.ensure(ev.projectId) : registry.active() @@ -1871,7 +1907,15 @@ const AgentManagerContent: Component = () => { if (!loaded()) return expandSidebar() dialog.show(() => ( - dialog.close()} defaultBaseBranch={repoDefaultBranch()} /> + dialog.close()} + projectId={multiProject() ? activeProjectId() : undefined} + projects={multiProject() ? projectList : undefined} + activeProjectId={activeProjectId()} + defaultBase={defaultBase} + onCreate={scheduleCreate} + /> )) } @@ -2348,6 +2392,8 @@ const AgentManagerContent: Component = () => { selection={selection() ?? undefined} currentSessionID={session.currentSessionID} mode={mode} + defaultBase={defaultBase} + onCreate={scheduleCreate} bindings={kb()} t={t} onSearchRef={(ref) => (sidebarSearchMenu = ref)} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx index 3a3a08869b3..a2d88dfbd5e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx @@ -2,10 +2,11 @@ /** @jsxImportSource solid-js */ -import { type Component, For, Show, createSignal, createEffect, createMemo, onMount, onCleanup } from "solid-js" +import { type Component, For, Show, createSignal, createEffect, createMemo, on, onMount, onCleanup } from "solid-js" import type { AgentManagerBranchesMessage, AgentManagerImportResultMessage, + AgentProjectSnapshot, BranchInfo, EnhancePromptResultMessage, EnhancePromptErrorMessage, @@ -49,13 +50,55 @@ import { BranchSelect, BranchSelectPopover } from "../src/components/shared/Bran import { tracker } from "./telemetry" import { cycleAgent } from "../src/context/session-agent" import type { ModeRouter } from "./mode-router" +import { ProjectSelect } from "./ProjectSelect" type VersionCount = 1 | 2 | 3 | 4 const VERSION_OPTIONS: VersionCount[] = [1, 2, 3, 4] -const WORKTREE_PROMPT_COMMANDS = new Set(["models", "agents", "variant", "sandbox"]) +const WORKTREE_PROMPT_COMMANDS = new Set(["models", "agents", "variant", "sandbox", "project"]) const WORKTREE_PROMPT_SCOPE = "agent-manager-worktree-prompt" type DialogTab = "new" | "import" +type Model = { providerID: string; modelID: string } + +type DialogSelections = { + agent?: string + model?: Model + variant?: string + sandbox?: boolean +} + +function readDialogSelections(value: unknown): DialogSelections { + if (!value || typeof value !== "object" || Array.isArray(value)) return {} + const data = value as Record + const raw = data.model + const model = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record) : undefined + + return { + agent: typeof data.agent === "string" ? data.agent : undefined, + model: + typeof model?.providerID === "string" && typeof model.modelID === "string" + ? { providerID: model.providerID, modelID: model.modelID } + : undefined, + variant: typeof data.variant === "string" ? data.variant : undefined, + sandbox: typeof data.sandbox === "boolean" ? data.sandbox : undefined, + } +} + +function restoreAgent(value: string | undefined, list: Array<{ name: string }>, base: string): string { + if (!value) return base + if (list.length === 0) return value + return list.some((item) => item.name === value) ? value : base +} + +function restoreModel(value: Model | undefined, providers: Record, valid: (value: Model) => boolean) { + if (!value) return undefined + if (Object.keys(providers).length === 0) return value + return valid(value) ? value : undefined +} + +function fallback(value: T | undefined, get: () => T): T { + return value === undefined ? get() : value +} const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent) @@ -83,8 +126,11 @@ function sanitizeBranchName(name: string): string { export const NewWorktreeDialog: Component<{ onClose: () => void - defaultBaseBranch?: string + defaultBase?: (projectId: string) => string | undefined projectId?: string + projects?: () => AgentProjectSnapshot[] + activeProjectId?: string + onCreate?: (projectId: string) => void mode: ModeRouter }> = (props) => { const { t } = useLanguage() @@ -99,21 +145,34 @@ export const NewWorktreeDialog: Component<{ const click = metrics.click const [tab, setTab] = createSignal("new") + const [project, setProject] = createSignal(props.projectId ?? props.activeProjectId) + const [projectOpen, setProjectOpen] = createSignal(false) + const projects = () => props.projects?.() ?? [] + const showProject = () => projects().length > 0 + const projectLabel = () => projects().find((item) => item.id === project())?.label ?? "" + const base = () => { + const id = project() + return id ? props.defaultBase?.(id) : undefined + } // --- Shared branch data (used by both New tab's base branch selector and Import tab) --- const [branches, setBranches] = createSignal([]) const [branchesLoading, setBranchesLoading] = createSignal(false) - const [defaultBranch, setDefaultBranch] = createSignal(props.defaultBaseBranch ?? "main") + const [defaultBranch, setDefaultBranch] = createSignal(base() ?? "main") const [branchSearch, setBranchSearch] = createSignal("") // --- New tab state --- const [name, setName] = createSignal("") const cached = vscode.getState>() const [prompt, setPrompt] = createSignal((cached?.advancedDialogPrompt as string) ?? "") + const saved = readDialogSelections(cached?.advancedDialogSelections) const [versions, setVersions] = createSignal(1) - const initialAgent = session.selectedAgent() - const initialModel = session.modelForAgent(initialAgent) - const [model, setModel] = createSignal<{ providerID: string; modelID: string } | null>(initialModel) + const initialAgent = restoreAgent(saved.agent, session.agents(), session.selectedAgent()) + const initialModel = fallback( + restoreModel(saved.model, provider.providers(), (value) => provider.isModelValid(value)), + () => session.modelForAgent(initialAgent), + ) + const [model, setModel] = createSignal(initialModel) const [compareMode, setCompareMode] = createSignal(false) const [modelAllocations, setModelAllocations] = createSignal(new Map()) const [agent, setAgent] = createSignal(initialAgent) @@ -125,8 +184,10 @@ export const NewWorktreeDialog: Component<{ const [baseBranchOpen, setBaseBranchOpen] = createSignal(false) const [compareOpen, setCompareOpen] = createSignal(false) const [highlightedIndex, setHighlightedIndex] = createSignal(0) - const [variant, setVariant] = createSignal(session.variantForAgent(initialAgent, initialModel)) - const [sandbox, setSandbox] = createSignal() + const [variant, setVariant] = createSignal( + fallback(saved.variant, () => session.variantForAgent(initialAgent, initialModel)), + ) + const [sandbox, setSandbox] = createSignal(saved.sandbox) const [sandboxDefault, setSandboxDefault] = createSignal() const [sandboxOverride, setSandboxOverride] = createSignal() const [sandboxAvailable, setSandboxAvailable] = createSignal(true) @@ -285,6 +346,19 @@ export const NewWorktreeDialog: Component<{ vscode.setState({ ...state, advancedDialogImages: imgs.length > 0 ? imgs : undefined }) } + createEffect(() => { + const state = vscode.getState>() ?? {} + vscode.setState({ + ...state, + advancedDialogSelections: { + agent: agent(), + model: model(), + variant: variant(), + sandbox: sandbox(), + }, + }) + }) + // Auto-persist images to webview state on any change createEffect(() => persistImages(imageAttach.images())) @@ -307,18 +381,25 @@ export const NewWorktreeDialog: Component<{ if (session.agents().length < 2) hidden.add("agents") if (variants().length === 0) hidden.add("variant") if (!sandboxVisible()) hidden.add("sandbox") + if (!showProject()) hidden.add("project") return hidden }, WORKTREE_PROMPT_COMMANDS, WORKTREE_PROMPT_SCOPE, + [ + { + name: "project", + description: t("agentManager.dialog.project.select"), + hints: [], + action: () => setProjectOpen(true), + }, + ], ) const onFocusPrompt = () => restorePrompt() window.addEventListener("focusPrompt", onFocusPrompt) onCleanup(() => window.removeEventListener("focusPrompt", onFocusPrompt)) onMount(() => { - setBranchesLoading(true) - vscode.postMessage({ type: "agentManager.requestBranches", projectId: props.projectId }) // Resize textarea if restoring a cached prompt if (prompt()) adjustHeight() const focus = () => { @@ -334,6 +415,20 @@ export const NewWorktreeDialog: Component<{ }) }) + // Branch data and base-branch defaults belong to the selected project. Other + // dialog state deliberately survives project changes. + createEffect( + on(project, (id) => { + setBranches([]) + setBranchSearch("") + setHighlightedIndex(0) + setBaseBranch(null) + setDefaultBranch(id ? (props.defaultBase?.(id) ?? "main") : "main") + setBranchesLoading(true) + vscode.postMessage({ type: "agentManager.requestBranches", projectId: id }) + }), + ) + const effectiveBaseBranch = () => baseBranch() ?? defaultBranch() const filteredBranches = createMemo(() => { @@ -367,10 +462,12 @@ export const NewWorktreeDialog: Component<{ const allocations = isCompare ? allocationsToArray(modelAllocations()) : undefined const count = total() const sel = isCompare ? null : model() + const target = project() + if (target) props.onCreate?.(target) vscode.postMessage({ type: "agentManager.createMultiVersion", - projectId: props.projectId, + projectId: target, text, name: name().trim() || undefined, versions: count, @@ -519,12 +616,14 @@ export const NewWorktreeDialog: Component<{ const importUnsub = vscode.onMessage((msg) => { if (msg.type === "agentManager.branches") { const ev = msg as AgentManagerBranchesMessage + if (ev.projectId !== project()) return setBranches(ev.branches) - if (!props.defaultBaseBranch) setDefaultBranch(ev.defaultBranch) + if (!base()) setDefaultBranch(ev.defaultBranch) setBranchesLoading(false) } if (msg.type === "agentManager.importResult") { const ev = msg as AgentManagerImportResultMessage + if (ev.projectId !== project()) return setPrPending(false) setImportPending(false) if (ev.success) { @@ -563,7 +662,9 @@ export const NewWorktreeDialog: Component<{ const url = prUrl().trim() if (!url || isPending()) return setPrPending(true) - vscode.postMessage({ type: "agentManager.importFromPR", projectId: props.projectId, url }) + const target = project() + if (target) props.onCreate?.(target) + vscode.postMessage({ type: "agentManager.importFromPR", projectId: target, url }) } const handleBranchSelect = (name: string) => { @@ -572,7 +673,9 @@ export const NewWorktreeDialog: Component<{ setImportPending(true) setBranchOpen(false) setBranchSearch("") - vscode.postMessage({ type: "agentManager.importFromBranch", projectId: props.projectId, branch: name }) + const target = project() + if (target) props.onCreate?.(target) + vscode.postMessage({ type: "agentManager.importFromBranch", projectId: target, branch: name }) } return ( @@ -595,6 +698,62 @@ export const NewWorktreeDialog: Component<{ > {t("agentManager.dialog.tab.import")} + {/* Project scope applies to both New and Import tabs. */} + +
+
+ + + + + {t("agentManager.dialog.project.select")} + + } + > + {projectLabel()} + + + + + + + } + > + { + track("project_select", { changed: id !== props.activeProjectId }) + setProject(id) + setProjectOpen(false) + }} + labels={{ + untrusted: t("agentManager.dialog.project.untrusted"), + missing: t("agentManager.dialog.project.missing"), + }} + /> + +
+
+ {/* New tab */} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx b/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx index 1575e4e4074..1f714f6d8ed 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx @@ -22,6 +22,11 @@ import { ProjectBranchDialog } from "./ProjectBranchDialog" import type { ProjectStore } from "./project/store" import type { ModeRouter } from "./mode-router" +const place = (state: AgentManagerStateMessage, session: ProjectSessionInfo, local: string) => { + const wt = state.worktrees.find((item) => item.id === session.worktreeId) + return wt?.label || wt?.branch || local +} + interface Props { projects: AgentProjectSnapshot[] states: Record @@ -34,6 +39,8 @@ interface Props { selection?: string currentSessionID?: () => string | undefined mode: ModeRouter + defaultBase?: (projectId: string) => string | undefined + onCreate?: (projectId: string) => void busy?: (projectId: string, id: string) => boolean working?: (projectId: string, id: string) => boolean localBusy?: (projectId: string) => boolean @@ -88,14 +95,16 @@ export const ProjectList: Component = (props) => { }) } for (const session of props.sessions[project.id] ?? []) { + const wt = state.worktrees.find((item) => item.id === session.worktreeId) + const where = place(state, session, props.t("agentManager.local")) items.push({ key: `${project.id}:session:${session.id}`, projectId: project.id, kind: "session", group: "sessions", title: session.title || props.t("agentManager.session.untitled"), - meta: [project.label], - search: [project.label, session.title, session.id].filter(Boolean).join(" "), + meta: [project.label, where], + search: [project.label, where, wt?.branch, session.title, session.id].filter(Boolean).join(" "), updatedAt: session.updatedAt, state: "idle", visible: project.expanded, @@ -134,12 +143,14 @@ export const ProjectList: Component = (props) => { return select({ projectId: item.projectId, kind: "session", sessionId: item.sessionId }) } const newWorktree = (projectId: string) => { - const state = props.states[projectId] dialog.show(() => ( props.projects} + activeProjectId={props.selectedProject} + defaultBase={props.defaultBase} + onCreate={props.onCreate} mode={props.mode} - defaultBaseBranch={state?.defaultBaseBranch ?? props.local[projectId]?.branch} onClose={() => dialog.close()} /> )) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/ProjectSelect.tsx b/packages/kilo-vscode/webview-ui/agent-manager/ProjectSelect.tsx new file mode 100644 index 00000000000..ea37b09a140 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/ProjectSelect.tsx @@ -0,0 +1,54 @@ +// Project picker list for the New Worktree dialog + +/** @jsxImportSource solid-js */ + +import { For, Show, type Component } from "solid-js" +import { Icon } from "@kilocode/kilo-ui/icon" +import type { AgentProjectSnapshot } from "../src/types/messages" + +interface ProjectSelectProps { + projects: AgentProjectSnapshot[] + selected?: string + onSelect: (id: string) => void + labels: { untrusted: string; missing: string } +} + +export const ProjectSelect: Component = (props) => ( +
+ + {(project) => { + const blocked = () => !project.trusted || project.missing + const hint = () => { + if (project.missing) return props.labels.missing + if (!project.trusted) return props.labels.untrusted + return project.root + } + const icon = () => { + if (project.missing) return "warning" as const + if (!project.trusted) return "lock" as const + return "folder" as const + } + + return ( + + ) + }} + +
+) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx b/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx index e3e2679321f..fa3d2f929f2 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx @@ -311,40 +311,45 @@ export const ProjectSidebarBody: Component = (props) => { - - {(shortcut) => ( - - {isMac ? "⌘" : "Ctrl+"} - {shortcut()} - - )} -
{props.t("agentManager.local")} {props.local!.branch}
- -
- - ↓{props.local!.behind} - - - ↑{props.local!.ahead} - - - +{props.local!.additions} - - - −{props.local!.deletions} +
+ +
+ + ↓{props.local!.behind} + + + ↑{props.local!.ahead} + + + +{props.local!.additions} + + + −{props.local!.deletions} + +
+
+
+ + {(shortcut) => ( + + {isMac ? "⌘" : "Ctrl+"} + {shortcut()} + + )}
- +
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx b/packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx index 840ec8cfba6..8f58fb07d44 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx @@ -124,62 +124,66 @@ export const SidebarBody: Component = (props) => { {props.repoBranch()}
- -
-
-
+
+ +
+
+
+
+ + 0 || + props.localStats()!.additions > 0 || + props.localStats()!.deletions > 0 || + props.localStats()!.ahead > 0 || + props.localStats()!.behind > 0) + } + > +
+ 0 || props.localStats()!.deletions > 0} + fallback={ + 0}> + {props.localStats()!.files}f + + } + > +
+ 0}> + +{props.localStats()!.additions} + + 0}> + + {"−"} + {props.localStats()!.deletions} + + +
+
+ 0 || props.localStats()!.behind > 0}> +
+ 0}> + + {"↑"} + {props.localStats()!.ahead} + + + 0}> + + {"↓"} + {props.localStats()!.behind} + + +
+
+
+
+
+ {isMac ? "⌘" : "Ctrl+"}1
- - 0 || - props.localStats()!.additions > 0 || - props.localStats()!.deletions > 0 || - props.localStats()!.ahead > 0 || - props.localStats()!.behind > 0) - } - > -
- 0 || props.localStats()!.deletions > 0} - fallback={ - 0}> - {props.localStats()!.files}f - - } - > -
- 0}> - +{props.localStats()!.additions} - - 0}> - - {"−"} - {props.localStats()!.deletions} - - -
-
- 0 || props.localStats()!.behind > 0}> -
- 0}> - - {"↑"} - {props.localStats()!.ahead} - - - 0}> - - {"↓"} - {props.localStats()!.behind} - - -
-
-
-
- {isMac ? "⌘" : "Ctrl+"}1 +
{/* WORKTREES section */} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index 6cfadee1a9e..50dd02d2a85 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -103,7 +103,7 @@ html[data-theme="kilo-vscode"] display: flex; align-items: center; gap: 8px; - padding: 8px 10px; + padding: 8px 6px; min-height: 40px; box-sizing: border-box; border-radius: var(--radius-sm); @@ -144,8 +144,9 @@ html[data-theme="kilo-vscode"] } .am-local-icon { - width: 18px; - height: 18px; + /* 16px to match .am-wt-icon and the heading chevron column. */ + width: 16px; + height: 16px; flex-shrink: 0; color: var(--text-weak); } @@ -198,11 +199,19 @@ html[data-theme="kilo-vscode"] .am-sidebar-header-main { display: flex; align-items: center; - gap: 6px; + /* Matches the card icon gap so heading labels share the cards' tab stop. */ + gap: 8px; flex: 1; min-width: 0; } +/* Headings without a toggle still reserve the 16px chevron column plus its gap, + so a non-collapsible heading like WORKTREES lands on the same tab stop as + collapsible ones like SESSIONS instead of sitting flush left. */ +.am-project-body .am-sidebar-header:not(.am-sidebar-header-toggleable) .am-sidebar-header-main { + padding-left: 24px; +} + .am-sidebar-header-chevron { display: flex; align-items: center; @@ -274,6 +283,12 @@ html[data-theme="kilo-vscode"] padding-left: 6px; } +/* This heading sits outside the projects list, so it needs its own inset to + land on the same glyph line as the rows inside the list. */ +.am-projects > .am-section-header { + padding-left: 2px; +} + .am-section-label { font-size: var(--font-size-small); font-weight: 600; @@ -301,6 +316,12 @@ html[data-theme="kilo-vscode"] flex-direction: column; min-height: 0; overflow-y: auto; + /* Pull out of .am-sidebar's 8px padding to an even 4px gutter on both sides, + so a selected row's rounded background is inset symmetrically. 4px on the + right is also the most we can reclaim before the resize handle's hit area + (8px wide, centered on the border) would cover the row's own controls. */ + margin-left: -4px; + margin-right: -4px; } .am-projects-tools, @@ -324,7 +345,9 @@ html[data-theme="kilo-vscode"] .am-project-item { min-height: 34px; - padding: 6px 8px 6px 12px; + /* Leading column aligns with the card icons below it (.am-local-item and + .am-worktree-item both use a 6px inset), so the tree reads as one line. */ + padding: 6px 6px; box-sizing: border-box; } @@ -366,7 +389,7 @@ html[data-theme="kilo-vscode"] display: flex; flex-direction: column; min-height: 0; - padding: 0 6px; + padding: 0; } .am-project-body > .am-local-item { @@ -422,7 +445,7 @@ html[data-theme="kilo-vscode"] display: flex; align-items: flex-start; gap: 8px; - padding: 6px 10px; + padding: 6px 6px; min-height: 36px; box-sizing: border-box; border-radius: var(--radius-sm); @@ -439,6 +462,8 @@ html[data-theme="kilo-vscode"] flex-shrink: 0; display: flex; align-items: center; + justify-content: center; + width: 16px; height: 20px; } @@ -474,6 +499,10 @@ html[data-theme="kilo-vscode"] /* Hover actions (shortcut badge + close button) — hidden by default, shown on hover */ .am-wt-hover-actions { + position: absolute; + top: 0; + right: 0; + bottom: 0; display: flex; align-items: center; justify-content: flex-end; @@ -492,6 +521,20 @@ html[data-theme="kilo-vscode"] visibility: hidden; } +/* Placeholder content must not reserve title width. */ +.am-wt-actions-cell > .am-worktree-stats-skeleton { + position: absolute; + top: 0; + right: 0; + bottom: 0; +} + +.am-worktree-item:hover .am-worktree-branch, +.am-show-shortcuts .am-worktree-item:has(.am-shortcut-badge) .am-worktree-branch { + mask-image: linear-gradient(to right, black calc(100% - 48px), transparent 100%); + -webkit-mask-image: linear-gradient(to right, black calc(100% - 48px), transparent 100%); +} + /* Row 2: always present for consistent height; PR badge right-aligned */ .am-wt-row2 { display: flex; @@ -566,15 +609,13 @@ html[data-theme="kilo-vscode"] white-space: nowrap; } -.am-local-item .am-shortcut-badge { - right: 8px; -} - -.am-local-item:hover .am-shortcut-badge { +.am-local-item:hover .am-wt-hover-actions { opacity: 1; + visibility: visible; } -.am-local-item:hover .am-local-branch { +.am-local-item:hover .am-local-branch, +.am-show-shortcuts .am-local-item:has(.am-shortcut-badge) .am-local-branch { mask-image: linear-gradient(to right, black calc(100% - 40px), transparent 100%); -webkit-mask-image: linear-gradient(to right, black calc(100% - 40px), transparent 100%); } @@ -606,9 +647,6 @@ html[data-theme="kilo-vscode"] opacity: 0; visibility: hidden; } -.am-show-shortcuts .am-local-item .am-shortcut-badge { - opacity: 1; -} .am-worktree-item:has(.am-worktree-rename-input) .am-wt-row2 { display: none; @@ -986,6 +1024,7 @@ html[data-theme="kilo-vscode"] .am-local-item:hover .am-worktree-stats, .am-local-item:hover .am-worktree-stats-skeleton { opacity: 0; + visibility: hidden; } /* User-defined sections — collapsible, color-coded groups */ @@ -2828,6 +2867,11 @@ body.am-wt-dragging-active * { overflow: visible; } +[data-component="dialog"]:has(.am-nv-project-inline [data-component="popover-content"]) [data-slot="dialog-content"], +[data-component="dialog"]:has(.am-nv-project-inline [data-component="popover-content"]) [data-slot="dialog-body"] { + overflow: visible; +} + .am-slash-command-dropdown { min-width: 0; max-width: none; @@ -3616,6 +3660,22 @@ body.am-wt-dragging-active * { color: var(--text-on-interactive-base) !important; } +/* Project scope selector — inline with the New/Import tabs */ + +.am-nv-project-inline { + display: flex; + align-items: center; + flex-shrink: 0; + flex: 0 1 260px; + min-width: 0; + margin-left: auto; +} + +.am-nv-project-inline .am-selector-wrapper { + width: 100%; + min-width: 0; +} + /* Import tab layout */ .am-import-tab { @@ -3867,6 +3927,68 @@ body.am-wt-dragging-active * { color: var(--text-weaker); } +/* Project option rows in the New Worktree project dropdown. */ + +.am-project-option { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + padding: 6px 8px; + border: none; + border-radius: var(--radius-sm); + background: none; + color: var(--text-base); + font-size: var(--font-size-base); + font-family: inherit; + text-align: left; + cursor: pointer; +} + +.am-project-option:hover:not(:disabled) { + background: var(--surface-inset-base-hover); +} + +.am-project-option-active { + background: var(--surface-inset-base); +} + +.am-project-option:disabled { + opacity: 0.5; + cursor: default; +} + +.am-project-option-left { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1; +} + +.am-project-option-left [data-component="icon"] { + color: var(--text-weaker); + flex-shrink: 0; +} + +.am-project-option-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex-shrink: 0; + max-width: 45%; +} + +.am-project-option-root { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + font-size: var(--kilo-font-size-11); + color: var(--text-weaker); +} + /* Import empty state */ .am-import-empty { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts index 586079b908d..04124ebe748 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts @@ -114,6 +114,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "إلغاء", "agentManager.dialog.removeStaleWorktree.confirm": "إزالة Worktree القديم", + "agentManager.dialog.project.select": "اختيار مشروع", + "agentManager.dialog.project.untrusted": "يُرجى الوثوق بهذا المشروع من الشريط الجانبي أولًا", + "agentManager.dialog.project.missing": "المستودع غير موجود", "agentManager.dialog.openWorktree": "شجرة عمل جديدة", "agentManager.dialog.configureWorktree": "تكوين Worktree جديد...", "agentManager.dialog.tab.new": "جديد", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts index 64ad7cc61a9..a50bc100b83 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts @@ -116,6 +116,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "Cancelar", "agentManager.dialog.removeStaleWorktree.confirm": "Remover Worktree obsoleto", + "agentManager.dialog.project.select": "Selecionar projeto", + "agentManager.dialog.project.untrusted": "Primeiro, confie neste projeto na barra lateral", + "agentManager.dialog.project.missing": "Repositório não encontrado", "agentManager.dialog.openWorktree": "Novo Worktree", "agentManager.dialog.configureWorktree": "Configurar Novo Worktree...", "agentManager.dialog.tab.new": "Novo", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts index a42401b977e..3071b152ece 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts @@ -116,6 +116,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "Otkaži", "agentManager.dialog.removeStaleWorktree.confirm": "Ukloni zastarjeli Worktree", + "agentManager.dialog.project.select": "Odaberi projekat", + "agentManager.dialog.project.untrusted": "Prvo vjeruj ovom projektu na bočnoj traci", + "agentManager.dialog.project.missing": "Repozitorij nije pronađen", "agentManager.dialog.openWorktree": "Novi worktree", "agentManager.dialog.configureWorktree": "Konfiguriši Novi Worktree...", "agentManager.dialog.tab.new": "Novo", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts index 705758f1b94..ef6342f1ac3 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts @@ -117,6 +117,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "Annuller", "agentManager.dialog.removeStaleWorktree.confirm": "Fjern forældet Worktree", + "agentManager.dialog.project.select": "Vælg projekt", + "agentManager.dialog.project.untrusted": "Godkend først dette projekt i sidepanelet", + "agentManager.dialog.project.missing": "Repository ikke fundet", "agentManager.dialog.openWorktree": "Ny Worktree", "agentManager.dialog.configureWorktree": "Konfigurer Nyt Worktree...", "agentManager.dialog.tab.new": "Ny", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts index 234e551f1aa..844eccb0e73 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts @@ -118,6 +118,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "Abbrechen", "agentManager.dialog.removeStaleWorktree.confirm": "Veralteten Worktree entfernen", + "agentManager.dialog.project.select": "Projekt auswählen", + "agentManager.dialog.project.untrusted": "Vertrauen Sie diesem Projekt zuerst in der Seitenleiste", + "agentManager.dialog.project.missing": "Repository nicht gefunden", "agentManager.dialog.openWorktree": "Neuer Worktree", "agentManager.dialog.configureWorktree": "Neuen Worktree konfigurieren...", "agentManager.dialog.tab.new": "Neu", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts index 56954f2c7ac..5e0e27eed38 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts @@ -125,6 +125,9 @@ export const dict = { "agentManager.dialog.tab.new": "New", "agentManager.dialog.tab.import": "Import", "agentManager.dialog.namePlaceholder": "Worktree name (optional)", + "agentManager.dialog.project.select": "Select project", + "agentManager.dialog.project.untrusted": "Trust this project in the sidebar first", + "agentManager.dialog.project.missing": "Repository not found", "agentManager.dialog.promptPlaceholder.mac": "Type a message (\u2318Enter to send)", "agentManager.dialog.promptPlaceholder.other": "Type a message (Ctrl+Enter to send)", "agentManager.dialog.advancedOptions": "Advanced options", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts index f1ec88f48c3..4b438f22249 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts @@ -117,6 +117,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "Cancelar", "agentManager.dialog.removeStaleWorktree.confirm": "Eliminar Worktree obsoleto", + "agentManager.dialog.project.select": "Seleccionar proyecto", + "agentManager.dialog.project.untrusted": "Confía primero en este proyecto desde la barra lateral", + "agentManager.dialog.project.missing": "Repositorio no encontrado", "agentManager.dialog.openWorktree": "Nuevo Worktree", "agentManager.dialog.configureWorktree": "Configurar Nuevo Worktree...", "agentManager.dialog.tab.new": "Nuevo", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts index e128e95a674..d318ab4256c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts @@ -121,6 +121,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "لغو", "agentManager.dialog.removeStaleWorktree.confirm": "حذف Worktree قدیمی", + "agentManager.dialog.project.select": "انتخاب پروژه", + "agentManager.dialog.project.untrusted": "ابتدا در نوار کناری به این پروژه اعتماد کنید", + "agentManager.dialog.project.missing": "مخزن یافت نشد", "agentManager.dialog.openWorktree": "Worktree جدید", "agentManager.dialog.tab.new": "جدید", "agentManager.dialog.tab.import": "وارد کردن", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts index 3a2f7871314..10d707785b0 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts @@ -117,6 +117,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "Annuler", "agentManager.dialog.removeStaleWorktree.confirm": "Supprimer le Worktree obsolète", + "agentManager.dialog.project.select": "Sélectionner un projet", + "agentManager.dialog.project.untrusted": "Approuvez d'abord ce projet dans la barre latérale", + "agentManager.dialog.project.missing": "Dépôt introuvable", "agentManager.dialog.openWorktree": "Nouveau worktree", "agentManager.dialog.configureWorktree": "Configurer un Nouveau Worktree...", "agentManager.dialog.tab.new": "Nouveau", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts index 8f0cfabc3d1..4105961ff05 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts @@ -123,6 +123,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "Annulla", "agentManager.dialog.removeStaleWorktree.confirm": "Rimuovi worktree obsoleto", + "agentManager.dialog.project.select": "Seleziona progetto", + "agentManager.dialog.project.untrusted": "Prima, fidati di questo progetto nella barra laterale", + "agentManager.dialog.project.missing": "Repository non trovata", "agentManager.dialog.openWorktree": "Nuovo worktree", "agentManager.dialog.tab.new": "Nuovo", "agentManager.dialog.tab.import": "Importa", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts index c689b6ac005..e3396a9144e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts @@ -117,6 +117,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "キャンセル", "agentManager.dialog.removeStaleWorktree.confirm": "無効な Worktree を削除", + "agentManager.dialog.project.select": "プロジェクトを選択", + "agentManager.dialog.project.untrusted": "まずサイドバーでこのプロジェクトを信頼してください", + "agentManager.dialog.project.missing": "リポジトリが見つかりません", "agentManager.dialog.openWorktree": "新規ワークツリー", "agentManager.dialog.configureWorktree": "新規 Worktree の構成...", "agentManager.dialog.tab.new": "新規", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts index 28855e0bb7b..db772c76315 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts @@ -115,6 +115,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "취소", "agentManager.dialog.removeStaleWorktree.confirm": "오래된 Worktree 제거", + "agentManager.dialog.project.select": "프로젝트 선택", + "agentManager.dialog.project.untrusted": "먼저 사이드바에서 이 프로젝트를 신뢰하세요", + "agentManager.dialog.project.missing": "저장소를 찾을 수 없음", "agentManager.dialog.openWorktree": "새 워크트리", "agentManager.dialog.configureWorktree": "새 Worktree 구성...", "agentManager.dialog.tab.new": "새로 만들기", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts index 81a003f9f03..125733840f8 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts @@ -122,6 +122,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "Annuleren", "agentManager.dialog.removeStaleWorktree.confirm": "Verouderde worktree verwijderen", + "agentManager.dialog.project.select": "Project selecteren", + "agentManager.dialog.project.untrusted": "Vertrouw dit project eerst in de zijbalk", + "agentManager.dialog.project.missing": "Repository niet gevonden", "agentManager.dialog.openWorktree": "Nieuwe worktree", "agentManager.dialog.configureWorktree": "Nieuwe Worktree Configureren...", "agentManager.dialog.tab.new": "Nieuw", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts index 4a0946996f5..194199df5e1 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts @@ -115,6 +115,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "Avbryt", "agentManager.dialog.removeStaleWorktree.confirm": "Fjern utdatert Worktree", + "agentManager.dialog.project.select": "Velg prosjekt", + "agentManager.dialog.project.untrusted": "Stol på dette prosjektet i sidepanelet først", + "agentManager.dialog.project.missing": "Repository ikke funnet", "agentManager.dialog.openWorktree": "Ny worktree", "agentManager.dialog.configureWorktree": "Konfigurer Nytt Worktree...", "agentManager.dialog.tab.new": "Ny", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts index 47ba2cbffbe..a20890b35d0 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts @@ -117,6 +117,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "Anuluj", "agentManager.dialog.removeStaleWorktree.confirm": "Usuń nieaktualny Worktree", + "agentManager.dialog.project.select": "Wybierz projekt", + "agentManager.dialog.project.untrusted": "Najpierw zaufaj temu projektowi na pasku bocznym", + "agentManager.dialog.project.missing": "Nie znaleziono repozytorium", "agentManager.dialog.openWorktree": "Nowy Worktree", "agentManager.dialog.configureWorktree": "Skonfiguruj Nowe Worktree...", "agentManager.dialog.tab.new": "Nowy", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts index 52cf8082e0c..84ef5b9ed46 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts @@ -117,6 +117,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "Отмена", "agentManager.dialog.removeStaleWorktree.confirm": "Удалить устаревший Worktree", + "agentManager.dialog.project.select": "Выбрать проект", + "agentManager.dialog.project.untrusted": "Сначала подтвердите доверие к этому проекту на боковой панели", + "agentManager.dialog.project.missing": "Репозиторий не найден", "agentManager.dialog.openWorktree": "Новый worktree", "agentManager.dialog.configureWorktree": "Настроить новое Worktree...", "agentManager.dialog.tab.new": "Новый", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts index 320a1bebbc3..5a7bb8eef8f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts @@ -112,6 +112,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "ยกเลิก", "agentManager.dialog.removeStaleWorktree.confirm": "ลบ Worktree ที่ล้าสมัย", + "agentManager.dialog.project.select": "เลือกโปรเจกต์", + "agentManager.dialog.project.untrusted": "โปรดเชื่อถือโปรเจกต์นี้ในแถบด้านข้างก่อน", + "agentManager.dialog.project.missing": "ไม่พบ Repository", "agentManager.dialog.openWorktree": "Worktree ใหม่", "agentManager.dialog.configureWorktree": "กำหนดค่า Worktree ใหม่...", "agentManager.dialog.tab.new": "ใหม่", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts index d02a01e95e7..820bbee740b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts @@ -123,6 +123,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "İptal", "agentManager.dialog.removeStaleWorktree.confirm": "Eskimiş worktree'yi kaldır", + "agentManager.dialog.project.select": "Proje seç", + "agentManager.dialog.project.untrusted": "Önce kenar çubuğunda bu projeye güvenin", + "agentManager.dialog.project.missing": "Depo bulunamadı", "agentManager.dialog.openWorktree": "Yeni Worktree", "agentManager.dialog.configureWorktree": "Yeni Worktree Yapılandır...", "agentManager.dialog.tab.new": "Yeni", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts index 54587c0c96a..b03f6b4fd8b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts @@ -124,6 +124,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "Скасувати", "agentManager.dialog.removeStaleWorktree.confirm": "Видалити застаріле робоче дерево", + "agentManager.dialog.project.select": "Вибрати проєкт", + "agentManager.dialog.project.untrusted": "Спочатку підтвердьте, що довіряєте цьому проєкту, на бічній панелі", + "agentManager.dialog.project.missing": "Репозиторій не знайдено", "agentManager.dialog.openWorktree": "Нове робоче дерево", "agentManager.dialog.configureWorktree": "Налаштувати нове Worktree...", "agentManager.dialog.tab.new": "Нове", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts index 4732e36dd68..39b3d811568 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts @@ -111,6 +111,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "取消", "agentManager.dialog.removeStaleWorktree.confirm": "移除失效 Worktree", + "agentManager.dialog.project.select": "选择项目", + "agentManager.dialog.project.untrusted": "请先在侧边栏中信任此项目", + "agentManager.dialog.project.missing": "未找到仓库", "agentManager.dialog.openWorktree": "新建工作树", "agentManager.dialog.configureWorktree": "配置新 Worktree...", "agentManager.dialog.tab.new": "新建", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts index 0357ac87df4..33d267caa5d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts @@ -111,6 +111,9 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.cancel": "取消", "agentManager.dialog.removeStaleWorktree.confirm": "移除失效 Worktree", + "agentManager.dialog.project.select": "選擇專案", + "agentManager.dialog.project.untrusted": "請先在側邊欄信任此專案", + "agentManager.dialog.project.missing": "找不到儲存庫", "agentManager.dialog.openWorktree": "新建工作樹", "agentManager.dialog.configureWorktree": "配置新 Worktree...", "agentManager.dialog.tab.new": "新建", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx b/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx index 8b6e2956b78..f6fcd115403 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx @@ -377,9 +377,9 @@ export const TerminalTab: Component = (props) => { open(url) } - // Resize: fit on any host size change and forward new cols/rows to - // the backend PTY. Debounced because a user drag can fire dozens of - // resize events per second. + // Resize the visible terminal and forward new cols/rows to the backend + // PTY. Hidden terminals refit when activated, avoiding scrollback reflow + // for every mounted terminal during an inspector drag. let resizeTimer: ReturnType | undefined let lastCols = term.cols let lastRows = term.rows @@ -395,6 +395,7 @@ export const TerminalTab: Component = (props) => { }) } const ro = new ResizeObserver(() => { + if (!props.active) return try { fit.fit() } catch (err) { diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx index f142dca2719..74e43dff552 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx @@ -45,9 +45,10 @@ import { autoSummary, buildTriggerLabel, sanitizeName, + mostUsedModels, + rankModelSearch, } from "./model-selector-utils" import { ModelPreview } from "./ModelPreview" -import { searchMatch } from "../../utils/search-match" // --------------------------------------------------------------------------- // Row / group key helpers — single source of truth for key formatting @@ -57,6 +58,7 @@ const CLEAR_KEY = "clear" const FAVORITES_KEY = "favorites" const AUTO_KEY = "auto" const RECOMMENDED_KEY = "recommended" +const MOST_USED_KEY = "most-used" function modelKey(providerID: string, modelID: string) { return `${providerID}/${modelID}` @@ -82,7 +84,7 @@ interface ModelRow { interface ModelGroup { key: string - label: string + label?: string rows: ModelRow[] } @@ -161,6 +163,7 @@ export const ModelSelectorBase: Component = (props) => { const expanded = vscode.getModelSelectorExpanded const setExpanded = vscode.setModelSelectorExpanded const [search, setSearch] = createSignal("") + const hasSearch = () => search().trim().length > 0 const [selectedKey, setSelectedKey] = createSignal(CLEAR_KEY) const [browsing, setBrowsing] = createSignal(false) const [navigating, setNavigating] = createSignal(false) @@ -234,9 +237,11 @@ export const ModelSelectorBase: Component = (props) => { if (!q) { return visibleModels() } - return visibleModels().filter( - (m) => searchMatch(q, m.name) || searchMatch(q, m.id) || searchMatch(q, m.providerName), - ) + return rankModelSearch(visibleModels(), q, { + usage: session?.modelUsageHistory(), + favorites: new Set(session?.favoriteModels().map((item) => modelKey(item.providerID, item.modelID))), + recent: session?.recentModels(), + }) }) // Live set of favorited keys — drives star icon visual state (filled vs outline). @@ -249,7 +254,7 @@ export const ModelSelectorBase: Component = (props) => { const favoriteModels = createMemo(() => { if (props.favorites === false) return [] - if (!session || search()) return [] + if (!session || hasSearch()) return [] const map = new Map(visibleModels().map((m) => [modelKey(m.providerID, m.id), m])) const list = session .favoriteModels() @@ -267,13 +272,30 @@ export const ModelSelectorBase: Component = (props) => { const groups = createMemo(() => { const autos: EnrichedModel[] = [] const recommended: EnrichedModel[] = [] + const mostUsed: EnrichedModel[] = [] const map = new Map() + if (!hasSearch() && session) { + mostUsed.push( + ...mostUsedModels( + visibleModels().filter((model) => !isAuto(model) && model.recommendedIndex === undefined), + session.modelUsageHistory(), + favoriteKeys(), + ), + ) + } + for (const m of filtered()) { if (isAuto(m)) { autos.push(m) continue } + if ( + !hasSearch() && + mostUsed.some((item) => modelKey(item.providerID, item.id) === modelKey(m.providerID, m.id)) + ) { + continue + } if (m.recommendedIndex !== undefined) { recommended.push(m) continue @@ -328,6 +350,18 @@ export const ModelSelectorBase: Component = (props) => { }) } + if (mostUsed.length > 0) { + result.push({ + key: MOST_USED_KEY, + label: language.t("model.group.mostUsed"), + rows: mostUsed.map((m) => ({ + key: rowKey("model", m.providerID, m.id), + kind: "model", + model: m, + })), + }) + } + const rest: ModelGroup[] = [...map.entries()] .sort(([a], [b]) => providerSortKey(a) - providerSortKey(b)) .map(([id, list]) => { @@ -343,11 +377,24 @@ export const ModelSelectorBase: Component = (props) => { } }) + if (hasSearch()) { + if (filtered().length === 0) return [] + return [ + { + key: "search-results", + rows: filtered().map((m) => ({ + key: rowKey("model", m.providerID, m.id), + kind: "model", + model: m, + })), + }, + ] + } + return [...result, ...rest] }) - // Collapse state is honored even during search so users can skip past - // large providers (e.g. Kilo Gateway) without scrolling through every match. + // Search results are flattened so matching provider variants stay adjacent. const isGroupOpen = (key: string) => !collapsed().has(key) function toggleGroup(key: string) { @@ -366,7 +413,7 @@ export const ModelSelectorBase: Component = (props) => { const rows = createMemo(() => { const c = collapsed() - const list = groups().flatMap((g) => (c.has(g.key) ? [] : g.rows)) + const list = groups().flatMap((g) => (hasSearch() || !c.has(g.key) ? g.rows : [])) if (!props.allowClear) return list return [{ key: CLEAR_KEY, kind: "clear" }, ...list] }) @@ -375,6 +422,10 @@ export const ModelSelectorBase: Component = (props) => { const result: ModelNode[] = [] if (props.allowClear) result.push({ key: CLEAR_KEY, kind: "row", row: { key: CLEAR_KEY, kind: "clear" } }) for (const group of groups()) { + if (hasSearch()) { + result.push(...group.rows.map((row) => ({ key: row.key, kind: "row" as const, row, group }))) + continue + } result.push({ key: groupKey(group.key), kind: "group", group }) if (!isGroupOpen(group.key)) continue result.push(...group.rows.map((row) => ({ key: row.key, kind: "row" as const, row, group }))) @@ -398,7 +449,7 @@ export const ModelSelectorBase: Component = (props) => { if (!m) return props.allowClear ? CLEAR_KEY : defaultKey() const key = modelKey(m.providerID, m.id) const favorite = favoriteKey(m) - if (!search() && favoriteKeys().has(key) && rowMap().has(favorite)) return favorite + if (!hasSearch() && favoriteKeys().has(key) && rowMap().has(favorite)) return favorite return canonicalKey(m) } const chosen = (row: ModelRow) => { @@ -461,15 +512,17 @@ export const ModelSelectorBase: Component = (props) => { const match = list[0] const first = match ? canonicalKey(match) : null const next = - canon && rowMap().has(canon) - ? canon - : first && rowMap().has(first) - ? first - : props.allowClear - ? CLEAR_KEY - : defaultKey() + hasSearch() && first + ? first + : canon && rowMap().has(canon) + ? canon + : first && rowMap().has(first) + ? first + : props.allowClear + ? CLEAR_KEY + : defaultKey() setSelectedKey(next) - setBrowsing(!!search() && nodeMap().has(next)) + setBrowsing(hasSearch() && (!!first || props.allowClear === true)) setNavigating(false) setPreActiveKey(next) setPreviewKey(next) @@ -612,9 +665,10 @@ export const ModelSelectorBase: Component = (props) => { } function horizontal(step: -1 | 1) { + if (hasSearch()) return const node = nodeMap().get(selectedKey()) if (!node) return - if (node.kind === "group" && node.group) { + if (node.kind === "group" && node.group && node.group.label) { if (step === -1 && isGroupOpen(node.group.key)) { toggleGroup(node.group.key) return @@ -925,7 +979,7 @@ export const ModelSelectorBase: Component = (props) => { {group.label} - +
@@ -957,7 +1011,7 @@ export const ModelSelectorBase: Component = (props) => { const hovered = () => isSelected(row.key) const preActive = () => isPreActive(row.key) const starred = () => favoriteKeys().has(modelKey(model.providerID, model.id)) - const showProvider = () => row.kind === "favorite" + const showProvider = () => row.kind === "favorite" || hasSearch() const showSelect = () => expanded() && preActive() && !isActive(model) const starLabel = () => `${starred() ? language.t("model.favorite.remove") : language.t("model.favorite.add")}: ${sanitizeName(model.name)}` diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/model-selector-utils.ts b/packages/kilo-vscode/webview-ui/src/components/shared/model-selector-utils.ts index 4cb59d26c61..d93d142e37b 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/model-selector-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/components/shared/model-selector-utils.ts @@ -1,5 +1,6 @@ -import type { ModelSelection } from "../../types/messages" +import type { ModelSelection, ModelUsageMap } from "../../types/messages" import type { EnrichedModel } from "../../context/provider" +import { searchMatch } from "../../utils/search-match" import { KILO_PROVIDER_ID as KILO_GATEWAY_ID, PROVIDER_PRIORITY as PROVIDER_ORDER, @@ -68,6 +69,140 @@ export function freeDataLabel(_free: string, data: string): string { return data } +export function modelSelectionKey(providerID: string, modelID: string): string { + return `${providerID}/${modelID}` +} + +function collapse(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]+/g, "") +} + +function words(value: string): string[] { + return value + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean) +} + +function tokenScore(token: string, value: string): number { + const list = words(value) + if (list.includes(token)) return 1000 + if (list.some((word) => word.startsWith(token))) return 700 + if (collapse(value).includes(collapse(token))) return 400 + if (searchMatch(token, value)) return 250 + return -1 +} + +function matchScore(model: EnrichedModel, query: string): number | undefined { + const name = stripSubProviderPrefix(sanitizeName(model.name)) + const tokens = words(query) + if (tokens.length === 0) return 0 + + const scores = tokens.map((token) => { + const modelScore = Math.max(tokenScore(token, name), tokenScore(token, model.id)) + const providerScore = modelScore < 0 ? tokenScore(token, model.providerName) : -1 + return { modelScore, providerScore } + }) + if (scores.some((score) => score.modelScore < 0 && score.providerScore < 0)) return undefined + + const modelScore = scores.reduce((sum, score) => sum + Math.max(score.modelScore, 0), 0) + const providerScore = scores.reduce((sum, score) => sum + Math.max(score.providerScore, 0), 0) + const exact = collapse(query) === collapse(name) || collapse(query) === collapse(model.id) + return modelScore + Math.floor(providerScore / 10) + (exact ? 5000 : 0) +} + +function logicalModelKey(model: EnrichedModel): string { + return collapse(stripSubProviderPrefix(sanitizeName(model.name))) || collapse(model.id) +} + +function usageFor(model: EnrichedModel, usage: ModelUsageMap | undefined) { + return usage?.[modelSelectionKey(model.providerID, model.id)] ?? { count: 0, lastUsed: 0 } +} + +export interface ModelSearchOptions { + usage?: ModelUsageMap + favorites?: ReadonlySet + recent?: readonly ModelSelection[] +} + +/** + * Ranks matching models globally instead of sorting each provider independently. + * Exact model tokens beat prefixes such as "sol" in "solar", while personal + * usage only breaks ties between similarly relevant matches. + */ +export function rankModelSearch( + models: readonly EnrichedModel[], + query: string, + options: ModelSearchOptions = {}, +): EnrichedModel[] { + const groups = new Map< + string, + { + key: string + score: number + count: number + lastUsed: number + items: Array<{ model: EnrichedModel; score: number; count: number; lastUsed: number }> + } + >() + const recent = new Map( + (options.recent ?? []).map((item, index) => [modelSelectionKey(item.providerID, item.modelID), index]), + ) + + for (const model of models) { + const score = matchScore(model, query) + if (score === undefined) continue + const usage = usageFor(model, options.usage) + const key = logicalModelKey(model) + const group = groups.get(key) ?? { key, score, count: 0, lastUsed: 0, items: [] } + group.score = Math.max(group.score, score) + group.count += usage.count + group.lastUsed = Math.max(group.lastUsed, usage.lastUsed) + group.items.push({ model, score, count: usage.count, lastUsed: usage.lastUsed }) + groups.set(key, group) + } + + return [...groups.values()] + .sort((a, b) => b.score - a.score || b.count - a.count || b.lastUsed - a.lastUsed || a.key.localeCompare(b.key)) + .flatMap((group) => + group.items + .sort( + (a, b) => + b.score - a.score || + b.count - a.count || + b.lastUsed - a.lastUsed || + (options.favorites?.has(modelSelectionKey(b.model.providerID, b.model.id)) ? 1 : 0) - + (options.favorites?.has(modelSelectionKey(a.model.providerID, a.model.id)) ? 1 : 0) || + (recent.get(modelSelectionKey(a.model.providerID, a.model.id)) ?? Infinity) - + (recent.get(modelSelectionKey(b.model.providerID, b.model.id)) ?? Infinity) || + providerSortKey(a.model.providerID) - providerSortKey(b.model.providerID) || + a.model.providerName.localeCompare(b.model.providerName) || + a.model.name.localeCompare(b.model.name) || + a.model.id.localeCompare(b.model.id), + ) + .map((item) => item.model), + ) +} + +export function mostUsedModels( + models: readonly EnrichedModel[], + usage: ModelUsageMap | undefined, + favorites: ReadonlySet = new Set(), + limit = 5, +): EnrichedModel[] { + return models + .filter((model) => { + const item = usageFor(model, usage) + return item.count > 0 && !favorites.has(modelSelectionKey(model.providerID, model.id)) + }) + .sort((a, b) => { + const left = usageFor(a, usage) + const right = usageFor(b, usage) + return right.count - left.count || right.lastUsed - left.lastUsed || a.name.localeCompare(b.name) + }) + .slice(0, limit) +} + // Strips trailing "(free)" parenthesized suffix from model display names, e.g. // "Llama 3 (free)" → "Llama 3". A separate "Free" label/tag is rendered // elsewhere, so preserve bare trailing "Free" words (e.g. "Kilo Auto Free"). diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 54917021876..dcfd8e57e24 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -40,6 +40,7 @@ import type { SuggestionRequest, TodoItem, ModelSelection, + ModelUsageMap, ContextUsage, AgentInfo, SkillInfo, @@ -122,6 +123,7 @@ interface SessionStore { variantSelections: Record // session/agent scoped variant key -> variant name recentModels: ModelSelection[] favoriteModels: ModelSelection[] + modelUsageHistory: ModelUsageMap modelUsage: Record } @@ -244,6 +246,8 @@ interface SessionContextValue { selectVariant: (value: string, sessionID?: string) => void // Model favorites + recentModels: Accessor + modelUsageHistory: Accessor favoriteModels: Accessor toggleFavorite: (providerID: string, modelID: string) => void @@ -511,7 +515,6 @@ export const SessionProvider: ParentComponent = (props) => { ) } - // Store for sessions, messages, parts, todos, modelSelections, agentSelections const [store, setStore] = createStore({ sessions: {}, messages: {}, @@ -524,6 +527,7 @@ export const SessionProvider: ParentComponent = (props) => { variantSelections: {}, recentModels: [], favoriteModels: [], + modelUsageHistory: {}, modelUsage: {}, }) const [modelUsageReady, setModelUsageReady] = createSignal(false) @@ -632,6 +636,14 @@ export const SessionProvider: ParentComponent = (props) => { vscode.postMessage({ type: "persistRecents", recents: updated }) } + function recordModelUsage(providerID?: string, modelID?: string) { + if (!providerID || !modelID) return + const key = `${providerID}/${modelID}` + const current = store.modelUsageHistory[key] ?? { count: 0, lastUsed: 0 } + setStore("modelUsageHistory", key, { count: current.count + 1, lastUsed: Date.now() }) + vscode.postMessage({ type: "recordModelUsage", providerID, modelID }) + } + function applyModel(agentName: string, selection: ModelSelection, sessionID?: string) { pushRecent(selection) if (sessionID) { @@ -965,6 +977,12 @@ export const SessionProvider: ParentComponent = (props) => { vscode.postMessage({ type: "requestRecents" }) onCleanup(unsubRecents) + const unsubModelUsage = vscode.onMessage((message: ExtensionMessage) => { + if (message.type !== "modelUsageLoaded") return + setStore("modelUsageHistory", message.usage) + }) + vscode.postMessage({ type: "requestModelUsage" }) + onCleanup(unsubModelUsage) // Load persisted favorite models from extension globalState const unsubFavorites = vscode.onMessage((message: ExtensionMessage) => { if (message.type !== "favoritesLoaded") return @@ -2288,6 +2306,8 @@ export const SessionProvider: ParentComponent = (props) => { const messageID = Identifier.ascending("message") const sid = origin === undefined ? currentSessionID() : (origin ?? undefined) + const selection = providerID && modelID ? { providerID, modelID } : selected(sid) + recordModelUsage(selection?.providerID, selection?.modelID) const preview = sid?.startsWith("cloud:") ? sid.slice("cloud:".length) : origin === undefined @@ -2364,6 +2384,8 @@ export const SessionProvider: ParentComponent = (props) => { // Cloud previews need import-then-command; post importAndSend with command metadata const sid = origin === undefined ? currentSessionID() : (origin ?? undefined) + const selection = providerID && modelID ? { providerID, modelID } : selected(sid) + recordModelUsage(selection?.providerID, selection?.modelID) const preview = sid?.startsWith("cloud:") ? sid.slice("cloud:".length) : origin === undefined @@ -3025,6 +3047,8 @@ export const SessionProvider: ParentComponent = (props) => { allMessages, allParts, allStatusMap, + recentModels: () => store.recentModels, + modelUsageHistory: () => store.modelUsageHistory, favoriteModels: () => store.favoriteModels, toggleFavorite, variantList, diff --git a/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts b/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts index 6dbfc9b979c..6b99617a3b1 100644 --- a/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts +++ b/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts @@ -58,6 +58,7 @@ export function useSlashCommand( exclude?: Set | Accessor>, include?: Set | Accessor>, scope?: string, + extra?: SlashCommandEntry[], ): SlashCommand { const [server, setServer] = createSignal([]) const [query, setQuery] = createSignal(null) @@ -191,6 +192,7 @@ export function useSlashCommand( }, }, ] + all.push(...(extra ?? [])) const excluded = () => { if (typeof exclude === "function") return exclude() diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 6c425940a8f..78cdba37faa 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -158,6 +158,7 @@ export const dict = { "model.group.auto": "النماذج التلقائية", "model.group.recommended": "موصى به", "model.group.favorites": "المفضلة", + "model.group.mostUsed": "الأكثر استخدامًا", "model.favorite.add": "إضافة إلى المفضلة", "model.favorite.remove": "إزالة من المفضلة", "model.preview.label.released": "الإصدار", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index 1a7d9ae17ba..ed475d19fa7 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -162,6 +162,7 @@ export const dict = { "model.group.auto": "Modelos automáticos", "model.group.recommended": "Recomendado", "model.group.favorites": "Favoritos", + "model.group.mostUsed": "Mais usados", "model.favorite.add": "Adicionar aos favoritos", "model.favorite.remove": "Remover dos favoritos", "model.preview.label.released": "Lançado", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index 509f0265201..bb27ffe0ee7 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -163,6 +163,7 @@ export const dict = { "model.group.auto": "Automatski modeli", "model.group.recommended": "Preporučeno", "model.group.favorites": "Favoriti", + "model.group.mostUsed": "Najčešće korišteni", "model.favorite.add": "Dodaj u favorite", "model.favorite.remove": "Ukloni iz favorita", "model.preview.label.released": "Objavljeno", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index 4f1d6c9034c..7badc2001f6 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -162,6 +162,7 @@ export const dict = { "model.group.auto": "Automatiske modeller", "model.group.recommended": "Anbefalet", "model.group.favorites": "Favoritter", + "model.group.mostUsed": "Mest brugte", "model.favorite.add": "Føj til favoritter", "model.favorite.remove": "Fjern fra favoritter", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 7c1366c7261..f7899cba210 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -169,6 +169,7 @@ export const dict = { "model.group.auto": "Automatische Modelle", "model.group.recommended": "Empfohlen", "model.group.favorites": "Favoriten", + "model.group.mostUsed": "Am häufigsten verwendet", "model.favorite.add": "Zu Favoriten hinzufügen", "model.favorite.remove": "Aus Favoriten entfernen", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index 5752f6b2a8c..316caa5c7c7 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -162,6 +162,7 @@ export const dict = { "model.group.auto": "Auto Models", "model.group.recommended": "Recommended", "model.group.favorites": "Favorites", + "model.group.mostUsed": "Most used", "model.favorite.add": "Add to favorites", "model.favorite.remove": "Remove from favorites", "model.preview.label.released": "Released", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index 2b7ed694b0b..30554b98498 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -164,6 +164,7 @@ export const dict = { "model.group.auto": "Modelos automáticos", "model.group.recommended": "Recomendado", "model.group.favorites": "Favoritos", + "model.group.mostUsed": "Más usados", "model.favorite.add": "Añadir a favoritos", "model.favorite.remove": "Eliminar de favoritos", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts index 71b97a2d3ff..bae8578feef 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts @@ -163,6 +163,7 @@ export const dict = { "model.group.auto": "مدل‌های خودکار", "model.group.recommended": "پیشنهادی", "model.group.favorites": "موردعلاقه‌ها", + "model.group.mostUsed": "پراستفاده‌ترین", "model.favorite.add": "افزودن به موردعلاقه‌ها", "model.favorite.remove": "حذف از موردعلاقه‌ها", "model.preview.label.released": "منتشر شده", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index 3fd9956d9de..d289df1ae98 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -163,6 +163,7 @@ export const dict = { "model.group.auto": "Modèles automatiques", "model.group.recommended": "Recommandé", "model.group.favorites": "Favoris", + "model.group.mostUsed": "Les plus utilisés", "model.favorite.add": "Ajouter aux favoris", "model.favorite.remove": "Retirer des favoris", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index b8ec52156f4..142a0e04057 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -120,6 +120,7 @@ export const dict = { "model.group.auto": "Modelli automatici", "model.group.recommended": "Consigliati", "model.group.favorites": "Preferiti", + "model.group.mostUsed": "Più usati", "model.favorite.add": "Aggiungi ai preferiti", "model.favorite.remove": "Rimuovi dai preferiti", "model.preview.label.released": "Rilasciato", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index fa1eda6f6b0..8eb6c5bf09d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -162,6 +162,7 @@ export const dict = { "model.group.auto": "自動モデル", "model.group.recommended": "推奨", "model.group.favorites": "お気に入り", + "model.group.mostUsed": "よく使うモデル", "model.favorite.add": "お気に入りに追加", "model.favorite.remove": "お気に入りから削除", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 360ed69cb00..080af3882e6 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -166,6 +166,7 @@ export const dict = { "model.group.auto": "자동 모델", "model.group.recommended": "추천", "model.group.favorites": "즐겨찾기", + "model.group.mostUsed": "가장 많이 사용됨", "model.favorite.add": "즐겨찾기에 추가", "model.favorite.remove": "즐겨찾기에서 제거", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index 64f70685b4c..6c3ffaafcdf 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -164,6 +164,7 @@ export const dict = { "model.group.auto": "Automatische modellen", "model.group.recommended": "Aanbevolen", "model.group.favorites": "Favorieten", + "model.group.mostUsed": "Meest gebruikt", "model.favorite.add": "Toevoegen aan favorieten", "model.favorite.remove": "Verwijderen uit favorieten", "model.preview.label.released": "Uitgebracht", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index 103de2edff6..818d41a3a92 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -165,6 +165,7 @@ export const dict = { "model.group.auto": "Automatiske modeller", "model.group.recommended": "Anbefalt", "model.group.favorites": "Favoritter", + "model.group.mostUsed": "Mest brukt", "model.favorite.add": "Legg til i favoritter", "model.favorite.remove": "Fjern fra favoritter", "model.preview.label.released": "Utgitt", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index 18830cde9be..bc9524b1741 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -162,6 +162,7 @@ export const dict = { "model.group.auto": "Modele automatyczne", "model.group.recommended": "Zalecane", "model.group.favorites": "Ulubione", + "model.group.mostUsed": "Najczęściej używane", "model.favorite.add": "Dodaj do ulubionych", "model.favorite.remove": "Usuń z ulubionych", "model.preview.label.released": "Wydano", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 5eca69def5d..49c4e2929fa 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -162,6 +162,7 @@ export const dict = { "model.group.auto": "Автоматические модели", "model.group.recommended": "Рекомендуемые", "model.group.favorites": "Избранное", + "model.group.mostUsed": "Часто используемые", "model.favorite.add": "Добавить в избранное", "model.favorite.remove": "Удалить из избранного", "model.preview.label.released": "Выпущена", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index c4dbc68bcf5..0bb9971a3ef 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -161,6 +161,7 @@ export const dict = { "model.group.auto": "โมเดลอัตโนมัติ", "model.group.recommended": "แนะนำ", "model.group.favorites": "รายการโปรด", + "model.group.mostUsed": "ใช้บ่อยที่สุด", "model.favorite.add": "เพิ่มในรายการโปรด", "model.favorite.remove": "ลบออกจากรายการโปรด", "model.preview.label.released": "เปิดตัว", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 0761b27b94a..dadc3f93ad8 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -162,6 +162,7 @@ export const dict = { "model.group.auto": "Otomatik Modeller", "model.group.recommended": "Önerilen", "model.group.favorites": "Favoriler", + "model.group.mostUsed": "En çok kullanılan", "model.favorite.add": "Favorilere ekle", "model.favorite.remove": "Favorilerden çıkar", "model.preview.label.released": "Yayınlanma", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index b4b8c529d24..a294e45fe5c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -163,6 +163,7 @@ export const dict = { "model.group.auto": "Автоматичні моделі", "model.group.recommended": "Рекомендовані", "model.group.favorites": "Обране", + "model.group.mostUsed": "Найчастіше використовувані", "model.favorite.add": "Додати до обраного", "model.favorite.remove": "Видалити з обраного", "model.preview.label.released": "Випущено", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index 041c72d116b..afa17238a53 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -158,6 +158,7 @@ export const dict = { "model.group.auto": "自动模型", "model.group.recommended": "推荐", "model.group.favorites": "收藏夹", + "model.group.mostUsed": "最常用", "model.favorite.add": "添加到收藏夹", "model.favorite.remove": "从收藏夹中移除", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index 52ecca5fa9e..3cb5f1c1521 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -158,6 +158,7 @@ export const dict = { "model.group.auto": "自動模型", "model.group.recommended": "推薦", "model.group.favorites": "我的最愛", + "model.group.mostUsed": "最常用", "model.favorite.add": "加入我的最愛", "model.favorite.remove": "從我的最愛中移除", diff --git a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx index 16361cf8f0d..51dba131c28 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx @@ -254,6 +254,8 @@ export function mockSessionValue(overrides?: { revertSession: noop, unrevertSession: noop, favoriteModels: () => [], + recentModels: () => [], + modelUsageHistory: () => ({}), toggleFavorite: noop, variantList: () => [], currentVariant: () => undefined, diff --git a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx index 9ebef0f8766..cc0a8696769 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx @@ -26,8 +26,16 @@ import { Icon } from "@kilocode/kilo-ui/icon" import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip" import { ContextMenu } from "@kilocode/kilo-ui/context-menu" import { ThinkingSelectorBase } from "../components/shared/ThinkingSelector" +import { DeferredPopover } from "../components/shared/DeferredPopover" +import { ProjectSelect } from "../../agent-manager/ProjectSelect" import { createSignal, onCleanup, onMount, type JSX } from "solid-js" -import type { WorktreeFileDiff, WorktreeState, WorktreeGitStats, PRStatus } from "../types/messages" +import type { + AgentProjectSnapshot, + WorktreeFileDiff, + WorktreeState, + WorktreeGitStats, + PRStatus, +} from "../types/messages" import type { ReviewComment } from "../../diff-viewer/review-comments" import { createModeRouter } from "../../agent-manager/mode-router" import "../../agent-manager/agent-manager.css" @@ -1075,6 +1083,115 @@ export const NewWorktreeVariantDropdown1280: Story = { ), } +const projectPickerProjects: AgentProjectSnapshot[] = [ + { + id: "project-main", + root: "/workspace/kilocode", + label: "kilocode", + pinned: true, + active: true, + expanded: true, + initialized: true, + trusted: true, + missing: false, + }, + { + id: "project-cloud", + root: "/workspace/cloud", + label: "cloud", + pinned: false, + active: false, + expanded: false, + initialized: true, + trusted: true, + missing: false, + }, + { + id: "project-untrusted", + root: "/workspace/sample-app", + label: "sample-app", + pinned: false, + active: false, + expanded: false, + initialized: false, + trusted: false, + missing: false, + }, +] + +export const NewWorktreeProjectDropdown: Story = { + name: "NewWorktreeDialog — project dropdown open", + parameters: { layout: "fullscreen" }, + render: () => ( + +
+
+
+
+
+
New Worktree
+
+
+
+ + +
+
+ undefined} + placement="bottom-start" + flip={false} + sameWidth + portal={false} + deferDismiss + class="am-dropdown" + trigger={ + + } + > + undefined} + labels={{ + untrusted: "Trust this project in the sidebar first", + missing: "Repository not found", + }} + /> + +
+
+
+
+
+
+
+ VERSIONS +
+
+
+
+
+
+
+
+ + ), +} + const searchSection = { id: "polish", name: "Polish", color: "Blue", order: 0, collapsed: false } const slackedSection = { id: "slacked", name: "SLACKED", color: "Yellow", order: 1, collapsed: false } const sidebarSearchItems: SidebarSearchItem[] = [ @@ -1198,12 +1315,7 @@ export const SidebarSearchOpen: Story = { // --------------------------------------------------------------------------- import { ProjectList } from "../../agent-manager/ProjectList" -import type { - AgentManagerStateMessage, - AgentProjectSnapshot, - LocalGitStats, - ProjectSessionInfo, -} from "../types/messages" +import type { AgentManagerStateMessage, LocalGitStats, ProjectSessionInfo } from "../types/messages" const projectA: AgentProjectSnapshot = { id: "prj-aaaa1111aaaa", diff --git a/packages/kilo-vscode/webview-ui/src/stories/shared.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/shared.stories.tsx index 81b27047dba..48d6bd94a8a 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/shared.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/shared.stories.tsx @@ -131,6 +131,28 @@ export const ModelSelectorSelectedFavorite: Story = { }, } +export const ModelSelectorMostUsed: Story = { + name: "ModelSelector - most used suggestions", + render: () => { + const session = { + ...mockSessionValue(), + modelUsageHistory: () => ({ + "kilo/alpha": { count: 3, lastUsed: 100 }, + "kilo/bravo": { count: 12, lastUsed: 200 }, + "nvidia/nova": { count: 7, lastUsed: 300 }, + }), + } + + return ( + + + + + + ) + }, +} + const LARGE_MODELS: EnrichedModel[] = Array.from({ length: 600 }, (_, i) => { const id = String(i).padStart(3, "0") const provider = `provider-${i % 12}` diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index 859482cae8a..a20c3278b40 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -18,7 +18,7 @@ import type { AgentManagerSidebarTarget } from "./webview-messages" import type { PermissionRequest } from "./permissions" import type { AnacondaDesktopExtensionMessage } from "../../../../src/shared/anaconda-desktop-messages" import type { QuestionRequest, SuggestionRequest, TodoItem } from "./questions" -import type { ModelSelection, Provider, ProviderAuthState } from "./providers" +import type { ModelSelection, ModelUsageMap, Provider, ProviderAuthState } from "./providers" import type { SpeechToTextModelDef } from "../../../../src/speech-to-text/models" import type { AgentInfo, AgentRequirementResult, SkillInfo, SlashCommandInfo } from "./agents" import type { @@ -920,6 +920,11 @@ export interface RecentsLoadedMessage { recents: ModelSelection[] } +export interface ModelUsageLoadedMessage { + type: "modelUsageLoaded" + usage: ModelUsageMap +} + // Persisted model-selector expand/collapse preference (extension → webview) export interface ModelSelectorExpandedLoadedMessage { type: "modelSelectorExpandedLoaded" @@ -947,6 +952,7 @@ export interface AgentManagerBranchesMessage { // Agent Manager Import tab: result feedback (extension → webview) export interface AgentManagerImportResultMessage { type: "agentManager.importResult" + projectId?: string success: boolean message: string errorCode?: WorktreeErrorCode @@ -1298,6 +1304,7 @@ export type ExtensionMessage = | MessagesLoadedMessage | SessionModelUsageLoadedMessage | SessionModelUsageChangedMessage + | ModelUsageLoadedMessage | MessageCreatedMessage | SessionsLoadedMessage | CloudSessionsLoadedMessage diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/providers.ts b/packages/kilo-vscode/webview-ui/src/types/messages/providers.ts index cb9b1094470..d2170190164 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/providers.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/providers.ts @@ -53,6 +53,13 @@ export interface ModelSelection { modelID: string } +export interface ModelUsage { + count: number + lastUsed: number +} + +export type ModelUsageMap = Record + export type ProviderAuthState = "api" | "oauth" | "wellknown" export interface ProviderConfig { diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts index 48e5a5764ac..87d5599d118 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -1242,6 +1242,16 @@ export interface RequestRecentsMessage { type: "requestRecents" } +export interface RecordModelUsageMessage { + type: "recordModelUsage" + providerID: string + modelID: string +} + +export interface RequestModelUsageMessage { + type: "requestModelUsage" +} + export interface PersistModelSelectorExpandedRequest { type: "persistModelSelectorExpanded" value: boolean @@ -1559,6 +1569,8 @@ export type WebviewMessage = | FetchCustomProviderModelsMessage | PersistRecentsRequest | RequestRecentsMessage + | RecordModelUsageMessage + | RequestModelUsageMessage | PersistModelSelectorExpandedRequest | RequestModelSelectorExpandedMessage | ToggleFavoriteRequest diff --git a/packages/opencode/src/cli/cmd/run/runtime.ts b/packages/opencode/src/cli/cmd/run/runtime.ts index 27361a6727a..f459fdea8b7 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.ts @@ -15,6 +15,7 @@ import { createKiloClient } from "@kilocode/sdk/v2" import { Flag } from "@opencode-ai/core/flag/flag" import { MessageID } from "@/session/schema" +import { KiloRunTerminal } from "@/kilocode/cli/cmd/run-terminal" // kilocode_change import { createRunDemo } from "./demo" import { resolveModelInfo, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot" import { createRuntimeLifecycle } from "./runtime.lifecycle" @@ -225,6 +226,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep return state.session } + const terminal = KiloRunTerminal.create(ctx.sdk, () => state.sessionID) // kilocode_change + const shell = await (deps.createRuntimeLifecycle ?? createRuntimeLifecycle)({ directory: ctx.directory, findFiles: (query) => @@ -267,21 +270,9 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep await ctx.sdk.question.reject(next) }, // kilocode_change start - human-driven terminal in direct interactive mode - onTerminalWrite: async (next) => { - await ctx.sdk.interactiveTerminal.write({ - terminalID: next.terminalID, - interactiveTerminalWriteInput: { data: next.data }, - }) - }, - onTerminalResize: async (next) => { - await ctx.sdk.interactiveTerminal.resize({ - terminalID: next.terminalID, - interactiveTerminalResizeInput: { cols: next.cols, rows: next.rows }, - }) - }, - onTerminalClose: async (terminalID) => { - await ctx.sdk.interactiveTerminal.close({ terminalID }) - }, + onTerminalWrite: terminal.write, + onTerminalResize: terminal.resize, + onTerminalClose: terminal.close, // kilocode_change end onCycleVariant: () => { if (!state.model || state.variants.length === 0) { diff --git a/packages/opencode/src/kilocode/cli/cmd/run-terminal.ts b/packages/opencode/src/kilocode/cli/cmd/run-terminal.ts new file mode 100644 index 00000000000..18b87fb2543 --- /dev/null +++ b/packages/opencode/src/kilocode/cli/cmd/run-terminal.ts @@ -0,0 +1,54 @@ +import type { KiloClient } from "@kilocode/sdk/v2" + +type Client = { + session: Pick + terminal: Pick +} + +export namespace KiloRunTerminal { + export function create(sdk: KiloClient, session: () => string) { + const client: Client = { session: sdk.session, terminal: sdk.interactiveTerminal } + const state = { + id: "", + workspace: undefined as Promise | undefined, + } + + function workspace() { + const id = session() + if (state.id === id && state.workspace) return state.workspace + state.id = id + state.workspace = client.session + .get({ sessionID: id }) + .then((result) => { + if (result.error) throw result.error + return result.data?.workspaceID + }) + .catch(() => { + state.id = "" + state.workspace = undefined + return undefined + }) + return state.workspace + } + + return { + write: async (input: { terminalID: string; data: string }) => { + await client.terminal.write({ + terminalID: input.terminalID, + workspace: await workspace(), + interactiveTerminalWriteInput: { data: input.data }, + }) + }, + resize: async (input: { terminalID: string; cols: number; rows: number }) => { + await client.terminal.resize({ + terminalID: input.terminalID, + workspace: await workspace(), + interactiveTerminalResizeInput: { cols: input.cols, rows: input.rows }, + }) + }, + close: async (terminalID: string) => { + await client.terminal.close({ terminalID, workspace: await workspace() }) + }, + } + } +} diff --git a/packages/opencode/src/kilocode/config/overlay.ts b/packages/opencode/src/kilocode/config/overlay.ts index 6785e654ba2..fce5d9dee02 100644 --- a/packages/opencode/src/kilocode/config/overlay.ts +++ b/packages/opencode/src/kilocode/config/overlay.ts @@ -88,6 +88,7 @@ export namespace KilocodeConfigOverlay { ["model"], ["small_model"], ["hide_prompt_training_models"], + ["privacy_mode"], ["default_agent"], ["snapshot"], ["share"], diff --git a/packages/opencode/src/kilocode/database/sqlite-error.ts b/packages/opencode/src/kilocode/database/sqlite-error.ts index 31e476a8005..fafda79b056 100644 --- a/packages/opencode/src/kilocode/database/sqlite-error.ts +++ b/packages/opencode/src/kilocode/database/sqlite-error.ts @@ -1,7 +1,5 @@ -import { isSqlError } from "effect/unstable/sql/SqlError" +import { isBusy } from "@opencode-ai/core/kilocode/sqlite-error" + +export { isBusy } export const busyMessage = "Database is busy. Please try again in a moment." - -export function isBusy(error: unknown) { - return isSqlError(error) && error.reason._tag === "LockTimeoutError" -} diff --git a/packages/opencode/src/kilocode/kilo-commands.tsx b/packages/opencode/src/kilocode/kilo-commands.tsx index 58bb3e8de26..5ad85cf9747 100644 --- a/packages/opencode/src/kilocode/kilo-commands.tsx +++ b/packages/opencode/src/kilocode/kilo-commands.tsx @@ -11,6 +11,8 @@ import { useRoute } from "@tui/context/route" import { useDialog } from "@tui/ui/dialog" import { useToast } from "@tui/ui/toast" import { DialogAlert } from "@tui/ui/dialog-alert" +import { DialogConfirm } from "@tui/ui/dialog-confirm" +import { reconcile } from "solid-js/store" import type { Organization } from "@kilocode/kilo-gateway" import type { ClawStatus } from "./claw/types.js" import { DialogKiloTeamSelect } from "./components/dialog-kilo-team-select.js" @@ -137,6 +139,15 @@ export function registerKiloCommands(useSDK: () => UseSDK) { hidden: !isKiloConnected(), run: async () => { try { + if (sync.data.globalConfig.privacy_mode === true) { + const confirmed = await DialogConfirm.show( + dialog, + "Privacy Mode Enabled", + "Privacy mode is on. Revealing your profile will display your email, name, balance, and team on screen.", + ) + if (confirmed !== true) return + } + // Fetch profile and balance using server endpoint const response = await sdk.client.kilo.profile() @@ -176,6 +187,39 @@ export function registerKiloCommands(useSDK: () => UseSDK) { ] : []), + // /privacy command + { + name: "kilo.privacy", + get title() { + return sync.data.globalConfig.privacy_mode === true ? "Disable privacy mode" : "Enable privacy mode" + }, + desc: "Blur PII (balance, email, etc.) and confirm before showing profile", + category: "Kilo", + slashName: "privacy", + run: async () => { + const next = sync.data.globalConfig.privacy_mode !== true + const response = await sdk.client.config.overlayUpdate({ + scope: "global", + set: { privacy_mode: next }, + }) + if (response.error) { + const status = response.response?.status ?? "?" + toast.show({ message: `Failed to update privacy mode (${status})`, variant: "error" }) + return + } + const [cfg, global] = await Promise.all([ + sdk.client.config.get({}), + sdk.client.global.config.get({}), + ]) + if (cfg.data) sync.set("config", reconcile(cfg.data)) + if (global.data) sync.set("globalConfig", reconcile(global.data)) + toast.show({ + message: next ? "Privacy mode enabled" : "Privacy mode disabled", + variant: "success", + }) + }, + }, + // /teams command { name: "kilo.teams", diff --git a/packages/opencode/src/kilocode/pii.ts b/packages/opencode/src/kilocode/pii.ts new file mode 100644 index 00000000000..f3d90021563 --- /dev/null +++ b/packages/opencode/src/kilocode/pii.ts @@ -0,0 +1 @@ +export const REDACTED_BALANCE = "•••" diff --git a/packages/opencode/src/kilocode/plugins/sidebar-footer.tsx b/packages/opencode/src/kilocode/plugins/sidebar-footer.tsx index cf605c78bf7..994a1b47a11 100644 --- a/packages/opencode/src/kilocode/plugins/sidebar-footer.tsx +++ b/packages/opencode/src/kilocode/plugins/sidebar-footer.tsx @@ -5,6 +5,7 @@ import * as Log from "@opencode-ai/core/util/log" import type { KiloPassState } from "@kilocode/kilo-gateway" import type { Message } from "@kilocode/sdk/v2" import { onBalanceRefresh } from "../balance-refresh" +import { REDACTED_BALANCE } from "../pii" const id = "internal:kilo-sidebar-footer" const TEAM_POLL_MS = 5 * 60_000 @@ -37,8 +38,9 @@ export function scope(org: string | null | undefined, list?: readonly { id: stri } } -export function creditLabel(value: ReturnType) { +export function creditLabel(value: ReturnType, masked = false) { if (value.kind === "Personal") return "Personal credits" + if (masked) return "Team credits" return value.name ? `${value.name} team` : "Team credits" } @@ -98,6 +100,9 @@ function View(props: { api: TuiPluginApi }) { name: list.at(-1) ?? "", } }) + const privacyMode = createMemo(() => props.api.state.globalConfig.privacy_mode === true) + const balanceText = createMemo(() => (privacyMode() ? REDACTED_BALANCE : null)) + const mutedColor = createMemo(() => (privacyMode() ? theme().textMuted : tone())) const refresh = () => { const id = ++seq // Cancel any prior request and time this one out — the client path has no fetch timeout, @@ -167,19 +172,20 @@ function View(props: { api: TuiPluginApi }) { {(() => { const balance = data().balance if (balance === undefined) return null + const masked = balanceText() return ( - + - {creditLabel(data().scope)} + {creditLabel(data().scope, privacyMode())} - {format(balance)} + {masked ?? format(balance)} ) })()} - + {(pass) => ( diff --git a/packages/opencode/src/kilocode/session/fork.ts b/packages/opencode/src/kilocode/session/fork.ts index b95374bf035..bef4fe8dfff 100644 --- a/packages/opencode/src/kilocode/session/fork.ts +++ b/packages/opencode/src/kilocode/session/fork.ts @@ -1,41 +1,168 @@ +import { Effect, Schema } from "effect" +import type { Session } from "@/session/session" import { MessageV2 } from "@/session/message-v2" +import { MessageID, PartID, SessionID } from "@/session/schema" +import { resumeHint } from "../task-resume" import { KiloPartLifecycle } from "./part-lifecycle" const task = "task" -const stale = /^[ \t]*task_id:[^\r\n]*(?:(?:\r?\n){1,2}|$)/m +type Ops = Pick -// Prepare a source part for a forked transcript copy: drop transient parts (returns undefined) and detach -// task calls into historical results. The caller assigns fresh ids and publishes via Session.updatePart. +// Keep terminal task references so the fork can give them private child sessions. +// In-flight jobs cannot be copied safely, so those remain historical errors. export function prepareForkedPart(part: MessageV2.Part): MessageV2.Part | undefined { if (KiloPartLifecycle.transient(part)) return undefined - return structuredClone(detachPart(part)) + if ( + part.type === "tool" && + part.tool === task && + (part.state.status === "pending" || part.state.status === "running") + ) { + return structuredClone(detachPart(part)) + } + return structuredClone(part) } -function metadata(value: Record | undefined) { +function childID(part: MessageV2.Part) { + if (part.type !== "tool" || part.tool !== task) return undefined + const state = part.state + const metadata = state.status === "pending" ? undefined : state.metadata + const values = [ + metadata?.sessionId, + metadata?.sessionID, + part.metadata?.sessionId, + part.metadata?.sessionID, + state.input.task_id, + ] + return values.find((value): value is string => typeof value === "string") +} + +function mapRecord(value: Record | undefined, map: Map, keys: string[]) { if (!value) return value const copy = { ...value } - delete copy.sessionId - delete copy.sessionID + for (const key of keys) { + const id = copy[key] + if (typeof id !== "string") continue + const replacement = map.get(id) + if (replacement) copy[key] = replacement + } return copy } -function input(value: Record) { - const copy = { ...value } - delete copy.task_id - return copy +function output(value: string, map: Map) { + return [...map].reduce( + (text, [source, target]) => (source === target ? text : text.replaceAll(source, target)), + value, + ) +} + +function remapPart(part: MessageV2.Part, map: Map) { + if (part.type === "text") { + const text = output(part.text, map) + return text === part.text ? part : { ...part, text } + } + if (part.type !== "tool" || part.tool !== task) return part + const next = structuredClone(part) + next.metadata = mapRecord(next.metadata, map, ["sessionId", "sessionID", "parentSessionId", "parentSessionID"]) + const input = mapRecord(next.state.input, map, ["task_id"]) + if (input) next.state.input = input + if (next.state.status !== "pending") { + const metadata = mapRecord(next.state.metadata, map, [ + "sessionId", + "sessionID", + "parentSessionId", + "parentSessionID", + ]) + if (metadata) next.state.metadata = metadata + } + if (next.state.status === "completed") next.state.output = output(next.state.output, map) + if (next.state.status === "error") next.state.error = output(next.state.error, map) + return next +} + +function copy(input: { source: Session.Info; parentID: SessionID; ops: Ops }) { + return Effect.gen(function* () { + const target = yield* input.ops.create({ + parentID: input.parentID, + title: input.source.title, + agent: input.source.agent, + model: input.source.model, + metadata: structuredClone(input.source.metadata), + permission: input.source.permission ? [...input.source.permission] : undefined, + workspaceID: input.source.workspaceID, + }) + const msgs = yield* input.ops.messages({ sessionID: input.source.id }) + const ids = new Map() + + for (const msg of msgs) { + const id = MessageID.ascending() + ids.set(msg.info.id, id) + const parentID = msg.info.role === "assistant" ? ids.get(msg.info.parentID) : undefined + const cloned = yield* input.ops.updateMessage({ + ...msg.info, + id, + sessionID: target.id, + ...(msg.info.role === "assistant" && { cost: 0 }), + ...(parentID && { parentID }), + }) + + for (const part of msg.parts) { + const prepared = prepareForkedPart(part) + if (!prepared) continue + const next: MessageV2.Part = { + ...prepared, + id: PartID.ascending(), + messageID: cloned.id, + sessionID: target.id, + ...(prepared.type === "step-finish" && { cost: 0 }), + } + if (next.type === "compaction" && next.tail_start_id) next.tail_start_id = ids.get(next.tail_start_id) + yield* input.ops.updatePart(next) + } + } + + return target + }) +} + +export function remapChildren(input: { + sessionID: SessionID + ops: Ops + remapped?: Map +}): Effect.Effect { + return Effect.gen(function* () { + const map = input.remapped ?? new Map() + const msgs = yield* input.ops.messages({ sessionID: input.sessionID }) + const refs = msgs.flatMap((msg) => + msg.parts.flatMap((part) => { + const child = childID(part) + return child ? [{ part, child }] : [] + }), + ) + + for (const ref of refs) { + if (map.has(ref.child)) continue + if (!Schema.is(SessionID)(ref.child)) continue + const source = yield* input.ops.get(SessionID.make(ref.child)).pipe(Effect.orElseSucceed(() => undefined)) + if (!source) continue + const target = yield* copy({ source, parentID: input.sessionID, ops: input.ops }) + map.set(ref.child, target.id) + yield* remapChildren({ sessionID: target.id, ops: input.ops, remapped: map }) + } + + for (const msg of msgs) { + for (const part of msg.parts) { + const next = remapPart(part, map) + if (next !== part) yield* input.ops.updatePart(next) + } + } + }) } -/** - * Turns copied task calls into detached historical results. - * - * Child sessions are execution state, not conversation context. Their final - * result is already embedded in the parent task part, so a fork keeps that - * result while dropping references that could resume, stream, or route prompts - * to a child owned by the source session. - */ function detachPart(part: MessageV2.Part): MessageV2.Part { if (part.type !== "tool" || part.tool !== task) return part + const child = childID(part) + const hint = child ? `\n${resumeHint(child)}` : "" const top = metadata(part.metadata) const state = part.state if (state.status === "pending") { @@ -46,46 +173,30 @@ function detachPart(part: MessageV2.Part): MessageV2.Part { state: { status: "error", input: input(state.input), - error: "Task was still pending when this session was forked.", + error: `Task was still pending when this session was forked.${hint}`, time: { start: now, end: now }, }, } } - if (state.status === "running") { - return { - ...part, - metadata: top, - state: { - status: "error", - input: input(state.input), - error: "Task was still running when this session was forked.", - metadata: metadata(state.metadata), - time: { start: state.time.start, end: Date.now() }, - }, - } - } - - if (state.status === "error") { - return { - ...part, - metadata: top, - state: { - ...state, - input: input(state.input), - metadata: metadata(state.metadata), - }, - } - } - return { ...part, metadata: top, state: { - ...state, + status: "error", input: input(state.input), - output: state.output.replace(stale, ""), - metadata: metadata(state.metadata) ?? {}, + error: `Task was still running when this session was forked.${hint}`, + metadata: metadata(state.metadata), + time: { start: state.time.start, end: Date.now() }, }, } } + +function metadata(value: Record | undefined) { + if (!value) return value + return { ...value } +} + +function input(value: Record) { + return { ...value } +} diff --git a/packages/opencode/src/kilocode/session/index.ts b/packages/opencode/src/kilocode/session/index.ts index 3810eafaf36..679b8440724 100644 --- a/packages/opencode/src/kilocode/session/index.ts +++ b/packages/opencode/src/kilocode/session/index.ts @@ -1,4 +1,4 @@ -import { prepareForkedPart as _prepareForkedPart } from "./fork" +import { prepareForkedPart as _prepareForkedPart, remapChildren as _remapChildren } from "./fork" import z from "zod" import { Cause, Effect, Schema } from "effect" import { Bus } from "@/bus" @@ -437,6 +437,7 @@ export namespace KiloSession { } export const prepareForkedPart = _prepareForkedPart + export const remapChildren = _remapChildren } export { kiloSessionFork } from "./fork-command" diff --git a/packages/opencode/src/kilocode/skills/builtin.ts b/packages/opencode/src/kilocode/skills/builtin.ts index 23275d040a3..50c6971dcfe 100644 --- a/packages/opencode/src/kilocode/skills/builtin.ts +++ b/packages/opencode/src/kilocode/skills/builtin.ts @@ -3,7 +3,7 @@ // Content is inlined at compile time via Bun's static import of .md files. // Registered before all discovery phases so user skills with the same name override. -import KILO_CONFIG from "./kilo-config.md" +import KILO_CONFIG from "./kilo-config.md" with { type: "text" } export interface BuiltinSkill { name: string diff --git a/packages/opencode/src/kilocode/snapshot/diff-full.ts b/packages/opencode/src/kilocode/snapshot/diff-full.ts index 82d2b6789cf..6beb5f97c8c 100644 --- a/packages/opencode/src/kilocode/snapshot/diff-full.ts +++ b/packages/opencode/src/kilocode/snapshot/diff-full.ts @@ -84,6 +84,66 @@ export namespace DiffFull { return map }) + // Cap the full-content sides we return for a single editor diff tab. Anything larger falls back + // to the hunk-only view rather than shipping tens of MB of text over RPC. + export const MAX_DETAIL_SIZE = 20 * 1024 * 1024 + + /** + * Authoritative full-content detail for one file between two snapshot refs, for the editor diff + * tab. Returns status, additions/deletions, the hunk patch, and the whole before/after file + * contents (read from the snapshot objects via `git show`), so the client can render a whole-file + * diff regardless of the current working tree. `before`/`after` are omitted when a side exceeds + * [MAX_DETAIL_SIZE]; binary files carry no content. `run.diff` wraps quote+args, `run.show` wraps + * the plain config (no quotepath) so `git show ref:path` resolves correctly. + */ + export const detail = Effect.fn("DiffFull.detail")(function* ( + run: { + diff: (cmd: string[]) => Effect.Effect + show: (cmd: string[]) => Effect.Effect + }, + from: string, + to: string, + path: string, + ) { + const names = yield* run.diff(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", path]) + const row = names.text.trim().split("\n").find(Boolean) + if (!row) return undefined + const code = row.split("\t")[0] ?? "" + const status: "added" | "deleted" | "modified" = + code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified" + + const numstat = yield* run.diff(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", path]) + const [adds, dels] = numstat.text.trim().split("\n").find(Boolean)?.split("\t") ?? [] + const binary = adds === "-" && dels === "-" + const additions = binary ? 0 : Number.parseInt(adds ?? "0", 10) + const deletions = binary ? 0 : Number.parseInt(dels ?? "0", 10) + + const patch = binary ? "" : ((yield* batch(run.diff, from, to, [path])).get(path) ?? "") + const base = { + file: path, + patch, + additions: Number.isFinite(additions) ? additions : 0, + deletions: Number.isFinite(deletions) ? deletions : 0, + status, + } + // Uniform shape across branches: binary files carry no content, but keep the keys present + // (undefined) so the return type is a single object, not a union missing before/after. + if (binary) return { ...base, before: undefined, after: undefined } + + const content = yield* Effect.all( + { + before: status === "added" ? Effect.succeed("") : run.show(["show", `${from}:${path}`]).pipe(Effect.map((r) => r.text)), + after: status === "deleted" ? Effect.succeed("") : run.show(["show", `${to}:${path}`]).pipe(Effect.map((r) => r.text)), + }, + { concurrency: 2 }, + ) + return { + ...base, + before: Buffer.byteLength(content.before) <= MAX_DETAIL_SIZE ? content.before : undefined, + after: Buffer.byteLength(content.after) <= MAX_DETAIL_SIZE ? content.after : undefined, + } + }) + /** * Generate a structured + unified diff for a single file in the working * tree vs HEAD using `git diff --ignore-all-space --unified=3`. diff --git a/packages/opencode/src/kilocode/task-resume.ts b/packages/opencode/src/kilocode/task-resume.ts new file mode 100644 index 00000000000..47f33f26655 --- /dev/null +++ b/packages/opencode/src/kilocode/task-resume.ts @@ -0,0 +1,6 @@ +export function resumeHint(sessionID: string) { + return [ + `This subagent session can be resumed: call the task tool again with task_id="${sessionID}"`, + `and a prompt describing how to continue or recover. Its prior context is preserved.`, + ].join(" ") +} diff --git a/packages/opencode/src/kilocode/tool/agent-manager.ts b/packages/opencode/src/kilocode/tool/agent-manager.ts index ba6e67619e9..8c4104e25eb 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager.ts +++ b/packages/opencode/src/kilocode/tool/agent-manager.ts @@ -117,7 +117,7 @@ const WireParams = Schema.Struct({ ), filter: Schema.optional(ListParams.fields.filter), sessionID: Schema.optional( - SessionID.annotate({ description: "For move, use a session ID returned by action=list." }), + Schema.String.annotate({ description: "For move, use a session ID returned by action=list (IDs start with ses_)." }), ), prompt: Schema.optional(PromptParams.fields.prompt), sectionID: Schema.optional(MoveParams.fields.sectionID), diff --git a/packages/opencode/src/kilocode/tool/chart.ts b/packages/opencode/src/kilocode/tool/chart.ts index c928c46ea79..11253134f2e 100644 --- a/packages/opencode/src/kilocode/tool/chart.ts +++ b/packages/opencode/src/kilocode/tool/chart.ts @@ -1,6 +1,7 @@ // kilocode_change - new file import { Effect, Schema } from "effect" import * as Tool from "../../tool/tool" +import { Telemetry } from "@kilocode/kilo-telemetry" const Parameters = Schema.Struct({ title: Schema.String.annotate({ @@ -66,6 +67,8 @@ export const ChartTool = Tool.define( } } + Telemetry.trackToolUsed("chart", ctx.sessionID) + return { title: params.title, output: JSON.stringify(spec), diff --git a/packages/opencode/src/kilocode/tool/grep-signal-controls.ts b/packages/opencode/src/kilocode/tool/grep-signal-controls.ts new file mode 100644 index 00000000000..49eb582c645 --- /dev/null +++ b/packages/opencode/src/kilocode/tool/grep-signal-controls.ts @@ -0,0 +1,64 @@ +import { NonNegativeInt, PositiveInt } from "@opencode-ai/core/schema" +import { Schema } from "effect" + +export const DEFAULT_LIMIT = 100 + +export const fields = { + context: Schema.optional(NonNegativeInt).annotate({ + description: "Number of context lines to show before and after each match (default 0)", + }), + limit: Schema.optional(PositiveInt).annotate({ + description: "Maximum matching lines to return (default 100)", + }), + literal: Schema.optional(Schema.Boolean).annotate({ + description: "Treat pattern as plain text instead of a regex (default false)", + }), + ignoreCase: Schema.optional(Schema.Boolean).annotate({ + description: "Match without regard to letter case (default false)", + }), +} + +type Input = { + readonly context?: number + readonly limit?: number + readonly literal?: boolean + readonly ignoreCase?: boolean +} + +export const metadata = (input: Input, limit: number, context: number) => ({ + context, + limit, + literal: input.literal, + ignoreCase: input.ignoreCase, +}) + +export const options = (input: Input, limit: number, context: number) => ({ + limit, + context, + literal: input.literal, + ignoreCase: input.ignoreCase, +}) + +export const describe = (description: string) => `${description} +- Searches file contents using regular expressions by default; use literal=true for plain-text patterns +- Use ignoreCase=true for case-insensitive matching, context=N for surrounding lines, and limit=N to bound matches (default 100) +- Context lines are explicitly labeled when requested` + +export const line = ( + row: { readonly line: number; readonly text: string; readonly context: boolean }, + context: number, +) => { + const label = context === 0 ? `Line ${row.line}` : `${row.context ? "[context]" : "[match]"} Line ${row.line}` + return ` ${label}: ${row.text}` +} + +export const limitNotice = (limit: number) => + `${limit} matches limit reached. Use limit=${Math.min(Number.MAX_SAFE_INTEGER, limit * 2)} for more, or refine pattern.` + +export const notices = (rows: readonly { readonly textTruncated: boolean }[]) => { + const output: string[] = [] + if (rows.some((row) => row.textTruncated)) { + output.push("", "Some matching or context lines were truncated. Use read for full lines.") + } + return output +} diff --git a/packages/opencode/src/kilocode/tui/config.ts b/packages/opencode/src/kilocode/tui/config.ts index e7d6d333f22..17d9b1d60f0 100644 --- a/packages/opencode/src/kilocode/tui/config.ts +++ b/packages/opencode/src/kilocode/tui/config.ts @@ -13,6 +13,7 @@ import { isRecord } from "@/util/record" import { GlobalBus } from "@/bus/global" import { Event } from "@/server/event" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { AppRuntime } from "@/effect/app-runtime" export namespace KilocodeTuiConfig { export const Scope = z.enum(["project", "global"]) @@ -26,7 +27,7 @@ export namespace KilocodeTuiConfig { const dirs = [".kilo", ".kilocode"] as const export async function get(input: { directory: string }) { - const cfg = await Effect.runPromise( + const cfg = await AppRuntime.runPromise( TuiConfig.Service.use((svc) => svc.info()).pipe( Effect.provide( AppNodeBuilder.build(TuiConfig.node).pipe( diff --git a/packages/opencode/src/kilocode/watcher.ts b/packages/opencode/src/kilocode/watcher.ts index 6553d3e1058..fd88764e24e 100644 --- a/packages/opencode/src/kilocode/watcher.ts +++ b/packages/opencode/src/kilocode/watcher.ts @@ -15,8 +15,17 @@ export namespace KilocodeWatcher { export class Service extends Context.Service()("@kilocode/Watcher") {} + // Embedded editor clients (VS Code, JetBrains) have their own file watching + // and git integration and do not consume the CLI's vcs.branch.updated event, + // so they must not eagerly warm the location stack — that starts a native + // @parcel/watcher subscription per instance that lives for the whole session. + // On macOS FSEvents watches the entire subtree recursively (the ignore list + // is only a userspace filter), so an always-on, consumer-less watcher on a + // churny workspace burns CPU and leaks native memory while idle. The + // standalone CLI/TUI stays eager because its sidebar branch label is the only + // consumer and no request-driven route would otherwise build the stack. export function eager(client = Flag.KILO_CLIENT) { - return client !== "vscode" + return client !== "vscode" && client !== "jetbrains" } export const layer = Layer.effect( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index 02837849be9..a7f5464a689 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -38,7 +38,8 @@ export const ListQuery = Schema.Struct({ }) export const DiffQuery = Schema.Struct({ ...WorkspaceRoutingQueryFields, - ...Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"]), + ...Struct.omit(SessionSummary.DiffInput.fields, ["sessionID", "full"]), // kilocode_change - full is a query boolean + full: Schema.optional(QueryBoolean), // kilocode_change - request full-content detail }) export const MessagesQuery = Schema.Struct({ ...WorkspaceRoutingQueryFields, diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index a54c5454da7..0e865a28e36 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -106,7 +106,14 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } query: typeof DiffQuery.Type }) { - return yield* summary.diff({ sessionID: ctx.params.sessionID, messageID: ctx.query.messageID }) + // kilocode_change start - pass full-content detail query fields through to the summary service + return yield* summary.diff({ + sessionID: ctx.params.sessionID, + messageID: ctx.query.messageID, + full: ctx.query.full, + file: ctx.query.file, + }) + // kilocode_change end }) const messages = Effect.fn("SessionHttpApi.messages")(function* (ctx: { diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 87c5e2fb7a3..4ad5fb566af 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -906,6 +906,13 @@ export const layer: Layer.Layer< } // kilocode_change - preserve imported/cumulative diffs when forking (self-contained Storage runtime keeps this shared file off the legacy Storage layer) yield* carryForkDiff(input.sessionID, session.id) + // kilocode_change start - fork terminal task children under the new parent and remap their references + yield* KiloSession.remapChildren({ + sessionID: session.id, + remapped: new Map([[input.sessionID, session.id]]), + ops: { get, messages, create, updateMessage, updatePart }, + }) + // kilocode_change end return session }) diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index dc98645f6b2..c05af961ce9 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -67,7 +67,7 @@ function unquoteGitPath(input: string) { export interface Interface { readonly summarize: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect - readonly diff: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect + readonly diff: (input: DiffInput) => Effect.Effect // kilocode_change - full-content detail input readonly computeDiff: (input: { messages: SessionV1.WithParts[] }) => Effect.Effect } @@ -144,7 +144,26 @@ const layer = Layer.effect( yield* sessions.updateMessage(target.info) }) - const diff = Effect.fn("SessionSummary.diff")(function* (input: { sessionID: SessionID; messageID?: MessageID }) { + const diff = Effect.fn("SessionSummary.diff")(function* (input: DiffInput) { // kilocode_change - full-content detail input + // kilocode_change start - authoritative full-content detail for one file (editor diff tabs) + if (input.full && input.file) { + const all = yield* sessions.messages({ sessionID: input.sessionID }).pipe(Effect.orDie) + const messages = input.messageID + ? all.filter( + (m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID), + ) + : all + let from: string | undefined + let to: string | undefined + for (const item of messages) { + if (!from) for (const part of item.parts) if (part.type === "step-start" && part.snapshot) { from = part.snapshot; break } + for (const part of item.parts) if (part.type === "step-finish" && part.snapshot) to = part.snapshot + } + if (!from || !to) return [] + const detail = yield* snapshot.diffFile(from, to, input.file) + return detail ? [detail] : [] + } + // kilocode_change end // kilocode_change start - retain cumulative diffs for legacy TUI and VS Code consumers if (!input.messageID) { const diffs = yield* storage @@ -182,6 +201,8 @@ const layer = Layer.effect( export const DiffInput = Schema.Struct({ sessionID: SessionID, messageID: Schema.optional(MessageID), + full: Schema.optional(Schema.Boolean), // kilocode_change - request full-content detail + file: Schema.optional(Schema.String), // kilocode_change - scope full detail to one file }) export type DiffInput = Schema.Schema.Type diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 37581048dd9..39779464ca9 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -34,8 +34,8 @@ export type Patch = typeof Patch.Type export const FileDiff = Info.pipe(withStatics((s) => ({ zod: zod(s) }))) export type FileDiff = typeof FileDiff.Type -// kilocode_change start - lightweight FileDiff without patch for session summaries -export const SummaryFileDiff = FileDiff.mapFields(Struct.omit(["patch"])) +// kilocode_change start - lightweight FileDiff without heavy content (patch/before/after) for session summaries +export const SummaryFileDiff = FileDiff.mapFields(Struct.omit(["patch", "before", "after"])) .annotate({ identifier: "SnapshotSummaryFileDiff" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type SummaryFileDiff = typeof SummaryFileDiff.Type @@ -72,6 +72,7 @@ export interface Interface { readonly revert: (patches: Patch[]) => Effect.Effect readonly diff: (hash: string) => Effect.Effect readonly diffFull: (from: string, to: string) => Effect.Effect + readonly diffFile: (from: string, to: string, file: string) => Effect.Effect // kilocode_change - authoritative full-content detail } export class Service extends Context.Service()("@opencode/Snapshot") {} @@ -894,7 +895,23 @@ export const layer: Layer.Layer = Effect.forkScoped, ) - return { cleanup, track, patch, restore, revert, diff, diffFull } + // kilocode_change start - authoritative full-content detail for editor diff tabs + const diffFile = Effect.fnUntraced(function* (from: string, to: string, file: string) { + return yield* locked( + DiffFull.detail( + { + diff: (cmd) => git([...quote, ...args(cmd)], { cwd: state.directory }), + show: (cmd) => git([...cfg, ...args(cmd)], { cwd: state.directory }), + }, + from, + to, + file, + ), + ) + }) + // kilocode_change end + + return { cleanup, track, patch, restore, revert, diff, diffFull, diffFile } // kilocode_change - diffFile }), ) @@ -970,6 +987,12 @@ export const layer: Layer.Layer = return yield* Effect.promise(() => pending) // kilocode_change end }), + // kilocode_change start - authoritative full-content detail for editor diff tabs + diffFile: Effect.fn("Snapshot.diffFile")(function* (from: string, to: string, file: string) { + if (from === to) return undefined + return yield* InstanceState.useEffect(state, (s) => s.diffFile(from, to, file)) + }), + // kilocode_change end }) }), ) diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index 30a1779208c..f4277831685 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -3,18 +3,20 @@ import { Effect, Schema } from "effect" import { InstanceState } from "@/effect/instance-state" import { FSUtil } from "@opencode-ai/core/fs-util" import { Ripgrep } from "@opencode-ai/core/ripgrep" +import * as KiloGrep from "@/kilocode/tool/grep-signal-controls" // kilocode_change import { assertExternalDirectoryEffect } from "./external-directory" import DESCRIPTION from "./grep.txt" import * as Tool from "./tool" export const Parameters = Schema.Struct({ - pattern: Schema.String.annotate({ description: "The regex pattern to search for in file contents" }), + pattern: Schema.String.annotate({ description: "Pattern to search for in file contents (regex by default)" }), // kilocode_change path: Schema.optional(Schema.String).annotate({ description: "The directory to search in. Defaults to the current working directory.", }), include: Schema.optional(Schema.String).annotate({ description: 'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")', }), + ...KiloGrep.fields, // kilocode_change }) export const GrepTool = Tool.define( @@ -23,10 +25,12 @@ export const GrepTool = Tool.define( const fs = yield* FSUtil.Service const ripgrep = yield* Ripgrep.Service return { - description: DESCRIPTION, + description: KiloGrep.describe(DESCRIPTION), // kilocode_change parameters: Parameters, - execute: (params: { pattern: string; path?: string; include?: string }, ctx: Tool.Context) => + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { + const limit = params.limit ?? KiloGrep.DEFAULT_LIMIT // kilocode_change + const context = params.context ?? 0 // kilocode_change const empty = { title: params.pattern, metadata: { matches: 0, truncated: false }, @@ -44,6 +48,7 @@ export const GrepTool = Tool.define( pattern: params.pattern, path: params.path, include: params.include, + ...KiloGrep.metadata(params, limit, context), // kilocode_change }, }) @@ -66,7 +71,7 @@ export const GrepTool = Tool.define( file: info?.type === "File" ? path.basename(search) : undefined, // kilocode_change - constrain exact-file searches pattern: params.pattern, include: params.include, - limit: 100, + ...KiloGrep.options(params, limit, context), // kilocode_change signal: ctx.abort, // kilocode_change - stop ripgrep when the tool call is cancelled }) // kilocode_change start @@ -74,18 +79,20 @@ export const GrepTool = Tool.define( if (matches.length === 0) return empty // kilocode_change end - const rows = matches.map((item) => ({ // kilocode_change + const rows = matches.map((item) => ({ + // kilocode_change path: path.resolve(cwd, item.entry.path), line: item.line, text: item.text, + context: item.context, // kilocode_change + textTruncated: item.textTruncated, // kilocode_change })) - const limit = 100 const truncated = result.truncated // kilocode_change const final = rows if (final.length === 0) return empty - const total = rows.length + const total = rows.filter((row) => !row.context).length // kilocode_change const hasMore = truncated // kilocode_change const output = [`Found ${total} matches${hasMore ? " (more matches available)" : ""}`] @@ -96,13 +103,14 @@ export const GrepTool = Tool.define( current = match.path output.push(`${match.path}:`) } - output.push(` Line ${match.line}: ${match.text}`) + output.push(KiloGrep.line(match, context)) // kilocode_change } if (truncated) { output.push("") - output.push("(Results truncated. Consider using a more specific path or pattern.)") + output.push(KiloGrep.limitNotice(limit)) // kilocode_change } + output.push(...KiloGrep.notices(rows)) // kilocode_change if (result.partial) output.push("", "(Some paths were inaccessible.)") // kilocode_change return { diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index a67cf4e12a0..751af5c7ff1 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -16,6 +16,7 @@ import { KiloTaskBackgroundProcess } from "../kilocode/tool/task-background-proc import { KiloCostPropagation } from "../kilocode/session/cost-propagation" // kilocode_change import { KiloSessionProcessor } from "../kilocode/session/processor" // kilocode_change import { KiloSession } from "../kilocode/session" // kilocode_change +import { resumeHint } from "../kilocode/task-resume" // kilocode_change import { errorMessage } from "@/util/error" // kilocode_change import { Effect, Exit, Schema, Scope } from "effect" import { EffectBridge } from "@/effect/bridge" @@ -90,15 +91,6 @@ function renderOutput(input: { ].join("\n") } -// kilocode_change start - tell the parent agent how to resume a stopped/failed subagent (#11620) -function resumeHint(sessionID: SessionID) { - return [ - `This subagent session can be resumed: call the task tool again with task_id="${sessionID}"`, - `and a prompt describing how to continue or recover. Its prior context is preserved.`, - ].join(" ") -} -// kilocode_change end - export const TaskTool = Tool.define( id, Effect.gen(function* () { diff --git a/packages/opencode/test/fixture/tui-plugin.ts b/packages/opencode/test/fixture/tui-plugin.ts index 06706bc96be..13860c0d0b4 100644 --- a/packages/opencode/test/fixture/tui-plugin.ts +++ b/packages/opencode/test/fixture/tui-plugin.ts @@ -97,6 +97,7 @@ type Opts = { state?: { ready?: HostPluginApi["state"]["ready"] config?: HostPluginApi["state"]["config"] + globalConfig?: HostPluginApi["state"]["globalConfig"] // kilocode_change provider?: HostPluginApi["state"]["provider"] path?: HostPluginApi["state"]["path"] vcs?: HostPluginApi["state"]["vcs"] @@ -303,6 +304,11 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { get config() { return opts.state?.config ?? {} }, + // kilocode_change start + get globalConfig() { + return opts.state?.globalConfig ?? {} + }, + // kilocode_change end get provider() { return opts.state?.provider ?? [] }, diff --git a/packages/opencode/test/kilocode/agent-manager-tool.test.ts b/packages/opencode/test/kilocode/agent-manager-tool.test.ts index ccba13be01d..2f44f3f5c7d 100644 --- a/packages/opencode/test/kilocode/agent-manager-tool.test.ts +++ b/packages/opencode/test/kilocode/agent-manager-tool.test.ts @@ -1,10 +1,10 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { describe, expect, test } from "bun:test" -import { Effect, Layer, ManagedRuntime, Queue } from "effect" +import { Effect, Layer, ManagedRuntime, Queue, Schema } from "effect" import { MessageID, SessionID } from "../../src/session/schema" import { provideTmpdirInstance } from "../fixture/fixture" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { AgentManagerTool } from "../../src/kilocode/tool/agent-manager" +import { AgentManagerTool, Params } from "../../src/kilocode/tool/agent-manager" import { AgentManagerEvent, type AgentManagerStart } from "../../src/kilocode/agent-manager/event" import { AgentManager } from "../../src/kilocode/agent-manager/service" import { Bus } from "../../src/bus" @@ -164,8 +164,9 @@ describe("agent_manager tool", () => { expect(action && typeof action === "object" ? action.description : undefined).toContain("Use list first") expect(action && typeof action === "object" ? action.description : undefined).toContain("Never edit") expect(schema.properties?.sessionID).toEqual( - expect.objectContaining({ description: expect.stringContaining("returned by action=list") }), + expect.objectContaining({ description: expect.stringContaining("IDs start with ses_") }), ) + expect(schema.properties?.sessionID).not.toHaveProperty("pattern") expect(schema.properties?.sectionID).toEqual( expect.objectContaining({ description: expect.stringContaining("Use null to unassign") }), ) @@ -186,6 +187,11 @@ describe("agent_manager tool", () => { ]) }) + test("keeps session ID validation local", () => { + expect(Schema.is(Params)({ action: "stop", sessionID: "ses_target" })).toBe(true) + expect(Schema.is(Params)({ action: "stop", sessionID: "invalid" })).toBe(false) + }) + test("asks for agent_manager permission", async () => { const tool = await init() const calls: unknown[] = [] diff --git a/packages/opencode/test/kilocode/cli/cmd/run-terminal.test.ts b/packages/opencode/test/kilocode/cli/cmd/run-terminal.test.ts new file mode 100644 index 00000000000..60e30d4171b --- /dev/null +++ b/packages/opencode/test/kilocode/cli/cmd/run-terminal.test.ts @@ -0,0 +1,72 @@ +import { expect, test } from "bun:test" +import { createKiloClient } from "@kilocode/sdk/v2" +import { KiloRunTerminal } from "@/kilocode/cli/cmd/run-terminal" + +test("routes direct interactive terminal requests through the session workspace", async () => { + const seen: URL[] = [] + const fetch = Object.assign( + async (input: URL | RequestInfo, init?: RequestInit) => { + const request = new Request(input, init) + const url = new URL(request.url) + seen.push(url) + if (url.pathname === "/session/ses_terminal") { + return Response.json({ + id: "ses_terminal", + slug: "terminal", + projectID: "proj_test", + workspaceID: "ws_terminal", + directory: "/tmp", + title: "Terminal", + version: "7.4.20", + time: { created: 1, updated: 1 }, + }) + } + return Response.json(true) + }, + { preconnect: globalThis.fetch.preconnect }, + ) + const sdk = createKiloClient({ + baseUrl: "http://test", + fetch, + }) + const terminal = KiloRunTerminal.create(sdk, () => "ses_terminal") + + await terminal.write({ terminalID: "itx_terminal", data: "Ada\r" }) + await terminal.resize({ terminalID: "itx_terminal", cols: 80, rows: 14 }) + await terminal.close("itx_terminal") + + const requests = seen.filter((url) => url.pathname.startsWith("/interactive-terminal/")) + expect(requests).toHaveLength(3) + expect(requests.every((url) => url.searchParams.get("workspace") === "ws_terminal")).toBe(true) + expect(seen.filter((url) => url.pathname === "/session/ses_terminal")).toHaveLength(1) +}) + +test("retries workspace lookup after a failed request", async () => { + const seen: URL[] = [] + let sessions = 0 + const fetch = Object.assign( + async (input: URL | RequestInfo, init?: RequestInit) => { + const request = new Request(input, init) + const url = new URL(request.url) + seen.push(url) + if (url.pathname === "/session/ses_terminal") { + sessions += 1 + if (sessions === 1) return new Response("busy", { status: 503 }) + return Response.json({ workspaceID: "ws_terminal" }) + } + return Response.json(true) + }, + { preconnect: globalThis.fetch.preconnect }, + ) + const sdk = createKiloClient({ baseUrl: "http://test", fetch }) + const terminal = KiloRunTerminal.create(sdk, () => "ses_terminal") + + await terminal.write({ terminalID: "itx_terminal", data: "Ada\r" }) + await terminal.write({ terminalID: "itx_terminal", data: "Grace\r" }) + + expect(sessions).toBe(2) + const inputs = seen.filter((url) => url.pathname === "/interactive-terminal/itx_terminal/input") + expect(inputs).toHaveLength(2) + expect(inputs[0]?.searchParams.get("workspace")).toBeNull() + expect(inputs[1]?.searchParams.get("workspace")).toBe("ws_terminal") +}) diff --git a/packages/opencode/test/kilocode/database/sqlite-error.test.ts b/packages/opencode/test/kilocode/database/sqlite-error.test.ts index 7a3ea7472e1..ff6e5e73d5f 100644 --- a/packages/opencode/test/kilocode/database/sqlite-error.test.ts +++ b/packages/opencode/test/kilocode/database/sqlite-error.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test" +import { Cause } from "effect" import { LockTimeoutError, SqlError, UnknownError } from "effect/unstable/sql/SqlError" +import { EffectDrizzleQueryError } from "drizzle-orm/effect-core/errors" import { busyMessage, isBusy } from "@/kilocode/database/sqlite-error" describe("SQLite errors", () => { @@ -27,4 +29,22 @@ describe("SQLite errors", () => { expect(isBusy(error)).toBe(false) }) + + test("recognizes lock timeouts wrapped by Drizzle", () => { + const error = new EffectDrizzleQueryError({ + query: "update credential set value = ?", + params: [""], + cause: Cause.fail( + new SqlError({ + reason: new LockTimeoutError({ + cause: new Error("database is locked"), + message: "Failed to execute statement", + operation: "execute", + }), + }), + ), + }) + + expect(isBusy(error)).toBe(true) + }) }) diff --git a/packages/opencode/test/kilocode/diff-full.test.ts b/packages/opencode/test/kilocode/diff-full.test.ts index d07972db653..7af85858477 100644 --- a/packages/opencode/test/kilocode/diff-full.test.ts +++ b/packages/opencode/test/kilocode/diff-full.test.ts @@ -184,6 +184,70 @@ describe("DiffFull.batch", () => { ) }) +describe("DiffFull.detail", () => { + const runners = (dir: string) => ({ diff: gitResult(dir), show: gitResult(dir) }) + + it.live("returns full before/after plus hunk patch for a modified file", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + yield* Effect.promise(() => Filesystem.write(path.join(dir, "a.txt"), "keep\nold\ntail\n")) + const from = yield* Effect.promise(() => commit(dir, "v1")) + yield* Effect.promise(() => Filesystem.write(path.join(dir, "a.txt"), "keep\nnew\ntail\n")) + const to = yield* Effect.promise(() => commit(dir, "v2")) + + const got = yield* DiffFull.detail(runners(dir), from, to, "a.txt") + expect(got?.status).toBe("modified") + expect(got?.before).toBe("keep\nold\ntail\n") + expect(got?.after).toBe("keep\nnew\ntail\n") + expect(got?.patch).toContain("-old") + expect(got?.patch).toContain("+new") + }), + ) + + it.live("returns an empty before for an added file", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + yield* Effect.promise(() => Filesystem.write(path.join(dir, "base.txt"), "x\n")) + const from = yield* Effect.promise(() => commit(dir, "v1")) + yield* Effect.promise(() => Filesystem.write(path.join(dir, "added.txt"), "hi\nthere\n")) + const to = yield* Effect.promise(() => commit(dir, "v2")) + + const got = yield* DiffFull.detail(runners(dir), from, to, "added.txt") + expect(got?.status).toBe("added") + expect(got?.before).toBe("") + expect(got?.after).toBe("hi\nthere\n") + }), + ) + + it.live("returns an empty after for a deleted file", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + yield* Effect.promise(() => Filesystem.write(path.join(dir, "gone.txt"), "bye\nnow\n")) + const from = yield* Effect.promise(() => commit(dir, "v1")) + yield* Effect.promise(() => $`git rm gone.txt`.cwd(dir).quiet()) + const to = yield* Effect.promise(() => commit(dir, "v2")) + + const got = yield* DiffFull.detail(runners(dir), from, to, "gone.txt") + expect(got?.status).toBe("deleted") + expect(got?.before).toBe("bye\nnow\n") + expect(got?.after).toBe("") + }), + ) + + it.live("returns undefined for a file unchanged between refs", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + yield* Effect.promise(() => Filesystem.write(path.join(dir, "same.txt"), "same\n")) + const from = yield* Effect.promise(() => commit(dir, "v1")) + yield* Effect.promise(() => Filesystem.write(path.join(dir, "other.txt"), "changed\n")) + const to = yield* Effect.promise(() => commit(dir, "v2")) + + const got = yield* DiffFull.detail(runners(dir), from, to, "same.txt") + expect(got).toBeUndefined() + }), + ) +}) + describe("DiffFull.file", () => { it.live("returns a structured + unified diff for a modified working-tree file", () => Effect.gen(function* () { diff --git a/packages/opencode/test/kilocode/instance-vcs-watcher.test.ts b/packages/opencode/test/kilocode/instance-vcs-watcher.test.ts index e3861069e97..cf8f7b5386b 100644 --- a/packages/opencode/test/kilocode/instance-vcs-watcher.test.ts +++ b/packages/opencode/test/kilocode/instance-vcs-watcher.test.ts @@ -34,6 +34,10 @@ describe("KilocodeWatcher.eager", () => { expect(KilocodeWatcher.eager("vscode")).toBe(false) }) + test("skips eager location watchers for JetBrains", () => { + expect(KilocodeWatcher.eager("jetbrains")).toBe(false) + }) + test("keeps eager location watchers for the standalone CLI", () => { expect(KilocodeWatcher.eager("cli")).toBe(true) expect(KilocodeWatcher.eager(undefined)).toBe(true) diff --git a/packages/opencode/test/kilocode/server/tui-config.test.ts b/packages/opencode/test/kilocode/server/tui-config.test.ts index 22909b26fe2..26bbc56ca5b 100644 --- a/packages/opencode/test/kilocode/server/tui-config.test.ts +++ b/packages/opencode/test/kilocode/server/tui-config.test.ts @@ -43,6 +43,34 @@ describe("TUI config routes", () => { expect(body.plugin_origins).toBeUndefined() }) + test("does not write TUI config logs to the terminal", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const cfg = path.join(dir, ".kilo") + await fs.mkdir(cfg, { recursive: true }) + await Bun.write(path.join(cfg, "tui.json"), JSON.stringify({ theme: "dracula" })) + }, + }) + + const output: unknown[][] = [] + const log = console.log + try { + console.log = (...args) => output.push(args) + const response = await Server.Default().app.request("/tui/config", { + headers: { "x-kilo-directory": tmp.path }, + }) + expect(response.status).toBe(200) + } finally { + console.log = log + } + + expect( + output.some((args) => + args.some((item) => typeof item === "string" && /loading tui config|applying tui config/.test(item)), + ), + ).toBe(false) + }) + test("loads legacy .kilocode TUI config and ignores .opencode", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/kilocode/session-fork-remap.test.ts b/packages/opencode/test/kilocode/session-fork-remap.test.ts index 5d0586988c6..9cd1d9d6cfb 100644 --- a/packages/opencode/test/kilocode/session-fork-remap.test.ts +++ b/packages/opencode/test/kilocode/session-fork-remap.test.ts @@ -49,15 +49,12 @@ afterAll(async () => { }) const sessions = { - create: (input?: Parameters[0]) => - runtime.runPromise((svc) => svc.create(input)), + create: (input?: Parameters[0]) => runtime.runPromise((svc) => svc.create(input)), + get: (id: SessionID) => runtime.runPromise((svc) => svc.get(id)), list: () => runtime.runPromise((svc) => svc.list()), - messages: (input: Parameters[0]) => - runtime.runPromise((svc) => svc.messages(input)), - updateMessage: (msg: T) => - runtime.runPromise((svc) => svc.updateMessage(msg)), - updatePart: (part: T) => - runtime.runPromise((svc) => svc.updatePart(part)), + messages: (input: Parameters[0]) => runtime.runPromise((svc) => svc.messages(input)), + updateMessage: (msg: T) => runtime.runPromise((svc) => svc.updateMessage(msg)), + updatePart: (part: T) => runtime.runPromise((svc) => svc.updatePart(part)), } afterEach(async () => { @@ -190,9 +187,9 @@ describe("Session.fork cost accounting", () => { ) }) -describe("Session.fork task detachment", () => { +describe("Session.fork task children", () => { test( - "keeps completed task outcomes without cloning child sessions", + "clones completed task children under the forked parent", async () => { await using tmp = await tmpdir({ git: true }) await instance({ @@ -212,6 +209,13 @@ describe("Session.fork task detachment", () => { const user = await userMsg(parent.id) const assistant = await asstMsg(parent.id, user) await sessions.updatePart(taskPart({ messageID: assistant, sessionID: parent.id, childSessionID: child.id })) + await sessions.updatePart({ + id: PartID.ascending(), + messageID: assistant, + sessionID: parent.id, + type: "text", + text: `Subagent task ID: ${child.id}`, + } as MessageV2.TextPart) const before = await sessions.list() const server = HttpRouter.toWebHandler(HttpApiApp.routes, { disableLogger: true }) @@ -220,23 +224,36 @@ describe("Session.fork task detachment", () => { directory: tmp.path, fetch: ((request: Request) => server.handler(request, HttpApiApp.context)) as unknown as typeof fetch, }) - const { data: forked } = await client.session.fork( - { sessionID: parent.id, directory: tmp.path }, - { throwOnError: true }, - ).finally(() => server.dispose()) + const { data: forked } = await client.session + .fork({ sessionID: parent.id, directory: tmp.path }, { throwOnError: true }) + .finally(() => server.dispose()) const after = await sessions.list() - expect(after).toHaveLength(before.length + 1) + expect(after).toHaveLength(before.length + 2) const msgs = await sessions.messages({ sessionID: SessionID.make(forked.id) }) const tool = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool") as MessageV2.ToolPart expect(tool.state.status).toBe("completed") if (tool.state.status !== "completed") throw new Error("expected completed task") - expect(tool.metadata).toEqual({ trace: "keep" }) - expect(tool.state.metadata).toEqual({ model: { modelID: "test", providerID: "test" } }) - expect(tool.state.input.task_id).toBeUndefined() + const clonedID = tool.state.input.task_id + expect(clonedID).not.toBe(child.id) + expect(tool.metadata).toEqual({ sessionId: clonedID, trace: "keep" }) + expect(tool.state.metadata).toEqual({ + sessionId: clonedID, + model: { modelID: "test", providerID: "test" }, + }) + if (typeof clonedID !== "string") throw new Error("expected a cloned task ID") expect(tool.state.output).toBe( - "Background task completed: test task\r\n\r\nchild outcome\r\n", + `Background task completed: test task\r\n\ttask_id: ${clonedID} (for resuming to continue this task if needed)\r\n\r\n\r\nchild outcome\r\n`, + ) + + const clone = await sessions.get(SessionID.descending(clonedID)) + expect(clone.parentID).toBe(SessionID.descending(forked.id)) + expect((await sessions.messages({ sessionID: clone.id }))[0]?.parts).toContainEqual( + expect.objectContaining({ text: "child message content" }), + ) + expect(msgs.flatMap((msg) => msg.parts)).toContainEqual( + expect.objectContaining({ text: `Subagent task ID: ${clonedID}` }), ) const source = await sessions.messages({ sessionID: parent.id }) @@ -252,7 +269,7 @@ describe("Session.fork task detachment", () => { ) test( - "turns copied running tasks into terminal historical errors", + "turns copied running tasks into resumable historical errors", async () => { await using tmp = await tmpdir({ git: true }) await instance({ @@ -278,15 +295,22 @@ describe("Session.fork task detachment", () => { }, } as MessageV2.ToolPart) + const before = await sessions.list() const forked = await Session.fork({ sessionID: parent.id }) + const after = await sessions.list() const msgs = await sessions.messages({ sessionID: forked.id }) const tool = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool") as MessageV2.ToolPart expect(tool.state.status).toBe("error") if (tool.state.status !== "error") throw new Error("expected detached task error") expect(tool.state.error).toContain("still running") - expect(tool.state.input.task_id).toBeUndefined() - expect(tool.state.metadata).toEqual({ variant: "high" }) - expect(tool.metadata).toEqual({}) + const clonedID = tool.state.input.task_id + if (typeof clonedID !== "string") throw new Error("expected a cloned task ID") + expect(clonedID).not.toBe(child.id) + expect(tool.state.error).toContain(`task_id="${clonedID}"`) + expect(tool.state.metadata).toEqual({ sessionId: clonedID, variant: "high" }) + expect(tool.metadata).toEqual({ sessionId: clonedID }) + expect(after).toHaveLength(before.length + 2) + expect((await sessions.get(SessionID.descending(clonedID))).parentID).toBe(forked.id) }, }) }, @@ -294,7 +318,7 @@ describe("Session.fork task detachment", () => { ) test( - "detaches pending and errored task references", + "detaches in-flight tasks and remaps errored task references", async () => { await using tmp = await tmpdir({ git: true }) await instance({ @@ -329,7 +353,7 @@ describe("Session.fork task detachment", () => { state: { status: "error", input: { task_id: child.id }, - error: "original error", + error: `original error; task_id="${child.id}"`, metadata: { sessionID: child.id, detail: "keep" }, time: { start: Date.now(), end: Date.now() }, }, @@ -344,15 +368,57 @@ describe("Session.fork task detachment", () => { expect(pending?.state.status).toBe("error") if (!pending || pending.state.status !== "error") throw new Error("expected detached pending task") expect(pending.state.error).toContain("still pending") - expect(pending.state.input.task_id).toBeUndefined() - expect(pending.metadata).toEqual({}) + const pendingID = pending.state.input.task_id + if (typeof pendingID !== "string") throw new Error("expected a cloned pending task ID") + expect(pendingID).not.toBe(child.id) + expect(pending.state.error).toContain(`task_id="${pendingID}"`) + expect(pending.metadata).toEqual({ sessionID: pendingID }) expect(errored?.state.status).toBe("error") if (!errored || errored.state.status !== "error") throw new Error("expected detached errored task") - expect(errored.state.error).toBe("original error") - expect(errored.state.input.task_id).toBeUndefined() - expect(errored.state.metadata).toEqual({ detail: "keep" }) - expect(errored.metadata).toEqual({}) + const clonedID = errored.state.input.task_id + if (typeof clonedID !== "string") throw new Error("expected a cloned task ID") + expect(clonedID).toBe(pendingID) + expect(errored.state.error).toBe(`original error; task_id="${clonedID}"`) + expect(errored.state.metadata).toEqual({ sessionID: clonedID, detail: "keep" }) + expect(errored.metadata).toEqual({ sessionId: clonedID }) + }, + }) + }, + { timeout: 30000 }, + ) + + test( + "ignores malformed task references while forking", + async () => { + await using tmp = await tmpdir({ git: true }) + await instance({ + directory: tmp.path, + fn: async () => { + const parent = await sessions.create({ title: "parent" }) + const user = await userMsg(parent.id) + const assistant = await asstMsg(parent.id, user) + await sessions.updatePart({ + id: PartID.ascending(), + messageID: assistant, + sessionID: parent.id, + type: "tool", + callID: "call_malformed", + tool: "task", + state: { + status: "error", + input: { task_id: "not-a-session-id" }, + error: "Cannot resume the malformed task reference", + time: { start: Date.now(), end: Date.now() }, + }, + } as MessageV2.ToolPart) + + const forked = await Session.fork({ sessionID: parent.id }) + const msgs = await sessions.messages({ sessionID: forked.id }) + const tool = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool") as MessageV2.ToolPart + expect(tool.state.status).toBe("error") + if (tool.state.status !== "error") throw new Error("expected malformed task error") + expect(tool.state.input.task_id).toBe("not-a-session-id") }, }) }, diff --git a/packages/opencode/test/kilocode/summary-file-diff.test.ts b/packages/opencode/test/kilocode/summary-file-diff.test.ts index c481716553c..84e055664bc 100644 --- a/packages/opencode/test/kilocode/summary-file-diff.test.ts +++ b/packages/opencode/test/kilocode/summary-file-diff.test.ts @@ -20,9 +20,9 @@ test("SummaryFileDiff parse strips `patch` when present on input", () => { expect(parsed).toEqual({ file: "a.txt", additions: 1, deletions: 1, status: "modified" }) }) -test("SummaryFileDiff differs from FileDiff by exactly `patch`", () => { +test("SummaryFileDiff drops the heavy content fields from FileDiff", () => { const full = new Set(Object.keys(Snapshot.FileDiff.fields)) const summary = new Set(Object.keys(Snapshot.SummaryFileDiff.fields)) - expect([...full].filter((k) => !summary.has(k))).toEqual(["patch"]) + expect([...full].filter((k) => !summary.has(k)).sort()).toEqual(["after", "before", "patch"]) expect([...summary].filter((k) => !full.has(k))).toEqual([]) }) diff --git a/packages/opencode/test/kilocode/tool/grep-signal-controls.test.ts b/packages/opencode/test/kilocode/tool/grep-signal-controls.test.ts new file mode 100644 index 00000000000..8a634d793ab --- /dev/null +++ b/packages/opencode/test/kilocode/tool/grep-signal-controls.test.ts @@ -0,0 +1,124 @@ +import { describe, expect } from "bun:test" +import path from "path" +import { Effect } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Ripgrep } from "@opencode-ai/core/ripgrep" +import { Agent } from "../../../src/agent/agent" +import { Git } from "../../../src/git" +import { GrepTool } from "../../../src/tool/grep" +import { Truncate } from "../../../src/tool/truncate" +import { MessageID, SessionID } from "../../../src/session/schema" +import { TestInstance } from "../../fixture/fixture" +import { testEffect } from "../../lib/effect" + +const it = testEffect( + LayerNode.compile( + LayerNode.group([CrossSpawnSpawner.node, FSUtil.node, Ripgrep.node, Truncate.node, Agent.node, Git.node]), + ), +) + +const ctx = { + sessionID: SessionID.make("ses_grep_signal_controls"), + messageID: MessageID.make("msg_grep_signal_controls"), + callID: "", + agent: "code", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, +} + +const file = (test: { readonly directory: string }, name: string) => path.join(test.directory, name) + +const init = Effect.gen(function* () { + const info = yield* GrepTool + return yield* info.init() +}) + +describe("Kilo grep signal-to-noise controls", () => { + it.instance("preserves the default match output", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => Bun.write(file(test, "default.txt"), "before\nneedle\nafter\n")) + const grep = yield* init + const result = yield* grep + .execute({ pattern: "needle", path: test.directory }, ctx) + .pipe(Effect.timeout("2 seconds")) + + expect(result.metadata.matches).toBe(1) + expect(result.output).toContain("Line 2: needle") + expect(result.output).not.toContain("[match]") + }), + ) + + it.instance("executes all signal controls and settles at the custom limit", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => + Bun.write( + file(test, "controls.txt"), + ["regex before", "NEEDLEzzz", "regex after", "before", "NEEDLE.*", "after", "far", "needle.*"].join("\n") + + "\n", + ), + ) + const grep = yield* init + const result = yield* grep + .execute( + { + pattern: "needle.*", + path: test.directory, + include: "*.txt", + context: 1, + limit: 1, + literal: true, + ignoreCase: true, + }, + ctx, + ) + .pipe(Effect.timeout("2 seconds")) + + expect(result.metadata).toEqual({ matches: 1, truncated: true }) + expect(result.output).toContain("[context] Line 4: before") + expect(result.output).toContain("[match] Line 5: NEEDLE.*") + expect(result.output).toContain("[context] Line 6: after") + expect(result.output).not.toContain("NEEDLEzzz") + expect(result.output).not.toContain("Line 7: far") + expect(result.output).not.toContain("Line 8: needle.*") + expect(result.output).toContain("1 matches limit reached. Use limit=2 for more, or refine pattern.") + }), + ) + + it.instance("does not count context lines toward the match limit", () => + Effect.gen(function* () { + const test = yield* TestInstance + const content = Array.from( + { length: 35 }, + (_, index) => `before-${index}\nneedle-${index}\nafter-${index}\ngap-${index}`, + ).join("\n") + yield* Effect.promise(() => Bun.write(file(test, "context-limit.txt"), `${content}\n`)) + const grep = yield* init + const result = yield* grep + .execute({ pattern: "needle", path: test.directory, context: 1, limit: 100 }, ctx) + .pipe(Effect.timeout("2 seconds")) + + expect(result.metadata.matches).toBe(35) + expect(result.metadata.truncated).toBe(false) + expect(result.output).toContain("needle-34") + expect(result.output).not.toContain("matches limit reached") + }), + ) + + it.instance("guides the model to read truncated lines", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => Bun.write(file(test, "long.txt"), `${"x".repeat(2_100)}needle\n`)) + const grep = yield* init + const result = yield* grep.execute({ pattern: "needle", path: test.directory }, ctx) + + expect(result.metadata.matches).toBe(1) + expect(result.output).toContain("Some matching or context lines were truncated. Use read for full lines.") + }), + ) +}) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index fbb683ac9ed..47cf322748c 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -295,6 +295,7 @@ const snap = Layer.succeed( revert: () => Effect.void, diff: () => Effect.succeed(""), diffFull: () => Effect.succeed([]), + diffFile: () => Effect.succeed(undefined), }), ) // kilocode_change end diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap index 368225175b6..a31700b7c0e 100644 --- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -114,16 +114,37 @@ exports[`tool parameters JSON Schema (wire shape) grep 1`] = ` { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { + "context": { + "description": "Number of context lines to show before and after each match (default 0)", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + "ignoreCase": { + "description": "Match without regard to letter case (default false)", + "type": "boolean", + }, "include": { "description": "File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")", "type": "string", }, + "limit": { + "description": "Maximum matching lines to return (default 100)", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer", + }, + "literal": { + "description": "Treat pattern as plain text instead of a regex (default false)", + "type": "boolean", + }, "path": { "description": "The directory to search in. Defaults to the current working directory.", "type": "string", }, "pattern": { - "description": "The regex pattern to search for in file contents", + "description": "Pattern to search for in file contents (regex by default)", "type": "string", }, }, diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index 0c0769d4dab..e125349cff5 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -145,7 +145,7 @@ describe("tool.grep", () => { const grep = yield* info.init() const result = yield* grep.execute({ pattern: "needle", path: test.directory, include: "*.txt" }, ctx) - expect(result.output).toContain("(Results truncated. Consider using a more specific path or pattern.)") + expect(result.output).toContain("100 matches limit reached. Use limit=200 for more, or refine pattern.") // kilocode_change expect(result.output).not.toMatch(/showing \d+ of \d+ matches/) }), ) diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts index 67247b00b8e..1b43d44206b 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -163,6 +163,21 @@ describe("tool parameters", () => { expect(parsed.path).toBe("/tmp") expect(parsed.include).toBe("*.ts") }) + // kilocode_change start - configurable grep signal controls + test("accepts signal controls", () => { + expect(parse(Grep, { pattern: "TODO", context: 0, limit: 1, literal: true, ignoreCase: true })).toMatchObject({ + context: 0, + limit: 1, + literal: true, + ignoreCase: true, + }) + }) + test("rejects invalid signal controls", () => { + expect(accepts(Grep, { pattern: "TODO", context: -1 })).toBe(false) + expect(accepts(Grep, { pattern: "TODO", limit: 0 })).toBe(false) + expect(accepts(Grep, { pattern: "TODO", limit: 1.5 })).toBe(false) + }) + // kilocode_change end test("rejects missing pattern", () => { expect(accepts(Grep, {})).toBe(false) }) diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index f3875fe1e7a..f0631cef6c3 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -136,7 +136,7 @@ Use this skill. ) // kilocode_change start - it.live("built-in kilo-config includes named command lookup guidance", () => + it.live("built-in kilo-config keeps rendered shell examples inert", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { @@ -157,9 +157,13 @@ Use this skill. })).find((t) => t.id === SkillTool.id) if (!tool) throw new Error("Skill tool not found") + const requests: Array> = [] const ctx: Tool.Context = { ...baseCtx, - ask: () => Effect.void, + ask: (req) => + Effect.sync(() => { + requests.push(req) + }), } const result = yield* tool.execute({ name: "kilo-config" }, ctx) @@ -170,6 +174,9 @@ Use this skill. expect(result.output).toContain("~/.kilocode/") expect(result.output).toContain("**/command/") expect(result.output).toContain("explicit search") + expect(result.output).toContain("`` !`cmd` ``") + expect(result.output).not.toContain("[skill shell command failed]") + expect(requests.map((request) => request.permission)).toEqual(["skill"]) }), { git: true }, ), diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 5f05b95df7e..8a03ea08f7e 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -269,6 +269,69 @@ describe("tool.task", () => { }), ) + // kilocode_change start - verify forked task children remain resumable + it.instance("execute resumes a cloned task session after the parent is forked", () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" }) + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: assistant.id, + sessionID: chat.id, + type: "tool", + callID: "call_1", + tool: "task", + metadata: { sessionId: child.id }, + state: { + status: "completed", + input: { description: "inspect bug", prompt: "continue", task_id: child.id }, + output: `done`, + title: "inspect bug", + metadata: { sessionId: child.id }, + time: { start: Date.now(), end: Date.now() }, + }, + } as MessageV2.ToolPart) + + const forked = yield* sessions.fork({ sessionID: chat.id }) + const msgs = yield* sessions.messages({ sessionID: forked.id }) + const part = msgs.flatMap((msg) => msg.parts).find((item) => item.type === "tool" && item.tool === "task") as + | MessageV2.ToolPart + | undefined + if (!part || part.state.status !== "completed") throw new Error("expected a completed task part") + const id = part.state.input.task_id + if (typeof id !== "string") throw new Error("expected a cloned task ID") + const parent = msgs.find((msg) => msg.info.role === "assistant") + if (!parent || parent.info.role !== "assistant") throw new Error("expected a forked assistant message") + + const tool = yield* TaskTool + const def = yield* tool.init() + let seen: SessionPrompt.PromptInput | undefined + yield* def.execute( + { + description: "inspect bug", + prompt: "continue from the fork", + subagent_type: "general", + task_id: id, + }, + { + sessionID: forked.id, + messageID: parent.info.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps: stubOps({ onPrompt: (input) => (seen = input) }) }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + expect(seen?.sessionID).toBe(SessionID.descending(id)) + expect((yield* sessions.get(SessionID.descending(id))).parentID).toBe(forked.id) + }), + ) + // kilocode_change end + // kilocode_change start - resumed children rebuild parent platform attribution after restart it.instance("execute preserves platform attribution when resuming a task", () => Effect.gen(function* () { diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index adffc2e086a..beb59238d9b 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -376,6 +376,7 @@ export type TuiKV = { export type TuiState = { readonly ready: boolean readonly config: SdkConfig + readonly globalConfig: SdkConfig readonly provider: ReadonlyArray readonly path: { state: string diff --git a/packages/schema/src/file-diff.ts b/packages/schema/src/file-diff.ts index 45226df28ee..82784de6564 100644 --- a/packages/schema/src/file-diff.ts +++ b/packages/schema/src/file-diff.ts @@ -6,6 +6,8 @@ import { optional } from "./schema" export const Info = Schema.Struct({ file: optional(Schema.String), patch: optional(Schema.String), + before: optional(Schema.String), // kilocode_change - full-content sides for editor diff tabs + after: optional(Schema.String), // kilocode_change - full-content sides for editor diff tabs additions: Schema.Finite, deletions: Schema.Finite, status: optional(Schema.Literals(["added", "deleted", "modified"])), diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 9e560d5001e..c9579f87ef7 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2555,6 +2555,7 @@ export type Config = { terminal_command_display?: "expanded" | "collapsed" code_edit_display?: "expanded" | "collapsed" hide_prompt_training_models?: boolean + privacy_mode?: boolean /** * Sandbox configuration for agent tools */ diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 60691352a16..e47e125728e 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -33415,6 +33415,9 @@ "hide_prompt_training_models": { "type": "boolean" }, + "privacy_mode": { + "type": "boolean" + }, "sandbox": { "type": "object", "properties": { diff --git a/packages/tui/src/plugin/adapters.tsx b/packages/tui/src/plugin/adapters.tsx index c8d03f33a34..9e970cad9d4 100644 --- a/packages/tui/src/plugin/adapters.tsx +++ b/packages/tui/src/plugin/adapters.tsx @@ -107,6 +107,11 @@ function stateApi(sync: ReturnType): TuiPluginApi["state"] { get config() { return sync.data.config }, + // kilocode_change start + get globalConfig() { + return sync.data.globalConfig + }, + // kilocode_change end get provider() { return sync.data.provider }, diff --git a/packages/tui/src/routes/session/terminal.tsx b/packages/tui/src/routes/session/terminal.tsx index 75a02f1bd27..2b4ab8376fe 100644 --- a/packages/tui/src/routes/session/terminal.tsx +++ b/packages/tui/src/routes/session/terminal.tsx @@ -16,6 +16,7 @@ export function TerminalPrompt(props: { sessionID: string; terminalID: string }) const renderer = useRenderer() const dimensions = useTerminalDimensions() const [snapshot, setSnapshot] = createSignal() + const workspace = () => sync.session.get(props.sessionID)?.workspaceID function terminal() { const live = sync.data.interactive_terminal[props.sessionID]?.find((item) => item.info.id === props.terminalID) const polled = snapshot() @@ -41,6 +42,7 @@ export function TerminalPrompt(props: { sessionID: string; terminalID: string }) .then(() => sdk.client.interactiveTerminal.write({ terminalID: props.terminalID, + workspace: workspace(), interactiveTerminalWriteInput: { data }, }), ) @@ -51,7 +53,7 @@ export function TerminalPrompt(props: { sessionID: string; terminalID: string }) function close() { if (closing()) return setClosing(true) - void sdk.client.interactiveTerminal.close({ terminalID: props.terminalID }).catch(() => { + void sdk.client.interactiveTerminal.close({ terminalID: props.terminalID, workspace: workspace() }).catch(() => { setClosing(false) }) } @@ -64,7 +66,7 @@ export function TerminalPrompt(props: { sessionID: string; terminalID: string }) if (state.polling || closing()) return state.polling = true void sdk.client.interactiveTerminal - .get({ terminalID: props.terminalID }) + .get({ terminalID: props.terminalID, workspace: workspace() }) .then((result) => { if (result.data) setSnapshot(result.data) }) @@ -131,6 +133,7 @@ export function TerminalPrompt(props: { sessionID: string; terminalID: string }) void sdk.client.interactiveTerminal .resize({ terminalID: props.terminalID, + workspace: workspace(), interactiveTerminalResizeInput: { cols: width, rows: height }, }) .catch(() => undefined) diff --git a/packages/tui/test/kilocode/interactive-terminal.test.tsx b/packages/tui/test/kilocode/interactive-terminal.test.tsx new file mode 100644 index 00000000000..5f66c2653bf --- /dev/null +++ b/packages/tui/test/kilocode/interactive-terminal.test.tsx @@ -0,0 +1,117 @@ +/** @jsxImportSource @opentui/solid */ +import { expect, test } from "bun:test" +import { Show } from "solid-js" +import type { InteractiveTerminalSnapshot, Session } from "@kilocode/sdk/v2" +import { testRender } from "@opentui/solid" +import path from "node:path" +import { ArgsProvider } from "../../src/context/args" +import { ExitProvider } from "../../src/context/exit" +import { KVProvider } from "../../src/context/kv" +import { PermissionProvider } from "../../src/context/permission" +import { ProjectProvider } from "../../src/context/project" +import { SDKProvider } from "../../src/context/sdk" +import { SyncProvider, useSync } from "../../src/context/sync" +import { ThemeProvider } from "../../src/context/theme" +import { TuiConfigProvider } from "../../src/config" +import { TerminalPrompt } from "../../src/routes/session/terminal" +import { ToastProvider } from "../../src/ui/toast" +import { createFetch, directory, eventSource, json } from "../fixture/tui-sdk" +import { tmpdir } from "../fixture/fixture" +import { TestTuiContexts } from "../fixture/tui-environment" +import { createTuiResolvedConfig } from "../fixture/tui-runtime" + +const session: Session = { + id: "ses_terminal", + slug: "terminal", + projectID: "proj_test", + workspaceID: "ws_terminal", + directory, + title: "Terminal", + version: "7.4.20", + time: { created: 1, updated: 1 }, +} + +const snapshot: InteractiveTerminalSnapshot = { + info: { + id: "itx_terminal", + sessionID: session.id, + pid: 123, + command: "prompt", + cwd: directory, + status: "running", + cols: 80, + rows: 14, + time: { started: 1, updated: 1 }, + }, + output: "READY", + cursor: 5, +} + +async function wait(fn: () => boolean, timeout = 2000) { + const start = Date.now() + while (!fn()) { + if (Date.now() - start > timeout) throw new Error("timed out waiting for terminal request") + await Bun.sleep(10) + } +} + +test("routes interactive terminal input through the session workspace", async () => { + await using tmp = await tmpdir() + await Bun.write(path.join(tmp.path, "kv.json"), "{}") + const seen: URL[] = [] + const calls = createFetch((url) => { + seen.push(url) + if (url.pathname === "/session") return json([session]) + if (url.pathname === "/interactive-terminal") return json([snapshot]) + if (url.pathname === "/interactive-terminal/itx_terminal") return json(snapshot) + if (url.pathname.startsWith("/interactive-terminal/itx_terminal/")) return json(true) + return undefined + }) + const config = createTuiResolvedConfig() + const app = await testRender(() => ( + + + + + + + + + {}}> + + + + + + + + + + + + + )) + + try { + await wait(() => seen.some((url) => url.pathname === "/interactive-terminal/itx_terminal")) + app.mockInput.pressKey("x") + await wait(() => seen.some((url) => url.pathname === "/interactive-terminal/itx_terminal/input")) + + const terminal = seen.filter((url) => url.pathname.startsWith("/interactive-terminal/itx_terminal")) + expect(terminal.length).toBeGreaterThan(0) + expect(terminal.every((url) => url.searchParams.get("workspace") === session.workspaceID)).toBe(true) + } finally { + app.renderer.destroy() + } +}) + +function Ready() { + const sync = useSync() + return ( + + ({}) }}> + + + + ) +} diff --git a/plans/agent-manager-git-poller-optimization.md b/plans/agent-manager-git-poller-optimization.md new file mode 100644 index 00000000000..10ca489e244 --- /dev/null +++ b/plans/agent-manager-git-poller-optimization.md @@ -0,0 +1,485 @@ +# Plan: Optimize Agent Manager Git Stats Polling + +## Problem + +Agent Manager periodically computes exact diff and ahead/behind statistics for +the local checkout and every visible managed worktree. The timer itself is not +the problem. The expensive part is that every poll reconstructs the same state +through several independent Git processes and filesystem passes, even when a +worktree has not changed. + +For each worktree, the current hot path does the following: + +1. `git merge-base HEAD ` +2. `git diff --name-status --no-renames ` +3. `git diff --numstat --no-renames ` +4. `git ls-files --others --exclude-standard` +5. `git rev-list --left-right --count ...HEAD` +6. `lstat`, binary detection, and line counting for every untracked file + +The poll also runs `git worktree list --porcelain` once for presence and branch +information. `GitStatsPoller` suppresses an unchanged webview message only after +all of the work above has finished, so the existing result hash does not reduce +Git CPU, process creation, or disk reads. + +The cost scales linearly with the number of worktrees and expanded projects. +The shared semaphore limits concurrent child processes, but it does not reduce +the total work. On endpoint-protected machines, every extra Git process and file +scan also creates security-agent work. + +## Constraints + +- Keep timer-based polling. Do not introduce filesystem watchers, FSEvents, or + another event-driven invalidation system. +- Preserve the current visible interval, hidden interval, skip behavior, + non-overlap guarantees, and manual `snapshot(true)` refresh behavior through + the command-consolidation and cache phases. Permit bounded timer-based + sharding only if measurement proves the mandatory status scans remain above + the CPU/disk or endpoint-security goals. +- Preserve exact UI semantics for tracked, staged, unstaged, deleted, binary, + image, and untracked files, including exact addition/deletion totals. +- Preserve ahead/behind semantics against each worktree's configured + `remoteRef(wt)`. Do not fetch from remotes. +- Do not modify user or repository Git configuration. In particular, do not + enable `core.fsmonitor`, untracked cache, split index, or maintenance as a + side effect of opening Agent Manager. +- Keep all child processes behind the existing shared `Semaphore` and abort + controller. +- Keep the implementation in the VS Code extension. Do not move polling back + to `kilo serve` because the local path intentionally avoids Bun child-process + memory growth on Windows. +- Do not include the separate Git executable resolution / macOS double-exec + work. That can land independently. + +## Goal + +Make each periodic poll proportional to the amount of changed state rather than +repeating every exact diff calculation for every worktree. + +For an unchanged worktree, the steady-state poll should perform one read-only +Git status probe and no base-relative diff or history walk. When a worktree does +change, it should compute the exact UI statistics with fewer Git processes and +fewer repeated index/worktree traversals than today. + +## Recommended Design + +Use a two-level timer-driven poll: + +1. One canonical status scan returns enough state to decide whether the + previous exact result is reusable. +2. Only a changed probe triggers exact diff and ahead/behind calculation. + +The cache is an optimization, not a source of truth. The first poll, a forced +snapshot, a failed probe, a changed base, or an uncertain fingerprint always +falls back to exact calculation. + +### Feasibility and lower bound + +The status, merge-base numstat, ref snapshot, and `rev-list` commands used here +are available in current Git and have stable machine-readable output. Every +optimization also has a failure fallback. + +This design does not make polling free. Without filesystem watchers or Git +fsmonitor, there is no repository-level hash that reveals arbitrary unstaged or +untracked file changes. Exact periodic detection must scan each worktree. Git +cannot status several independent worktree/index pairs in one invocation, so +one status process and one working-tree scan per active worktree per interval is +the practical lower bound under these constraints. + +The expected gain comes from removing duplicate scans, exact line-count diffs, +and history walks after that mandatory status scan. Process-count savings will +therefore be larger than CPU and disk savings. CrowdStrike CPU normalization is +a measured acceptance gate, not an assumed consequence of reducing process +launches. + +### Level 1: Worktree fingerprint + +Add a polling-specific helper that runs: + +```text +git --no-optional-locks status \ + --porcelain=v2 --branch -z \ + --no-ahead-behind --untracked-files=all --no-renames +``` + +Parse its stable machine format into: + +- current `HEAD` OID and branch, +- tracked and untracked paths, +- staged/unstaged status and index object IDs, +- a deterministic status payload. + +The status payload alone is not a safe cache key. Editing a file that is +already reported as modified can leave the porcelain text unchanged. Complete +the fingerprint in Node with bigint `lstat` metadata for every changed +non-deleted path: size, nanosecond mtime/ctime when supported, inode, mode, and +file type. Include these values in a deterministic hash together with: + +- worktree path, +- configured base ref, +- `HEAD` OID, +- status records and paths, +- the cached base-ref OID from the project-level ref snapshot described below. + +This does not hash file contents. It detects normal editor writes, atomic +renames, staging, commits, branch switches, untracked-file changes, and local +tracking-ref changes while avoiding a full exact diff on an unchanged tree. + +If any path cannot be statted, contains unsupported status data, or changes +during probing, mark the fingerprint uncertain and run the exact path. Never +reuse cached stats on uncertainty. + +The fingerprint cache is in memory and scoped to one `GitStatsPoller`. Store per +worktree: + +```ts +type CachedStats = { + base: string + fingerprint: string + stats: WorktreeStats +} +``` + +Do not retain every `WorktreeDiffEntry` in the poller cache. Large worktrees can +have thousands of changed files, while the poller only needs aggregate stats. +Keep review and file-detail metadata outside this cache. Bound aggregate cache +entries by active worktree IDs and clear them on `stop()` so project switches +and disposal cannot leak stale state. + +### Level 2: Exact recomputation with fewer traversals + +When a fingerprint changes, reuse the probe's untracked paths instead of +running `git ls-files --others --exclude-standard` again. + +Replace the separate merge-base process with Git's built-in merge-base diff +form: + +```text +git -c core.quotepath=false diff \ + --merge-base --numstat -z --no-renames +``` + +The poller only renders aggregate file/addition/deletion counts, so it does not +need tracked-file status records or `WorktreeDiffEntry` objects. Numstat alone +provides the tracked file count, exact text counts, and binary markers currently +obtained from `merge-base`, `diff --name-status`, and `diff --numstat`. `-z` +makes paths safe for tabs, newlines, Unicode, and unusual filenames. + +The polling helper should therefore compute a changed worktree with: + +- one status/fingerprint process, +- one combined tracked-diff process, +- no second untracked enumeration, +- one history calculation only when the commit/base pair changed. + +Do not change `diffFile`'s on-demand detail path in this work. It is not periodic +and has different materialization requirements. + +### Batch project ref state + +Run one project-level ref snapshot per poll before processing individual +worktrees: + +```text +git for-each-ref \ + --format=%(refname)%00%(objectname)%00%(upstream)%00%(symref)%00 \ + refs/heads refs/remotes +``` + +Use it to map every local branch and remote base ref to an OID, each local +branch to its configured upstream, and each remote `HEAD` to its symbolic +default branch. Combine this with `git worktree list --porcelain -z`, whose +records already contain each worktree's `HEAD` OID and branch. Extend the +existing worktree parser and `listWorktreePaths` result instead of launching +`rev-parse`, `symbolic-ref`, or config queries in each worktree. + +For the local checkout, preserve the current tracking-resolution fallback when +the ref snapshot is insufficient (for example, unusual configuration with a +branch remote but no upstream merge ref). Cache that resolution as today. Do +not trade a small process reduction for a change in which base branch is used. + +The project snapshot supplies three things: + +- the presence and checked-out branch information already used by the poller, +- the `HEAD` OID for each worktree, +- the base-ref OID used in the fingerprint and ahead/behind cache key. + +Cache ahead/behind by `\0`. If both OIDs are unchanged, reuse +the previous counts even when only working-tree files changed. This removes +history walks from normal editing polls. + +The implementation uses the ref snapshot to cache base OIDs and reuses +ahead/behind counts when the worktree `HEAD` and base OID are unchanged. A +changed worktree uses the existing `rev-list --left-right --count ...HEAD` +path. Batched ahead/behind and merge-base fast paths were tested but removed +because they did not improve the dominant steady-state workload enough to +justify their compatibility and maintenance cost. + +### Forced refresh and failure behavior + +- The initial poll always computes exact results. +- `snapshot(true)` bypasses fingerprint reuse and recomputes exact results for + skipped and unskipped worktrees, matching current behavior. +- A base string or base OID change invalidates diff and ahead/behind caches. +- A branch or `HEAD` OID change invalidates both caches. +- A fingerprint change invalidates exact diff stats but not ahead/behind when + the commit/base OIDs are unchanged. +- Missing worktrees are removed from all caches when presence is reconciled. +- A transient exact-diff failure preserves last-known stats, matching the + current poller contract. +- A transient fingerprint/ref-snapshot failure runs exact calculation. If that + also fails, preserve last-known stats. +- Do not promote an exact result to a reusable cache entry when metadata for its + known paths changes while the exact calculation is running. Emit the result, + then let the next timer tick confirm or recompute it; this preserves the + current eventual correction behavior without adding a second status process + to every changed poll. +- `syncSkips()` must not discard cached data. Collapsing and re-expanding a + section should reuse the last exact result after a confirming fingerprint. + +## Changes + +### 1. Add a polling snapshot module + +Create `packages/kilo-vscode/src/agent-manager/git-stats-snapshot.ts` as a +VS Code-free module. Keep parsing, hashing, cache decisions, and aggregation out +of `GitStatsPoller.ts` so the existing file does not grow into another mixed +responsibility controller. + +The module should own: + +- porcelain-v2 `-z` parsing, +- numstat `-z` parsing, +- metadata fingerprint construction, +- exact aggregate file/addition/deletion calculation, and +- cache-key and invalidation decisions. + +Use explicit parse-result errors rather than silently treating malformed output +as a clean worktree. + +### 2. Extend `GitOps` + +Use the existing `GitOps` buffered execution methods through the narrow +`GitStatsSnapshot` source boundary. Do not expose the generic private `raw()` +method or bypass the existing semaphore. Use `execGitBuffer()` for NUL-delimited +output. + +Retain the current untracked-file safeguards in the aggregate implementation: +content reads capped at 1 MB and content-based binary detection. Do not call +`git status` without `--no-ahead-behind`; otherwise every supposedly cheap probe +can perform the history walk this design is trying to cache. + +### 3. Update `GitStatsPoller` + +Replace the independent `localDiff` + `aheadBehind` calls with a project poll +coordinator: + +1. collect presence, worktree `HEAD`s, and ref OIDs once, +2. run status probes for active worktrees through the shared semaphore, +3. reuse cached exact results for matching fingerprints, +4. recompute exact summaries only for changed/uncertain worktrees, +5. merge successful results into `lastStats` and emit only when the aggregate + UI hash changed. + +Replace the injected `localDiff` callback with a narrow exact aggregate callback +or snapshot service. Do not construct full `WorktreeDiffEntry[]` merely to reduce +them to three numbers. + +The local checkout should use the same snapshot/cache path instead of separately +resolving branch, tracking branch, diff, and ahead/behind every tick. Preserve +the existing no-base fallback to `workingTreeStats`. + +Keep the current trailing-delay scheduler. Do not change polling intervals in +this optimization. + +### 4. Keep review/detail diff code separate + +Leave `local-diff.ts`, `createLocalDiff`, `diffSummary`, and `diffFile` behavior +unchanged in this optimization. Review and file-detail requests need per-file +status, stamps, merge-base identity, and materialization data that the poller +does not need. Sharing those objects would retain potentially thousands of +entries per worktree and broaden the regression surface for an infrequent path. + +## Final Implementation + +The measured implementation combines the useful parts of the original proposal: + +- one porcelain-v2 status/fingerprint probe for each selected worktree, +- one shared ref snapshot per project poll, +- exact numstat and untracked line counting only after a fingerprint change, +- cached ahead/behind counts keyed by `HEAD` and base OIDs, +- five-second polling for dirty, selected, busy, and newly confirmed worktrees, +- deterministic 30-second round-robin polling for clean dormant worktrees, +- immediate unsharded `snapshot(true)` refreshes, and +- last-known stats preservation when a later Git operation fails. + +The clean dormant sharding was added only after direct workload and extension-host +profiles showed that status probes remained the dominant steady-state cost. It is +timer-based polling, not filesystem event invalidation. Product approval is still +required for the bounded 30-second freshness tradeoff. + +Batched ahead/behind, merge-base shortcuts, persistent Git workers, and shipped +benchmark or profiling hooks were explicitly discarded after measurement. + +## Tests + +### Parser and exact-result parity + +Add focused tests for the new snapshot module using the real temporary-repository +fixtures and helpers in `tests/unit/local-diff.test.ts`: + +- clean worktree, +- committed changes since base, +- staged-only, unstaged-only, and staged-plus-unstaged changes, +- added, modified, deleted, type-changed, and conflicted files, +- untracked text and binary files, +- ignored files excluded, +- filenames containing spaces, tabs, newlines, and non-ASCII characters, +- symlinks and deleted paths, +- stale/missing base refs, +- detached HEAD, +- base ref advancing without a working-tree change. + +For every fixture, compare the optimized aggregate output to the sum of the +current `diffSummary` entries and to the current `aheadBehind` result before +switching the poller to the new implementation. + +### Cache invalidation + +Extend `tests/unit/git-stats-poller.test.ts` with command recording and assert: + +- second unchanged poll runs a status probe but no exact diff or `rev-list`, +- editing an already-modified file changes the metadata fingerprint and updates + line counts, +- staging without changing file contents invalidates the fingerprint, +- commit and branch changes invalidate diff and ahead/behind, +- remote-tracking ref changes invalidate base-dependent values, +- working-tree-only edits reuse ahead/behind, +- `snapshot(true)` bypasses cache reuse, +- skipped worktrees remain cached but are not emitted, +- missing worktrees evict cached state, +- malformed/failed probes fall back to exact computation, +- exact failures retain last-known stats, +- `stop()` clears caches and stale generations cannot publish results. + +- hot worktrees are polled on every visible tick, +- clean dormant worktrees rotate without starvation and are sampled within + 30 seconds, +- a dirty result promotes a worktree to hot immediately, +- two consecutive clean polls return it to the dormant queue, +- forced refreshes bypass the shard budget, +- deleted sessions cannot leave stale busy IDs indefinitely, and +- every Git operation remains behind the shared semaphore. + +## Performance Verification + +Use disposable fixtures and isolated VS Code instances for before/after +measurements. Do not add benchmark scripts, Git hooks, or profiling observers to +the product diff. + +The final matched extension-host profile used 40 rendered worktrees for 30 +seconds. It recorded 1,994 baseline GitOps commands versus 466 optimized commands +and 42.16 seconds versus 13.62 seconds of cumulative Git command time. The +direct workload profile also showed a 78.5% reduction in combined Git CPU. + +Acceptance criteria now are: + +- exact aggregate output parity across real temporary-repository fixtures, +- no exact diff or history walk for an unchanged cached worktree, +- dirty, selected, busy, and new worktrees observed on five-second ticks, +- every clean dormant worktree observed within 30 seconds, +- forced refresh bypasses sharding and cache reuse, +- no monotonic memory growth across repeated polls, and +- no repository configuration or index-format changes. + +CrowdStrike CPU remains an external managed-endpoint measurement. It must be +recorded directly before claiming an endpoint-security reduction; Git command or +process reductions are not a substitute for that measurement. + +## Verification + +From `packages/kilo-vscode/`: + +- `bun run test:unit -- --grep "GitStatsPoller|diffSummary|GitOps|parseWorktreeList"` +- `bun run typecheck` +- `bun run lint` +- `bun run knip` +- `bun run check-kilocode-change` + +Manually verify that Agent Manager stats update within one visible poll after +editing, staging, committing, switching branches, and updating a local tracking +ref. Verify that clean dormant worktrees rotate within 30 seconds and that +forced refreshes bypass the shard budget. + +## Risks And Mitigations + +- **False cache hits from unchanged status text.** Include per-path metadata and + fail open to exact computation. Test repeated edits to the same modified path. +- **timestamp granularity or preserved timestamps.** Use bigint stat metadata + including nanosecond mtime/ctime where supported, size, inode, mode, and + status/index metadata. Treat this as a performance cache and force exact + refresh on manual requests. If tests show a supported filesystem can preserve + all fields across a content edit, add a bounded content sample hash for + changed files rather than hashing every full file. +- **Races while files are changing.** The status fingerprint is sampled before + exact calculation. A later status probe corrects any edit that races the + calculation, and failed exact operations retain the prior known result. +- **User Git configuration changes output or cost.** Use porcelain/numstat + machine formats, explicit `--no-renames`, `-z`, and `--no-optional-locks`. +- **Older Git versions.** Use the existing `merge-base` and `rev-list` commands; + do not require an upgrade merely to show stats. +- **Large untracked sets remain expensive.** Enumeration is required for exact + feature parity. The optimization ensures it happens once per tick and line or + binary reads happen only when the fingerprint changes. +- **Shared refs change during a poll.** Bind one project ref snapshot to a poll + generation. A later tick corrects races; failed exact operations retain the + prior known result. + +## Out Of Scope + +- Filesystem watchers or event-driven invalidation. +- Changing the five-second visible or 60-second hidden intervals. Clean dormant + worktrees use the measured 30-second round-robin bound. +- Resolving the macOS `/usr/bin/git` launcher to avoid double execution. +- CrowdStrike or other endpoint-security exclusion policies. +- Enabling Git fsmonitor, untracked cache, split index, sparse checkout, or + repository maintenance. +- Optimizing infrequent apply, merge, PR, fetch, or file-detail commands. + +## Measured Outcome + +Implementation retained only the optimizations that reduced the real workload: + +- one porcelain-v2 status fingerprint per polled worktree, +- exact diff and ahead/behind reuse while file and ref fingerprints are stable, +- 30-second round-robin polling for clean dormant worktrees, +- five-second polling for dirty, selected, or running worktrees, +- immediate full polling for forced snapshots. + +Removed after measurement: + +- the temporary legacy/optimized benchmark implementation, +- generic Git command instrumentation, +- batched ahead/behind and merge-base fast paths that affected cold polls but + not the dominant steady-state workload. + +Measurements on the same repository on 2026-08-05: + +- 52 linked worktrees: 32 clean, 20 dirty, no status failures. +- Required cache phases, 40 comparable worktrees: Git launches fell from 200 to + 41 (79.5%); warm wall time fell from 3.54 seconds to 2.75 seconds (22.1%). +- Final steady-state policy scanned 20 dirty worktrees plus a six-worktree clean + shard and the local checkout. +- Prior full-poll workload: 10.63 seconds wall, 20.61 seconds user CPU, 35.88 + seconds system CPU, 411,914 involuntary context switches. +- Final steady-state workload: 1.42 seconds wall, 0.59 seconds user CPU, 11.54 + seconds system CPU, 188,273 involuntary context switches. +- Matched 30-second extension-host windows with 40 rendered worktrees: GitOps + command count fell from 1,994 to 466 (76.6%), and cumulative Git command time + fell from 42.16 seconds to 13.62 seconds (67.7%). Both runs had no trace data + loss. + +The final comparison is a local process-level measurement, not a direct +CrowdStrike process measurement because this session has no sudo access. Falcon +CPU must still be checked on the managed endpoint after deployment; no security +policy exception is justified by this implementation result alone. diff --git a/plans/agent-manager-git-poller-remaining.md b/plans/agent-manager-git-poller-remaining.md new file mode 100644 index 00000000000..33429eff680 --- /dev/null +++ b/plans/agent-manager-git-poller-remaining.md @@ -0,0 +1,319 @@ +# Agent Manager Git Poller: Remaining Work And Blockers + +## Status + +The implementation is ready for merge from the code and validation perspective. +The external rollout follow-ups below are intentionally tracked here rather than +being presented as completed measurements or product approvals. + +The current worktree contains: + +- status-based fingerprints for tracked and untracked changes, +- cached exact diff and ahead/behind results, +- one shared ref snapshot per poll, +- five-second polling for dirty, selected, and busy worktrees, +- 30-second round-robin polling for clean dormant worktrees, +- immediate forced refresh support, +- focused real-repository and scheduler tests, +- a patch changeset, +- the original implementation plan and measured direct-command results. + +The production runtime has one polling implementation. Temporary benchmark code, +Git command observers, batched ahead/behind, and alternate merge-base paths were +removed after measurement showed they did not materially improve the dominant +steady-state workload. + +## Current Diff + +Expected implementation files: + +- `packages/kilo-vscode/src/agent-manager/git-stats-snapshot.ts` +- `packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts` +- `packages/kilo-vscode/src/agent-manager/project/pollers.ts` +- `packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts` +- `packages/kilo-vscode/src/KiloProvider.ts` +- `packages/kilo-vscode/tests/unit/git-stats-snapshot.test.ts` +- `packages/kilo-vscode/tests/unit/git-stats-poller.test.ts` +- `.changeset/calm-agent-manager-git-polling.md` +- `plans/agent-manager-git-poller-optimization.md` +- this handoff file + +No benchmark implementation or profiling instrumentation should be committed. + +## Completed Validation + +The latest minimized implementation passed: + +- `222` focused and Agent Manager architecture tests, +- extension lint, +- extension TypeScript checking, +- webview TypeScript checking, +- extension/webview bundling, +- `knip`, +- `check-kilocode-change`, +- markdown table padding validation, +- `git diff --check`. + +Manual isolated VS Code verification also passed for local stats: + +1. Agent Manager showed `9 files`, `+1340`, `-82`. +2. Creating one one-line untracked file changed it to `10 files`, `+1341`, + `-82` within one visible poll. +3. Removing the file restored `9 files`, `+1340`, `-82` within one poll. + +The temporary file was removed and is not in the working tree. + +## Trustworthy Measurements + +### Direct workload measurement + +These measurements used read-only Git commands against the same linked +worktrees. They did not change worktree contents or Git metadata. + +On 52 linked Kilo worktrees: + +- 32 were clean, +- 20 were dirty, +- no status command failed. + +Required cache phases on 40 comparable worktrees: + +- Git launches: `200` to `41`, a 79.5% reduction, +- warm wall time: `3.54s` to `2.75s`, a 22.1% reduction. + +Final steady-state policy, scanning 20 dirty worktrees, six clean worktrees, +and the local checkout: + +| Metric | Previous full poll | Optimized steady state | Change | +|---|---:|---:|---:| +| Wall time | 10.63s | 1.42s | -86.6% | +| User CPU | 20.61s | 0.59s | -97.1% | +| System CPU | 35.88s | 11.54s | -67.8% | +| Combined CPU | 56.49s | 12.13s | -78.5% | +| Involuntary context switches | 411,914 | 188,273 | -54.3% | + +The filesystem cache was warm and `/usr/bin/time` reported zero block-input +operations, so this comparison does not establish a reliable disk-read +reduction. + +### Valid self-test profiles + +A disposable fixture repository was created under the approved temp directory +with: + +- 40 linked worktrees, +- 16 intentionally dirty worktrees, +- 24 clean worktrees, +- a valid canonical Agent Manager project ID, +- the bundled Kilo CLI, +- all 40 worktree cards rendered in the Agent Manager DOM, +- `wt-01` selected. + +Matched 35-second self-test profiles were captured from a committed baseline +extension and the optimized extension. Both profiles had no trace data loss. + +| Metric | Baseline | Optimized | Change | +|---|---:|---:|---:| +| Distinct Git PIDs observed at 10 Hz | 59 | 53 | -10.2% | +| Longest renderer task | 70.42ms | 68.81ms | -2.3% | +| Renderer task duration | 98.46ms | 94.42ms | -4.1% | + +The renderer results are expected to be small because Git polling runs in the +extension host, not the webview renderer. PID sampling undercounts short-lived +processes and is useful only as supporting evidence. + +### Exact extension-host Git profile + +Matched 30-second windows were captured from the same disposable fixture after +validating 40 worktree cards in both runs. Both traces had no data loss. The +temporary GitOps hook recorded every Git command from the extension host. + +| Metric | Baseline | Optimized | Change | +|---|---:|---:|---:| +| GitOps command count | 1,994 | 466 | -76.6% | +| Cumulative Git command time | 42.16s | 13.62s | -67.7% | +| `merge-base` commands | 389 | 43 | -88.9% | +| `diff --numstat` commands | 389 | 43 | -88.9% | +| `ls-files --others` commands | 389 | 0 | -100% | +| `rev-list --left-right --count` commands | 389 | 43 | -88.9% | + +The optimized run replaced the baseline's repeated command families with 285 +porcelain-v2 status probes and 33 shared ref snapshots. The renderer remained +near idle in both runs, with approximately 0.72 seconds of script work over the +30-second window. + +Artifacts are temporary and currently live under: + +`/var/folders/6c/3j3r25ds6pd1dw3nlrfnvv280000gp/T/kilo/git-poller-profile.F0VLor` + +Do not add those artifacts to Git. + +## Invalid Or Incomplete Measurements + +Do not cite the following as final evidence: + +- Early self-test profiles where the bundled CLI was absent. Agent Manager did + not receive `sessionsLoaded`, so only the local card rendered. +- Early profiles whose synthetic `activeTarget.projectId` used `local:` + instead of `projectIdFor(canonicalRoot)`. +- PATH-wrapper Git logs. VS Code shell-environment resolution bypassed the + wrapper for extension-host Git processes. +- The earlier partial `baseline-exact.tsv`; it was discarded after the matched + final windows completed. + +## Remaining Required Work + +### 1. Measure CrowdStrike directly + +This session does not have sudo access, so Falcon CPU could not be measured in a +controlled before/after experiment. + +Required managed-endpoint comparison: + +1. Use the same 40-worktree fixture and matched baseline/optimized extension + builds. +2. Warm both runs before recording. +3. Record at least five minutes per build. +4. Measure the CrowdStrike process group, including the Agent, + `FileAnalysisService`, and `FXPredictService`. +5. Record average CPU, peak CPU, bytes read, and wakeups. +6. Keep VS Code, fixture state, visibility, and other workload constant. + +Target: + +- at least 50% lower Agent-Manager-attributable CrowdStrike CPU, +- no more than 0.10 average CPU core or 20% over the closed-panel baseline, + whichever allowance is larger. + +If Git workload drops but CrowdStrike does not, the remaining status scans are +the likely floor. Do not add more cache layers without profiler evidence. + +### 2. Product decision on dormant freshness + +The implementation changes clean dormant-worktree freshness from five seconds +to at most 30 seconds. This is timer-based polling, not filesystem events. + +Current behavior: + +- dirty worktree: five seconds, +- selected worktree: five seconds, +- worktree with a busy session: five seconds, +- new or not-yet-confirmed-clean worktree: five seconds, +- clean dormant worktree: round-robin, at most 30 seconds, +- forced snapshot: immediate and unsharded, +- hidden panel: existing 60-second full poll. + +This tradeoff needs explicit product approval. If all worktrees must retain +five-second freshness, remove dormant sharding and keep only status fingerprints +and exact-result caching. The direct measurements show that this leaves status +scans as the dominant cost. + +### 3. Review busy-session lifecycle + +`AgentManagerProvider` keeps a `busySessions` set so worktrees with actively +working Kilo sessions remain hot. Session deletion and `session.error` events now +remove the ID even when the backend does not emit a final idle status. Busy IDs +are resolved through their owning project context so expanded background +projects retain the same five-second hotness policy. + +The lifecycle review is complete: + +- every non-idle status should make the worktree hot, +- idle removes it, +- closed/deleted sessions cannot leave stale IDs indefinitely, +- project switch, panel close, and provider disposal clear the set, +- remote/retry/offline status semantics are correct. + +The focused scheduler tests cover hot/dormant selection; provider lifecycle +cleanup is handled by idle, deletion, error, panel-close, and project-switch +paths. + +### 4. Final minimization review + +Review the final diff after exact profiling and remove anything not justified by +the data. + +Specific review points: + +- `GitStatsPoller.ts` grew substantially. Extract only if it improves clarity + and remains within architecture caps; do not create generic abstractions. +- `GitStatsSource` exists as a narrow test seam and snapshot boundary. Confirm + no broader interface is needed. +- `semaphore` remains an existing option but is not consumed directly by the + poller. Do not add another semaphore layer. +- Keep `local-diff.ts` and review/detail behavior unchanged. +- Keep the temporary benchmark, exact-profile hooks, fixture, CLI copies, and + profile artifacts out of the commit. +- Update `plans/agent-manager-git-poller-optimization.md` so its final design and + measured outcome match the minimized implementation. Remove stale proposed + phases that were explicitly discarded. + +### 5. Final automated validation + +After the last code change, rerun from `packages/kilo-vscode/`: + +- `bun run format` +- `bun run lint` +- `bun run check-types` +- `bun run check-types:webview` +- `bun run bundle` +- focused Git poller/snapshot and Agent Manager architecture tests +- `bun run knip` +- `bun run check-kilocode-change` + +From repository root: + +- `bun run script/check-md-table-padding.ts` +- `git diff --check` + +Also rerun the isolated UI mutation check for local stats after the final build. + +## Real Checkout Guard + +All profiling worktrees and state used for the valid profiles were created in a +disposable temp fixture. They did not reference real managed worktree paths. + +The original real-checkout guard became invalid because the main checkout +changed concurrently during profiling: + +- main advanced from `b135b4e` to `efbae40`, +- `packages/opencode/package.json` changed, +- existing `bun.lock` and changeset changes remained. + +Those changes were not reverted or modified by this work. Because the baseline +changed concurrently, establish a fresh guard immediately before any remaining +profile and compare it immediately afterward. + +The guard should include: + +- SHA-256 of `.kilo/agent-manager.json`, +- SHA-256 of `.git/info/exclude`, +- SHA-256 of every `.git/worktrees/*/gitdir`, +- main checkout status and HEAD, +- `git worktree list --porcelain`, +- porcelain status of every linked worktree. + +Abort and investigate if the guard changes. Never revert concurrent user or +agent changes. + +## External Follow-ups + +These are rollout or product follow-ups, not untracked implementation work: + +1. Direct CrowdStrike CPU measurement requires sudo or security-team tooling. +2. The 30-second dormant freshness change needs product approval. +3. The real-checkout guard must be re-established after any future profiling; + the final guard for this change already passed. + +## Stop Conditions + +Do not merge if any of these remain true: + +- exact extension-host Git work does not fall materially, +- a clean dormant worktree can exceed 30 seconds stale, +- a dirty, selected, or busy worktree misses the five-second cadence, +- forced refresh reuses stale cached stats, +- repeated edits to an already-dirty file fail to invalidate exact stats, +- memory grows monotonically across repeated polls, +- CrowdStrike remains abnormal and no evidence explains why, +- temporary profiling code or artifacts remain in the diff.