mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
Merge remote-tracking branch 'origin/main' into johnnyeric/kilo-opencode-v1.18.0
# Conflicts: # package.json
This commit is contained in:
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Remember Agent Manager worktree dialog model, variant, mode, and sandbox selections when reopened.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Reduce Agent Manager background Git polling load across large worktree sets.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Prevent built-in skill documentation examples from triggering shell permission prompts.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Prevent extension-managed GitHub CLI commands from opening transient Windows Terminal windows.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Restore keyboard input for interactive terminal prompts when the CLI session uses a workspace.
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Allow subagent tasks to be resumed after their parent session is forked.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Add bounded, context-aware grep controls without leaving agents waiting on completed searches.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Improve JetBrains session transcript layout, icons, reverted-change summaries, and multi-hunk diff rendering.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Improve slash command completion to match separators, camel-case humps, and contained command names.
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Changes from opencode v1.17.13 to v1.18.0 upstream:
|
||||
|
||||
- Core Improvements: Added a code mode MCP adapter for running confined orchestration scripts against connected MCP tools.
|
||||
- Core Improvements: Hid the `execute` tool unless code mode is enabled.
|
||||
- Core Improvements: Add a model-specific system prompt for Meta Muse Spark.
|
||||
- Core Improvements: Updated Azure AI support for GPT-5.6.
|
||||
- Core Bugfixes: Fixed paginated MCP tool catalogs losing tool metadata and output schema validation.
|
||||
- Core Bugfixes: Preserved low reasoning effort for OpenRouter small-model variants instead of disabling it.
|
||||
- Core Bugfixes: Fixed GitHub Copilot model routing to honor each model's advertised chat or responses endpoint.
|
||||
- Core Bugfixes: Fixed session lists to match equivalent instance directories reliably.
|
||||
- Core Bugfixes: Fixed Cerebras reasoning replay so earlier assistant reasoning is sent back in the provider-supported field.
|
||||
- Core Bugfixes: Better classify Z.ai context-window overflow errors so oversized requests surface the right failure mode (@fengjikui)
|
||||
- Core Bugfixes: Handle unavailable config directories more gracefully when reading config files
|
||||
- Core Bugfixes: Exposed reasoning effort variants for Grok models.
|
||||
- Core Bugfixes: Improved xAI prompt cache routing and PDF file support in Responses models.
|
||||
- Core Bugfixes: Improved Meta model handling for reasoning variants and provider requests.
|
||||
- Core Bugfixes: Prevent crashes and bad pricing data when GitHub Copilot returns models with a zero billing batch size.
|
||||
- Core Bugfixes: Supported OpenAI pro reasoning mode.
|
||||
- Core Bugfixes: Disabled response storage by default for xAI Responses. (@geraint0923)
|
||||
- Core Bugfixes: Added OAuth support for Luna Responses Lite.
|
||||
- Core Bugfixes: Switched to another available org after logging out in the console.
|
||||
- Core Bugfixes: Used Codex context limits for GPT-5.6 over OAuth. (@nabilfreeman)
|
||||
- Core Bugfixes: Removed an obsolete Codex workaround that could interfere with OpenAI Luna Responses Lite requests.
|
||||
- TUI Bugfixes: Fixed spinner registration so loading indicators keep rendering across TUI surfaces.
|
||||
- TUI Bugfixes: Forwarded CLI environment variables to the TUI worker.
|
||||
@@ -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.
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Adopt OpenCode v1.18.0 improvements, including code mode, expanded model reasoning controls, MCP reliability updates, and TUI enhancements.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Use a compatible Kilo wordmark in embedded Agent Manager terminals.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Prevent TUI config reload logs from corrupting the interactive terminal.
|
||||
@@ -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 `<button class="am-local-item">` block (lines **301-348**) with this. The badge
|
||||
`<Show>` moves from before `am-local-text` to the end, and stats + badge get wrapped:
|
||||
|
||||
```tsx
|
||||
<button
|
||||
class="am-local-item"
|
||||
classList={{ "am-local-item-active": active() && props.selection === "local" }}
|
||||
data-sidebar-id={`${props.project.id}:local`}
|
||||
onClick={() => props.onSelectLocal(props.project.id)}
|
||||
>
|
||||
<Show when={!props.localBusy?.()} fallback={<Spinner class="am-worktree-spinner" />}>
|
||||
<svg class="am-local-icon" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2.5" y="3.5" width="15" height="10" rx="1" stroke="currentColor" />
|
||||
<path d="M6 16.5H14" stroke="currentColor" stroke-linecap="square" />
|
||||
<path d="M10 13.5V16.5" stroke="currentColor" />
|
||||
</svg>
|
||||
</Show>
|
||||
<div class="am-local-text">
|
||||
<span class="am-local-label">{props.t("agentManager.local")}</span>
|
||||
<Show when={props.local?.branch}>
|
||||
<span class="am-local-branch">{props.local!.branch}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="am-wt-actions-cell">
|
||||
<Show
|
||||
when={
|
||||
props.local &&
|
||||
(props.local.additions || props.local.deletions || props.local.ahead || props.local.behind)
|
||||
}
|
||||
>
|
||||
<div class="am-worktree-stats">
|
||||
<Show when={props.local!.behind}>
|
||||
<span class="am-worktree-behind">↓{props.local!.behind}</span>
|
||||
</Show>
|
||||
<Show when={props.local!.ahead}>
|
||||
<span class="am-worktree-commits">↑{props.local!.ahead}</span>
|
||||
</Show>
|
||||
<Show when={props.local!.additions}>
|
||||
<span class="am-stat-additions">+{props.local!.additions}</span>
|
||||
</Show>
|
||||
<Show when={props.local!.deletions}>
|
||||
<span class="am-stat-deletions">−{props.local!.deletions}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="am-wt-hover-actions">
|
||||
<Show when={props.shortcutMap?.().get(`${props.project.id}:local`)}>
|
||||
{(shortcut) => (
|
||||
<span class="am-shortcut-badge">
|
||||
{isMac ? "⌘" : "Ctrl+"}
|
||||
{shortcut()}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
```
|
||||
|
||||
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** (`<Show when={props.localStats() === undefined}>`):
|
||||
|
||||
```tsx
|
||||
<div class="am-wt-actions-cell">
|
||||
```
|
||||
|
||||
Replace line **182**:
|
||||
|
||||
```tsx
|
||||
<span class="am-shortcut-badge">{isMac ? "⌘" : "Ctrl+"}1</span>
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```tsx
|
||||
<div class="am-wt-hover-actions">
|
||||
<span class="am-shortcut-badge">{isMac ? "⌘" : "Ctrl+"}1</span>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Result: one `am-wt-actions-cell` div containing the skeleton `<Show>`, the stats `<Show>`, and the
|
||||
hover-actions div, closing before `</button>`. 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 `<project> · <worktree or local>`.
|
||||
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.
|
||||
@@ -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 <command> [--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=<absolute 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`.
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 <command> [--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.`)
|
||||
}
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.14",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/opencode --conditions=browser 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",
|
||||
|
||||
@@ -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<readonly [NodeChildProcess.ChildProcess, ExitSignal], PlatformError.PlatformError>((resume) => {
|
||||
const signal = Deferred.makeUnsafe<readonly [code: number | null, signal: NodeJS.Signals | null]>()
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ChildProcess } from "effect/unstable/process"
|
||||
|
||||
const commands = new WeakSet<object>()
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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<Error>()("Ripgrep.Error", {
|
||||
message: Schema.String,
|
||||
@@ -69,7 +71,8 @@ export interface GlobInput {
|
||||
readonly validate?: Effect.Effect<void, unknown> // 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 Entry[], Error>
|
||||
readonly glob: (input: GlobInput) => Effect.Effect<SearchResult<Entry>, Error> // kilocode_change
|
||||
readonly grep: (input: GrepInput) => Effect.Effect<SearchResult<Match>, Error | InvalidPatternError> // kilocode_change
|
||||
readonly grep: (input: GrepInput) => Effect.Effect<SearchResult<KiloGrep.GrepMatch>, 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<A | undefined, Error>
|
||||
readonly pattern?: string
|
||||
readonly onItem?: (item: A) => Effect.Effect<void>
|
||||
readonly stop?: (item: A) => boolean // kilocode_change - stop bounded searches at the overflow match
|
||||
readonly validate?: Effect.Effect<void, unknown> // 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<A>((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<RawMatchData>({
|
||||
...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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
@@ -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:
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "<leader>d",
|
||||
"session_move": "<leader>o",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Permissions
|
||||
|
||||
Kilo Code uses the permission config to decide whether a given action should run automatically, prompt you, or be blocked.
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"}]}'
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" /%}
|
||||
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e9241e89fae82f69f1fab0371423ab45c905b54ff0c60ec1408b54d60f6c4c6c
|
||||
size 19636
|
||||
oid sha256:785cb43d5208977a69f03056bf16e0bbc32b29aeba1d14257848a32d19590bd7
|
||||
size 19702
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a96dd287b6ffdbdc0360baa9af99d09c3b709a5adaef18f5e7b7e43a83e44746
|
||||
size 3134
|
||||
oid sha256:950cf0a3053a283651b2232b4a4e2cfdced3c6feb2f053b0a5976f8d169cfcf1
|
||||
size 3193
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:60daae227133cedd35f93753128453177400f83233268ae19af984bc9a72ad4b
|
||||
size 5203
|
||||
oid sha256:c02aed8f52a7b22d14d2068e1f87b65bb42ed6737115dadf89615ff5a4c870e5
|
||||
size 5214
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4cda12827fa0de0590119ab707f6403fc98291843701ab0d4300e493b4b284aa
|
||||
size 14965
|
||||
oid sha256:faae43fcbe8c42aef80184b4a815bbe6a024e0345243b5b8eb1be04d62b8611f
|
||||
size 15184
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8099695f3cb2f79a54f6ada28eaf30a4e31afc5e660e293853d1ff34a2afaed1
|
||||
size 1605
|
||||
oid sha256:82ee97e40bd03a7c126e154c1644d878e374f3a573d81b22daf602a8e74ed840
|
||||
size 1596
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0bee9b58fb35c2484a9da4bffcae07f98a942c1097df33b549e13de781790824
|
||||
size 8026
|
||||
oid sha256:4d7fa00cb51b6ab591a6f25f6f863d17b78cfcd8166cf09d179ab485e394a084
|
||||
size 8149
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bf6056375e5bf9d9ad78487d58a7fc1fb2d570333d057d412f1d70893a73e5df
|
||||
size 6176
|
||||
oid sha256:1d841a283f99962a7aa3e0ecc1b24aba5a5351534e74ebe3a6794f79e6d9f374
|
||||
size 6204
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1ebd138c8d5e59bc09ce0d8d571d5598622fb61631cf5a698c80380872bef0a4
|
||||
size 7031
|
||||
oid sha256:adb4fa97f6e2dfbf1d5dd7aa58181d548c99cc0ca170f8618af72d3e6f252974
|
||||
size 7008
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b629ad25ef4f3da8bd8a9e59c64cc05279dfc262db2d8d9d25c3697f48ef8393
|
||||
size 12834
|
||||
oid sha256:8caa6d6b2d7e1b0bbab8e47a486e27bcbfd7716ef84ad8771980a658c8cc41ab
|
||||
size 13002
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b78544f9c306dbcb0278502b1f929f0af5389a90b73362abbea6f76612c5def9
|
||||
size 7761
|
||||
oid sha256:089e30f789f0f592e0ee3794dfe736fc503290d05e300830fa720a5b75046894
|
||||
size 7768
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4785426a34c06b0c74fe62c117528bb16ea579b2d677902c839336f6a29a380e
|
||||
size 5926
|
||||
oid sha256:3792bcfd82e37fd2ae9664f0abfbd50a6823be2da55b96640557d3696b490ab9
|
||||
size 6018
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e2b861de27fb8e6d64becd50bb4b3b05d748fba74b98aafaf5cb2a182ff74655
|
||||
size 26259
|
||||
oid sha256:f65d353dac1fd4bf4ddaee9f7171e348b19bb279cb220b7f745651c3d65ad821
|
||||
size 26237
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:afa789c231b370c2ba874029a8a0597b5ca7ffff5624ddd343fcd54139a94012
|
||||
size 6132
|
||||
oid sha256:4a133a9d0b13ba7effcd1dab38a1843526b7aac99a9be5df9647d1ba7fb8d858
|
||||
size 6140
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a0f11ac60e7dd3e393739e85d279bca7e53dc22af01bade22a741e5267d96300
|
||||
size 11053
|
||||
oid sha256:aad33864d63a6d5eb7868daaa7989e3271806ddd385b4847a5a4221d189c4d5c
|
||||
size 11063
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:94227c822d88aa83f286136c05d92c8209b2cb08eaf6870cf79172aa004e8402
|
||||
size 39173
|
||||
oid sha256:d1c6647a2cb4da318b9ec60761c248345c2a6fdc6e95a0dc5e03c31a939ace4c
|
||||
size 38950
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:facae793453671cf34b950352dc19041d420d096fac2a7358d1a4550e05a8033
|
||||
size 3383
|
||||
oid sha256:f2349f8c0d7049811702e5b8cbeb5fc2f402bdda8630d1a5210a970a13284197
|
||||
size 3694
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:fc81d91792781a9784775db3af44ea8058e87df9e029c2d7fc5acf1548fa7ec7
|
||||
size 3423
|
||||
oid sha256:311bb5062867449b9d5e2de41e77b399be1953fa6663097fa730218bed64a30a
|
||||
size 3753
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2923838fbc9ce24851e2b8ed6c5d4c2a098db9667fc528d1c00162595640bc05
|
||||
size 3550
|
||||
oid sha256:52cd6565a96bee5f55872fad4faf1c88030bec70d223187e51942bb70c2da253
|
||||
size 3687
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c087f8e2b50bf43f9ca03ab31e8955e995304473688bf8db88fb6901d743a1d4
|
||||
size 3541
|
||||
oid sha256:f2349f8c0d7049811702e5b8cbeb5fc2f402bdda8630d1a5210a970a13284197
|
||||
size 3694
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2923838fbc9ce24851e2b8ed6c5d4c2a098db9667fc528d1c00162595640bc05
|
||||
size 3550
|
||||
oid sha256:4e29131b2a6e68fc26d032396c872698d1882eacc3830fe4059fca810981915c
|
||||
size 3679
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6c13cc44cec6092d8294a535a3e6057d6e3a2b2d59cb18e09b0a073576972f80
|
||||
size 3582
|
||||
oid sha256:62b2731184acdc022a75338779296b8d217d5abe565d8d763075bfc68939b2e8
|
||||
size 3695
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:79f5495b171fc9de3d04b7e15cc30ac2f5082c6d7db3818b3e288ce9c3ba41f3
|
||||
size 3461
|
||||
oid sha256:b36b2ab3912ec70139285c19a226e02c35cb982a321e3cd140d84a29756d817e
|
||||
size 3590
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:dda50cfb4ebebd29e6f0791ec13ee9e16e48c9fa6f6d6be03c78b1d900516427
|
||||
size 3604
|
||||
oid sha256:1b787fe5310a002e7c5ac2bf8101964c0e4576d16db973e77a5e893b0db1fb66
|
||||
size 3728
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2680e8f97baee13ab92ac6fb3bcce56eb9cbb7e0c5fb0f76a76251abc016eac3
|
||||
size 3567
|
||||
oid sha256:e2bcc9c6e0dbf398194431bdbca27138608304e95c6de5874aeff2ca06fe73e6
|
||||
size 3684
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2680e8f97baee13ab92ac6fb3bcce56eb9cbb7e0c5fb0f76a76251abc016eac3
|
||||
size 3567
|
||||
oid sha256:e2bcc9c6e0dbf398194431bdbca27138608304e95c6de5874aeff2ca06fe73e6
|
||||
size 3684
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:270e0dafc2601996e7d048873e55db4680e8803d6800dc44b1fa6ce6239827cc
|
||||
size 2017
|
||||
oid sha256:769935237790bbf85293a260a4dded22ad0f33dcffb41cbcd0d7695136775bd2
|
||||
size 2215
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5feb854ee2693b655ab504b8e2ed8794af77a357c3d716e2deb9668a08f3aa0c
|
||||
size 1847
|
||||
oid sha256:6905b74776e20e3bdf0ca2ba6a89888e9160f5055d2f1719bc0cafa59f808134
|
||||
size 2056
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1d9935cc07b9eefe0c1eca1852b00ac2b39b76cdf12ee2f01c52e4ac0771b39e
|
||||
size 2910
|
||||
oid sha256:8cb40c61ea8472804f6a067d64cec84a6c492756abf386acded8e2df57868653
|
||||
size 3012
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:54e7142d591391c6e99631c9bf87b011321e52f17a8ee39e5aeeaa9b1350f2f9
|
||||
size 1831
|
||||
oid sha256:3ed27d7e73679fe2ccbe267cf6ca52143882ae72026d01beb8862e6add661531
|
||||
size 2207
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7e4a37b5b7d71e0eb7f043176f410030385a8a61f3913bd6419b1d96c6cad2de
|
||||
size 2422
|
||||
oid sha256:71034449e848b4d6f2fe88624694842cfddd8a1890996f6cbc66499cbefafb33
|
||||
size 2547
|
||||
|
||||
@@ -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|unpin|regen|clean>`.
|
||||
|
||||
**Pin mode** (`kilo.cli.pinned` in `packages/kilo-jetbrains/gradle.properties`) controls release CLI vs local repo CLI.
|
||||
|
||||
| Ask | Do |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+10
-2
@@ -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?) =
|
||||
|
||||
+8
-4
@@ -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).
|
||||
|
||||
+58
-1
@@ -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<DiffFileDto> {
|
||||
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>): 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>): 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
|
||||
// ================================================================
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
+86
@@ -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<String>(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<String>()
|
||||
val beforeBody = ArrayList<String>()
|
||||
|
||||
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 ") }
|
||||
}
|
||||
+3
@@ -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()
|
||||
|
||||
+80
@@ -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 ?: "<missing>"} 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) }
|
||||
|
||||
|
||||
+8
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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)))
|
||||
|
||||
+55
@@ -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", ""))
|
||||
}
|
||||
}
|
||||
+142
@@ -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<KiloBackendAppService>()
|
||||
|
||||
@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<KiloAppState.Ready>(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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -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
|
||||
|
||||
@@ -49,6 +49,9 @@ abstract class GenerateOpenApiSpecTask : DefaultTask() {
|
||||
@get:Internal
|
||||
abstract val cacheDir: DirectoryProperty
|
||||
|
||||
@get:Internal
|
||||
abstract val bunPath: Property<String>
|
||||
|
||||
@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
|
||||
|
||||
+3
-1
@@ -15,8 +15,10 @@ class CoreInfoAction : AnAction(), DumbAware {
|
||||
val app = service<KiloAppService>()
|
||||
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
|
||||
|
||||
+33
@@ -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 {
|
||||
|
||||
+3
@@ -219,6 +219,9 @@ class KiloSessionService internal constructor(
|
||||
suspend fun diff(id: String, dir: String): List<DiffFileDto> =
|
||||
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) }
|
||||
|
||||
|
||||
+15
-2
@@ -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<Int>): DocumentContent = apply {
|
||||
if (lines.isNotEmpty()) putUserData(DiffUserDataKeysEx.LINE_NUMBER_CONVERTOR, IntUnaryOperator { lines.getOrElse(it) { -1 } })
|
||||
}
|
||||
|
||||
+38
-17
@@ -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<Int> = emptyList(),
|
||||
val rightLines: List<Int> = 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<Int>()
|
||||
val rightLines = mutableListOf<Int>()
|
||||
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
|
||||
|
||||
+43
-2
@@ -150,17 +150,58 @@ internal class KiloDiffEditorService(
|
||||
val dir = params["directory"].takeIfPresent() ?: return DiffEditorData.Empty
|
||||
val workspace = service<KiloWorkspaceService>()
|
||||
val store = project.service<KiloInlineDiffStore>()
|
||||
val session = project.service<KiloSessionService>()
|
||||
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<KiloSessionService>().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<String, String>,
|
||||
dir: String,
|
||||
files: List<DiffFileDto>,
|
||||
session: KiloSessionService,
|
||||
): List<DiffFileDto> {
|
||||
// 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:<sessionId>:<turnId>" and single-edit diffs carry "tool:<sessionId>:<messageId>";
|
||||
// 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, String>): 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 {
|
||||
|
||||
+4
-2
@@ -822,15 +822,17 @@ class SessionUi(
|
||||
}
|
||||
|
||||
private fun openInlineDiff(files: List<DiffFileDto>, 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<KiloInlineDiffStore>().put(key, files)
|
||||
project.service<KiloVfsManager>().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"))
|
||||
}
|
||||
|
||||
+22
@@ -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)
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user