Merge upstream/main into codex/add-default-diff-expansion-setting

Resolve conflicts in:
- packages/opencode/src/config/config.ts
- packages/kilo-vscode/webview-ui/src/types/messages/config.ts
- packages/kilo-vscode/webview-ui/src/components/settings/settings-io.ts
- packages/sdk/js/src/v2/gen/types.gen.ts
- packages/sdk/openapi.json
- packages/opencode/test/kilocode/cli/error.test.ts
- packages/opencode/test/kilocode/config/indexing-default-plugin.test.ts

Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com>
This commit is contained in:
Githubguy132010
2026-06-16 05:57:41 +00:00
co-authored by kiloconnect[bot]
642 changed files with 18525 additions and 9061 deletions
-6
View File
@@ -1,6 +0,0 @@
---
"@kilocode/cli": patch
"kilo-code": patch
---
Support model-specific reasoning overrides for task subagents, including custom subagents with their own model and variant settings.
+6
View File
@@ -0,0 +1,6 @@
---
"@kilocode/cli": patch
"kilo-code": patch
---
Show a clear, retryable provider rate-limit error instead of raw response JSON in chat.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Hide TUI news after they have been opened and add a button to close the news dialog.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Accelerate initial snapshots for regular Git sessions while preserving existing changes and asynchronously storing snapshots independently from the source repository.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Fix upgrades to resolve Kilo CLI packages and releases instead of OpenCode packages and versions.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Show current-worktree sessions by default in the TUI sessions dialog and keep all/current scope toggling working when a scope has no sessions.
@@ -0,0 +1,6 @@
---
"kilo-code": patch
"@kilocode/cli": patch
---
Add a shared model setting to hide Kilo Gateway models that may train on your prompts across Kilo clients.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Highlight shell tool commands in JetBrains chat transcripts.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Improve markdown readability in JetBrains chat transcripts.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Hide generated read-tool payload lines from JetBrains prompt bubbles while keeping attachments and assistant tool output visible.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show JetBrains prompt attachments in one horizontal scrolling row in session history.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Support pasting files and images into JetBrains chat prompts as attachments.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show JetBrains prompt attachments inside the prompt bubble with previews and open embedded attachments in editor tabs.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Support sending file and image attachments from the JetBrains chat prompt.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Limit JetBrains prompt input growth to the session while preserving scrolling for long prompts.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Stop Kilo backend processes and clear JetBrains UI resources during restartless plugin unloads.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Support dropping files anywhere in a JetBrains chat session to add them to the prompt.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Polish session header controls and align session view icons.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Open embedded JetBrains message attachments in frontend-managed Kilo editor tabs with loading and connection retry states.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Replace remaining OpenCode-branded CLI and TUI copy with Kilo branding.
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Show Agent Manager and other Git worktrees in the Kilo Console project view.
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Make native Plan mode follow Architect-style planning behavior while preserving Plan mode restrictions and repo-root plan files.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Offer opt-in attention sounds when sessions finish, error, or need input across the sidebar, editor tabs, and Agent Manager.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Restore session timestamp prefixes for generated plan filenames while preserving descriptive model-chosen names.
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Keep expanded Kilo Console file diffs open while resizing the context sidebar.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Keep Kilo Console terminal sessions open when changing diff layout and other console preferences.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Render file attachments as attachment cards in JetBrains prompts and session history.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Render JetBrains shell tool output with markdown code blocks.
-5
View File
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Route question responses to the worktree where the question was created.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Validate GitHub attachments and language server release paths before downloading or executing them.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Open embedded transcript attachments in stable Kilo editor tabs.
@@ -12,7 +12,7 @@ permissions:
jobs:
check-release:
if: vars.OPENCODE_WATCH_ENABLED != 'false'
if: github.repository == 'Kilo-Org/kilocode' && vars.OPENCODE_WATCH_ENABLED != 'false'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
@@ -0,0 +1,67 @@
# JetBrains MdView Editor Theme Colors Plan
## Goal
Keep JetBrains markdown layout compact while making markdown colors and backgrounds come from the active editor color scheme, and ensure existing theme/editor-setting listeners refresh all existing `MdView` instances after changes.
## Findings
- `SessionUi` already subscribes to `EditorColorsManager.TOPIC` and `LafManagerListener.TOPIC`, then calls `applyStyle(SessionEditorStyle.current())` on the session tree.
- Most markdown consumers already propagate that style through `MdView.applyStyle(style)` via `TextView`, `ReasoningView`, message lists, and session panels.
- `PlanExitView.applyStyle()` sets only font/code font/foreground and does not call `md.applyStyle(style)`, so internal markdown role colors can stay tied to the initial style.
- `MdViewHybrid.applyStyle()` restyles retained HTML panes and code block containers, but retained `CodeField` editors need explicit reapplication of `SessionEditorStyle.applyToEditor()` after creation so syntax highlighting and editor colors follow scheme changes.
- `MdCommon.defaults()` currently mixes editor colors, UI theme colors, and one literal inline-code color fallback. The literal fallback should be removed, and markdown roles should derive from `EditorColorsScheme`/syntax attributes wherever possible.
- `TextView` currently overrides markdown background with `SessionUiStyle.Transcript.bgColor()` (`UiStyle.Colors.bg()`), while prompt markdown already uses `style.editorBackground`. If markdown surfaces should consistently use editor background, normal text views need to use `style.editorBackground` too.
## Implementation Plan
1. Keep compact CSS unchanged.
- Do not reintroduce line-height, margin, padding, heading sizing, or other geometry rules.
- Keep the current role color/background selectors only.
2. Derive markdown role defaults from editor settings.
- Update `MdCommon.defaults(style)` to use `style.editorForeground` and `style.editorBackground` for primary text/background.
- Add small local helper functions for editor attributes, for example foreground/background from `style.editorScheme.getAttributes(key)` and color keys from `style.editorScheme.getColor(key)`.
- Use public IntelliJ editor keys for role colors:
- Links: `CodeInsightColors.HYPERLINK_ATTRIBUTES.foregroundColor`, fallback to platform link color if absent.
- Inline code foreground/background: `DefaultLanguageHighlighterColors.DOC_CODE_INLINE`, fallback to `STRING`, then editor foreground/background.
- Code block foreground/background: `DefaultLanguageHighlighterColors.DOC_CODE_BLOCK`, fallback to editor foreground/background.
- Quote/emphasis/list weak text: comment/doc-comment attributes, fallback to editor foreground or `UIUtil.getContextHelpForeground()` only when the scheme has no useful value.
- Borders/HR/table/code border: editor preview/border color keys such as `EditorColors.PREVIEW_BORDER_COLOR`, fallback to `UiStyle.Colors.contentBorder()`.
- Remove `JBColor(0x...)` or other literal runtime color fallbacks from `MdCommon`.
- Keep public `MdView` API unchanged; role colors stay internal unless a concrete external override need appears.
3. Ensure style propagation reaches every markdown instance.
- Update `PlanExitView.applyStyle(style)` to call `md.applyStyle(style)` before applying its explicit font/code-font/foreground overrides.
- Audit existing markdown callers after the change; keep using the existing `SessionEditorStyleTarget` propagation path rather than adding per-`MdView` theme listeners.
- Keep explicit foreground overrides in `TextView`, `ReasoningView`, and `PlanExitView` where they intentionally set body text role, but let internal markdown role colors refresh from the new style snapshot.
4. Ensure retained hybrid code blocks update after editor setting changes.
- In `MdViewHybrid.CodeView.style(opts)`, for retained `CodeField` blocks, reapply `style.applyToEditor(editor)` to the underlying editor if it exists.
- Reapply code editor background/scroll pane/viewport backgrounds from `opts.preBg` in the same path.
- Preserve retained component/editor reuse: do not rebuild code block panes just to update style.
5. Align markdown backgrounds with editor settings.
- Keep `MdCommon` default background as `style.editorBackground`.
- Change normal `TextView` markdown background to `style.editorBackground` if the intent is that all markdown surfaces use editor background, matching `PromptView` and session root behavior.
- Preserve `transparent` handling: when `md.opaque = false`, background should still be the editor-derived value for child/code surfaces, but the Swing component should remain non-opaque.
6. Update tests.
- Extend `MdViewTest` to verify markdown role CSS changes when applying a `SessionEditorStyle` backed by a customized editor scheme, especially inline code, code block, link, and border colors.
- Extend `MdViewHybridTest` to assert `applyStyle()` updates retained HTML panes and retained code editors without replacing them, including editor scheme/background changes.
- Add or extend `PlanExitViewTest` so `applyStyle()` refreshes the nested markdown style, not just foreground/font overrides.
- Keep existing compactness expectations: tests should not assert new spacing, padding, margin, line-height, or size rules.
## Verification
- From `packages/kilo-jetbrains/`, run `./gradlew frontend:test --tests '*MdView*'`.
- From `packages/kilo-jetbrains/`, run `./gradlew frontend:test --tests '*PlanExitViewTest*'` if the focused test is not covered by the MdView filter.
- From `packages/kilo-jetbrains/`, run `bun run typecheck`.
## Constraints
- Do not introduce JCEF, Compose, or Kotlin UI DSL.
- Do not add new theme/editor listeners in `MdView`; use the existing `SessionUi` listener and `SessionEditorStyleTarget` propagation path.
- Avoid hardcoded runtime colors in markdown styling; prefer editor scheme attributes/color keys, then platform/theme APIs as non-literal fallbacks.
- Preserve retained Swing behavior and `MdViewHybrid.sync()` component reuse.
- Keep the public `MdView` override API stable unless implementation proves a new external override is required.
@@ -0,0 +1,69 @@
# JetBrains MdView VS Code Styling Plan
## Goal
Improve JetBrains markdown output so assistant/user transcript markdown visually matches the VS Code webview markdown style while keeping the existing Swing/JBHtmlPane + editor-backed code block architecture.
## Findings
- VS Code markdown styling is split across `packages/ui/src/components/markdown.css`, `packages/kilo-ui/src/components/markdown.css`, `packages/kilo-ui/src/styles/vscode-bridge.css`, and message-part overrides.
- Base VS Code markdown uses 14px sans text, 160% line height, tight first/last margins, same-size medium headings, 12px paragraph spacing, link-colored anchors, compact lists, weak list markers, weak blockquotes with a 2px left border, invisible HR spacing, bordered/padded code blocks, green inline code, and lightly bordered tables.
- The VS Code theme bridge maps markdown roles to editor/theme tokens: heading/link/list/image use `textLinkForeground`, text/strong/code-block use editor foreground, inline code uses charts/syntax green, quote/emphasis use description foreground, HR uses panel border.
- JetBrains markdown is rendered by `MdViewHybrid` and `MdViewHtmlPane`, with shared CSS from `MdCommon.rules()` and defaults from `MdCommon.defaults()`.
- JetBrains currently styles only broad tag font/color, links, code/pre colors, blockquote border/text color, and table border. It lacks VS Code-equivalent spacing, heading/strong/emphasis/list marker/table cell rules, inline-code foreground, blockquote geometry, HR spacing, and code block surface polish.
- JetBrains fenced code blocks are already stronger than VS Code in one respect: they use `EditorTextField` with real IDE syntax highlighting and streaming retention. Preserve this instead of switching to web/JCEF rendering.
## Implementation Plan
1. Expand JetBrains markdown style tokens.
- Add internal fields to `MdStyle` for heading, strong, emphasis, inline code foreground, list marker, HR, table/header, and code block border colors.
- Keep the public `MdView` override API stable unless a new external override is clearly needed.
- Compute defaults in `MdCommon.defaults(style)` from IntelliJ/editor theme sources and centralized Kilo semantic colors where no platform key matches.
- Use `JBColor.namedColor("Kilo.Markdown.*", fallback)` for Kilo-specific markdown palette fallbacks, so themes can override them and runtime code avoids scattered hardcoded colors.
2. Mirror VS Code markdown CSS in `MdCommon.rules()`.
- Add root/body wrapping rules: max width behavior, break-word wrapping, base line-height, and first/last-child margin trimming where supported by `JBHtmlPane` CSS.
- Add heading rules: same base size, medium/bold weight, role-specific color, line height, and bottom spacing.
- Add paragraph, list, list item, nested list, and marker rules. If Swing HTML does not support `::marker`, fall back to `li { color: ... }` plus child text color reset only if supported; otherwise keep list text normal and document the limitation in tests.
- Add strong/emphasis colors matching VS Code token roles.
- Add anchor styling matching VS Code: themed link color, no forced background, and underline behavior where `JBHtmlPane` supports it.
- Add blockquote geometry: 2px left border, 24px vertical margin, 8px left padding, weak text, and normal style.
- Add table layout rules: collapse borders, full width where possible, 24px vertical margin, 12px cell padding, weak row borders, stronger header text.
- Keep HRs visually hidden but spaced consistently with VS Code if the renderer includes them. `MdViewHybrid` currently filters thematic breaks, so this mainly benefits `MdViewHtmlPane` and future reuse.
- Add inline-code foreground and medium font weight. Avoid inline code backgrounds unless the current `JBHtmlPane` configuration already draws them acceptably.
3. Polish JetBrains code block containers without losing IDE highlighting.
- Keep `EditorTextField` for fenced/indented blocks and `JBTextArea` fallback.
- Style `JBScrollPane` code blocks to match VS Codes `markdown-code` wrapper feel: subtle background, subtle border, rounded-ish platform arc if feasible, 12px-ish padding, and thin horizontal scrollbar behavior.
- Use `SessionUiStyle.View.Code` for geometry constants. Add only minimal new constants there if current values cannot represent the VS Code spacing.
- Separate code block border color from table border internally so table styling can change without affecting code boxes.
- Continue applying `SessionEditorStyle.applyToEditor(ed)` so code blocks follow IDE syntax highlighting and editor font changes.
4. Add file/path affordance parity where safe.
- For markdown links whose `href` looks like a relative file path, keep existing link dispatch so the current caller can open files/URLs appropriately.
- Consider decorating inline code that looks like a path with a `file-link` class only when an `openFile` callback is available through the existing usage path. If the current `MdView` abstraction only has `openUrl`, do not widen it unless the call sites can pass file opening cleanly.
- At minimum, make inline code/path-looking content visually closer to VS Code by using the inline-code foreground and dotted underline for explicit file links where generated HTML contains link/code classes.
5. Preserve retained Swing behavior.
- Keep `MdViewHybrid.sync()` prefix reuse logic unchanged unless necessary.
- Ensure style updates call `reloadCssStylesheets()` and reassign text only for retained `JBHtmlPane` blocks, not by rebuilding all blocks.
- Keep streaming fenced-code fast path and editor disposal behavior intact.
6. Add focused tests.
- Extend `MdViewTest` and/or `MdViewHybridTest` to assert `overrideSheet()` contains the new VS Code-equivalent rules for headings, strong/emphasis, links, inline code foreground, list/table/blockquote spacing, HR, and code block/table border separation.
- Add component tests for code block pane styling: background, viewport background, border color, padding, scrollbar policy, and retained editor instance after `applyStyle()`.
- Keep existing stress/leak tests green. Add a small stress assertion only if the implementation changes style application semantics.
- Add a changeset: `@kilocode/kilo-jetbrains` patch with user-facing wording such as `Improve markdown readability in JetBrains chat transcripts.`
## Verification
- Run targeted JetBrains markdown tests first from `packages/kilo-jetbrains/`: `./gradlew frontend:test --tests '*MdView*'` if the Gradle module supports it; otherwise run the closest supported targeted Gradle test command.
- Run `bun run typecheck` from `packages/kilo-jetbrains/`.
- If targeted Gradle filtering is unreliable, run `./gradlew test` from `packages/kilo-jetbrains/`.
## Constraints
- Do not introduce JCEF, Compose, or Kotlin UI DSL.
- Keep changes inside `packages/kilo-jetbrains/` and `.changeset/` unless a shared Kilo UI source of truth is explicitly required.
- No `kilocode_change` markers are needed for JetBrains or Kilo UI paths.
- Prefer IntelliJ theme APIs and centralized semantic tokens over scattered literal colors.
@@ -0,0 +1,63 @@
# JetBrains Session UI Icons And Header Layout Plan
## Goal
Improve JetBrains session UI icon consistency and reduce accidental header interactions:
- Use Kilo/VS Code-aligned session icons in session views.
- Fix the reasoning header icon.
- Normalize session part collapse/expand chevrons so collapsed/expanded states do not jump between differently sized glyphs.
- Move the session-details toggle away from compaction and place it before the session title.
## Findings
- Session view icons are centralized in `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionViewIcons.kt` and loaded from `frontend/src/main/resources/icons/views/*.svg`.
- The JetBrains `views` SVGs already mirror the shared VS Code/UI icon paths from `packages/ui/src/components/icon.tsx` for the audited names, including `brain`, `chevron-down`, `chevron-right`, `checklist`, `console`, `warning`, etc.
- The reasoning view currently renders `SessionViewIcons.eye` in `ReasoningView.kt`; VS Code/shared UI uses the `brain` icon for reasoning/thinking surfaces, and `SessionViewIcons.brain` already exists.
- Standard collapsible session parts use `SessionViewIcons.chevronDown` when expanded and `SessionViewIcons.chevronRight` when collapsed in `AbstractSessionPartView.kt`; `QuestionResultView.kt` repeats this pattern manually. The down/right SVG paths have different visual extents.
- The session header currently places the details toggle next to the compact button in the right-side controls in `SessionHeaderPanel.kt`, making the two actions easy to confuse.
## Implementation Steps
1. **Keep icon sources aligned with VS Code/shared UI**
- Treat `packages/ui/src/components/icon.tsx` as the source for Kilo web/session glyph shapes.
- Re-check `SessionViewIcons.kt` entries against available JetBrains assets; only update or add SVGs if a session view uses a Kilo icon missing from `frontend/src/main/resources/icons/views/`.
- Preserve JetBrains SVG theming rules: no `currentColor`; use literal palette colors and dark variants where assets are added or changed.
2. **Fix reasoning icon**
- In `ReasoningView.kt`, change the reasoning header glyph from `SessionViewIcons.eye` to `SessionViewIcons.brain`.
- Add or update test coverage in `ReasoningViewTest.kt` by inspecting the rendered Swing label tree and asserting the reasoning icon is `SessionViewIcons.brain`.
3. **Normalize collapse/expand chevrons for session parts**
- Stop using the mixed `chevronRight`/`chevronDown` pair for collapsible session content.
- Use a single base Kilo chevron glyph for both states, matching the current custom chevron used by the session header (`/icons/chevron-down.svg` / equivalent `SessionViewIcons.chevronDown`).
- Add a shared rotated icon for the opposite state instead of switching to a differently sized right-facing asset. Prefer a small reusable helper or centralized icon field rather than importing header-specific UI into session views.
- Update `AbstractSessionPartView.kt` and `QuestionResultView.kt` to use the normalized chevron pair.
- Leave `QuestionView.kt` navigation chevrons alone unless auditing shows they are being used for collapse/expand; those are previous/next controls, not expand/collapse controls.
4. **Relocate and change header show/hide details toggle**
- In `SessionHeaderPanel.kt`, replace the custom header details chevron with platform `AllIcons` arrows/chevrons, e.g. collapsed = `AllIcons.General.ArrowRight`, expanded = `AllIcons.General.ArrowDown`.
- Move the details toggle out of the right-side controls and into `BorderLayout.WEST` of the header row.
- Rebuild the top header as:
- outer border layout
- west: details toggle button
- center: inner border layout
- inner center: session title
- inner east: horizontal stack/row with price/context and compact button
- Remove the details toggle from the right-side row so compaction remains visually separate from show/hide details.
- Keep existing tooltip/accessibility strings and expansion persistence behavior unchanged.
5. **Tests**
- Update `SessionHeaderPanelTest.kt` to assert:
- collapsed/expanded header details icons use the selected `AllIcons` constants;
- the details toggle persists expansion state as before;
- the details toggle is parented/laid out separately from the compact button.
- Update `AbstractSessionPartViewTest.kt` to assert collapsible parts keep the same icon dimensions across collapsed/expanded states and no longer use the mismatched right/down pair.
- Update `QuestionResultViewTest.kt` similarly because it has its own chevron implementation.
- Update `ReasoningViewTest.kt` for the brain icon.
6. **Verification**
- Run the smallest relevant JetBrains checks from `packages/kilo-jetbrains/`:
- `./gradlew test --tests "ai.kilocode.client.session.views.ReasoningViewTest" --tests "ai.kilocode.client.session.views.base.AbstractSessionPartViewTest" --tests "ai.kilocode.client.session.views.QuestionResultViewTest" --tests "ai.kilocode.client.session.ui.header.SessionHeaderPanelTest"`
- `./gradlew typecheck`
- If the filtered Gradle test syntax is not accepted by the project, run `./gradlew test` from `packages/kilo-jetbrains/` instead.
## Notes
- No shared upstream `opencode` files are involved; changes stay under `packages/kilo-jetbrains/`.
- A changeset may be needed because this is user-facing JetBrains UI polish; confirm existing changeset policy for the private JetBrains package during implementation.
+1 -1
View File
@@ -1 +1 @@
v1.14.51
v1.15.4
+1 -1
View File
@@ -1,7 +1,7 @@
---
mode: primary
hidden: true
model: openai/gpt-5-nano
model: kilo/openai/gpt-5-nano # kilocode_change
color: "#44BA81"
tools:
"*": false
+56 -35
View File
@@ -33,7 +33,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "7.3.45",
"version": "7.3.46",
"bin": {
"opencode": "./bin/opencode",
},
@@ -96,7 +96,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "7.3.45",
"version": "7.3.46",
"dependencies": {
"@effect/platform-node": "catalog:",
"effect": "catalog:",
@@ -109,7 +109,7 @@
},
"packages/kilo-console": {
"name": "@kilocode/kilo-console",
"version": "7.3.45",
"version": "7.3.46",
"dependencies": {
"@kilocode/kilo-web-ui": "workspace:*",
"@kilocode/sdk": "workspace:*",
@@ -131,7 +131,7 @@
},
"packages/kilo-docs": {
"name": "@kilocode/kilo-docs",
"version": "7.3.45",
"version": "7.3.46",
"dependencies": {
"@docsearch/css": "^4",
"@docsearch/js": "^4",
@@ -161,7 +161,7 @@
},
"packages/kilo-gateway": {
"name": "@kilocode/kilo-gateway",
"version": "7.3.45",
"version": "7.3.46",
"dependencies": {
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/anthropic": "3.0.71",
@@ -198,7 +198,7 @@
},
"packages/kilo-i18n": {
"name": "@kilocode/kilo-i18n",
"version": "7.3.45",
"version": "7.3.46",
"devDependencies": {
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
@@ -208,7 +208,7 @@
},
"packages/kilo-indexing": {
"name": "@kilocode/kilo-indexing",
"version": "7.3.45",
"version": "7.3.46",
"dependencies": {
"@aws-sdk/client-bedrock-runtime": "3.1005.0",
"@aws-sdk/credential-provider-ini": "3.972.31",
@@ -240,11 +240,11 @@
},
"packages/kilo-jetbrains": {
"name": "@kilocode/kilo-jetbrains",
"version": "7.3.45",
"version": "7.3.46",
},
"packages/kilo-telemetry": {
"name": "@kilocode/kilo-telemetry",
"version": "7.3.45",
"version": "7.3.46",
"dependencies": {
"@kilocode/kilo-gateway": "workspace:*",
"posthog-node": "4.4.0",
@@ -258,7 +258,7 @@
},
"packages/kilo-ui": {
"name": "@kilocode/kilo-ui",
"version": "7.3.45",
"version": "7.3.46",
"dependencies": {
"@kilocode/sdk": "workspace:*",
"@kobalte/core": "0.13.11",
@@ -295,13 +295,14 @@
},
"packages/kilo-vscode": {
"name": "kilo-code",
"version": "7.3.45",
"version": "7.3.46",
"dependencies": {
"@anthropic-ai/sdk": "^0.39.0",
"@kilocode/kilo-gateway": "workspace:*",
"@kilocode/kilo-i18n": "workspace:*",
"@kilocode/kilo-indexing": "workspace:*",
"@kilocode/kilo-ui": "workspace:*",
"@kilocode/plugin": "workspace:*",
"@kilocode/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@pierre/diffs": "catalog:",
@@ -361,7 +362,7 @@
},
"packages/kilo-web-ui": {
"name": "@kilocode/kilo-web-ui",
"version": "7.3.45",
"version": "7.3.46",
"dependencies": {
"@kilocode/kilo-ui": "workspace:*",
"@kobalte/core": "catalog:",
@@ -376,7 +377,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "7.3.45",
"version": "7.3.46",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -394,7 +395,7 @@
},
"packages/opencode": {
"name": "@kilocode/cli",
"version": "7.3.45",
"version": "7.3.46",
"bin": {
"kilo": "./bin/kilo",
"kilocode": "./bin/kilo",
@@ -562,7 +563,7 @@
},
"packages/plugin": {
"name": "@kilocode/plugin",
"version": "7.3.45",
"version": "7.3.46",
"dependencies": {
"@kilocode/sdk": "workspace:*",
"effect": "catalog:",
@@ -578,9 +579,9 @@
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.2.10",
"@opentui/keymap": ">=0.2.10",
"@opentui/solid": ">=0.2.10",
"@opentui/core": ">=0.2.11",
"@opentui/keymap": ">=0.2.11",
"@opentui/solid": ">=0.2.11",
},
"optionalPeers": [
"@opentui/core",
@@ -590,7 +591,7 @@
},
"packages/plugin-atomic-chat": {
"name": "@kilocode/plugin-atomic-chat",
"version": "7.3.45",
"version": "7.3.46",
"dependencies": {
"@kilocode/plugin": "workspace:*",
},
@@ -604,7 +605,7 @@
},
"packages/script": {
"name": "@opencode-ai/script",
"version": "7.3.45",
"version": "7.3.46",
"dependencies": {
"semver": "^7.6.3",
},
@@ -615,7 +616,7 @@
},
"packages/sdk/js": {
"name": "@kilocode/sdk",
"version": "7.3.45",
"version": "7.3.46",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -630,7 +631,7 @@
},
"packages/storybook": {
"name": "@opencode-ai/storybook",
"version": "7.3.45",
"version": "7.3.46",
"devDependencies": {
"@opencode-ai/ui": "workspace:*",
"@solidjs/meta": "catalog:",
@@ -653,7 +654,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "7.3.45",
"version": "7.3.46",
"dependencies": {
"@kilocode/sdk": "workspace:*",
"@kobalte/core": "catalog:",
@@ -746,9 +747,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.2.10",
"@opentui/keymap": "0.2.10",
"@opentui/solid": "0.2.10",
"@opentui/core": "0.2.11",
"@opentui/keymap": "0.2.11",
"@opentui/solid": "0.2.11",
"@pierre/diffs": "1.1.22",
"@playwright/test": "1.59.1",
"@solid-primitives/storage": "4.3.3",
@@ -1591,23 +1592,23 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
"@opentui/core": ["@opentui/core@0.2.10", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.10", "@opentui/core-darwin-x64": "0.2.10", "@opentui/core-linux-arm64": "0.2.10", "@opentui/core-linux-x64": "0.2.10", "@opentui/core-win32-arm64": "0.2.10", "@opentui/core-win32-x64": "0.2.10" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-oviCtx0jYjc7F8X2b8+0IkQLg6WH47Nwl6CFeZo5dU0k6OpSbTbi07ZleObaiECAp+S1YLhAtVdgzHU7hBZlaw=="],
"@opentui/core": ["@opentui/core@0.2.11", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.11", "@opentui/core-darwin-x64": "0.2.11", "@opentui/core-linux-arm64": "0.2.11", "@opentui/core-linux-x64": "0.2.11", "@opentui/core-win32-arm64": "0.2.11", "@opentui/core-win32-x64": "0.2.11" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-X0zLmcDEvMrPzWYp769I7VEVb+og38vaete9tGZXu9HnJgu/paPUUplUT+6denBQccr2qx1rBYV6EtgbBpLEyw=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+lbDDj42Og+UtTZEwlHhGXichmOlkxSqn0J+Jqjat5/Tt5oZykj1NZjFIQ7ZSz4Miz7EmZwgYKE2CyOmmm9MoQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-h2MXtE2Cu3XlKVoQMXthnbhleO68zGXkoh/r1Q5pCoZh6RuXqns5/94D/aZThXBWwzPuEoyarMlxxR9OqrpvHw=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-5iAoA0aqMWWAQ93nh8Bb0ipwt9h+tvEFc88+YO9St43uUJ+XrXcmMj3T8wtl6dSu/SN0UoDWNaUMHUmtykiPtg=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-Y0jbPClnOBTPSIy+2THG86MTqIG/jGFlOOKuw4JfCDqEjPBM3pLWIHnJb3WxHRi2LlvfyBxvrUTXWlW6JpI0QQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-EnrkxgH5K76Oi/Br1UHPZblXG5P60snmtySfnxuVaeECNZrbTkV6BV/A0WoBeWshJweGbx1D+eTF+sEEjQCi8w=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-blQyyuTaW4q/OQ3whs7Kt7GCXhBUR5EQHHDdjOqQAr0HYpohUa6sbHMbiBcX2Ehc9ZWwtiaOoWiyZ5YXy2SAvg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.10", "", { "os": "linux", "cpu": "x64" }, "sha512-fI+r3kCPqIxsWwPVGpKUQy4zHK8y+jkDRCwa3UbaUy48RQ44jMuf2RhVhmi4xmCvSc8UPJBbYsw1tLuh9kmXjg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.11", "", { "os": "linux", "cpu": "x64" }, "sha512-0nEB5+MgzQRYiVcQd1vHXPWNPWGh4JEmQTJKyG3OHnTzPaJ1FVSQ/V71ECyRSl3ymY3F+U0eW9cFgw1hCieK2w=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-8F4z2hIRgkVWcr6CMVeJ9N4+1rmURPt2Pq2GBPko8ch6rxHR+a//KD1MfphyuLTHBS1tJ4vfZSWSoiaESImtrA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-+KKH77fzm0qF8py9G2pU32DzB1bAgDMfBajrs7gKL5NtSEnknrwfh7hIs/tq41aF6j9zvIzgtykByh26tcjFog=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.10", "", { "os": "win32", "cpu": "x64" }, "sha512-Ki+qNBlIFW5K2wcG/RHrlPp7yEQKXeiNX3mlje25iwX62Ac5w391HBpOmUjbPoq20McPyDRnhbLfbXQSPtickg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.11", "", { "os": "win32", "cpu": "x64" }, "sha512-dMmb9DX0W0HWadLdgciMbonqIc1xdcKiVmaQSYxw5eGCzFRPZIOrKHByesP+2ipkMuLx85W/MJUFal/lW8XSNg=="],
"@opentui/keymap": ["@opentui/keymap@0.2.10", "", { "dependencies": { "@opentui/core": "0.2.10" }, "peerDependencies": { "@opentui/react": "0.2.10", "@opentui/solid": "0.2.10", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-80fU3Lr/98sNIpVYd8PApAeQw8A8D9BemyOGi6jGvTQCl0rxKgvaVBviDRGKxl1INTVjZy9By8UPncc2KJOuWQ=="],
"@opentui/keymap": ["@opentui/keymap@0.2.11", "", { "dependencies": { "@opentui/core": "0.2.11" }, "peerDependencies": { "@opentui/react": "0.2.11", "@opentui/solid": "0.2.11", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-pCrJrY3mTuXdDaaRneId1JsJCtGE+7prTtWihzOLZzVJTJYyYtT38gMI7MpyAoloVDfEL5cTe8C+v7wv+IYREw=="],
"@opentui/solid": ["@opentui/solid@0.2.10", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.10", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-+4/MB90yIQiPwg8Y4wY092yva9BvRTsJeeeEO3e2H7P8k8zxYk4G9bzuhqYLxA9mTVQ+zVDlrmFoPQhT7vpIRw=="],
"@opentui/solid": ["@opentui/solid@0.2.11", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.11", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-M3WHxBFORHVE0yqMJYpi9PfjXWlnRTw/LYuBhZaJv0HTo+zTs60P/ukGcwnHDWnMpTGf3BH9x0Yi2dIqjHRY6Q=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -5065,6 +5066,10 @@
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"opentui-spinner/@opentui/core": ["@opentui/core@0.2.10", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.10", "@opentui/core-darwin-x64": "0.2.10", "@opentui/core-linux-arm64": "0.2.10", "@opentui/core-linux-x64": "0.2.10", "@opentui/core-win32-arm64": "0.2.10", "@opentui/core-win32-x64": "0.2.10" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-oviCtx0jYjc7F8X2b8+0IkQLg6WH47Nwl6CFeZo5dU0k6OpSbTbi07ZleObaiECAp+S1YLhAtVdgzHU7hBZlaw=="],
"opentui-spinner/@opentui/solid": ["@opentui/solid@0.2.10", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.10", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-+4/MB90yIQiPwg8Y4wY092yva9BvRTsJeeeEO3e2H7P8k8zxYk4G9bzuhqYLxA9mTVQ+zVDlrmFoPQhT7vpIRw=="],
"ora/cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="],
"ora/log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="],
@@ -5589,6 +5594,20 @@
"opencontrol/@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
"opentui-spinner/@opentui/core/@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+lbDDj42Og+UtTZEwlHhGXichmOlkxSqn0J+Jqjat5/Tt5oZykj1NZjFIQ7ZSz4Miz7EmZwgYKE2CyOmmm9MoQ=="],
"opentui-spinner/@opentui/core/@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-5iAoA0aqMWWAQ93nh8Bb0ipwt9h+tvEFc88+YO9St43uUJ+XrXcmMj3T8wtl6dSu/SN0UoDWNaUMHUmtykiPtg=="],
"opentui-spinner/@opentui/core/@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-EnrkxgH5K76Oi/Br1UHPZblXG5P60snmtySfnxuVaeECNZrbTkV6BV/A0WoBeWshJweGbx1D+eTF+sEEjQCi8w=="],
"opentui-spinner/@opentui/core/@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.10", "", { "os": "linux", "cpu": "x64" }, "sha512-fI+r3kCPqIxsWwPVGpKUQy4zHK8y+jkDRCwa3UbaUy48RQ44jMuf2RhVhmi4xmCvSc8UPJBbYsw1tLuh9kmXjg=="],
"opentui-spinner/@opentui/core/@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-8F4z2hIRgkVWcr6CMVeJ9N4+1rmURPt2Pq2GBPko8ch6rxHR+a//KD1MfphyuLTHBS1tJ4vfZSWSoiaESImtrA=="],
"opentui-spinner/@opentui/core/@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.10", "", { "os": "win32", "cpu": "x64" }, "sha512-Ki+qNBlIFW5K2wcG/RHrlPp7yEQKXeiNX3mlje25iwX62Ac5w391HBpOmUjbPoq20McPyDRnhbLfbXQSPtickg=="],
"opentui-spinner/@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"ora/log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="],
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
@@ -5819,6 +5838,8 @@
"mocha/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"opentui-spinner/@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"pkg-conf/find-up/locate-path/p-locate": ["p-locate@6.0.0", "", { "dependencies": { "p-limit": "^4.0.0" } }, "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw=="],
"pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
+5 -2
View File
@@ -76,8 +76,11 @@
};
kilo-dev = pkgs.writeShellScriptBin "kilo-dev" ''
cd "$KILO_ROOT"
exec ${bun}/bin/bun dev "$@"
set -euo pipefail
: "''${KILO_ROOT:?KILO_ROOT is not set. Enter the flake dev shell from the repo root.}"
export KILO_DEV_CWD="$PWD"
exec ${bun}/bin/bun --cwd "$KILO_ROOT/packages/opencode" --conditions=browser ./src/index.ts "$@"
'';
kilo-install-bin = pkgs.writeShellScriptBin "kilo-install" ''
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-Q/F/FbHmJ2To96E8Y/iLL+nILLOk+oB+qpGC3P0T43Q=",
"aarch64-linux": "sha256-wvXhCOmdLwQv8/mshDHPBrqL1slOs+Q1oOCzF1J6ZVs=",
"aarch64-darwin": "sha256-4E77I50PSaI6S+euQOz6SDvasAtJadicXqxlPVBj5nA=",
"x86_64-darwin": "sha256-Ebz3fGdA85Akrbau0PNIr+m+Yu/C0nz7AXNMOOHFhXQ="
"x86_64-linux": "sha256-jvRriUZ4wVKCs5Lek4b+Ln98WJe3QUZ9xzLX7E6XUG4=",
"aarch64-linux": "sha256-tcr6VJXR0H0iCS4LaRPb8tjapNIt8+WVn1jyWWiwUdg=",
"aarch64-darwin": "sha256-Rwg8EbHvxNo1mDr2RqPUDAX7QR//pFcnmgXfDbIx4oY=",
"x86_64-darwin": "sha256-Wsu1lxKb/gzdi7i0iwok9Uejk9QLEn/K3jaqURjsyT0="
}
}
+4 -4
View File
@@ -32,8 +32,8 @@
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.2.10",
"@opentui/solid": "0.2.10",
"@opentui/core": "0.2.11",
"@opentui/solid": "0.2.11",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
@@ -78,7 +78,7 @@
"solid-js": "1.9.12",
"vite-plugin-solid": "2.11.10",
"@lydell/node-pty": "1.2.0-beta.10",
"@opentui/keymap": "0.2.10"
"@opentui/keymap": "0.2.11"
}
},
"devDependencies": {
@@ -153,6 +153,6 @@
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"mammoth@1.12.0": "patches/mammoth@1.12.0.patch"
},
"version": "7.3.45",
"version": "7.3.46",
"peerDependencies": {}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "7.3.45",
"version": "7.3.46",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
+17 -8
View File
@@ -5,7 +5,8 @@ import { produce, type Draft } from "immer"
import { ModelV2 } from "./model"
import { PluginV2 } from "./plugin"
import { ProviderV2 } from "./provider"
import { Instance } from "./instance"
import { Location } from "./location"
import { EventV2 } from "./event"
type ProviderRecord = {
provider: ProviderV2.Info
@@ -24,6 +25,15 @@ export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundErr
modelID: ModelV2.ID,
}) {}
export const Event = {
ModelUpdated: EventV2.define({
type: "catalog.model.updated",
schema: {
model: ModelV2.Info,
},
}),
}
export interface Interface {
readonly provider: {
readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info, ProviderNotFoundError>
@@ -57,10 +67,11 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
yield* Instance.Service
yield* Location.Service
let records = HashMap.empty<ProviderV2.ID, ProviderRecord>()
let defaultModel: { providerID: ProviderV2.ID; modelID: ModelV2.ID } | undefined
const plugin = yield* PluginV2.Service
const events = yield* EventV2.Service
const resolve = (model: ModelV2.Info) => {
const provider = Option.getOrThrow(HashMap.get(records, model.providerID)).provider
@@ -157,14 +168,12 @@ export const layer = Layer.effect(
)
const updated = yield* plugin.trigger("model.update", {}, { model, cancel: false })
if (updated.cancel) return
const next = new ModelV2.Info({ ...updated.model, id: modelID, providerID })
records = HashMap.set(records, providerID, {
provider: record.provider,
models: HashMap.set(
record.models,
modelID,
new ModelV2.Info({ ...updated.model, id: modelID, providerID }),
),
models: HashMap.set(record.models, modelID, next),
})
yield* events.publish(Event.ModelUpdated, { model: resolve(next) })
return
}),
@@ -257,4 +266,4 @@ export const layer = Layer.effect(
const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
export const defaultLayer = layer.pipe(Layer.provide(PluginV2.defaultLayer))
export const defaultLayer = layer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provide(PluginV2.defaultLayer))
+157
View File
@@ -0,0 +1,157 @@
import { Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Location } from "./location"
import { withStatics } from "./schema"
import { Identifier } from "./util/identifier"
export const ID = Schema.String.pipe(
Schema.brand("Event.ID"),
withStatics((schema) => ({ create: () => schema.make("evt_" + Identifier.ascending()) })),
)
export type ID = typeof ID.Type
export type Definition<Type extends string = string, DataSchema extends Schema.Top = Schema.Top> = {
readonly type: Type
readonly version?: number
readonly aggregate?: string
readonly data: DataSchema
}
export type Data<D extends Definition> = Schema.Schema.Type<D["data"]>
export type Payload<D extends Definition = Definition> = {
readonly id: ID
readonly type: D["type"]
readonly data: Data<D>
readonly version?: number
readonly location?: Location.Ref
readonly metadata?: Record<string, unknown>
}
export type Sync = (event: Payload) => Effect.Effect<void>
export const registry = new Map<string, Definition>()
export function define<const Type extends string, Fields extends Schema.Struct.Fields>(input: {
readonly type: Type
readonly version?: number
readonly aggregate?: string
readonly schema: Fields
}): Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> & Definition<Type, Schema.Struct<Fields>> {
const Data = Schema.Struct(input.schema)
const Payload = Schema.Struct({
id: ID,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
type: Schema.Literal(input.type),
version: Schema.optional(Schema.Number),
location: Schema.optional(Location.Ref),
data: Data,
}).annotate({ identifier: input.type })
const definition = Object.assign(Payload, {
type: input.type,
...(input.version === undefined ? {} : { version: input.version }),
...(input.aggregate === undefined ? {} : { aggregate: input.aggregate }),
data: Data,
})
registry.set(input.type, definition)
return definition as Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> &
Definition<Type, Schema.Struct<Fields>>
}
export function definitions() {
return registry.values().toArray()
}
export interface PublishOptions {
readonly id?: ID
readonly metadata?: Record<string, unknown>
}
export type Unsubscribe = Effect.Effect<void>
export interface Interface {
readonly publish: <D extends Definition>(
definition: D,
data: Data<D>,
options?: PublishOptions,
) => Effect.Effect<Payload<D>>
readonly publishEvent: <D extends Definition>(event: Payload<D>) => Effect.Effect<Payload<D>>
readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>>
readonly all: () => Stream.Stream<Payload>
readonly sync: (handler: Sync) => Effect.Effect<Unsubscribe>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const all = yield* PubSub.unbounded<Payload>()
const typed = new Map<string, PubSub.PubSub<Payload>>()
const syncHandlers = new Array<Sync>()
const getOrCreate = (definition: Definition) =>
Effect.gen(function* () {
const existing = typed.get(definition.type)
if (existing) return existing
const pubsub = yield* PubSub.unbounded<Payload>()
typed.set(definition.type, pubsub)
return pubsub
})
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
yield* PubSub.shutdown(all)
yield* Effect.forEach(typed.values(), PubSub.shutdown, { discard: true })
}),
)
function publishEvent<D extends Definition>(event: Payload<D>) {
return Effect.gen(function* () {
for (const sync of syncHandlers) {
yield* sync(event as Payload)
}
const pubsub = typed.get(event.type)
if (pubsub) yield* PubSub.publish(pubsub, event as Payload)
yield* PubSub.publish(all, event as Payload)
return event
})
}
function publish<D extends Definition>(definition: D, data: Data<D>, options?: PublishOptions) {
return Effect.gen(function* () {
const location = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
const event = {
id: options?.id ?? ID.create(),
...(options?.metadata ? { metadata: options.metadata } : {}),
type: definition.type,
...(definition.version === undefined ? {} : { version: definition.version }),
...(location ? { location } : {}),
data,
} as Payload<D>
return yield* publishEvent(event)
})
}
const subscribe = <D extends Definition>(definition: D): Stream.Stream<Payload<D>> =>
Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))).pipe(
Stream.map((event) => event as Payload<D>),
)
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(all)
const sync = (handler: Sync): Effect.Effect<Unsubscribe> =>
Effect.sync(() => {
syncHandlers.push(handler)
return Effect.sync(() => {
const index = syncHandlers.indexOf(handler)
if (index >= 0) syncHandlers.splice(index, 1)
})
})
return Service.of({ publish, publishEvent, subscribe, all: streamAll, sync })
}),
)
export const defaultLayer = layer
export * as EventV2 from "./event"
+1 -1
View File
@@ -53,7 +53,7 @@ export const Flag = {
KILO_DISABLE_CLAUDE_CODE_PROMPT: KILO_DISABLE_CLAUDE_CODE || truthy("KILO_DISABLE_CLAUDE_CODE_PROMPT"),
KILO_DISABLE_CLAUDE_CODE_SKILLS,
KILO_DISABLE_EXTERNAL_SKILLS: truthy("KILO_DISABLE_EXTERNAL_SKILLS"),
KILO_EXPERIMENTAL_CUSTOMIZE_SKILL: unstableDefault("KILO_EXPERIMENTAL_CUSTOMIZE_SKILL"),
KILO_EXPERIMENTAL_CUSTOMIZE_SKILL: unstableDefault("KILO_EXPERIMENTAL_CUSTOMIZE_SKILL"), // kilocode_change
KILO_FAKE_VCS: process.env["KILO_FAKE_VCS"],
KILO_SERVER_PASSWORD: process.env["KILO_SERVER_PASSWORD"],
KILO_SERVER_USERNAME: process.env["KILO_SERVER_USERNAME"],
-12
View File
@@ -1,12 +0,0 @@
import { Layer, LayerMap } from "effect"
import { Instance } from "./instance"
import { Catalog } from "./catalog"
import { PluginBoot } from "./plugin/boot"
export class InstanceServiceMap extends LayerMap.Service<InstanceServiceMap>()("@opencode/example/InstanceServiceMap", {
lookup: (ref: Instance.Ref) => {
const instance = Layer.succeed(Instance.Service, Instance.Service.of(ref))
return Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe(Layer.provide(instance))
},
idleTimeToLive: "5 minutes",
}) {}
-10
View File
@@ -1,10 +0,0 @@
import { Context } from "effect"
export * as Instance from "./instance"
export type Ref = {
readonly directory: string
readonly workspaceID?: string
}
export class Service extends Context.Service<Service, Ref>()("@opencode/Instance") {}
+12
View File
@@ -0,0 +1,12 @@
import { Layer, LayerMap } from "effect"
import { Location } from "./location"
import { Catalog } from "./catalog"
import { PluginBoot } from "./plugin/boot"
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
lookup: (ref: Location.Ref) => {
const location = Layer.succeed(Location.Service, Location.Service.of(ref))
return Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe(Layer.provide(location))
},
idleTimeToLive: "5 minutes",
}) {}
+11
View File
@@ -0,0 +1,11 @@
import { Context, Schema } from "effect"
export * as Location from "./location"
export const Ref = Schema.Struct({
directory: Schema.String,
workspaceID: Schema.optional(Schema.String),
}).annotate({ identifier: "Location.Ref" })
export type Ref = typeof Ref.Type
export class Service extends Context.Service<Service, Ref>()("@opencode/Location") {}
@@ -1,12 +1,13 @@
import { SessionID } from "@/session/schema"
import { NonNegativeInt } from "@opencode-ai/core/schema"
import { EventV2 } from "./event"
import { FileAttachment, Prompt } from "@opencode-ai/core/session-prompt"
import { Schema } from "effect"
import { EventV2 } from "./event"
import { ModelV2 } from "./model"
import { NonNegativeInt } from "./schema"
import { Session } from "./session"
import { FileAttachment, Prompt } from "./session-prompt"
import { ToolOutput } from "./tool-output"
import { V2Schema } from "./v2-schema"
export { FileAttachment }
import { ToolOutput } from "@opencode-ai/core/tool-output"
import { V2Schema } from "@opencode-ai/core/v2-schema"
import { ModelV2 } from "@opencode-ai/core/model"
export const Source = Schema.Struct({
start: NonNegativeInt,
@@ -15,92 +16,94 @@ export const Source = Schema.Struct({
}).annotate({
identifier: "session.next.event.source",
})
export type Source = Schema.Schema.Type<typeof Source>
export type Source = typeof Source.Type
const Base = {
timestamp: V2Schema.DateTimeUtcFromMillis,
sessionID: SessionID,
sessionID: Session.ID,
}
const options = {
aggregate: "sessionID",
version: 1,
} as const
export const UnknownError = Schema.Struct({
type: Schema.Literal("unknown"),
message: Schema.String,
}).annotate({
identifier: "Session.Error.Unknown",
})
export type UnknownError = Schema.Schema.Type<typeof UnknownError>
export type UnknownError = typeof UnknownError.Type
export const AgentSwitched = EventV2.define({
type: "session.next.agent.switched",
aggregate: "sessionID",
version: 1,
...options,
schema: {
...Base,
agent: Schema.String,
},
})
export type AgentSwitched = Schema.Schema.Type<typeof AgentSwitched>
export type AgentSwitched = typeof AgentSwitched.Type
export const ModelSwitched = EventV2.define({
type: "session.next.model.switched",
aggregate: "sessionID",
version: 1,
...options,
schema: {
...Base,
model: ModelV2.Ref,
},
})
export type ModelSwitched = Schema.Schema.Type<typeof ModelSwitched>
export type ModelSwitched = typeof ModelSwitched.Type
export const Prompted = EventV2.define({
type: "session.next.prompted",
aggregate: "sessionID",
version: 1,
...options,
schema: {
...Base,
prompt: Prompt,
},
})
export type Prompted = Schema.Schema.Type<typeof Prompted>
export type Prompted = typeof Prompted.Type
export const Synthetic = EventV2.define({
type: "session.next.synthetic",
aggregate: "sessionID",
...options,
schema: {
...Base,
text: Schema.String,
},
})
export type Synthetic = Schema.Schema.Type<typeof Synthetic>
export type Synthetic = typeof Synthetic.Type
export namespace Shell {
export const Started = EventV2.define({
type: "session.next.shell.started",
aggregate: "sessionID",
...options,
schema: {
...Base,
callID: Schema.String,
command: Schema.String,
},
})
export type Started = Schema.Schema.Type<typeof Started>
export type Started = typeof Started.Type
export const Ended = EventV2.define({
type: "session.next.shell.ended",
aggregate: "sessionID",
...options,
schema: {
...Base,
callID: Schema.String,
output: Schema.String,
},
})
export type Ended = Schema.Schema.Type<typeof Ended>
export type Ended = typeof Ended.Type
}
export namespace Step {
export const Started = EventV2.define({
type: "session.next.step.started",
aggregate: "sessionID",
...options,
schema: {
...Base,
agent: Schema.String,
@@ -108,11 +111,11 @@ export namespace Step {
snapshot: Schema.String.pipe(Schema.optional),
},
})
export type Started = Schema.Schema.Type<typeof Started>
export type Started = typeof Started.Type
export const Ended = EventV2.define({
type: "session.next.step.ended",
aggregate: "sessionID",
...options,
schema: {
...Base,
finish: Schema.String,
@@ -129,123 +132,123 @@ export namespace Step {
snapshot: Schema.String.pipe(Schema.optional),
},
})
export type Ended = Schema.Schema.Type<typeof Ended>
export type Ended = typeof Ended.Type
export const Failed = EventV2.define({
type: "session.next.step.failed",
aggregate: "sessionID",
...options,
schema: {
...Base,
error: UnknownError,
},
})
export type Failed = Schema.Schema.Type<typeof Failed>
export type Failed = typeof Failed.Type
}
export namespace Text {
export const Started = EventV2.define({
type: "session.next.text.started",
aggregate: "sessionID",
...options,
schema: {
...Base,
},
})
export type Started = Schema.Schema.Type<typeof Started>
export type Started = typeof Started.Type
export const Delta = EventV2.define({
type: "session.next.text.delta",
aggregate: "sessionID",
...options,
schema: {
...Base,
delta: Schema.String,
},
})
export type Delta = Schema.Schema.Type<typeof Delta>
export type Delta = typeof Delta.Type
export const Ended = EventV2.define({
type: "session.next.text.ended",
aggregate: "sessionID",
...options,
schema: {
...Base,
text: Schema.String,
},
})
export type Ended = Schema.Schema.Type<typeof Ended>
export type Ended = typeof Ended.Type
}
export namespace Reasoning {
export const Started = EventV2.define({
type: "session.next.reasoning.started",
aggregate: "sessionID",
...options,
schema: {
...Base,
reasoningID: Schema.String,
},
})
export type Started = Schema.Schema.Type<typeof Started>
export type Started = typeof Started.Type
export const Delta = EventV2.define({
type: "session.next.reasoning.delta",
aggregate: "sessionID",
...options,
schema: {
...Base,
reasoningID: Schema.String,
delta: Schema.String,
},
})
export type Delta = Schema.Schema.Type<typeof Delta>
export type Delta = typeof Delta.Type
export const Ended = EventV2.define({
type: "session.next.reasoning.ended",
aggregate: "sessionID",
...options,
schema: {
...Base,
reasoningID: Schema.String,
text: Schema.String,
},
})
export type Ended = Schema.Schema.Type<typeof Ended>
export type Ended = typeof Ended.Type
}
export namespace Tool {
export namespace Input {
export const Started = EventV2.define({
type: "session.next.tool.input.started",
aggregate: "sessionID",
...options,
schema: {
...Base,
callID: Schema.String,
name: Schema.String,
},
})
export type Started = Schema.Schema.Type<typeof Started>
export type Started = typeof Started.Type
export const Delta = EventV2.define({
type: "session.next.tool.input.delta",
aggregate: "sessionID",
...options,
schema: {
...Base,
callID: Schema.String,
delta: Schema.String,
},
})
export type Delta = Schema.Schema.Type<typeof Delta>
export type Delta = typeof Delta.Type
export const Ended = EventV2.define({
type: "session.next.tool.input.ended",
aggregate: "sessionID",
...options,
schema: {
...Base,
callID: Schema.String,
text: Schema.String,
},
})
export type Ended = Schema.Schema.Type<typeof Ended>
export type Ended = typeof Ended.Type
}
export const Called = EventV2.define({
type: "session.next.tool.called",
aggregate: "sessionID",
...options,
schema: {
...Base,
callID: Schema.String,
@@ -257,11 +260,11 @@ export namespace Tool {
}),
},
})
export type Called = Schema.Schema.Type<typeof Called>
export type Called = typeof Called.Type
export const Progress = EventV2.define({
type: "session.next.tool.progress",
aggregate: "sessionID",
...options,
schema: {
...Base,
callID: Schema.String,
@@ -269,11 +272,11 @@ export namespace Tool {
content: Schema.Array(ToolOutput.Content),
},
})
export type Progress = Schema.Schema.Type<typeof Progress>
export type Progress = typeof Progress.Type
export const Success = EventV2.define({
type: "session.next.tool.success",
aggregate: "sessionID",
...options,
schema: {
...Base,
callID: Schema.String,
@@ -285,11 +288,11 @@ export namespace Tool {
}),
},
})
export type Success = Schema.Schema.Type<typeof Success>
export type Success = typeof Success.Type
export const Failed = EventV2.define({
type: "session.next.tool.failed",
aggregate: "sessionID",
...options,
schema: {
...Base,
callID: Schema.String,
@@ -300,7 +303,7 @@ export namespace Tool {
}),
},
})
export type Failed = Schema.Schema.Type<typeof Failed>
export type Failed = typeof Failed.Type
}
export const RetryError = Schema.Struct({
@@ -313,49 +316,50 @@ export const RetryError = Schema.Struct({
}).annotate({
identifier: "session.next.retry_error",
})
export type RetryError = Schema.Schema.Type<typeof RetryError>
export type RetryError = typeof RetryError.Type
export const Retried = EventV2.define({
type: "session.next.retried",
aggregate: "sessionID",
...options,
schema: {
...Base,
attempt: Schema.Finite,
error: RetryError,
},
})
export type Retried = Schema.Schema.Type<typeof Retried>
export type Retried = typeof Retried.Type
export namespace Compaction {
export const Started = EventV2.define({
type: "session.next.compaction.started",
aggregate: "sessionID",
...options,
schema: {
...Base,
reason: Schema.Union([Schema.Literal("auto"), Schema.Literal("manual")]),
},
})
export type Started = Schema.Schema.Type<typeof Started>
export type Started = typeof Started.Type
export const Delta = EventV2.define({
type: "session.next.compaction.delta",
aggregate: "sessionID",
...options,
schema: {
...Base,
text: Schema.String,
},
})
export type Delta = typeof Delta.Type
export const Ended = EventV2.define({
type: "session.next.compaction.ended",
aggregate: "sessionID",
...options,
schema: {
...Base,
text: Schema.String,
include: Schema.String.pipe(Schema.optional),
},
})
export type Ended = Schema.Schema.Type<typeof Ended>
export type Ended = typeof Ended.Type
}
export const All = Schema.Union(
@@ -392,16 +396,7 @@ export const All = Schema.Union(
},
).pipe(Schema.toTaggedUnion("type"))
// user
// assistant
// assistant
// assistant
// user
// compaction marker
// -> text
// assistant
export type Event = Schema.Schema.Type<typeof All>
export type Event = typeof All.Type
export type Type = Event["type"]
export * as SessionEvent from "./session-event"
@@ -1,10 +1,10 @@
import { Schema } from "effect"
import { Prompt } from "@opencode-ai/core/session-prompt"
import { Prompt } from "./session-prompt"
import { SessionEvent } from "./session-event"
import { EventV2 } from "./event"
import { ToolOutput } from "@opencode-ai/core/tool-output"
import { V2Schema } from "@opencode-ai/core/v2-schema"
import { ModelV2 } from "@opencode-ai/core/model"
import { ToolOutput } from "./tool-output"
import { V2Schema } from "./v2-schema"
import { ModelV2 } from "./model"
export const ID = EventV2.ID
export type ID = Schema.Schema.Type<typeof ID>
@@ -20,7 +20,7 @@ const Base = {
export class AgentSwitched extends Schema.Class<AgentSwitched>("Session.Message.AgentSwitched")({
...Base,
type: Schema.Literal("agent-switched"),
agent: SessionEvent.AgentSwitched.fields.data.fields.agent,
agent: SessionEvent.AgentSwitched.data.fields.agent,
}) {}
export class ModelSwitched extends Schema.Class<ModelSwitched>("Session.Message.ModelSwitched")({
@@ -43,16 +43,16 @@ export class User extends Schema.Class<User>("Session.Message.User")({
export class Synthetic extends Schema.Class<Synthetic>("Session.Message.Synthetic")({
...Base,
sessionID: SessionEvent.Synthetic.fields.data.fields.sessionID,
text: SessionEvent.Synthetic.fields.data.fields.text,
sessionID: SessionEvent.Synthetic.data.fields.sessionID,
text: SessionEvent.Synthetic.data.fields.text,
type: Schema.Literal("synthetic"),
}) {}
export class Shell extends Schema.Class<Shell>("Session.Message.Shell")({
...Base,
type: Schema.Literal("shell"),
callID: SessionEvent.Shell.Started.fields.data.fields.callID,
command: SessionEvent.Shell.Started.fields.data.fields.command,
callID: SessionEvent.Shell.Started.data.fields.callID,
command: SessionEvent.Shell.Started.data.fields.command,
output: Schema.String,
time: Schema.Struct({
created: V2Schema.DateTimeUtcFromMillis,
@@ -130,7 +130,7 @@ export class Assistant extends Schema.Class<Assistant>("Session.Message.Assistan
...Base,
type: Schema.Literal("assistant"),
agent: Schema.String,
model: SessionEvent.Step.Started.fields.data.fields.model,
model: SessionEvent.Step.Started.data.fields.model,
content: AssistantContent.pipe(Schema.Array),
snapshot: Schema.Struct({
start: Schema.String.pipe(Schema.optional),
@@ -147,7 +147,7 @@ export class Assistant extends Schema.Class<Assistant>("Session.Message.Assistan
write: Schema.Finite,
}),
}).pipe(Schema.optional),
error: SessionEvent.Step.Failed.fields.data.fields.error.pipe(Schema.optional),
error: SessionEvent.Step.Failed.data.fields.error.pipe(Schema.optional),
time: Schema.Struct({
created: V2Schema.DateTimeUtcFromMillis,
completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional),
@@ -156,7 +156,7 @@ export class Assistant extends Schema.Class<Assistant>("Session.Message.Assistan
export class Compaction extends Schema.Class<Compaction>("Session.Message.Compaction")({
type: Schema.Literal("compaction"),
reason: SessionEvent.Compaction.Started.fields.data.fields.reason,
reason: SessionEvent.Compaction.Started.data.fields.reason,
summary: Schema.String,
include: Schema.String.pipe(Schema.optional),
...Base,
+13
View File
@@ -0,0 +1,13 @@
export * as Session from "./session"
import { Schema } from "effect"
import { withStatics } from "./schema"
import { Identifier } from "./util/identifier"
export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe(
Schema.brand("SessionID"),
withStatics((schema) => ({
descending: (id?: string) => schema.make(id ?? "ses_" + Identifier.descending()),
})),
)
export type ID = typeof ID.Type
+36 -4
View File
@@ -1,14 +1,21 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Layer, Option } from "effect"
import { DateTime, Effect, Fiber, Layer, Option, Stream } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Instance } from "@opencode-ai/core/instance"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "./lib/effect"
const instanceLayer = Layer.succeed(Instance.Service, Instance.Service.of({ directory: "test" }))
const it = testEffect(Catalog.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer), Layer.provide(instanceLayer)))
const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))
const it = testEffect(
Catalog.layer.pipe(
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(PluginV2.defaultLayer),
Layer.provideMerge(locationLayer),
),
)
describe("CatalogV2", () => {
it.effect("normalizes provider baseURL into endpoint url", () =>
@@ -69,6 +76,31 @@ describe("CatalogV2", () => {
}),
)
it.effect("publishes model updated events", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const events = yield* EventV2.Service
const providerID = ProviderV2.ID.make("test")
const modelID = ModelV2.ID.make("model")
const fiber = yield* events
.subscribe(Catalog.Event.ModelUpdated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* catalog.provider.update(providerID, () => {})
yield* catalog.model.update(providerID, modelID, (model) => {
model.name = "Updated Model"
})
const event = Array.from(yield* Fiber.join(fiber))[0]
expect(event?.type).toBe("catalog.model.updated")
expect(event?.data.model.providerID).toBe(providerID)
expect(event?.data.model.id).toBe(modelID)
expect(event?.data.model.name).toBe("Updated Model")
expect(event?.location).toEqual({ directory: "test" })
}),
)
it.effect("resolves unknown model endpoint from provider endpoint", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
+132
View File
@@ -0,0 +1,132 @@
import { describe, expect } from "bun:test"
import { Effect, Fiber, Layer, Schema, Stream } from "effect"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { testEffect } from "./lib/effect"
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of({ directory: "project", workspaceID: "workspace" }),
)
const it = testEffect(EventV2.layer.pipe(Layer.provideMerge(locationLayer)))
const itWithoutLocation = testEffect(EventV2.layer)
const Message = EventV2.define({
type: "test.message",
schema: {
text: Schema.String,
},
})
const GlobalMessage = EventV2.define({
type: "test.global",
schema: {
text: Schema.String,
},
})
const VersionedMessage = EventV2.define({
type: "test.versioned",
version: 2,
schema: {
text: Schema.String,
},
})
describe("EventV2", () => {
it.effect("publishes events with the current location", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const fiber = yield* events.subscribe(Message).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
const event = yield* events.publish(Message, { text: "hello" })
const received = Array.from(yield* Fiber.join(fiber))
expect(received).toEqual([event])
expect(event.type).toBe("test.message")
expect(event).not.toHaveProperty("version")
expect(event.data).toEqual({ text: "hello" })
expect(event.location).toEqual({ directory: "project", workspaceID: "workspace" })
}),
)
itWithoutLocation.effect("omits location when no location is available", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const event = yield* events.publish(GlobalMessage, { text: "hello" })
expect(event).not.toHaveProperty("location")
expect(event.type).toBe("test.global")
}),
)
it.effect("publishes definition version", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const event = yield* events.publish(VersionedMessage, { text: "hello" })
expect(event.type).toBe("test.versioned")
expect(event.version).toBe(2)
}),
)
it.effect("stores definitions in the exported registry", () =>
Effect.sync(() => {
expect(EventV2.registry.get(Message.type)).toBe(Message)
}),
)
it.effect("publishes to typed and wildcard subscriptions", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const typed = yield* events.subscribe(Message).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
const wildcard = yield* events.all().pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
const event = yield* events.publish(Message, { text: "hello" })
expect(Array.from(yield* Fiber.join(typed))).toEqual([event])
expect(Array.from(yield* Fiber.join(wildcard))).toEqual([event])
}),
)
it.effect("runs sync handlers inline", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const received = new Array<EventV2.Payload>()
const unsubscribe = yield* events.sync((event) =>
Effect.sync(() => {
received.push(event)
}),
)
const event = yield* events.publish(Message, { text: "hello" })
yield* unsubscribe
yield* events.publish(Message, { text: "after unsubscribe" })
expect(received).toEqual([event])
}),
)
it.effect("runs sync handlers before publishing to streams", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const received = new Array<string>()
const fiber = yield* events.all().pipe(
Stream.take(1),
Stream.runForEach(() => Effect.sync(() => received.push("stream"))),
Effect.forkScoped,
)
yield* events.sync((event) =>
Effect.sync(() => {
received.push(event.type)
}),
)
yield* Effect.yieldNow
yield* events.publish(Message, { text: "hello" })
yield* Fiber.join(fiber)
expect(received).toEqual([Message.type, "stream"])
}),
)
})
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Layer, Option } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Instance } from "@opencode-ai/core/instance"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
@@ -9,7 +9,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
import { it, model, provider, withEnv } from "./provider-helper"
const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }]
const instanceLayer = Layer.succeed(Instance.Service, Instance.Service.of({ directory: "test" }))
const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))
describe("OpencodePlugin", () => {
it.effect("uses a public key and cancels paid models without credentials", () =>
@@ -192,6 +192,6 @@ describe("OpencodePlugin", () => {
const selected = yield* catalog.model.small(providerID)
expect(Option.getOrUndefined(selected)?.id).toBe(ModelV2.ID.make("gpt-5-nano"))
}).pipe(Effect.provide(Catalog.defaultLayer.pipe(Layer.provide(instanceLayer)))),
}).pipe(Effect.provide(Catalog.defaultLayer.pipe(Layer.provide(locationLayer)))),
)
})
+6 -6
View File
@@ -1,7 +1,7 @@
id = "kilo"
name = "Kilo"
description = "The open source coding agent."
version = "7.3.45"
version = "7.3.46"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/Kilo-Org/kilocode"
@@ -11,26 +11,26 @@ name = "Kilo"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.45/opencode-darwin-arm64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.46/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.45/opencode-darwin-x64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.46/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.45/opencode-linux-arm64.tar.gz"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.46/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.45/opencode-linux-x64.tar.gz"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.46/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.45/opencode-windows-x64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.46/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "7.3.45",
"version": "7.3.46",
"name": "@opencode-ai/http-recorder",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kilocode/kilo-console",
"version": "7.3.45",
"version": "7.3.46",
"private": true,
"type": "module",
"scripts": {
+3 -2
View File
@@ -29,6 +29,7 @@ import type {
VcsInfo,
Worktree,
WorktreeDiffItem,
WorktreeListResponse,
} from "@kilocode/sdk/v2/client"
export type Scope = "global" | "project"
@@ -54,7 +55,7 @@ export type ProjectConsoleSnapshot = {
project: ProjectItem
config: EffectiveConfig
vcs: VcsInfo
worktrees: string[]
worktrees: WorktreeListResponse
terminals: ProjectTerminalItem[]
}
@@ -447,7 +448,7 @@ export async function loadProjectConsole(input: ProjectConsoleQuery): Promise<Pr
])
const dirs = demand("Worktrees", worktrees)
const terminals = await Promise.all(
[query.dir, ...dirs].map((dir) => loadProjectTerminals({ url: input.url, dir }, dir)),
[query.dir, ...dirs.map((item) => item.directory)].map((dir) => loadProjectTerminals({ url: input.url, dir }, dir)),
)
return {
@@ -58,6 +58,7 @@ type Context = {
dir: string
label: string
kind: "local" | "worktree"
managed: boolean
}
type Editor = { kind: "create"; value: string } | { kind: "rename"; item: Context; value: string }
@@ -135,6 +136,7 @@ function refreshEvent(event: ProjectConsoleEvent) {
if (type.startsWith("permission.")) return true
if (type.startsWith("question.")) return true
if (type.startsWith("message.")) return true
if (type === "global.config.updated") return true
return false
}
@@ -179,8 +181,14 @@ export function ProjectConsoleRoute() {
const data = snap()
if (!data) return []
return [
{ id: "local", dir: data.project.worktree, label: "Local", kind: "local" },
...data.worktrees.map((dir) => ({ id: dir, dir, label: title(dir), kind: "worktree" as const })),
{ id: "local", dir: data.project.worktree, label: "Local", kind: "local", managed: false },
...data.worktrees.map((item) => ({
id: item.directory,
dir: item.directory,
label: title(item.directory),
kind: "worktree" as const,
managed: item.managed,
})),
]
})
const terminals = createMemo(() => {
@@ -499,8 +507,12 @@ export function ProjectConsoleRoute() {
setEditor({ kind: "rename", item, value: displayLabel(item) })
}
function canManage(item: Context | undefined) {
return item?.kind === "worktree" && item.managed
}
function removeWorktree(item: Context) {
if (!projectInput() || item.kind === "local") return
if (!projectInput() || !canManage(item)) return
setPending({ kind: "delete", item })
}
@@ -512,7 +524,7 @@ export function ProjectConsoleRoute() {
function resetSelected() {
const item = current()
if (!projectInput() || !item || item.kind === "local") return
if (!projectInput() || !canManage(item)) return
setPending({ kind: "reset", item })
}
@@ -653,11 +665,14 @@ export function ProjectConsoleRoute() {
const base = query()
const data = snap()
if (!base || !data) return
const dirs = new Set([data.project.worktree, ...data.worktrees])
const dirs = new Set([data.project.worktree, ...data.worktrees.map((item) => item.directory)])
const stop = subscribeProjectEvents({ url: base.url, dir: data.project.worktree }, (event) => {
if (event.directory !== "global" && !dirs.has(event.directory)) return
const id = eventSession(event)
if (id && messageEvent(event)) markUnread(id)
// Terminal fitting emits pty.updated for every width change. Ignore those refreshes while
// dragging so the controlled review accordion keeps its expanded files mounted.
if (resize.pending && eventType(event) === "pty.updated") return
if (refreshEvent(event)) scheduleRefetch()
})
onCleanup(stop)
@@ -765,19 +780,21 @@ export function ProjectConsoleRoute() {
>
<Icon name="edit" size="small" />
</button>
<button
type="button"
class="project-inline-action danger"
onClick={(event) => {
event.stopPropagation()
removeWorktree(item)
}}
disabled={!!saving()}
title={`Delete ${displayLabel(item)}`}
aria-label={`Delete ${displayLabel(item)}`}
>
<Icon name="trash" size="small" />
</button>
<Show when={item.managed}>
<button
type="button"
class="project-inline-action danger"
onClick={(event) => {
event.stopPropagation()
removeWorktree(item)
}}
disabled={!!saving()}
title={`Delete ${displayLabel(item)}`}
aria-label={`Delete ${displayLabel(item)}`}
>
<Icon name="trash" size="small" />
</button>
</Show>
</Show>
</div>
</div>
@@ -913,7 +930,7 @@ export function ProjectConsoleRoute() {
<code class="project-info-path" title={current()?.dir}>
{current()?.dir ?? snap()?.project.worktree ?? project()}
</code>
<Show when={current()?.kind === "worktree"}>
<Show when={canManage(current())}>
<div class="project-info-actions">
<Button variant="secondary" size="small" onClick={resetSelected} disabled={!!saving()}>
Reset
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kilocode/kilo-docs",
"version": "7.3.45",
"version": "7.3.46",
"private": true,
"scripts": {
"dev": "next dev --webpack --port 3002",
@@ -650,7 +650,7 @@ Positionals:
Options:
--help Show help [boolean]
--version Show version number [boolean]
-m, --method installation method to use [string] [choices: "curl", "npm", "pnpm", "bun", "brew", "choco", "scoop"]
-m, --method installation method to use [string] [choices: "curl", "npm", "yarn", "pnpm", "bun", "brew", "choco", "scoop"]
```
## kilo uninstall
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bbfed6606c5e1ccd6a744bdadf34ee597007d5f82c436c8c53e189560e0837d4
size 30244
oid sha256:bae54b771e2d7da8c8b1dcab42d9d51662ec888ebb618b24af11e597c79df7b7
size 37836
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bbfed6606c5e1ccd6a744bdadf34ee597007d5f82c436c8c53e189560e0837d4
size 30244
oid sha256:bae54b771e2d7da8c8b1dcab42d9d51662ec888ebb618b24af11e597c79df7b7
size 37836
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6bcfc6c12f69c5131dcc8f294f480e26ff53f87e0c5943bf525e69c4c894ad21
size 43744
oid sha256:57fa5c60dd92a2f4299e58ead8a682c4802de415a496c247af2eba2faf0d63e4
size 50341
+12 -14
View File
@@ -27,6 +27,8 @@
<!-- packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts -->
- <https://app.kilo.ai/profile>
<!-- packages/kilo-vscode/webview-ui/src/components/profile/ProfileView.tsx -->
- <https://app.kilo.ai/tui.json>
<!-- packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts -->
- <https://app.kilo.ai/usage>
<!-- packages/opencode/src/kilocode/components/dialog-kilo-profile.tsx -->
- <https://auth.x.ai>
@@ -116,15 +118,25 @@
- <https://kilo.ai/docs>
<!-- packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts -->
<!-- packages/opencode/src/kilocode/cli/cmd/tui/app.tsx -->
- <https://kilo.ai/docs/ai-providers/cloudflare>
<!-- packages/opencode/src/cli/cmd/providers.ts -->
- <https://kilo.ai/docs/ai-providers#custom-provider>
<!-- packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderDialog.tsx -->
- <https://kilo.ai/docs/code-with-ai/platforms/vscode/whats-new>
<!-- packages/kilo-vscode/webview-ui/src/components/migration/MigrationWizard.tsx -->
- <https://kilo.ai/docs/customize/custom-subagents>
<!-- packages/opencode/src/config/config.ts -->
- <https://kilo.ai/docs/customize/workflows>
<!-- packages/opencode/src/config/config.ts -->
- <https://kilo.ai/gateway>
<!-- packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-provider.tsx -->
- <https://kilo.ai/install>
<!-- packages/opencode/src/kilocode/installation/index.ts -->
- <https://kilo.ai/kiloclaw>
<!-- packages/kilo-vscode/webview-ui/kiloclaw/components/SetupView.tsx -->
<!-- packages/opencode/src/kilocode/components/dialog-claw-setup.tsx -->
- <https://kilo.ai/pricing>
<!-- packages/opencode/src/cli/cmd/tui/component/dialog-retry-action.tsx -->
- <https://kilo.ai/support>
<!-- packages/kilo-vscode/webview-ui/src/components/chat/FeedbackDialog.tsx -->
<!-- packages/kilo-vscode/webview-ui/src/components/settings/AboutKiloCodeTab.tsx -->
@@ -133,20 +145,6 @@
- <https://opencode.ai/>
<!-- packages/opencode/src/cli/cmd/generate.ts -->
<!-- packages/opencode/src/kilocode/server/httpapi/public.ts -->
- <https://opencode.ai/auth>
<!-- packages/opencode/src/cli/cmd/providers.ts -->
- <https://opencode.ai/docs/agents>
<!-- packages/opencode/src/config/config.ts -->
- <https://opencode.ai/docs/commands>
<!-- packages/opencode/src/config/config.ts -->
- <https://opencode.ai/docs/providers/#cloudflare-ai-gateway>
<!-- packages/opencode/src/cli/cmd/providers.ts -->
- <https://opencode.ai/go>
<!-- packages/opencode/src/cli/cmd/tui/component/dialog-retry-action.tsx -->
- <https://opencode.ai/install>
<!-- packages/opencode/src/installation/index.ts -->
- <https://opencode.ai/tui.json>
<!-- packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts -->
- <https://opencode.ai/zen>
<!-- packages/kilo-vscode/webview-ui/src/i18n/en.ts -->
- <https://openrouter.ai/docs/cookbook/administration/usage-accounting>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-gateway",
"version": "7.3.45",
"version": "7.3.46",
"type": "module",
"license": "MIT",
"description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-i18n",
"version": "7.3.45",
"version": "7.3.46",
"type": "module",
"license": "MIT",
"description": "Kilo-specific i18n translations and overrides",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-indexing",
"version": "7.3.45",
"version": "7.3.46",
"type": "module",
"license": "MIT",
"description": "Standalone indexing engine and host helpers for Kilo Code",
+11
View File
@@ -32,6 +32,17 @@
## [Unreleased]
## [7.0.1-rc.9] - 2026-06-15
### Added
- Add prompt enhancement support.
- Support prompt and transcript attachments, including paste, drop, preview, and editor tab opening flows.
### Fixed
- Improve shell and markdown rendering, including code block spacing, terminal block retention, shell command highlighting, and session layout polish.
## [7.0.1-rc.8] - 2026-06-09
### Added
@@ -110,6 +110,7 @@ class KiloBackendAppService private constructor(
private var watcher: Job? = null
private var eventWatcher: Job? = null
private var loader: Job? = null
private var closed = false
private val loadLock = Any()
private val _appState = MutableStateFlow<KiloAppState>(KiloAppState.Disconnected)
@@ -159,6 +160,12 @@ class KiloBackendAppService private constructor(
}
}
suspend fun shutdownForUnload() {
mutex.withLock {
shutdown()
}
}
suspend fun retry() {
mutex.withLock {
when (val current = _appState.value) {
@@ -806,6 +813,12 @@ class KiloBackendAppService private constructor(
}
override fun dispose() {
shutdown()
}
private fun shutdown() {
if (closed) return
closed = true
watcher?.cancel()
watcher = null
clear()
@@ -10,6 +10,7 @@ import ai.kilocode.rpc.dto.ModelSelectionDto
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
import ai.kilocode.rpc.dto.PermissionReplyDto
import ai.kilocode.rpc.dto.PermissionRequestDto
import ai.kilocode.rpc.dto.PartDto
import ai.kilocode.rpc.dto.PromptDto
import ai.kilocode.rpc.dto.QuestionReplyDto
import ai.kilocode.rpc.dto.QuestionRequestDto
@@ -79,6 +80,7 @@ class KiloBackendChatManager(
private var client: OkHttpClient? = null
private var base: String? = null
private var watcher: Job? = null
private var normalizer = KiloCliDataParser.ChatEventNormalizer()
fun start(http: OkHttpClient, port: Int, sse: SharedFlow<SseEvent>) {
client = http
@@ -87,16 +89,18 @@ class KiloBackendChatManager(
watcher = cs.launch {
sse.collect { event ->
if (event.type in CHAT_EVENTS) {
val parsed = KiloCliDataParser.parseChatEvent(event.type, event.data)
if (parsed != null) {
log.debug { ChatLogSummary.event(parsed) }
if (parsed is ChatEventDto.SessionStatusChanged && parsed.status.type != "busy") {
log.info(
"${ChatLogSummary.sid(parsed.sessionID)} kind=status route=chat-events emit=true " +
"${ChatLogSummary.status(parsed.status)} bytes=${event.data.length}",
)
val events = normalizer.parse(event.type, event.data)
if (events != null) {
for (parsed in events) {
log.debug { ChatLogSummary.event(parsed) }
if (parsed is ChatEventDto.SessionStatusChanged && parsed.status.type != "busy") {
log.info(
"${ChatLogSummary.sid(parsed.sessionID)} kind=status route=chat-events emit=true " +
"${ChatLogSummary.status(parsed.status)} bytes=${event.data.length}",
)
}
_events.emit(parsed)
}
_events.emit(parsed)
} else {
log.warn("SSE parse returned null for type=${event.type} bytes=${event.data.length}")
}
@@ -111,6 +115,7 @@ class KiloBackendChatManager(
watcher = null
client = null
base = null
normalizer = KiloCliDataParser.ChatEventNormalizer()
log.info("Chat manager stopped")
}
@@ -251,6 +256,17 @@ class KiloBackendChatManager(
}
}
fun attachmentPart(id: String, dir: String, message: String, part: String, key: String?): PartDto? {
return messages(id, dir)
.firstOrNull { it.info.id == message }
?.parts
?.firstOrNull {
if (it.type != "file") return@firstOrNull false
if (!key.isNullOrBlank()) attachmentKey(it.id, it.filename.orEmpty(), it.url.orEmpty()) == key
else it.id == part
}
}
// ------ config update ------
fun updateConfig(dir: String, update: ConfigUpdateDto) {
@@ -370,4 +386,10 @@ class KiloBackendChatManager(
private fun encode(value: String): String =
java.net.URLEncoder.encode(value, "UTF-8")
private fun attachmentKey(part: String, name: String, url: String): String {
val value = listOf(part, name, url).joinToString("\u0000")
val bytes = java.security.MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8))
return bytes.take(16).joinToString("") { "%02x".format(it) }
}
}
@@ -40,7 +40,10 @@ class KiloBackendCliManager(
@Volatile
private var process: Process? = null
@Volatile
private var closing: Process? = null
private var hook: Thread? = null
private var stderr: Thread? = null
@Volatile
override var forceExtract = false
@@ -59,8 +62,7 @@ class KiloBackendCliManager(
process?.let { proc ->
log.info("Cleaning up orphaned CLI process (pid=${proc.pid()})")
process = null
uninstall()
kill(proc, "startup failure cleanup")
cleanup(proc, "startup failure cleanup")
}
CliServer.State.Error(
message = e.message ?: "Unknown error",
@@ -73,13 +75,13 @@ class KiloBackendCliManager(
if (process != proc) return
process = null
uninstall()
stderr = null
}
override fun stop() {
val proc = process ?: return
process = null
uninstall()
kill(proc, "stop()")
cleanup(proc, "stop()")
}
private fun extractCli(): File {
@@ -158,14 +160,19 @@ class KiloBackendCliManager(
val stderr = StringBuilder()
Thread({
BufferedReader(InputStreamReader(proc.errorStream)).use { reader ->
reader.lineSequence().forEach { line ->
log.warn("CLI stderr: $line")
synchronized(stderr) { stderr.appendLine(line) }
val err = Thread({
runCatching {
BufferedReader(InputStreamReader(proc.errorStream)).use { reader ->
reader.lineSequence().forEach { line ->
log.warn("CLI stderr: $line")
synchronized(stderr) { stderr.appendLine(line) }
}
}
}.onFailure { err ->
if (proc.isAlive && closing !== proc) log.warn("CLI stderr reader failed", err)
}
}, "kilo-cli-stderr").apply { isDaemon = true; start() }
this@KiloBackendCliManager.stderr = err
BufferedReader(InputStreamReader(proc.inputStream)).use { reader ->
for (line in reader.lineSequence()) {
@@ -185,6 +192,7 @@ class KiloBackendCliManager(
val details = synchronized(stderr) { stderr.toString().trim() }
process = null
uninstall()
this@KiloBackendCliManager.stderr = null
log.warn("CLI process exited with code $code before announcing a port: $details")
CliServer.State.Error(
message = "CLI process exited with code $code before announcing a port",
@@ -195,8 +203,23 @@ class KiloBackendCliManager(
override fun dispose() {
val proc = process ?: return
process = null
uninstall()
kill(proc, "Disposing")
cleanup(proc, "Disposing")
}
private fun cleanup(proc: Process, source: String) {
closing = proc
try {
uninstall()
close(proc)
kill(proc, source)
val thread = stderr
stderr = null
if (thread != null && thread != Thread.currentThread()) {
thread.join(TimeUnit.SECONDS.toMillis(1))
}
} finally {
closing = null
}
}
private fun install(proc: Process) {
@@ -237,6 +260,12 @@ class KiloBackendCliManager(
private fun children(proc: Process): List<ProcessHandle> =
proc.toHandle().descendants().toList().asReversed()
private fun close(proc: Process) {
runCatching { proc.errorStream.close() }.onFailure { log.info("CLI stderr stream close skipped: ${it.message}") }
runCatching { proc.inputStream.close() }.onFailure { log.info("CLI stdout stream close skipped: ${it.message}") }
runCatching { proc.outputStream.close() }.onFailure { log.info("CLI stdin stream close skipped: ${it.message}") }
}
private fun platform(): String {
val os = when {
SystemInfo.isMac -> "darwin"
@@ -24,6 +24,7 @@ import ai.kilocode.rpc.dto.PermissionReplyDto
import ai.kilocode.rpc.dto.PermissionRequestDto
import ai.kilocode.rpc.dto.PartTimeDto
import ai.kilocode.rpc.dto.PromptDto
import ai.kilocode.rpc.dto.PromptPartDto
import ai.kilocode.rpc.dto.QuestionInfoDto
import ai.kilocode.rpc.dto.QuestionOptionDto
import ai.kilocode.rpc.dto.QuestionReplyDto
@@ -68,6 +69,8 @@ object KiloCliDataParser {
private val json = Json { ignoreUnknownKeys = true }
private val pretty = Json { ignoreUnknownKeys = true; prettyPrint = true }
private val TYPE_REGEX = Regex(""""type"\s*:\s*"([^"]+)"""")
private val READ_TOOL_LINE = Regex("^\\s*Called\\s+the\\s+Read\\s+tool\\s+with\\s+the\\s+following\\s+input:", RegexOption.IGNORE_CASE)
private val READ_TOOL_PATH = Regex("\"(?:filePath|path)\"\\s*:")
private val FIELD_RE = ConcurrentHashMap<String, Regex>()
// ================================================================
@@ -233,6 +236,88 @@ object KiloCliDataParser {
}
}
class ChatEventNormalizer {
private val roles = mutableMapOf<String, String>()
private val raw = mutableMapOf<Key, String>()
private val text = mutableMapOf<Key, String>()
fun parse(type: String, data: String): List<ChatEventDto>? {
val event = parseChatEvent(type, data) ?: return null
return when (event) {
is ChatEventDto.MessageUpdated -> {
roles[event.info.id] = event.info.role
listOf(event)
}
is ChatEventDto.MessageRemoved -> {
roles.remove(event.messageID)
clear(event.messageID)
listOf(event)
}
is ChatEventDto.PartUpdated -> listOf(update(event))
is ChatEventDto.PartDelta -> delta(event)
is ChatEventDto.PartRemoved -> {
val key = Key(event.messageID, event.partID)
raw.remove(key)
text.remove(key)
listOf(event)
}
else -> listOf(event)
}
}
private fun update(event: ChatEventDto.PartUpdated): ChatEventDto {
val part = event.part
val key = Key(part.messageID, part.id)
if (roles[part.messageID] != "user" || part.type != "text") {
raw.remove(key)
text.remove(key)
return event
}
val value = part.text.orEmpty()
val clean = sanitizeUserPromptText(value)
raw[key] = value
text[key] = clean
return event.copy(part = part.copy(text = clean))
}
private fun delta(event: ChatEventDto.PartDelta): List<ChatEventDto> {
if (event.field != "text" || roles[event.messageID] != "user") return listOf(event)
val key = Key(event.messageID, event.partID)
val prev = text[key].orEmpty()
val next = raw[key].orEmpty() + event.delta
val clean = sanitizeUserPromptText(next)
raw[key] = next
text[key] = clean
if (clean == prev) return emptyList()
if (clean.startsWith(prev)) return listOf(event.copy(delta = clean.removePrefix(prev)))
return listOf(ChatEventDto.PartUpdated(
sessionID = event.sessionID,
part = PartDto(
id = event.partID,
sessionID = event.sessionID,
messageID = event.messageID,
type = "text",
text = clean,
),
))
}
private fun clear(id: String) {
raw.keys.filter { it.messageID == id }.forEach(raw::remove)
text.keys.filter { it.messageID == id }.forEach(text::remove)
}
private data class Key(val messageID: String, val partID: String)
}
/**
* Parse an SSE `session.status` event into a (sessionID, [SessionStatusDto]) pair.
* Returns null if the required fields are missing.
@@ -267,14 +352,36 @@ object KiloCliDataParser {
return arr.mapNotNull { elem ->
val obj = elem.jsonObject
val info = obj["info"]?.jsonObject ?: return@mapNotNull null
val msg = parseMessage(info)
val parts = obj["parts"]?.jsonArray ?: JsonArray(emptyList())
MessageWithPartsDto(
info = parseMessage(info),
parts = parts.map { parsePart(it.jsonObject) },
info = msg,
parts = parts.map { sanitizePart(parsePart(it.jsonObject), msg.role) },
)
}
}
internal fun sanitizeUserPromptText(text: String): String {
val lines = text.lines()
if (lines.none(::readPayload)) return text
val out = mutableListOf<String>()
var gap = false
for (line in lines) {
if (readPayload(line)) {
gap = true
continue
}
if (line.isBlank() && out.lastOrNull()?.isBlank() == true && gap) {
gap = false
continue
}
out.add(line)
if (line.isNotBlank()) gap = false
}
return out.joinToString("\n")
}
fun parseCloudSessions(raw: String): CloudSessionListDto {
val obj = tryParseObject(raw) ?: return CloudSessionListDto(emptyList())
val items = obj["cliSessions"]?.jsonArray ?: JsonArray(emptyList())
@@ -377,7 +484,7 @@ object KiloCliDataParser {
*/
fun buildPromptJson(prompt: PromptDto): String {
val parts = prompt.parts.joinToString(",") { part ->
"""{"type":"${part.type}","text":${escape(part.text)}}"""
buildPromptPartJson(part)
}
val sb = StringBuilder()
sb.append("""{"parts":[$parts]""")
@@ -406,6 +513,18 @@ object KiloCliDataParser {
return sb.toString()
}
private fun buildPromptPartJson(part: PromptPartDto): String {
val fields = mutableListOf("\"type\":${escape(part.type)}")
if (part.type == "file") {
part.mime?.let { fields += "\"mime\":${escape(it)}" }
part.url?.let { fields += "\"url\":${escape(it)}" }
part.filename?.let { fields += "\"filename\":${escape(it)}" }
return "{${fields.joinToString(",")}}"
}
fields += "\"text\":${escape(part.text.orEmpty())}"
return "{${fields.joinToString(",")}}"
}
/**
* Build the JSON body for `POST /session/{id}/summarize`.
*/
@@ -509,6 +628,9 @@ object KiloCliDataParser {
messageID = obj.str("messageID") ?: "",
type = obj.str("type") ?: "unknown",
text = obj.str("text"),
mime = obj.str("mime"),
url = obj.str("url"),
filename = obj.str("filename"),
tool = obj.str("tool"),
callID = obj.str("callID"),
state = state?.str("status"),
@@ -526,6 +648,16 @@ object KiloCliDataParser {
)
}
private fun sanitizePart(part: PartDto, role: String): PartDto {
if (role != "user" || part.type != "text") return part
return part.copy(text = part.text?.let(::sanitizeUserPromptText))
}
private fun readPayload(line: String): Boolean {
if (!READ_TOOL_LINE.containsMatchIn(line)) return false
return READ_TOOL_PATH.containsMatchIn(line)
}
internal fun parseTodos(raw: JsonElement?): List<TodoDto> {
return parseTodosOrNull(raw) ?: emptyList()
}
@@ -0,0 +1,21 @@
package ai.kilocode.backend.plugin
import ai.kilocode.KiloPlugin
import ai.kilocode.backend.app.KiloBackendAppService
import ai.kilocode.log.KiloLog
import com.intellij.ide.plugins.DynamicPluginListener
import com.intellij.ide.plugins.IdeaPluginDescriptor
import com.intellij.openapi.components.service
import kotlinx.coroutines.runBlocking
class KiloBackendDynamicPluginListener : DynamicPluginListener {
private val log = KiloLog.create(KiloBackendDynamicPluginListener::class.java)
override fun beforePluginUnload(pluginDescriptor: IdeaPluginDescriptor, isUpdate: Boolean) {
if (pluginDescriptor.pluginId != KiloPlugin.id) return
log.info("Shutting down Kilo backend for plugin unload (isUpdate=$isUpdate)")
runBlocking {
service<KiloBackendAppService>().shutdownForUnload()
}
}
}
@@ -16,6 +16,7 @@ import ai.kilocode.rpc.dto.ModelSelectionDto
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
import ai.kilocode.rpc.dto.PermissionReplyDto
import ai.kilocode.rpc.dto.PermissionRequestDto
import ai.kilocode.rpc.dto.PartDto
import ai.kilocode.rpc.dto.PromptDto
import ai.kilocode.rpc.dto.QuestionReplyDto
import ai.kilocode.rpc.dto.QuestionRequestDto
@@ -118,6 +119,9 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi {
override suspend fun messages(id: String, directory: String): List<MessageWithPartsDto> =
ready { chat.messages(id, directory) }
override suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto? =
ready { chat.attachmentPart(id, directory, messageId, partId, attachmentKey) }
override suspend fun events(id: String, directory: String): Flow<ChatEventDto> =
chat.events.filter { event ->
val sid = when (event) {
@@ -12,4 +12,9 @@
<platform.rpc.backend.remoteApiProvider implementation="ai.kilocode.backend.rpc.KiloMigrationRpcApiProvider"/>
<applicationService serviceImplementation="ai.kilocode.backend.migration.KiloBackendLegacyMigrationStoreService"/>
</extensions>
<applicationListeners>
<listener class="ai.kilocode.backend.plugin.KiloBackendDynamicPluginListener"
topic="com.intellij.ide.plugins.DynamicPluginListener"/>
</applicationListeners>
</idea-plugin>
@@ -54,6 +54,28 @@ class KiloBackendAppServiceTest {
assertNotNull(ready.data.notifications)
}
@Test
fun `shutdown for unload clears runtime and disposes server once`() = runBlocking {
val server = FakeCliServer(mock)
val svc = KiloBackendAppService.create(scope, server, log)
svc.connect()
withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Ready }
}
svc.shutdownForUnload()
svc.shutdownForUnload()
svc.dispose()
assertEquals(KiloAppState.Disconnected, svc.appState.value)
assertNull(svc.profile)
assertNull(svc.config)
assertTrue(svc.notifications.isEmpty())
assertTrue(svc.warnings.isEmpty())
assertEquals(1, server.disposeCount)
}
@Test
fun `config is loaded`() = runBlocking {
mock.config = """{"model":"claude-4","username":"testuser"}"""
@@ -216,10 +216,30 @@ class ChatDtoSerializationTest {
assertEquals(2.0, decoded.time?.end)
}
@Test
fun `PartDto file fields are preserved in round-trip`() {
val part = PartDto(
id = "p1", sessionID = "s1", messageID = "m1",
type = "file", mime = "image/png", url = "file:///tmp/a.png", filename = "a.png",
)
val encoded = json.encodeToString(PartDto.serializer(), part)
assertTrue(encoded.contains(""""mime":"image/png""""))
assertTrue(encoded.contains(""""url":"file:///tmp/a.png""""))
assertTrue(encoded.contains(""""filename":"a.png""""))
val decoded = json.decodeFromString(PartDto.serializer(), encoded)
assertEquals("image/png", decoded.mime)
assertEquals("file:///tmp/a.png", decoded.url)
assertEquals("a.png", decoded.filename)
}
@Test
fun `PromptDto variant is preserved in round-trip`() {
val prompt = PromptDto(
parts = listOf(PromptPartDto("text", "hello")),
parts = listOf(
PromptPartDto("text", "hello"),
PromptPartDto(type = "file", mime = "image/png", url = "file:///tmp/a.png", filename = "a.png"),
),
providerID = "kilo",
modelID = "gpt-5",
agent = "code",
@@ -228,7 +248,12 @@ class ChatDtoSerializationTest {
val encoded = json.encodeToString(PromptDto.serializer(), prompt)
assertTrue(encoded.contains(""""variant":"medium""""))
assertEquals("medium", json.decodeFromString(PromptDto.serializer(), encoded).variant)
val decoded = json.decodeFromString(PromptDto.serializer(), encoded)
assertEquals("medium", decoded.variant)
assertEquals("file", decoded.parts[1].type)
assertEquals("image/png", decoded.parts[1].mime)
assertEquals("file:///tmp/a.png", decoded.parts[1].url)
assertEquals("a.png", decoded.parts[1].filename)
}
// ------ helpers ------
@@ -154,6 +154,26 @@ class ChatLogSummaryTest {
assertTrue(out.contains("variant=medium"), out)
}
@Test
fun `prompt dto summary redacts file attachment urls`() {
System.setProperty("kilo.dev.log.chat.content", "preview")
val out = ChatLogSummary.prompt(
PromptDto(
parts = listOf(
PromptPartDto(type = "text", text = "inspect"),
PromptPartDto(type = "file", mime = "image/png", url = "file:///secret/path.png", filename = "path.png"),
)
)
)
assertTrue(out.contains("attachments=1"), out)
assertTrue(out.contains("media=1"), out)
assertTrue(out.contains("attachmentTypes=image/png"), out)
assertFalse(out.contains("secret"), out)
assertFalse(out.contains("file:///"), out)
}
@Test
fun `message updated summary includes role and model`() {
val out = ChatLogSummary.event(
@@ -17,6 +17,7 @@ import org.junit.jupiter.api.Nested
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
@@ -162,6 +163,107 @@ class KiloCliDataParserTest {
assertEquals("Hello", result.part.text)
}
@Test
fun `parseChatEvent - file part preserves metadata`() {
val data = globalEvent("""
"type": "message.part.updated",
"properties": {
"sessionID": "ses_1",
"part": {
"id": "file_1",
"sessionID": "ses_1",
"messageID": "msg_1",
"type": "file",
"mime": "image/png",
"url": "file:///tmp/a.png",
"filename": "a.png"
}
}
""")
val result = KiloCliDataParser.parseChatEvent("message.part.updated", data)
assertNotNull(result)
assertTrue(result is ChatEventDto.PartUpdated)
assertEquals("file", result.part.type)
assertEquals("image/png", result.part.mime)
assertEquals("file:///tmp/a.png", result.part.url)
assertEquals("a.png", result.part.filename)
}
@Test
fun `ChatEventNormalizer - user part updated sanitizes text`() {
val norm = KiloCliDataParser.ChatEventNormalizer()
norm.parse("message.updated", messageUpdated("m1", "user"))
val events = norm.parse("message.part.updated", partUpdated(
"m1",
"p1",
"text",
"before\nCalled the Read tool with the following input: {\"filePath\":\"/tmp/a.kt\"}\nafter",
))
val event = events!!.single() as ChatEventDto.PartUpdated
assertEquals("before\nafter", event.part.text)
}
@Test
fun `ChatEventNormalizer - assistant part updated preserves text`() {
val norm = KiloCliDataParser.ChatEventNormalizer()
norm.parse("message.updated", messageUpdated("m1", "assistant"))
val payload = "Called the Read tool with the following input: {\"filePath\":\"/tmp/a.kt\"}"
val events = norm.parse("message.part.updated", partUpdated("m1", "p1", "text", payload))
val event = events!!.single() as ChatEventDto.PartUpdated
assertEquals(payload, event.part.text)
}
@Test
fun `ChatEventNormalizer - user text deltas append normally`() {
val norm = KiloCliDataParser.ChatEventNormalizer()
norm.parse("message.updated", messageUpdated("m1", "user"))
val first = norm.parse("message.part.delta", partDelta("m1", "p1", "hello"))
val second = norm.parse("message.part.delta", partDelta("m1", "p1", " world"))
assertEquals("hello", (first!!.single() as ChatEventDto.PartDelta).delta)
assertEquals(" world", (second!!.single() as ChatEventDto.PartDelta).delta)
}
@Test
fun `ChatEventNormalizer - split generated payload delta is suppressed`() {
val norm = KiloCliDataParser.ChatEventNormalizer()
norm.parse("message.updated", messageUpdated("m1", "user"))
val first = norm.parse("message.part.delta", partDelta("m1", "p1", "hello\n"))
val second = norm.parse(
"message.part.delta",
partDelta("m1", "p1", "Called the Read tool with the following input: {\"filePath\":\"/tmp/a.kt\"}"),
)
assertEquals("hello\n", (first!!.single() as ChatEventDto.PartDelta).delta)
val event = second!!.single() as ChatEventDto.PartUpdated
assertEquals("hello", event.part.text)
assertFalse(event.part.text!!.contains("Read tool"))
assertFalse(event.part.text!!.contains("/tmp/a.kt"))
}
@Test
fun `ChatEventNormalizer - partial noisy line is replaced when identified`() {
val norm = KiloCliDataParser.ChatEventNormalizer()
norm.parse("message.updated", messageUpdated("m1", "user"))
val first = norm.parse("message.part.delta", partDelta("m1", "p1", "before\nCalled the Read"))
val second = norm.parse(
"message.part.delta",
partDelta("m1", "p1", " tool with the following input: {\"path\":\"/tmp/a.kt\"}\nafter"),
)
assertEquals("before\nCalled the Read", (first!!.single() as ChatEventDto.PartDelta).delta)
val event = second!!.single() as ChatEventDto.PartUpdated
assertEquals("before\nafter", event.part.text)
}
@Test
fun `parseChatEvent - read tool part preserves input metadata and time`() {
val data = globalEvent("""
@@ -982,6 +1084,34 @@ class KiloCliDataParserTest {
assertEquals("Hi there", result[1].parts[0].text)
}
@Test
fun `parseMessages - sanitizes user text read payloads only`() {
val raw = """[
{
"info": { "id": "m1", "sessionID": "s1", "role": "user", "time": { "created": 1.0 } },
"parts": [
{ "id": "p1", "sessionID": "s1", "messageID": "m1", "type": "text", "text": "before\nCalled the Read tool with the following input: {\"filePath\":\"/tmp/user.kt\"}\nafter" },
{ "id": "f1", "sessionID": "s1", "messageID": "m1", "type": "file", "filename": "a.png", "url": "file:///tmp/a.png" },
{ "id": "t1", "sessionID": "s1", "messageID": "m1", "type": "tool", "tool": "read", "state": { "input": { "filePath": "/tmp/tool.kt" } } }
]
},
{
"info": { "id": "m2", "sessionID": "s1", "role": "assistant", "time": { "created": 2.0 } },
"parts": [{ "id": "p2", "sessionID": "s1", "messageID": "m2", "type": "text", "text": "Called the Read tool with the following input: {\"filePath\":\"/tmp/assistant.kt\"}" }]
}
]"""
val result = KiloCliDataParser.parseMessages(raw)
assertEquals("before\nafter", result[0].parts[0].text)
assertEquals("a.png", result[0].parts[1].filename)
assertEquals("/tmp/tool.kt", result[0].parts[2].input["filePath"])
assertEquals(
"Called the Read tool with the following input: {\"filePath\":\"/tmp/assistant.kt\"}",
result[1].parts[0].text,
)
}
@Test
fun `parseMessages - message with tool parts`() {
val raw = """[{
@@ -1370,6 +1500,48 @@ class KiloCliDataParserTest {
assertTrue(result.contains("""line1\nline2\t\"quoted\""""))
}
@Test
fun `buildPromptJson - mixed text and file parts`() {
val prompt = PromptDto(
parts = listOf(
PromptPartDto(type = "text", text = "see this"),
PromptPartDto(type = "file", mime = "image/png", url = "file:///tmp/a.png", filename = "a.png"),
)
)
val result = KiloCliDataParser.buildPromptJson(prompt)
assertEquals(
"""{"parts":[{"type":"text","text":"see this"},{"type":"file","mime":"image/png","url":"file:///tmp/a.png","filename":"a.png"}]}""",
result,
)
}
@Test
fun `buildPromptJson - file only omits optional filename`() {
val prompt = PromptDto(
parts = listOf(PromptPartDto(type = "file", mime = "application/pdf", url = "file:///tmp/a.pdf"))
)
val result = KiloCliDataParser.buildPromptJson(prompt)
assertEquals(
"""{"parts":[{"type":"file","mime":"application/pdf","url":"file:///tmp/a.pdf"}]}""",
result,
)
}
@Test
fun `buildPromptJson - escapes file metadata`() {
val prompt = PromptDto(
parts = listOf(PromptPartDto(type = "file", mime = "text/plain", url = "file:///tmp/a%20b.txt", filename = "a \"b\".txt"))
)
val result = KiloCliDataParser.buildPromptJson(prompt)
assertTrue(result.contains(""""filename":"a \"b\".txt""""), result)
}
// ---- buildSummarizeJson ----
@Test
@@ -1716,6 +1888,34 @@ class KiloCliDataParserTest {
assertNull(result[0].message)
}
@Test
fun `sanitizeUserPromptText - removes read payload lines`() {
val text = "before\nCalled the Read tool with the following input: {\"filePath\":\"/tmp/a.kt\"}\nafter"
assertEquals("before\nafter", KiloCliDataParser.sanitizeUserPromptText(text))
}
@Test
fun `sanitizeUserPromptText - handles read case variants and path key`() {
val text = "before\nCalled the READ tool with the following input: {\"path\":\"/tmp/a.kt\"}\nafter"
assertEquals("before\nafter", KiloCliDataParser.sanitizeUserPromptText(text))
}
@Test
fun `sanitizeUserPromptText - preserves ordinary prose without path key`() {
val text = "Called the Read tool with the following input: please inspect the file"
assertEquals(text, KiloCliDataParser.sanitizeUserPromptText(text))
}
@Test
fun `sanitizeUserPromptText - collapses only blanks introduced by payload removal`() {
val text = "before\n\nCalled the Read tool with the following input: {\"filePath\":\"/tmp/a.kt\"}\n\nafter\n\n\nkeep"
assertEquals("before\n\nafter\n\n\nkeep", KiloCliDataParser.sanitizeUserPromptText(text))
}
// ================================================================
// Helpers
// ================================================================
@@ -1723,4 +1923,44 @@ class KiloCliDataParserTest {
/** Wrap payload content in a GlobalEvent structure. */
private fun globalEvent(payload: String): String =
"""{"directory":"/tmp","payload":{$payload}}"""
private fun messageUpdated(id: String, role: String): String = globalEvent("""
"type": "message.updated",
"properties": {
"sessionID": "s1",
"info": { "id": "$id", "sessionID": "s1", "role": "$role", "time": { "created": 1.0 } }
}
""")
private fun partUpdated(mid: String, pid: String, type: String, text: String): String = globalEvent("""
"type": "message.part.updated",
"properties": {
"sessionID": "s1",
"part": { "id": "$pid", "sessionID": "s1", "messageID": "$mid", "type": "$type", "text": ${escape(text)} }
}
""")
private fun partDelta(mid: String, pid: String, delta: String): String = globalEvent("""
"type": "message.part.delta",
"properties": {
"sessionID": "s1",
"messageID": "$mid",
"partID": "$pid",
"field": "text",
"delta": ${escape(delta)}
}
""")
private fun escape(text: String) = buildString {
append('"')
for (ch in text) {
when (ch) {
'\\' -> append("\\\\")
'"' -> append("\\\"")
'\n' -> append("\\n")
else -> append(ch)
}
}
append('"')
}
}
@@ -13,6 +13,10 @@ import ai.kilocode.backend.cli.CliServer
class FakeCliServer(private val mock: MockCliServer) : CliServer {
override var forceExtract = false
var stopCount = 0
private set
var disposeCount = 0
private set
override fun process(): Process? = null
@@ -23,11 +27,13 @@ class FakeCliServer(private val mock: MockCliServer) : CliServer {
/** Shutdown the server socket but keep the mock alive for restart. */
override fun stop() {
stopCount++
mock.shutdown()
}
/** Final cleanup. */
override fun dispose() {
disposeCount++
mock.close()
}
}
@@ -6,6 +6,7 @@ import ai.kilocode.client.session.SessionSidePanelManager
import ai.kilocode.client.telemetry.Telemetry
import ai.kilocode.log.KiloLog
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.project.DumbAware
import com.intellij.openapi.project.Project
@@ -14,7 +15,6 @@ import com.intellij.openapi.wm.ToolWindowFactory
import com.intellij.ui.content.ContentFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -27,16 +27,22 @@ import kotlinx.coroutines.withContext
* completes.
*/
class KiloToolWindowFactory : ToolWindowFactory, DumbAware {
companion object {
private val LOG = KiloLog.create(KiloToolWindowFactory::class.java)
}
override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
project.service<KiloToolWindowSetupService>().create(toolWindow)
}
}
private val LOG = KiloLog.create(KiloToolWindowFactory::class.java)
@Service(Service.Level.PROJECT)
internal class KiloToolWindowSetupService(
private val project: Project,
private val cs: CoroutineScope,
) {
fun create(toolWindow: ToolWindow) {
val start = System.currentTimeMillis()
try {
val workspaces = service<KiloWorkspaceService>()
val cs = CoroutineScope(SupervisorJob())
val hint = project.basePath ?: ""
cs.launch {
@@ -13,6 +13,7 @@ import ai.kilocode.rpc.dto.ModelSelectionDto
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
import ai.kilocode.rpc.dto.PermissionReplyDto
import ai.kilocode.rpc.dto.PermissionRequestDto
import ai.kilocode.rpc.dto.PartDto
import ai.kilocode.rpc.dto.PromptDto
import ai.kilocode.rpc.dto.QuestionReplyDto
import ai.kilocode.rpc.dto.QuestionRequestDto
@@ -185,6 +186,9 @@ class KiloSessionService internal constructor(
call { messages(id, dir) }
.also { LOG.debug { "${ChatLogSummary.sid(id)} ${ChatLogSummary.history(it)} ${ChatLogSummary.dir(dir)}" } }
suspend fun attachmentPart(id: String, dir: String, message: String, part: String, key: String?): PartDto? =
call { attachmentPart(id, dir, message, part, key) }
/** Subscribe to streaming chat events for a session. */
fun events(id: String, dir: String): Flow<ChatEventDto> {
val api = rpc
@@ -181,4 +181,5 @@ class KiloWorkspaceService internal constructor(
done(ok)
}
}
}
@@ -0,0 +1,51 @@
package ai.kilocode.client.plugin
import ai.kilocode.KiloPlugin
import ai.kilocode.client.session.ui.attachment.unregisterAttachmentEditorKind
import ai.kilocode.client.vfs.KiloEditorKindRegistry
import ai.kilocode.client.vfs.KiloVirtualFileSystem
import ai.kilocode.log.KiloLog
import com.intellij.ide.plugins.DynamicPluginListener
import com.intellij.ide.plugins.IdeaPluginDescriptor
import com.intellij.openapi.components.service
import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.openapi.project.ProjectManager
import com.intellij.openapi.wm.ToolWindowManager
import javax.swing.SwingUtilities
class KiloFrontendDynamicPluginListener : DynamicPluginListener {
override fun beforePluginUnload(pluginDescriptor: IdeaPluginDescriptor, isUpdate: Boolean) {
if (pluginDescriptor.pluginId != KiloPlugin.id) return
KiloFrontendUnloadCleanup.cleanup(isUpdate)
}
}
object KiloFrontendUnloadCleanup {
private val log = KiloLog.create(KiloFrontendUnloadCleanup::class.java)
fun cleanup(isUpdate: Boolean) {
log.info("Cleaning up Kilo frontend for plugin unload (isUpdate=$isUpdate)")
runEdt {
ProjectManager.getInstance().openProjects.forEach { project ->
if (project.isDisposed) return@forEach
ToolWindowManager.getInstance(project).getToolWindow("Kilo Code")
?.contentManager
?.removeAllContents(true)
val editors = FileEditorManager.getInstance(project).openFiles
.filter { it.fileSystem === KiloVirtualFileSystem.getInstance() }
editors.forEach { file -> FileEditorManager.getInstance(project).closeFile(file) }
}
}
unregisterAttachmentEditorKind()
service<KiloEditorKindRegistry>().clear()
KiloVirtualFileSystem.getInstance().clear()
}
private fun runEdt(block: () -> Unit) {
if (SwingUtilities.isEventDispatchThread()) {
block()
return
}
SwingUtilities.invokeAndWait(block)
}
}
@@ -8,6 +8,7 @@ import ai.kilocode.client.migration.KiloMigrationService
import ai.kilocode.client.migration.MigrationUiController
import ai.kilocode.client.migration.MigrationUiState
import ai.kilocode.client.migration.ui.MigrationOverlayPanel
import ai.kilocode.client.session.model.FileAttachment
import ai.kilocode.client.session.model.SessionModelEvent
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.session.scroll.SessionScroll
@@ -19,8 +20,13 @@ import ai.kilocode.client.session.ui.mode.ModePicker
import ai.kilocode.client.session.ui.model.ModelPicker
import ai.kilocode.client.session.ui.prompt.PromptPanel
import ai.kilocode.client.session.ui.account.SessionAccountOverlay
import ai.kilocode.client.session.ui.SessionDropOverlay
import ai.kilocode.client.session.ui.SessionRootPanel
import ai.kilocode.client.session.ui.SessionMessageListPanel
import ai.kilocode.client.session.ui.attachment.AttachmentEditorKind
import ai.kilocode.client.session.ui.attachment.attachmentParams
import ai.kilocode.client.session.ui.attachment.ensureAttachmentEditorKind
import ai.kilocode.client.session.ui.attachment.isEmbeddedAttachment
import ai.kilocode.client.session.ui.header.SessionHeaderPanel
import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.ui.style.SessionEditorStyle
@@ -35,7 +41,10 @@ import ai.kilocode.client.session.views.question.QuestionView
import ai.kilocode.client.settings.profile.UserProfileConfigurable
import ai.kilocode.client.telemetry.Telemetry
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.client.vfs.KiloVfsManager
import ai.kilocode.log.ChatLogSummary
import ai.kilocode.rpc.dto.PromptDto
import ai.kilocode.rpc.dto.PromptPartDto
import com.intellij.util.ui.JBUI
import ai.kilocode.log.KiloLog
import com.intellij.ide.BrowserUtil
@@ -64,8 +73,11 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.awt.BorderLayout
import java.awt.event.HierarchyEvent
import java.net.URI
import java.nio.file.Path
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.Timer
import javax.swing.UIManager
/**
@@ -89,6 +101,7 @@ class SessionUi(
companion object {
private val LOG = KiloLog.create(SessionUi::class.java)
private const val HIDE_MS = 120
}
private val project = project
@@ -125,6 +138,13 @@ class SessionUi(
private lateinit var root: SessionRootPanel
private lateinit var account: SessionAccountOverlay
private lateinit var drop: SessionDropOverlay
private val hide = Timer(HIDE_MS) {
if (disposed || !this::drop.isInitialized) return@Timer
drop.setActive(false)
}.apply {
isRepeats = false
}
private lateinit var sessionContent: JPanel
@@ -296,6 +316,7 @@ class SessionUi(
::openFile,
::openUrl,
selection,
::openAttachment,
repo = workspace.directory,
resize = { anchor, fn -> scroll.preserve(anchor, fn) },
)
@@ -306,11 +327,21 @@ class SessionUi(
prompt = PromptPanel(
project = project,
onSend = { text -> sendPrompt(text) },
onSend = { text, files -> sendPrompt(text, files) },
onAbort = { controller.abort() },
onEnhance = controller::enhancePrompt,
)
drop = SessionDropOverlay()
root.addOverlay(drop) { pane, _ ->
java.awt.Rectangle(0, 0, pane.width, pane.height)
}
root.overlay.setComponentZOrder(drop, 0)
prompt.onFileDrag = ::syncDrop
prompt.installFileDrop(root, "session-root")
// The visual overlay returns contains(false) so normal UI remains clickable.
// Registering it as a native DnD target makes IntelliJ resolve a null over-component.
sessionContent.add(header, BorderLayout.NORTH)
sessionContent.add(scroll.component, BorderLayout.CENTER)
root.content.add(sessionContent, BorderLayout.CENTER)
@@ -320,7 +351,10 @@ class SessionUi(
private fun bindUi() {
prompt.mode.onSelect = { item -> controller.selectAgent(item.id) }
prompt.model.onSelect = { item -> controller.selectModel(item.provider, item.id) }
prompt.model.onSelect = { item ->
prompt.setAttachmentEnabled(item.attachment)
controller.selectModel(item.provider, item.id)
}
prompt.reasoning.onSelect = { item -> controller.selectVariant(item.id) }
prompt.onReset = { controller.clearModelOverride() }
prompt.onChange = { scroll.refresh() }
@@ -356,14 +390,16 @@ class SessionUi(
it.display,
it.provider,
it.providerName,
it.recommendedIndex,
it.free,
it.variants,
)
it.recommendedIndex,
it.free,
it.variants,
it.attachment,
)
}
val selected =
m.model?.let { full -> items.firstOrNull { it.key == full }?.key }
prompt.model.setItems(items, selected)
prompt.setAttachmentEnabled(items.firstOrNull { it.key == selected }?.attachment ?: true)
prompt.reasoning.setItems(m.variants.map { ReasoningPicker.Item(it, variantTitle(it)) }, m.variant)
prompt.setResetVisible(m.modelOverride)
prompt.setReady(m.isReady())
@@ -432,6 +468,17 @@ class SessionUi(
}
}
@RequiresEdt
private fun syncDrop(value: Boolean) {
if (disposed) return
if (value) {
hide.stop()
drop.setActive(true)
return
}
hide.restart()
}
private fun bindMigration() {
cs.launch {
migration.state.collect { state ->
@@ -515,16 +562,20 @@ class SessionUi(
}
}
private fun sendPrompt(text: String) {
if (text.isBlank()) return
private fun sendPrompt(text: String, files: List<PromptPartDto>) {
if (text.isBlank() && files.isEmpty()) return
val parts = buildList {
text.takeIf { it.isNotBlank() }?.let { add(PromptPartDto(type = "text", text = it)) }
addAll(files)
}
LOG.debug {
val agent = controller.model.agent ?: "none"
val model = controller.model.model ?: "none"
"${ChatLogSummary.prompt(text)} agent=$agent model=$model ready=${controller.ready}"
"${ChatLogSummary.prompt(PromptDto(parts = parts))} agent=$agent model=$model ready=${controller.ready}"
}
prompt.clear()
val follow = scroll.atBottom()
controller.prompt(text)
controller.prompt(text, files)
scroll.followBottom(follow)
}
@@ -538,6 +589,54 @@ class SessionUi(
BrowserUtil.browse(url)
}
private fun openAttachment(messageId: String, item: FileAttachment) {
val url = item.url.takeIf { it.isNotBlank() } ?: run {
LOG.info("kind=attachment-open skipped=true reason=blank-url message=$messageId part=${item.id} name=${attachmentName(item)} mime=${item.mime}")
return
}
LOG.info(
"kind=attachment-open session=${controller.id ?: "none"} message=$messageId part=${item.id} " +
"name=${attachmentName(item)} mime=${item.mime} url=${attachmentUrl(url)} dir=${workspace.directory}"
)
if (isEmbeddedAttachment(url)) {
val id = controller.id ?: run {
LOG.info("kind=attachment-open skipped=true reason=missing-session message=$messageId part=${item.id} name=${attachmentName(item)}")
return
}
LOG.info("kind=attachment-open route=kilo-vfs session=$id message=$messageId part=${item.id} name=${attachmentName(item)}")
ensureAttachmentEditorKind()
project.service<KiloVfsManager>().open(
AttachmentEditorKind.ID,
attachmentParams(id, messageId, item, attachmentName(item), workspace.directory),
)
return
}
val uri = runCatching { URI.create(url) }.getOrNull() ?: run {
LOG.info("kind=attachment-open skipped=true reason=invalid-uri message=$messageId part=${item.id} url=${attachmentUrl(url)}")
return
}
if (uri.scheme == "file") {
val path = runCatching { Path.of(uri).toString() }.getOrNull() ?: run {
LOG.info("kind=attachment-open skipped=true reason=invalid-file-uri message=$messageId part=${item.id} url=${attachmentUrl(url)}")
return
}
LOG.info("kind=attachment-open route=file session=${controller.id ?: "none"} message=$messageId part=${item.id} path=$path")
openFile(path)
return
}
LOG.info("kind=attachment-open route=browser session=${controller.id ?: "none"} message=$messageId part=${item.id} url=${attachmentUrl(url)}")
openUrl(url)
}
private fun attachmentName(item: FileAttachment) = item.filename?.takeIf { it.isNotBlank() }
?: item.url.substringBefore(',').substringAfterLast('/').takeIf { it.isNotBlank() }
?: "attachment"
private fun attachmentUrl(url: String): String {
val scheme = url.substringBefore(':', missingDelimiterValue = "none")
return "scheme=$scheme chars=${url.length} embedded=${isEmbeddedAttachment(url)}"
}
private fun onStateChanged(state: SessionState) {
if (disposed) return
prompt.setBusy(state.isBusy())
@@ -596,6 +695,7 @@ class SessionUi(
override fun dispose() {
disposed = true
hide.stop()
modalFocus = null
empty = null
if (this::root.isInitialized) root.setModalContent(null)
@@ -229,13 +229,13 @@ class SessionController(
}
}
fun prompt(text: String) {
fun prompt(text: String, files: List<PromptPartDto> = emptyList()) {
assertEdt()
val start = sid ?: ref?.key ?: "pending"
val exists = sid != null
val dto = promptDto(text)
val props = promptProps()
LOG.debug { "${ChatLogSummary.sid(start)} ${ChatLogSummary.prompt(text)} ${ChatLogSummary.dir(directory)}" }
val dto = promptDto(text, files)
val props = promptProps(files)
LOG.debug { "${ChatLogSummary.sid(start)} ${ChatLogSummary.prompt(dto)} ${ChatLogSummary.dir(directory)}" }
capture("Conversation Send Clicked", sessionProps(sid ?: ref?.key) + mapOf(
"source" to "user",
"hasExistingSession" to exists.toString(),
@@ -658,11 +658,12 @@ class SessionController(
info.name,
provider.id,
provider.name,
info.recommendedIndex,
info.free,
info.variants,
info.limit?.let { ModelLimitItem(it.context, it.input, it.output) },
)
info.recommendedIndex,
info.free,
info.variants,
info.limit?.let { ModelLimitItem(it.context, it.input, it.output) },
info.attachment,
)
}
}
} ?: emptyList()
@@ -1237,12 +1238,16 @@ class SessionController(
}
}
private fun promptDto(text: String): PromptDto {
private fun promptDto(text: String, files: List<PromptPartDto> = emptyList()): PromptDto {
val full = model.model
val sel = full?.let(::parseModel)
val variant = model.variant?.takeIf { it in model.variants }
val parts = buildList {
text.takeIf { it.isNotBlank() }?.let { add(PromptPartDto(type = "text", text = it)) }
addAll(files)
}
return PromptDto(
parts = listOf(PromptPartDto(type = "text", text = text)),
parts = parts,
providerID = sel?.first,
modelID = sel?.second,
agent = model.agent,
@@ -1455,7 +1460,7 @@ class SessionController(
}
}
private fun promptProps(): Map<String, String> = buildMap {
private fun promptProps(files: List<PromptPartDto> = emptyList()): Map<String, String> = buildMap {
model.agent?.let { put("agent", it) }
model.model?.let { key ->
put("model", key)
@@ -1465,6 +1470,10 @@ class SessionController(
}
}
model.variant?.takeIf { it in model.variants }?.let { put("variant", it) }
if (files.isNotEmpty()) {
put("attachmentCount", files.size.toString())
put("mediaAttachmentCount", files.count { it.mime?.startsWith("image/") == true || it.mime == "application/pdf" }.toString())
}
}
private fun bucket(text: String): String = when (text.length) {
@@ -66,6 +66,13 @@ class Reasoning(id: String) : Content(id) {
var done: Boolean = true
}
/** User-provided file or image attachment. */
class FileAttachment(id: String) : Content(id) {
var mime: String = "application/octet-stream"
var url: String = ""
var filename: String? = null
}
/** Tool invocation with lifecycle state. */
class Tool(id: String, val name: String, var kind: ToolKind) : Content(id) {
var state: ToolExecState = ToolExecState.PENDING
@@ -0,0 +1,101 @@
package ai.kilocode.client.session.model
import ai.kilocode.rpc.dto.PromptPartDto
import java.awt.Image
import java.awt.image.BufferedImage
import java.awt.image.MultiResolutionImage
import java.io.ByteArrayOutputStream
import java.util.Base64
import java.util.UUID
import javax.imageio.ImageIO
import java.nio.file.Path
import kotlin.io.path.name
import kotlin.io.path.readBytes
data class PromptAttachment(
val id: String,
val name: String,
val mime: String,
val url: String,
val path: Path? = null,
) {
fun part() = PromptPartDto(
type = "file",
mime = mime,
url = path?.let { data(it, mime) } ?: url,
filename = name,
)
}
object PromptAttachmentExtractor {
private const val MAX_BYTES = 10 * 1024 * 1024
fun files(files: List<java.io.File>): List<PromptAttachment> = files
.filter { it.exists() && it.isFile && it.canRead() && it.length() <= MAX_BYTES }
.map { file ->
val path = file.toPath()
val mime = mime(file)
if (!media(mime)) return@map null
PromptAttachment(
id = path.toAbsolutePath().normalize().toString(),
name = path.fileName?.toString() ?: path.name,
mime = mime,
url = path.toUri().toString(),
path = path,
)
}
.filterNotNull()
fun media(mime: String): Boolean = mime.startsWith("image/") || mime == "text/plain"
fun image(raw: Any): PromptAttachment? {
val image = when (raw) {
is MultiResolutionImage -> raw.resolutionVariants.firstOrNull()?.buffered()
is BufferedImage -> raw
is Image -> raw.buffered()
else -> null
} ?: return null
val out = ByteArrayOutputStream()
ImageIO.write(image, "png", out)
val id = UUID.randomUUID().toString()
val data = Base64.getEncoder().encodeToString(out.toByteArray())
return PromptAttachment(
id = "clipboard-image:$id",
name = "pasted-image-$id.png",
mime = "image/png",
url = "data:image/png;base64,$data",
)
}
private fun mime(file: java.io.File): String {
if (file.isDirectory) return "application/x-directory"
return when (file.extension.lowercase()) {
"png" -> "image/png"
"jpg", "jpeg" -> "image/jpeg"
"gif" -> "image/gif"
"webp" -> "image/webp"
"bmp" -> "image/bmp"
"svg" -> "image/svg+xml"
"pdf" -> "application/pdf"
"txt", "md", "kt", "kts", "java", "js", "jsx", "ts", "tsx", "json", "xml", "html", "css", "scss", "yml", "yaml", "toml", "sh", "py", "rb", "go", "rs", "c", "cc", "cpp", "h", "hpp" -> "text/plain"
else -> "application/octet-stream"
}
}
private fun Image.buffered(): BufferedImage? {
if (this is BufferedImage) return this
val width = getWidth(null)
val height = getHeight(null)
if (width <= 0 || height <= 0) return null
val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB)
val g = image.createGraphics()
try {
g.drawImage(this, 0, 0, null)
} finally {
g.dispose()
}
return image
}
}
private fun data(path: Path, mime: String) = "data:$mime;base64,${Base64.getEncoder().encodeToString(path.readBytes())}"
@@ -158,6 +158,10 @@ class SessionModel {
if (dto.type in SILENT_PART_TYPES) return
val msg = entries[messageId] ?: return
val existing = msg.parts[dto.id]
if (empty(dto)) {
if (existing is Text) removeContent(messageId, dto.id)
return
}
if (existing != null) {
updateExisting(messageId, existing, dto)
return
@@ -243,6 +247,7 @@ class SessionModel {
val item = Message(msg.info)
for (part in msg.parts) {
if (part.type in SILENT_PART_TYPES) continue
if (empty(part)) continue
val content = fromDto(part, part.text)
item.parts[content.id] = content
}
@@ -373,6 +378,11 @@ class SessionModel {
existing.content.append(text)
existing.done = dto.time?.end != null || dto.time == null
}
is FileAttachment -> {
existing.mime = dto.mime ?: "application/octet-stream"
existing.url = dto.url ?: ""
existing.filename = dto.filename
}
is Tool -> {
existing.kind = toolKind(dto.tool)
existing.state = parseToolState(dto.state)
@@ -398,6 +408,8 @@ class SessionModel {
updateHeader()
}
private fun empty(dto: PartDto) = dto.type == "text" && dto.text?.isNotBlank() != true
private fun fromDto(dto: PartDto, text: CharSequence? = null): Content {
val content = text ?: dto.text
return when (dto.type) {
@@ -408,6 +420,11 @@ class SessionModel {
if (content != null && content.isNotEmpty()) this.content.append(content)
done = dto.time?.end != null || dto.time == null
}
"file" -> FileAttachment(dto.id).apply {
mime = dto.mime ?: "application/octet-stream"
url = dto.url ?: ""
filename = dto.filename
}
"tool" -> Tool(dto.id, dto.tool ?: "unknown", toolKind(dto.tool)).apply {
state = parseToolState(dto.state)
callId = dto.callID
@@ -568,6 +585,7 @@ data class ModelItem(
val free: Boolean,
val variants: List<String>,
val limit: ModelLimitItem?,
val attachment: Boolean = false,
) {
val key: String get() = "$provider/$id"
}
@@ -602,6 +620,7 @@ private fun parseModelKey(value: String): Pair<String, String>? {
private fun Content.timelineTitle(): String = when (this) {
is Text -> "Text"
is Reasoning -> "Reasoning"
is FileAttachment -> filename?.takeIf { it.isNotBlank() } ?: "File"
is Tool -> fileActionTitle() ?: title?.takeIf { it.isNotBlank() } ?: name
is Compaction -> "Compaction"
is StepFinish -> "Step finish"
@@ -635,6 +654,7 @@ private fun tail(path: String): String {
private fun Content.weight(): Int = when (this) {
is Text -> content.length / 200 + 1
is Reasoning -> content.length / 200 + 1
is FileAttachment -> 1
is Tool -> listOf(input.size, output?.length?.div(400) ?: 0, error?.length?.div(200) ?: 0).sum() + 1
is Compaction -> 2
is StepFinish -> tokens?.stepWeight() ?: 1
@@ -661,6 +681,7 @@ private fun renderMessage(msg: Message): List<String> {
out.add("reasoning#${part.id} done=${part.done}:")
out.addAll(renderText(part.content))
}
is FileAttachment -> out.add("file#${part.id} ${part.mime} ${part.filename ?: tail(part.url)}")
is Tool -> out.add(renderTool(part))
is Compaction -> out.add("compaction#${part.id}")
is StepFinish -> out.add("step-finish#${part.id}")
@@ -0,0 +1,91 @@
package ai.kilocode.client.session.ui
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.ui.RoundedContentPanel
import ai.kilocode.client.ui.layout.HAlign
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.client.ui.layout.VAlign
import ai.kilocode.client.ui.layout.align
import com.intellij.icons.AllIcons
import com.intellij.ui.components.JBLabel
import com.intellij.util.IconUtil
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBFont
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import com.intellij.util.ui.components.BorderLayoutPanel
import java.awt.BorderLayout
import java.awt.Color
import java.awt.Graphics
import java.awt.Graphics2D
class SessionDropOverlay : BorderLayoutPanel() {
private val title = KiloBundle.message("session.drop.files.title")
private val subtitle = KiloBundle.message("session.drop.files.subtitle")
private val text = "$title $subtitle"
private val card = Card()
private var active = false
init {
isOpaque = false
accessibleContext?.accessibleName = text
val primary = JBLabel(title).apply {
font = JBFont.h0()
foreground = UIUtil.getLabelForeground()
}
val secondary = JBLabel(subtitle).apply {
font = JBFont.h2()
foreground = UIUtil.getLabelForeground()
}
val icon = JBLabel(IconUtil.scale(AllIcons.Actions.Download, null, 3f))
val labels = Stack.vertical(JBUI.scale(SessionUiStyle.View.DropOverlay.LABEL_GAP))
.next(primary.align(HAlign.CENTER, VAlign.CENTER))
.next(secondary.align(HAlign.CENTER, VAlign.CENTER))
.gap(JBUI.scale(SessionUiStyle.View.DropOverlay.ICON_GAP))
.next(icon.align(HAlign.CENTER, VAlign.CENTER))
card.apply {
isVisible = false
add(labels, BorderLayout.CENTER)
}
add(card.align(HAlign.CENTER, VAlign.CENTER), BorderLayout.CENTER)
}
@RequiresEdt
fun setActive(value: Boolean) {
if (active == value) return
active = value
card.isVisible = value
revalidate()
repaint()
}
override fun contains(x: Int, y: Int): Boolean = false
override fun paintComponent(g: Graphics) {
if (!active) {
super.paintComponent(g)
return
}
val g2 = g.create() as Graphics2D
try {
g2.color = SessionUiStyle.View.DropOverlay.scrim()
g2.fillRect(0, 0, width, height)
} finally {
g2.dispose()
}
super.paintComponent(g)
}
private class Card : RoundedContentPanel(
JBUI.scale(SessionUiStyle.View.DropOverlay.CARD_VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.DropOverlay.CARD_HORIZONTAL_PADDING),
) {
override fun contentColor(): Color = SessionUiStyle.View.DropOverlay.card()
override fun outlineColor(): Color? = null
override fun cornerArc(): Int = JBUI.scale(SessionUiStyle.View.DropOverlay.CARD_ARC)
}
}
@@ -3,6 +3,7 @@ package ai.kilocode.client.session.ui
import ai.kilocode.client.session.model.SessionModel
import ai.kilocode.client.session.model.SessionModelEvent
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.session.model.FileAttachment
import ai.kilocode.client.session.model.ToolCallRef
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.selection.SessionSelection
@@ -53,15 +54,16 @@ class SessionMessageListPanel(
private val openFile: (String) -> Unit,
private val openUrl: (String) -> Unit = {},
private val selection: SessionSelection? = null,
private val openAttachment: (String, FileAttachment) -> Unit = { _, item -> ai.kilocode.client.session.views.AttachmentView.openDefault(item, openFile, openUrl) },
private val repo: String? = null,
private val resize: ((JComponent, () -> Unit) -> Unit)? = null,
) : SessionLayoutPanel(
JBUI.scale(SessionUiStyle.SessionLayout.GAP),
JBUI.insets(
SessionUiStyle.SessionLayout.TRANSCRIPT_PADDING,
SessionUiStyle.SessionLayout.TRANSCRIPT_PADDING,
SessionUiStyle.SessionLayout.TRANSCRIPT_PADDING,
SessionUiStyle.SessionLayout.TRANSCRIPT_PADDING + SessionUiStyle.SessionLayout.TRANSCRIPT_SCROLLBAR_PADDING,
SessionUiStyle.SessionLayout.InnerInsets.top,
SessionUiStyle.SessionLayout.InnerInsets.left,
SessionUiStyle.SessionLayout.InnerInsets.bottom,
SessionUiStyle.SessionLayout.InnerInsets.right + SessionUiStyle.SessionLayout.TRANSCRIPT_SCROLLBAR_PADDING,
),
), Disposable, SessionEditorStyleTarget {
@@ -182,7 +184,7 @@ class SessionMessageListPanel(
// ------ private event handlers ------
private fun onTurnAdded(turn: ai.kilocode.client.session.model.Turn) {
val tv = TurnView(turn.id, openFile, style, openUrl, selection, resize, repo, ::hover)
val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover)
turnViews[turn.id] = tv
for (msgId in turn.messageIds) {
val msg = model.message(msgId) ?: continue
@@ -239,7 +241,7 @@ class SessionMessageListPanel(
removeAll()
for (turn in model.turns()) {
val tv = TurnView(turn.id, openFile, style, openUrl, selection, resize, repo, ::hover)
val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover)
turnViews[turn.id] = tv
for (msgId in turn.messageIds) {
val msg = model.message(msgId) ?: continue
@@ -0,0 +1,299 @@
package ai.kilocode.client.session.ui.attachment
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.iconButton
import ai.kilocode.client.ui.layout.HAlign
import ai.kilocode.client.ui.layout.VAlign
import ai.kilocode.client.ui.layout.align
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.util.IconLoader
import com.intellij.openapi.util.text.StringUtil
import com.intellij.xml.util.XmlStringUtil
import com.intellij.ui.components.JBLabel
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBUI
import java.awt.BorderLayout
import java.awt.Component
import java.awt.Container
import java.awt.Cursor
import java.awt.Dimension
import java.awt.Graphics
import java.awt.Graphics2D
import java.awt.Image
import java.awt.LayoutManager2
import java.awt.RenderingHints
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.io.ByteArrayInputStream
import java.net.URI
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import java.nio.file.Path
import javax.imageio.ImageIO
import javax.swing.Icon
import javax.swing.ImageIcon
import javax.swing.JButton
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.SwingConstants
import javax.swing.SwingUtilities
data class AttachmentCardItem(
val name: String,
val mime: String,
val url: String,
val path: Path? = null,
)
open class AttachmentCard(
private val item: AttachmentCardItem,
remove: (() -> Unit)? = null,
open: (() -> Unit)? = null,
) : JPanel(CardLayout()) {
private var gen = 0
private var loaded = false
private val icon = attachmentIcon(item.mime, item.name)
private val tip = tooltip(item)
private val open = open?.let { callback ->
object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
callback()
}
}
}
private val hover = object : MouseAdapter() {
override fun mouseEntered(e: MouseEvent) {
showAction(true)
}
override fun mouseMoved(e: MouseEvent) {
showAction(true)
}
override fun mouseExited(e: MouseEvent) {
val point = SwingUtilities.convertPoint(e.component, e.point, this@AttachmentCard)
showAction(contains(point))
}
}
private val preview = PreviewPanel(::watch).apply { setIcon(icon) }
private val content = JPanel(BorderLayout()).apply {
isOpaque = false
border = JBUI.Borders.empty(UiStyle.Gap.xs())
add(preview, BorderLayout.CENTER)
}
private val action = remove?.let { callback ->
CloseButton().apply {
isVisible = false
toolTipText = KiloBundle.message("prompt.attachment.remove", item.name)
accessibleContext?.accessibleName = toolTipText
addActionListener { callback() }
}
}
init {
isOpaque = false
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
toolTipText = tip
accessibleContext?.accessibleName = KiloBundle.message("prompt.attachment.open", item.name)
add(content)
if (action != null) {
add(action)
setComponentZOrder(action, 0)
}
watch(this)
}
override fun getPreferredSize(): Dimension = JBUI.size(
SessionUiStyle.View.Attachment.CARD_WIDTH,
SessionUiStyle.View.Attachment.CARD_HEIGHT,
)
override fun getMinimumSize(): Dimension = preferredSize
override fun getMaximumSize(): Dimension = preferredSize
override fun addNotify() {
super.addNotify()
if (loaded) return
loaded = true
load()
}
override fun paintComponent(g: Graphics) {
val g2 = g.create() as Graphics2D
try {
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
val arc = JBUI.scale(SessionUiStyle.View.Attachment.CORNER_ARC)
g2.color = SessionUiStyle.View.Surface.bgColor()
g2.fillRoundRect(0, 0, width, height, arc, arc)
g2.color = SessionUiStyle.View.Outline.color()
g2.drawRoundRect(0, 0, width - 1, height - 1, arc, arc)
} finally {
g2.dispose()
}
super.paintComponent(g)
}
@RequiresEdt
private fun load() {
if (!item.mime.startsWith("image/")) return
val stamp = ++gen
val size = JBUI.size(
SessionUiStyle.View.Attachment.CARD_WIDTH - UiStyle.Gap.xs() * 2,
SessionUiStyle.View.Attachment.CARD_HEIGHT - UiStyle.Gap.xs() * 2,
)
ApplicationManager.getApplication().executeOnPooledThread {
val image = runCatching {
val data = decodeDataImage(item.url)
val path = local(item)
if (data != null) ImageIO.read(ByteArrayInputStream(data)) else path?.let { ImageIO.read(it.toFile()) }
}.getOrNull()
val scaled = image?.let { scale(it, size.width, size.height) }
if (scaled == null) return@executeOnPooledThread
ApplicationManager.getApplication().invokeLater {
if (gen != stamp || !isDisplayable) return@invokeLater
preview.setIcon(ImageIcon(scaled))
}
}
}
private fun watch(node: Component) {
if (node is JComponent && node !is JButton) node.toolTipText = tip
node.removeMouseListener(hover)
node.removeMouseMotionListener(hover)
node.addMouseListener(hover)
node.addMouseMotionListener(hover)
open?.let {
node.removeMouseListener(it)
if (node !is JButton) {
node.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
node.addMouseListener(it)
}
}
if (node is Container) node.components.forEach(::watch)
}
private fun showAction(value: Boolean) {
val button = action ?: return
if (button.isVisible == value) return
button.isVisible = value
revalidate()
repaint()
}
private class PreviewPanel(private val watch: (Component) -> Unit) : JPanel(BorderLayout()) {
init {
isOpaque = false
}
override fun getPreferredSize(): Dimension = JBUI.size(
SessionUiStyle.View.Attachment.CARD_WIDTH - UiStyle.Gap.xs() * 2,
SessionUiStyle.View.Attachment.CARD_HEIGHT - UiStyle.Gap.xs() * 2,
)
fun setIcon(next: Icon) {
val label = JBLabel(next, SwingConstants.CENTER).align(HAlign.CENTER, VAlign.CENTER)
removeAll()
add(label, BorderLayout.CENTER)
watch(label)
revalidate()
repaint()
}
override fun paintComponent(g: Graphics) {
val g2 = g.create() as Graphics2D
try {
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
val arc = JBUI.scale(SessionUiStyle.View.Attachment.CORNER_ARC)
g2.color = SessionUiStyle.View.Surface.headerHoverBgColor()
g2.fillRoundRect(0, 0, width, height, arc, arc)
} finally {
g2.dispose()
}
super.paintComponent(g)
}
}
private class CardLayout : LayoutManager2 {
override fun addLayoutComponent(comp: Component, constraints: Any?) = Unit
override fun addLayoutComponent(name: String?, comp: Component) = Unit
override fun removeLayoutComponent(comp: Component) = Unit
override fun minimumLayoutSize(parent: Container) = preferredLayoutSize(parent)
override fun preferredLayoutSize(parent: Container) = JBUI.size(
SessionUiStyle.View.Attachment.CARD_WIDTH,
SessionUiStyle.View.Attachment.CARD_HEIGHT,
)
override fun maximumLayoutSize(target: Container) = preferredLayoutSize(target)
override fun getLayoutAlignmentX(target: Container) = 0f
override fun getLayoutAlignmentY(target: Container) = 0f
override fun invalidateLayout(target: Container) = Unit
override fun layoutContainer(parent: Container) {
val size = JBUI.scale(SessionUiStyle.View.Attachment.CLOSE_SIZE)
for (i in 0 until parent.componentCount) {
val child = parent.getComponent(i)
if (child is JButton) {
child.setBounds(parent.width - size - UiStyle.Gap.xs(), UiStyle.Gap.xs(), size, size)
continue
}
child.setBounds(0, 0, parent.width, parent.height)
}
}
}
private class CloseButton : JButton() {
init {
iconButton(this)
icon = REMOVE_ICON
addMouseListener(object : MouseAdapter() {
override fun mouseEntered(e: MouseEvent) {
icon = REMOVE_HOVER_ICON
}
override fun mouseExited(e: MouseEvent) {
icon = REMOVE_ICON
}
})
}
}
companion object {
private val REMOVE_ICON: Icon = IconLoader.getIcon("/icons/remove.svg", AttachmentCard::class.java)
private val REMOVE_HOVER_ICON: Icon = IconLoader.getIcon("/icons/remove-hover.svg", AttachmentCard::class.java)
}
}
private fun scale(image: Image, width: Int, height: Int): Image {
val iw = image.getWidth(null)
val ih = image.getHeight(null)
if (iw <= 0 || ih <= 0) return image
val ratio = minOf(width.toDouble() / iw, height.toDouble() / ih)
val w = maxOf(1, (iw * ratio).toInt())
val h = maxOf(1, (ih * ratio).toInt())
return image.getScaledInstance(w, h, Image.SCALE_SMOOTH)
}
private fun local(item: AttachmentCardItem): Path? {
if (item.path != null) return item.path
val uri = runCatching { URI.create(item.url) }.getOrNull() ?: return null
if (uri.scheme != "file") return null
return runCatching { Path.of(uri) }.getOrNull()
}
private fun tooltip(item: AttachmentCardItem): String = XmlStringUtil.wrapInHtml(
StringUtil.escapeXmlEntities(
KiloBundle.message("prompt.attachment.tooltip", item.name, item.mime, location(item)),
).replace("\n", "<br>"),
)
private fun location(item: AttachmentCardItem): String {
if (item.path != null) return item.path.toString()
val uri = runCatching { URI.create(item.url) }.getOrNull()
if (uri?.scheme == "data") return KiloBundle.message("prompt.attachment.embedded")
if (uri?.scheme == "file") return runCatching { Path.of(uri).toString() }
.getOrElse { URLDecoder.decode(uri.rawSchemeSpecificPart.removePrefix("//"), StandardCharsets.UTF_8) }
return item.url
}
@@ -0,0 +1,316 @@
package ai.kilocode.client.session.ui.attachment
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.app.KiloSessionService
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.model.FileAttachment
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.client.vfs.KiloEditorKind
import ai.kilocode.client.vfs.KiloEditorKindRegistry
import ai.kilocode.client.vfs.KiloVirtualFile
import ai.kilocode.log.KiloLog
import ai.kilocode.rpc.dto.KiloAppStatusDto
import com.intellij.openapi.Disposable
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
import com.intellij.ui.AnimatedIcon
import com.intellij.ui.components.ActionLink
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBScrollPane
import com.intellij.ui.components.JBTextArea
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.Centerizer
import com.intellij.util.ui.JBUI
import java.awt.BorderLayout
import java.awt.image.BufferedImage
import java.io.ByteArrayInputStream
import java.security.MessageDigest
import javax.imageio.ImageIO
import javax.swing.Icon
import javax.swing.ImageIcon
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.SwingConstants
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
internal object AttachmentEditorKind : KiloEditorKind {
const val ID = "attachment"
override val id: String = ID
override fun title(params: Map<String, String>): String = ref(params)?.filename ?: KiloBundle.message("session.attachment.title")
override fun icon(params: Map<String, String>): Icon? = attachmentIcon(params["mime"].orEmpty(), title(params))
override fun presentablePath(params: Map<String, String>): String {
val ref = ref(params)
return KiloBundle.message("session.attachment.path", ref?.sessionId.orEmpty(), ref?.filename ?: title(params))
}
override fun isValid(params: Map<String, String>): Boolean = ref(params) != null
@RequiresEdt
override fun createContent(project: Project, file: KiloVirtualFile, parent: Disposable): JComponent {
val panel = JPanel(BorderLayout()).apply {
border = JBUI.Borders.empty(UiStyle.Gap.pad())
}
panel.add(component(AttachmentData.Connecting), BorderLayout.CENTER)
val ref = ref(file.path.params)
LOG.info("kind=attachment-editor phase=create-content valid=${ref != null} project=${project.name} hash=${project.locationHash} ref=${ref?.let(::brief) ?: "invalid"}")
if (ref == null) {
panel.removeAll()
panel.add(component(AttachmentData.Missing), BorderLayout.CENTER)
return panel
}
project.service<KiloAttachmentEditorService>().load(ref, parent) { data ->
LOG.info("kind=attachment-editor phase=render data=${describe(data)} ref=${brief(ref)}")
panel.removeAll()
panel.add(component(data), BorderLayout.CENTER)
panel.revalidate()
panel.repaint()
}
return panel
}
private fun ref(params: Map<String, String>): AttachmentRef? {
val session = params["sessionId"].takeIfPresent() ?: return null
val message = params["messageId"].takeIfPresent() ?: return null
val part = params["partId"].takeIfPresent() ?: return null
val dir = params["directory"].takeIfPresent() ?: return null
return AttachmentRef(
directory = dir,
sessionId = session,
messageId = message,
partId = part,
attachmentKey = params["attachmentKey"].takeIfPresent(),
filename = params["filename"].takeIfPresent() ?: part,
mime = params["mime"].orEmpty(),
)
}
private val LOG = KiloLog.create(AttachmentEditorKind::class.java)
}
private fun component(data: AttachmentData): JComponent = when (data) {
is AttachmentData.Text -> text(data.text)
is AttachmentData.Image -> JBScrollPane(JBLabel(ImageIcon(data.image), SwingConstants.CENTER))
is AttachmentData.Binary -> metadata(data.name, data.mime, data.size)
is AttachmentData.Missing -> center(KiloBundle.message("session.attachment.missing"))
is AttachmentData.Error -> center(KiloBundle.message("session.attachment.error", data.message))
AttachmentData.Connecting -> connecting()
AttachmentData.ConnectionFailed -> failed()
}
private fun connecting(): JComponent {
return Stack.horizontal(gap = UiStyle.Gap.sm()).apply {
border = JBUI.Borders.empty(UiStyle.Gap.pad())
next(JBLabel(AnimatedIcon.Default()))
next(JBLabel(KiloBundle.message("session.connection.connecting")))
}.let { Centerizer(it, Centerizer.TYPE.BOTH) }
}
private fun failed(): JComponent {
return Stack.horizontal(gap = UiStyle.Gap.sm()).apply {
border = JBUI.Borders.empty(UiStyle.Gap.pad())
next(JBLabel(KiloBundle.message("session.connection.error.app")))
next(ActionLink(KiloBundle.message("session.connection.retry")) {
service<KiloAppService>().retryAsync()
})
}.let { Centerizer(it, Centerizer.TYPE.BOTH) }
}
private fun text(value: String): JComponent {
val area = JBTextArea(value).apply {
isEditable = false
lineWrap = false
border = JBUI.Borders.empty(UiStyle.Gap.sm())
}
return JBScrollPane(area)
}
private fun metadata(name: String, mime: String, size: Int): JComponent {
return Stack.vertical(gap = UiStyle.Gap.sm()).apply {
border = JBUI.Borders.empty(UiStyle.Gap.pad())
next(JBLabel(KiloBundle.message("session.attachment.unsupported", name)))
next(JBLabel(KiloBundle.message("session.attachment.mime", mime.ifBlank { "unknown" })))
next(JBLabel(KiloBundle.message("session.attachment.size", size)))
}
}
private fun center(value: String): JComponent = Centerizer(JBLabel(value), Centerizer.TYPE.BOTH)
@Service(Service.Level.PROJECT)
internal class KiloAttachmentEditorService(
private val project: Project,
private val cs: CoroutineScope,
) {
companion object {
private val LOG = KiloLog.create(KiloAttachmentEditorService::class.java)
}
fun load(ref: AttachmentRef, parent: Disposable, done: (AttachmentData) -> Unit) {
LOG.info("kind=attachment-load phase=start project=${project.name} hash=${project.locationHash} ref=${brief(ref)}")
val disposed = AtomicBoolean(false)
val job = cs.launch {
val app = service<KiloAppService>()
app.connect()
while (!disposed.get()) {
withContext(Dispatchers.Main) {
if (alive(disposed)) {
LOG.info("kind=attachment-load phase=connecting ref=${brief(ref)}")
done(AttachmentData.Connecting)
}
}
val state = app.state.first { it.status == KiloAppStatusDto.READY || it.status == KiloAppStatusDto.ERROR }
LOG.info("kind=attachment-load phase=app-state status=${state.status} ref=${brief(ref)}")
if (state.status == KiloAppStatusDto.ERROR) {
withContext(Dispatchers.Main) {
if (alive(disposed)) {
LOG.info("kind=attachment-load phase=connection-failed ref=${brief(ref)}")
done(AttachmentData.ConnectionFailed)
}
}
app.state.first { it.status != KiloAppStatusDto.ERROR }
continue
}
val data = runCatching { fetch(ref) }
.getOrElse {
LOG.warn("kind=attachment-load phase=fetch-error ref=${brief(ref)} message=${it.message}", it)
AttachmentData.Error(it.message ?: it::class.java.simpleName)
}
withContext(Dispatchers.Main) {
if (alive(disposed)) {
LOG.info("kind=attachment-load phase=done data=${describe(data)} ref=${brief(ref)}")
done(data)
}
}
return@launch
}
}
Disposer.register(parent) {
disposed.set(true)
LOG.info("kind=attachment-load phase=dispose ref=${brief(ref)}")
job.cancel()
}
}
private fun alive(disposed: AtomicBoolean): Boolean = !project.isDisposed && !disposed.get()
private suspend fun fetch(ref: AttachmentRef): AttachmentData {
val item = project.service<KiloSessionService>().attachmentPart(
ref.sessionId,
ref.directory,
ref.messageId,
ref.partId,
ref.attachmentKey,
) ?: run {
LOG.info("kind=attachment-fetch result=missing reason=part-not-found session=${ref.sessionId} message=${ref.messageId} part=${ref.partId} key=${ref.attachmentKey ?: "none"}")
return AttachmentData.Missing
}
val mode = if (ref.attachmentKey.isPresent()) "attachmentKey" else "partId"
LOG.info("kind=attachment-fetch phase=matched mode=$mode session=${ref.sessionId} message=${ref.messageId} part=${item.id} name=${item.filename.orEmpty()} mime=${item.mime.orEmpty()} url=${urlInfo(item.url.orEmpty())}")
val data = parseDataUrl(item.url.orEmpty()) ?: run {
LOG.info("kind=attachment-fetch result=missing reason=parse-data-url session=${ref.sessionId} message=${ref.messageId} part=${item.id} url=${urlInfo(item.url.orEmpty())}")
return AttachmentData.Missing
}
val mime = item.mime?.takeIf { it.isNotBlank() } ?: data.mime
val name = item.filename?.takeIf { it.isNotBlank() } ?: ref.filename
LOG.info("kind=attachment-fetch phase=parsed session=${ref.sessionId} message=${ref.messageId} part=${item.id} name=$name dtoMime=${item.mime.orEmpty()} dataMime=${data.mime} mime=$mime bytes=${data.bytes.size}")
if (textual(mime)) return AttachmentData.Text(data.bytes.toString(Charsets.UTF_8))
if (mime.startsWith("image/")) {
return withContext(Dispatchers.IO) {
val image = ImageIO.read(ByteArrayInputStream(data.bytes)) ?: return@withContext AttachmentData.Binary(name, mime, data.bytes.size)
LOG.info("kind=attachment-fetch phase=image session=${ref.sessionId} message=${ref.messageId} part=${item.id} width=${image.width} height=${image.height} bytes=${data.bytes.size}")
AttachmentData.Image(image)
}
}
return AttachmentData.Binary(name, mime, data.bytes.size)
}
}
internal data class AttachmentRef(
val directory: String,
val sessionId: String,
val messageId: String,
val partId: String,
val attachmentKey: String?,
val filename: String,
val mime: String,
)
fun ensureAttachmentEditorKind() {
service<KiloEditorKindRegistry>().register(AttachmentEditorKind)
}
internal fun unregisterAttachmentEditorKind() {
service<KiloEditorKindRegistry>().unregister(AttachmentEditorKind.ID)
}
internal fun attachmentParams(
sessionId: String,
messageId: String,
item: FileAttachment,
filename: String,
directory: String,
): Map<String, String> = linkedMapOf(
"directory" to directory,
"sessionId" to sessionId,
"messageId" to messageId,
"partId" to item.id,
"attachmentKey" to attachmentKey(item.id, item.filename.orEmpty(), item.url),
"filename" to filename,
"mime" to item.mime,
)
internal sealed interface AttachmentData {
data class Text(val text: String) : AttachmentData
data class Image(val image: BufferedImage) : AttachmentData
data class Binary(val name: String, val mime: String, val size: Int) : AttachmentData
data object Missing : AttachmentData
data class Error(val message: String) : AttachmentData
data object Connecting : AttachmentData
data object ConnectionFailed : AttachmentData
}
private fun brief(ref: AttachmentRef): String {
return listOf(
"sessionId=${ref.sessionId}",
"messageId=${ref.messageId}",
"partId=${ref.partId}",
"attachmentKey=${ref.attachmentKey ?: ""}",
"filename=${ref.filename}",
"mime=${ref.mime}",
"directory=${ref.directory}",
).joinToString(prefix = "{", postfix = "}")
}
private fun String?.isPresent(): Boolean = !this.isNullOrBlank()
private fun String?.takeIfPresent(): String? = takeIf { !it.isNullOrBlank() }
private fun describe(data: AttachmentData): String = when (data) {
is AttachmentData.Text -> "text chars=${data.text.length}"
is AttachmentData.Image -> "image width=${data.image.width} height=${data.image.height}"
is AttachmentData.Binary -> "binary name=${data.name} mime=${data.mime} bytes=${data.size}"
is AttachmentData.Error -> "error message=${data.message}"
AttachmentData.Missing -> "missing"
AttachmentData.Connecting -> "connecting"
AttachmentData.ConnectionFailed -> "connection-failed"
}
private fun urlInfo(url: String): String {
val scheme = url.substringBefore(':', missingDelimiterValue = "none")
return "urlScheme=$scheme urlChars=${url.length} embedded=${isEmbeddedAttachment(url)}"
}
private fun attachmentKey(part: String, name: String, url: String): String {
val value = listOf(part, name, url).joinToString("\u0000")
val bytes = MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8))
return bytes.take(16).joinToString("") { "%02x".format(it) }
}
@@ -0,0 +1,49 @@
package ai.kilocode.client.session.ui.attachment
import com.intellij.icons.AllIcons
import com.intellij.openapi.fileTypes.FileTypeManager
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import java.util.Base64
import javax.swing.Icon
fun decodeDataImage(url: String): ByteArray? {
val data = parseDataUrl(url) ?: return null
if (!data.mime.startsWith("image/")) return null
return data.bytes
}
internal data class DataUrl(val mime: String, val bytes: ByteArray)
internal fun parseDataUrl(url: String): DataUrl? {
if (!url.startsWith("data:")) return null
val comma = url.indexOf(',')
if (comma < 0) return null
val meta = url.substring(5, comma)
val body = url.substring(comma + 1)
val parts = meta.split(';').filter { it.isNotBlank() }
val mime = parts.firstOrNull()?.takeIf { it.contains('/') } ?: "text/plain"
val bytes = if (parts.any { it.equals("base64", ignoreCase = true) }) {
runCatching { Base64.getDecoder().decode(body) }.getOrNull() ?: return null
} else {
URLDecoder.decode(body, StandardCharsets.UTF_8).toByteArray(StandardCharsets.UTF_8)
}
return DataUrl(mime, bytes)
}
internal fun textual(mime: String) = mime.startsWith("text/") || mime in setOf(
"application/json",
"application/javascript",
"application/xml",
"application/x-yaml",
)
internal fun attachmentIcon(mime: String, name: String = "attachment"): Icon = when {
mime.startsWith("image/") -> AllIcons.FileTypes.Image
mime == "application/x-directory" -> AllIcons.Nodes.Folder
else -> FileTypeManager.getInstance().getFileTypeByFileName(name).icon ?: AllIcons.FileTypes.Text
}
fun isEmbeddedAttachment(url: String) = url.startsWith("data:")
fun isLocalAttachment(url: String) = runCatching { java.net.URI.create(url).scheme == "file" }.getOrDefault(false)
@@ -1,27 +0,0 @@
package ai.kilocode.client.session.ui.header
import java.awt.Component
import java.awt.Graphics
import java.awt.Graphics2D
import java.awt.geom.AffineTransform
import javax.swing.Icon
internal class RotatedIcon(private val base: Icon) : Icon {
override fun getIconWidth(): Int = base.iconWidth
override fun getIconHeight(): Int = base.iconHeight
override fun paintIcon(c: Component?, g: Graphics, x: Int, y: Int) {
val g2 = g.create() as Graphics2D
try {
val tx = AffineTransform()
tx.translate((x + iconWidth / 2.0), (y + iconHeight / 2.0))
tx.rotate(Math.PI)
tx.translate((-iconWidth / 2.0), (-iconHeight / 2.0))
g2.transform(tx)
base.paintIcon(c, g2, 0, 0)
} finally {
g2.dispose()
}
}
}
@@ -10,12 +10,14 @@ import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.todo.TodoListPanel
import ai.kilocode.client.ui.HoverIcon
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.rpc.dto.TodoDto
import ai.kilocode.rpc.dto.TokensDto
import com.intellij.icons.AllIcons
import com.intellij.ide.util.PropertiesComponent
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.IconLoader
import com.intellij.ui.JBColor
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.components.BorderLayoutPanel
@@ -42,8 +44,6 @@ class SessionHeaderPanel(
companion object {
private val COMPRESS_ICON: Icon = IconLoader.getIcon("/icons/compress.svg", SessionHeaderPanel::class.java)
private val CHEVRON_ICON: Icon = IconLoader.getIcon("/icons/chevron-down.svg", SessionHeaderPanel::class.java)
private val CHEVRON_UP_ICON: Icon = RotatedIcon(CHEVRON_ICON)
private val UP_ICON: Icon = IconLoader.getIcon("/icons/arrow-up.svg", SessionHeaderPanel::class.java)
private val DOWN_ICON: Icon = IconLoader.getIcon("/icons/arrow-down-to-line.svg", SessionHeaderPanel::class.java)
private const val TOUCH_BEGIN = 2
@@ -60,15 +60,20 @@ class SessionHeaderPanel(
private val todoList = TodoListPanel()
private val compact = HoverIcon().apply {
icon = COMPRESS_ICON
cursor = java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.HAND_CURSOR)
toolTipText = KiloBundle.message("session.header.compact.description")
accessibleContext.accessibleName = KiloBundle.message("session.header.compact")
addActionListener { controller.compact() }
}
private val expand = HoverIcon().apply {
icon = CHEVRON_ICON
private val expand = JBLabel().apply {
cursor = java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.HAND_CURSOR)
toolTipText = KiloBundle.message("session.header.expand")
accessibleContext.accessibleName = KiloBundle.message("session.header.expand")
addActionListener { toggle() }
addMouseListener(object : MouseAdapter() {
override fun mouseClicked(event: MouseEvent) {
toggle()
}
})
}
private val timeline = TimelinePanel()
private val viewport = JViewport().apply {
@@ -97,16 +102,18 @@ class SessionHeaderPanel(
iconTextGap = UiStyle.Gap.xs()
}
private val top = BorderLayoutPanel()
private val right = JPanel(FlowLayout(FlowLayout.RIGHT, UiStyle.Gap.md(), 0)).apply {
isOpaque = false
add(cost)
add(context)
add(compact)
add(expand)
private val center = BorderLayoutPanel().apply {
border = JBUI.Borders.empty(0, UiStyle.Gap.md(), 0, 0)
}
private val right = Stack.horizontal()
.next(cost)
.gap(UiStyle.Gap.xl())
.next(context)
.gap(UiStyle.Gap.sm())
.next(compact)
private val tokens = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply {
isOpaque = false
border = JBUI.Borders.empty(UiStyle.Gap.sm(), 0, 0, 0)
border = JBUI.Borders.empty(UiStyle.Gap.sm(), UiStyle.Gap.pad(), 0, UiStyle.Gap.pad())
add(tokenTitle)
add(Box.createHorizontalStrut(UiStyle.Gap.md()))
add(input)
@@ -134,20 +141,28 @@ class SessionHeaderPanel(
private val body = JPanel().apply {
isOpaque = false
layout = BoxLayout(this, BoxLayout.Y_AXIS)
border = JBUI.Borders.empty(UiStyle.Gap.sm(), 0, 0, 0)
border = JBUI.Borders.empty(
UiStyle.Gap.sm(),
UiStyle.Gap.xl(),
UiStyle.Gap.md(),
UiStyle.Gap.xl(),
)
add(viewport)
add(tokens)
add(bar)
add(todoBox)
}
private var style = SessionEditorStyle.current()
private var costValue = ""
init {
isOpaque = true
updateUI()
top.add(title, BorderLayout.CENTER)
top.add(right, BorderLayout.EAST)
center.add(title, BorderLayout.CENTER)
center.add(right, BorderLayout.EAST)
top.add(expand, BorderLayout.WEST)
top.add(center, BorderLayout.CENTER)
add(top, BorderLayout.NORTH)
timeline.addMouseListener(object : MouseAdapter() {
override fun mousePressed(event: MouseEvent) {
@@ -208,11 +223,13 @@ class SessionHeaderPanel(
override fun updateUI() {
super.updateUI()
border = JBUI.Borders.compound(
JBUI.Borders.customLine(JBUI.CurrentTheme.ToolWindow.borderColor(), 1, 0, 1, 0),
JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.pad(), UiStyle.Gap.sm(), UiStyle.Gap.pad()),
JBUI.Borders.customLine(separator(), 0, 0, 1, 0),
JBUI.Borders.empty(),
)
}
private fun separator() = JBColor.namedColor("EditorTabs.underTabsBorderColor", JBUI.CurrentTheme.EditorTabs.borderColor())
fun update(header: SessionHeaderSnapshot) {
val before = isVisible
title.text = header.title
@@ -227,7 +244,7 @@ class SessionHeaderPanel(
syncExpanded(expanded())
set(cost, money(header.cost))
setCost(money(header.cost))
set(context, contextText(header.context))
context.toolTipText = contextTip(header.context)
setTokens(header.tokens)
@@ -247,6 +264,10 @@ class SessionHeaderPanel(
background = style.editorBackground
foreground = style.editorForeground
top.background = style.editorBackground
top.isOpaque = true
top.border = JBUI.Borders.empty(UiStyle.Gap.md(), UiStyle.Gap.sm(), UiStyle.Gap.md(), UiStyle.Gap.sm())
center.background = style.editorBackground
center.isOpaque = true
right.background = style.editorBackground
tokens.background = style.editorBackground
todoRow.background = style.editorBackground
@@ -257,6 +278,7 @@ class SessionHeaderPanel(
title.foreground = style.editorForeground
cost.font = style.regularFont
cost.foreground = style.editorForeground
cost.icon = null
context.font = style.regularFont
context.foreground = style.editorForeground
todos.font = style.smallFont
@@ -279,7 +301,9 @@ class SessionHeaderPanel(
internal fun titleText(): String = title.text
internal fun costText(): String = cost.text
internal fun costText(): String = costValue
internal fun costTip() = cost.toolTipText
internal fun contextText(): String = context.text
@@ -392,6 +416,16 @@ class SessionHeaderPanel(
tokens.isVisible = total > 0
}
private fun setCost(value: String?) {
costValue = value.orEmpty()
cost.text = costValue
cost.icon = null
val tip = costValue.takeIf { it.isNotBlank() }?.let { KiloBundle.message("session.header.cost.tooltip", it) }
cost.toolTipText = tip
cost.accessibleContext.accessibleName = tip
cost.isVisible = costValue.isNotBlank()
}
private fun syncTodos(items: List<TodoDto>) {
val total = items.size
val done = items.count { it.status == "completed" }
@@ -447,7 +481,10 @@ class SessionHeaderPanel(
private fun collapse(): Boolean {
val attached = body.parent === this
if (!attached) return false
if (!attached) {
setExpand(false)
return false
}
remove(body)
setExpand(false)
return attached
@@ -455,7 +492,8 @@ class SessionHeaderPanel(
private fun setExpand(expanded: Boolean) {
val key = if (expanded) "session.header.collapse" else "session.header.expand"
expand.icon = if (expanded) CHEVRON_UP_ICON else CHEVRON_ICON
expand.text = ""
expand.icon = if (expanded) AllIcons.General.ChevronDown else AllIcons.General.ChevronRight
expand.toolTipText = KiloBundle.message(key)
expand.accessibleContext.accessibleName = KiloBundle.message(key)
}
@@ -57,6 +57,7 @@ class ModelPicker : PickerButton() {
val recommendedIndex: Double? = null,
val free: Boolean = false,
val variants: List<String> = emptyList(),
val attachment: Boolean = false,
) {
val key: String get() = "$provider/$id"

Some files were not shown because too many files have changed in this diff Show More