diff --git a/.changeset/bright-subagents-reason.md b/.changeset/bright-subagents-reason.md deleted file mode 100644 index 3c86f545762..00000000000 --- a/.changeset/bright-subagents-reason.md +++ /dev/null @@ -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. diff --git a/.changeset/clear-rate-limit-errors.md b/.changeset/clear-rate-limit-errors.md new file mode 100644 index 00000000000..7feca1af9cd --- /dev/null +++ b/.changeset/clear-rate-limit-errors.md @@ -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. diff --git a/.changeset/close-read-tui-news.md b/.changeset/close-read-tui-news.md new file mode 100644 index 00000000000..ce1fd8845cd --- /dev/null +++ b/.changeset/close-read-tui-news.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Hide TUI news after they have been opened and add a button to close the news dialog. diff --git a/.changeset/fast-regular-snapshots.md b/.changeset/fast-regular-snapshots.md deleted file mode 100644 index 2dcc981012b..00000000000 --- a/.changeset/fast-regular-snapshots.md +++ /dev/null @@ -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. diff --git a/.changeset/fix-kilo-npm-upgrade.md b/.changeset/fix-kilo-npm-upgrade.md new file mode 100644 index 00000000000..307a40eb8f5 --- /dev/null +++ b/.changeset/fix-kilo-npm-upgrade.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Fix upgrades to resolve Kilo CLI packages and releases instead of OpenCode packages and versions. diff --git a/.changeset/hide-prompt-training-models.md b/.changeset/hide-prompt-training-models.md new file mode 100644 index 00000000000..6221e66b3cd --- /dev/null +++ b/.changeset/hide-prompt-training-models.md @@ -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. diff --git a/.changeset/highlight-jetbrains-shell-commands.md b/.changeset/highlight-jetbrains-shell-commands.md new file mode 100644 index 00000000000..394ac4ea35e --- /dev/null +++ b/.changeset/highlight-jetbrains-shell-commands.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Highlight shell tool commands in JetBrains chat transcripts. diff --git a/.changeset/improve-jetbrains-markdown.md b/.changeset/improve-jetbrains-markdown.md new file mode 100644 index 00000000000..ff1a1845e0b --- /dev/null +++ b/.changeset/improve-jetbrains-markdown.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Improve markdown readability in JetBrains chat transcripts. diff --git a/.changeset/jetbrains-hide-read-payloads.md b/.changeset/jetbrains-hide-read-payloads.md new file mode 100644 index 00000000000..6216844f0cd --- /dev/null +++ b/.changeset/jetbrains-hide-read-payloads.md @@ -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. diff --git a/.changeset/jetbrains-horizontal-attachments.md b/.changeset/jetbrains-horizontal-attachments.md new file mode 100644 index 00000000000..2b167a0198c --- /dev/null +++ b/.changeset/jetbrains-horizontal-attachments.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show JetBrains prompt attachments in one horizontal scrolling row in session history. diff --git a/.changeset/jetbrains-paste-attachments.md b/.changeset/jetbrains-paste-attachments.md new file mode 100644 index 00000000000..1003291b27a --- /dev/null +++ b/.changeset/jetbrains-paste-attachments.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Support pasting files and images into JetBrains chat prompts as attachments. diff --git a/.changeset/jetbrains-prompt-attachment-preview.md b/.changeset/jetbrains-prompt-attachment-preview.md new file mode 100644 index 00000000000..f5b33305c46 --- /dev/null +++ b/.changeset/jetbrains-prompt-attachment-preview.md @@ -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. diff --git a/.changeset/jetbrains-prompt-attachments.md b/.changeset/jetbrains-prompt-attachments.md new file mode 100644 index 00000000000..efde8186459 --- /dev/null +++ b/.changeset/jetbrains-prompt-attachments.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Support sending file and image attachments from the JetBrains chat prompt. diff --git a/.changeset/jetbrains-session-drop-overlay.md b/.changeset/jetbrains-session-drop-overlay.md new file mode 100644 index 00000000000..64be585e380 --- /dev/null +++ b/.changeset/jetbrains-session-drop-overlay.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Support dropping files anywhere in a JetBrains chat session to add them to the prompt. diff --git a/.changeset/jetbrains-session-icons.md b/.changeset/jetbrains-session-icons.md new file mode 100644 index 00000000000..7787da11995 --- /dev/null +++ b/.changeset/jetbrains-session-icons.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Polish session header controls and align session view icons. diff --git a/.changeset/jetbrains-vfs-frontend-handoff.md b/.changeset/jetbrains-vfs-frontend-handoff.md new file mode 100644 index 00000000000..51914683663 --- /dev/null +++ b/.changeset/jetbrains-vfs-frontend-handoff.md @@ -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. diff --git a/.changeset/native-plan-architect-parity.md b/.changeset/native-plan-architect-parity.md new file mode 100644 index 00000000000..2de5bf1895f --- /dev/null +++ b/.changeset/native-plan-architect-parity.md @@ -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. diff --git a/.changeset/notify-background-sessions.md b/.changeset/notify-background-sessions.md new file mode 100644 index 00000000000..6b5646bcf73 --- /dev/null +++ b/.changeset/notify-background-sessions.md @@ -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. diff --git a/.changeset/plan-timestamp-prefix.md b/.changeset/plan-timestamp-prefix.md new file mode 100644 index 00000000000..d757c9686bf --- /dev/null +++ b/.changeset/plan-timestamp-prefix.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Restore session timestamp prefixes for generated plan filenames while preserving descriptive model-chosen names. diff --git a/.changeset/render-jetbrains-attachment-cards.md b/.changeset/render-jetbrains-attachment-cards.md new file mode 100644 index 00000000000..fc26c664e3c --- /dev/null +++ b/.changeset/render-jetbrains-attachment-cards.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Render file attachments as attachment cards in JetBrains prompts and session history. diff --git a/.changeset/render-jetbrains-shell-markdown.md b/.changeset/render-jetbrains-shell-markdown.md new file mode 100644 index 00000000000..267e7bd8059 --- /dev/null +++ b/.changeset/render-jetbrains-shell-markdown.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Render JetBrains shell tool output with markdown code blocks. diff --git a/.changeset/route-worktree-questions.md b/.changeset/route-worktree-questions.md deleted file mode 100644 index f0943a4470e..00000000000 --- a/.changeset/route-worktree-questions.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Route question responses to the worktree where the question was created. diff --git a/.changeset/secure-external-downloads.md b/.changeset/secure-external-downloads.md deleted file mode 100644 index 561867241db..00000000000 --- a/.changeset/secure-external-downloads.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Validate GitHub attachments and language server release paths before downloading or executing them. diff --git a/.changeset/silent-comet-attachments.md b/.changeset/silent-comet-attachments.md new file mode 100644 index 00000000000..fb622dba660 --- /dev/null +++ b/.changeset/silent-comet-attachments.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Open embedded transcript attachments in stable Kilo editor tabs. diff --git a/.kilo/plans/jetbrains-mdview-editor-theme-colors.md b/.kilo/plans/jetbrains-mdview-editor-theme-colors.md new file mode 100644 index 00000000000..20e85b546f8 --- /dev/null +++ b/.kilo/plans/jetbrains-mdview-editor-theme-colors.md @@ -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. diff --git a/.kilo/plans/jetbrains-mdview-vscode-styling.md b/.kilo/plans/jetbrains-mdview-vscode-styling.md new file mode 100644 index 00000000000..89e816d0235 --- /dev/null +++ b/.kilo/plans/jetbrains-mdview-vscode-styling.md @@ -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 Code’s `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. diff --git a/.kilo/plans/jetbrains-session-ui-icons-header.md b/.kilo/plans/jetbrains-session-ui-icons-header.md new file mode 100644 index 00000000000..7ed4ae52502 --- /dev/null +++ b/.kilo/plans/jetbrains-session-ui-icons-header.md @@ -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. diff --git a/.opencode-version b/.opencode-version index 2bc9bb1ba3e..fff8b777303 100644 --- a/.opencode-version +++ b/.opencode-version @@ -1 +1 @@ -v1.14.51 +v1.15.4 diff --git a/.opencode/agent/triage.md b/.opencode/agent/triage.md index 69ab7ac86b6..5215fb7c2a9 100644 --- a/.opencode/agent/triage.md +++ b/.opencode/agent/triage.md @@ -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 diff --git a/bun.lock b/bun.lock index 63f5b9823d9..b47af1c7e9b 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/nix/hashes.json b/nix/hashes.json index 153fc112847..d484352aa82 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -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=" } } diff --git a/package.json b/package.json index af88022f336..9317d05d31d 100644 --- a/package.json +++ b/package.json @@ -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": {} } diff --git a/packages/core/package.json b/packages/core/package.json index 2d0b4fb7ad4..9b2c68a6f6c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index 70cdba78563..d27f17bfb87 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -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 Effect.Effect @@ -57,10 +67,11 @@ export class Service extends Context.Service()("@opencode/v2 export const layer = Layer.effect( Service, Effect.gen(function* () { - yield* Instance.Service + yield* Location.Service let records = HashMap.empty() 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)) diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts new file mode 100644 index 00000000000..e01dc5b0d63 --- /dev/null +++ b/packages/core/src/event.ts @@ -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 = { + readonly type: Type + readonly version?: number + readonly aggregate?: string + readonly data: DataSchema +} + +export type Data = Schema.Schema.Type + +export type Payload = { + readonly id: ID + readonly type: D["type"] + readonly data: Data + readonly version?: number + readonly location?: Location.Ref + readonly metadata?: Record +} + +export type Sync = (event: Payload) => Effect.Effect + +export const registry = new Map() + +export function define(input: { + readonly type: Type + readonly version?: number + readonly aggregate?: string + readonly schema: Fields +}): Schema.Schema>>> & Definition> { + 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>>> & + Definition> +} + +export function definitions() { + return registry.values().toArray() +} + +export interface PublishOptions { + readonly id?: ID + readonly metadata?: Record +} + +export type Unsubscribe = Effect.Effect + +export interface Interface { + readonly publish: ( + definition: D, + data: Data, + options?: PublishOptions, + ) => Effect.Effect> + readonly publishEvent: (event: Payload) => Effect.Effect> + readonly subscribe: (definition: D) => Stream.Stream> + readonly all: () => Stream.Stream + readonly sync: (handler: Sync) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Event") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const all = yield* PubSub.unbounded() + const typed = new Map>() + const syncHandlers = new Array() + + const getOrCreate = (definition: Definition) => + Effect.gen(function* () { + const existing = typed.get(definition.type) + if (existing) return existing + const pubsub = yield* PubSub.unbounded() + 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(event: Payload) { + 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(definition: D, data: Data, 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 + return yield* publishEvent(event) + }) + } + + const subscribe = (definition: D): Stream.Stream> => + Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))).pipe( + Stream.map((event) => event as Payload), + ) + + const streamAll = (): Stream.Stream => Stream.fromPubSub(all) + const sync = (handler: Sync): Effect.Effect => + 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" diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index ab0647c8af3..0ed6eb47700 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -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"], diff --git a/packages/core/src/instance-layer.ts b/packages/core/src/instance-layer.ts deleted file mode 100644 index 20e6ac1aceb..00000000000 --- a/packages/core/src/instance-layer.ts +++ /dev/null @@ -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()("@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", -}) {} diff --git a/packages/core/src/instance.ts b/packages/core/src/instance.ts deleted file mode 100644 index af6de951be4..00000000000 --- a/packages/core/src/instance.ts +++ /dev/null @@ -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()("@opencode/Instance") {} diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts new file mode 100644 index 00000000000..84dfb3dfae7 --- /dev/null +++ b/packages/core/src/location-layer.ts @@ -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()("@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", +}) {} diff --git a/packages/core/src/location.ts b/packages/core/src/location.ts new file mode 100644 index 00000000000..00ff9cd3ea7 --- /dev/null +++ b/packages/core/src/location.ts @@ -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()("@opencode/Location") {} diff --git a/packages/opencode/src/v2/session-event.ts b/packages/core/src/session-event.ts similarity index 71% rename from packages/opencode/src/v2/session-event.ts rename to packages/core/src/session-event.ts index 1fd0f909d5a..a98d9cc0514 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/core/src/session-event.ts @@ -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 +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 +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 +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 +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 +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 +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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 +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 +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 + 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 + 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 +export type Event = typeof All.Type export type Type = Event["type"] export * as SessionEvent from "./session-event" diff --git a/packages/opencode/src/v2/session-message-updater.ts b/packages/core/src/session-message-updater.ts similarity index 100% rename from packages/opencode/src/v2/session-message-updater.ts rename to packages/core/src/session-message-updater.ts diff --git a/packages/opencode/src/v2/session-message.ts b/packages/core/src/session-message.ts similarity index 87% rename from packages/opencode/src/v2/session-message.ts rename to packages/core/src/session-message.ts index fa7c299ae57..73b6dd7da2b 100644 --- a/packages/opencode/src/v2/session-message.ts +++ b/packages/core/src/session-message.ts @@ -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 @@ -20,7 +20,7 @@ const Base = { export class AgentSwitched extends Schema.Class("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("Session.Message.ModelSwitched")({ @@ -43,16 +43,16 @@ export class User extends Schema.Class("Session.Message.User")({ export class Synthetic extends Schema.Class("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("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("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("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("Session.Message.Assistan export class Compaction extends Schema.Class("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, diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts new file mode 100644 index 00000000000..756531e3280 --- /dev/null +++ b/packages/core/src/session.ts @@ -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 diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index c5ac2c06cc4..594f42d1c8b 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -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 diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts new file mode 100644 index 00000000000..b67b2897a1b --- /dev/null +++ b/packages/core/test/event.test.ts @@ -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() + 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() + 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"]) + }), + ) +}) diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 8271a4a5c82..ed82686a21c 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -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)))), ) }) diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 41a7664d5b1..c0c9940f35e 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -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"] diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index 4f195e7c6f2..24c96c918d7 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -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", diff --git a/packages/kilo-console/package.json b/packages/kilo-console/package.json index 4bf49fd6119..4a9cd9366c7 100644 --- a/packages/kilo-console/package.json +++ b/packages/kilo-console/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-console", - "version": "7.3.45", + "version": "7.3.46", "private": true, "type": "module", "scripts": { diff --git a/packages/kilo-docs/markdoc/partials/cli-commands-table.md b/packages/kilo-docs/markdoc/partials/cli-commands-table.md index 962447f0d12..877e4a17785 100644 --- a/packages/kilo-docs/markdoc/partials/cli-commands-table.md +++ b/packages/kilo-docs/markdoc/partials/cli-commands-table.md @@ -13,12 +13,14 @@ | `kilo upgrade [target]` | upgrade kilo to the latest or a specific version | | `kilo uninstall` | uninstall kilo and remove all related files | | `kilo serve` | starts a headless kilo server | +| `kilo web` | start kilo server and open web interface | | `kilo models [provider]` | list all available models | | `kilo roll-call ` | batch-test text models matching a filter for connectivity and latency | | `kilo profile` | show Kilo account profile | | `kilo stats` | show token usage and cost statistics | | `kilo export [sessionID]` | export session data as JSON | | `kilo import ` | import session data from JSON file or URL | +| `kilo github` | manage GitHub agent | | `kilo pr ` | fetch and checkout a GitHub PR branch, then run kilo | | `kilo session` | manage sessions | | `kilo remote` | enable remote connection for real-time session relay | diff --git a/packages/kilo-docs/package.json b/packages/kilo-docs/package.json index 14b4dceda69..6b246174f17 100644 --- a/packages/kilo-docs/package.json +++ b/packages/kilo-docs/package.json @@ -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", diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md b/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md index e6bc537b981..6dc7b7467c3 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md @@ -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 @@ -682,6 +682,21 @@ Options: --cors additional domains to allow for CORS [array] [default: []] ``` +## kilo web + +``` +start kilo server and open web interface + +Options: + --help Show help [boolean] + --version Show version number [boolean] + --port port to listen on [number] [default: 0] + --hostname hostname to listen on [string] [default: "127.0.0.1"] + --mdns enable mDNS service discovery (defaults hostname to 0.0.0.0) [boolean] [default: false] + --mdns-domain custom domain name for mDNS service (default: kilo.local) [string] [default: "kilo.local"] + --cors additional domains to allow for CORS [array] [default: []] +``` + ## kilo models ``` @@ -768,6 +783,42 @@ Options: --version Show version number [boolean] ``` +## kilo github + +``` +manage GitHub agent + +Commands: + kilo github install install the GitHub agent + kilo github run run the GitHub agent + +Options: + --help Show help [boolean] + --version Show version number [boolean] +``` + +### kilo github install + +``` +install the GitHub agent + +Options: + --help Show help [boolean] + --version Show version number [boolean] +``` + +### kilo github run + +``` +run the GitHub agent + +Options: + --help Show help [boolean] + --version Show version number [boolean] + --event GitHub mock event to run the agent for [string] + --token GitHub personal access token (github_pat_********) [string] +``` + ## kilo pr ``` @@ -904,8 +955,6 @@ Options: ## kilo console -Open Kilo Console to manage CLI configuration, including **Settings > CLI > Notifications**. See [CLI Notifications and Sounds](/docs/code-with-ai/platforms/cli#cli-notifications-and-sounds) for the equivalent `tui.json` settings and custom sound overrides. - ``` open the local Kilo Console diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/models-accessible-labels-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/models-accessible-labels-chromium-linux.png index 01ea6e7ac02..db7441c87c1 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/models-accessible-labels-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/models-accessible-labels-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bbfed6606c5e1ccd6a744bdadf34ee597007d5f82c436c8c53e189560e0837d4 -size 30244 +oid sha256:bae54b771e2d7da8c8b1dcab42d9d51662ec888ebb618b24af11e597c79df7b7 +size 37836 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/models-autocomplete-open-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/models-autocomplete-open-chromium-linux.png index 01ea6e7ac02..db7441c87c1 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/models-autocomplete-open-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/models-autocomplete-open-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bbfed6606c5e1ccd6a744bdadf34ee597007d5f82c436c8c53e189560e0837d4 -size 30244 +oid sha256:bae54b771e2d7da8c8b1dcab42d9d51662ec888ebb618b24af11e597c79df7b7 +size 37836 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/settings-panel-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/settings-panel-chromium-linux.png index 542e06b36ac..9fb41085b48 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/settings-panel-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/settings-panel-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6bcfc6c12f69c5131dcc8f294f480e26ff53f87e0c5943bf525e69c4c894ad21 -size 43744 +oid sha256:57fa5c60dd92a2f4299e58ead8a682c4802de415a496c247af2eba2faf0d63e4 +size 50341 diff --git a/packages/kilo-docs/source-links.md b/packages/kilo-docs/source-links.md index 5ae4764c680..a1311dad23c 100644 --- a/packages/kilo-docs/source-links.md +++ b/packages/kilo-docs/source-links.md @@ -122,6 +122,8 @@ - +- + - @@ -143,8 +145,6 @@ - -- - - - diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index fd4129fafd0..5e37cf4e1f8 100644 --- a/packages/kilo-gateway/package.json +++ b/packages/kilo-gateway/package.json @@ -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", diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json index 8eafeea1b94..5958cf52994 100644 --- a/packages/kilo-i18n/package.json +++ b/packages/kilo-i18n/package.json @@ -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", diff --git a/packages/kilo-indexing/package.json b/packages/kilo-indexing/package.json index f3de02abd4a..ccf524599a3 100644 --- a/packages/kilo-indexing/package.json +++ b/packages/kilo-indexing/package.json @@ -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", diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt index d0891ba871c..b31f8884192 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt @@ -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) { 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) } + } } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index 192ea1a1081..d35c96debd0 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -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() // ================================================================ @@ -233,6 +236,88 @@ object KiloCliDataParser { } } + class ChatEventNormalizer { + private val roles = mutableMapOf() + private val raw = mutableMapOf() + private val text = mutableMapOf() + + fun parse(type: String, data: String): List? { + 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 { + 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() + 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 { return parseTodosOrNull(raw) ?: emptyList() } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt index cb8dbbe4001..156d2336639 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt @@ -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 = 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 = chat.events.filter { event -> val sid = when (event) { diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatDtoSerializationTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatDtoSerializationTest.kt index 194f9098b86..09df6ad5902 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatDtoSerializationTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatDtoSerializationTest.kt @@ -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 ------ diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatLogSummaryTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatLogSummaryTest.kt index 507c7503c0c..29deb0cce41 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatLogSummaryTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatLogSummaryTest.kt @@ -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( diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index 979e7738d3c..00fdd9ba1e3 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -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('"') + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt index f49f9ed8ee6..00e8a6be944 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt @@ -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 { val api = rpc diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt index 969466abc8f..a414a0adb2b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt @@ -181,4 +181,5 @@ class KiloWorkspaceService internal constructor( done(ok) } } + } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index e1d3f55bca7..98e63cbca2e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -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) { + 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().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) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index 940437682e1..49d265623b2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -229,13 +229,13 @@ class SessionController( } } - fun prompt(text: String) { + fun prompt(text: String, files: List = 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 = 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 = buildMap { + private fun promptProps(files: List = emptyList()): Map = 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) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt index a8169f7749d..adf6fd84915 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/PromptAttachment.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/PromptAttachment.kt new file mode 100644 index 00000000000..ffb642a7e96 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/PromptAttachment.kt @@ -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): List = 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())}" diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt index 0533705f7e9..79eeb432f8f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt @@ -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, val limit: ModelLimitItem?, + val attachment: Boolean = false, ) { val key: String get() = "$provider/$id" } @@ -602,6 +620,7 @@ private fun parseModelKey(value: String): Pair? { 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 { 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}") diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionDropOverlay.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionDropOverlay.kt new file mode 100644 index 00000000000..c0a99af67ef --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionDropOverlay.kt @@ -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) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt index 18c7d7410c9..3d7f01029e1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentCard.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentCard.kt new file mode 100644 index 00000000000..2ae5cdc7596 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentCard.kt @@ -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", "
"), +) + +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 +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentEditorKind.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentEditorKind.kt new file mode 100644 index 00000000000..38da71dc4f2 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentEditorKind.kt @@ -0,0 +1,312 @@ +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 = ref(params)?.filename ?: KiloBundle.message("session.attachment.title") + override fun icon(params: Map): Icon? = attachmentIcon(params["mime"].orEmpty(), title(params)) + override fun presentablePath(params: Map): String { + val ref = ref(params) + return KiloBundle.message("session.attachment.path", ref?.sessionId.orEmpty(), ref?.filename ?: title(params)) + } + + override fun isValid(params: Map): 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().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): 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().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() + 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().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().register(AttachmentEditorKind) +} + +internal fun attachmentParams( + sessionId: String, + messageId: String, + item: FileAttachment, + filename: String, + directory: String, +): Map = 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) } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentOpeners.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentOpeners.kt new file mode 100644 index 00000000000..371addaa17b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentOpeners.kt @@ -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) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/RotatedIcon.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/RotatedIcon.kt deleted file mode 100644 index e4f37d69334..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/RotatedIcon.kt +++ /dev/null @@ -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() - } - } -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt index c92b1a255b0..02562e013bf 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt @@ -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) { 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) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt index d0d4071c90e..337a6f8ea0f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt @@ -57,6 +57,7 @@ class ModelPicker : PickerButton() { val recommendedIndex: Double? = null, val free: Boolean = false, val variants: List = emptyList(), + val attachment: Boolean = false, ) { val key: String get() = "$provider/$id" diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptAttachmentPasteProvider.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptAttachmentPasteProvider.kt new file mode 100644 index 00000000000..e6b0ffbae5f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptAttachmentPasteProvider.kt @@ -0,0 +1,42 @@ +package ai.kilocode.client.session.ui.prompt + +import com.intellij.ide.PasteProvider +import com.intellij.ide.dnd.FileCopyPasteUtil +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.editor.actions.PasteAction +import com.intellij.openapi.util.Key +import java.awt.datatransfer.DataFlavor +import java.awt.datatransfer.Transferable + +internal fun interface PromptAttachmentPasteHandler { + fun paste(transferable: Transferable) +} + +internal val PROMPT_ATTACHMENT_PASTE_HANDLER_KEY: Key = + Key.create("ai.kilocode.client.session.ui.prompt.PromptAttachmentPasteHandler") + +internal class PromptAttachmentPasteProvider : PasteProvider { + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + override fun isPastePossible(dataContext: DataContext): Boolean = transferable(dataContext) != null + + override fun isPasteEnabled(dataContext: DataContext): Boolean = isPastePossible(dataContext) + + override fun performPaste(dataContext: DataContext) { + val editor = dataContext.getData(CommonDataKeys.EDITOR) ?: return + val handler = editor.getUserData(PROMPT_ATTACHMENT_PASTE_HANDLER_KEY) ?: return + val item = transferable(dataContext) ?: return + handler.paste(item) + } + + private fun transferable(dataContext: DataContext): Transferable? { + val editor = dataContext.getData(CommonDataKeys.EDITOR) ?: return null + if (editor.getUserData(PROMPT_ATTACHMENT_PASTE_HANDLER_KEY) == null) return null + val item = dataContext.getData(PasteAction.TRANSFERABLE_PROVIDER)?.produce() ?: return null + if (FileCopyPasteUtil.isFileListFlavorAvailable(item.transferDataFlavors)) return item + if (item.isDataFlavorSupported(DataFlavor.imageFlavor)) return item + return null + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptAttachmentStrip.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptAttachmentStrip.kt new file mode 100644 index 00000000000..9efa44aabf1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptAttachmentStrip.kt @@ -0,0 +1,89 @@ +package ai.kilocode.client.session.ui.prompt + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.model.PromptAttachment +import ai.kilocode.client.session.ui.attachment.AttachmentCard +import ai.kilocode.client.session.ui.attachment.AttachmentCardItem +import ai.kilocode.client.ui.UiStyle +import com.intellij.notification.Notification +import com.intellij.notification.NotificationType +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import java.awt.FlowLayout +import javax.swing.JPanel + +class PromptAttachmentStrip( + private val project: Project, + private val removed: (PromptAttachment) -> Unit, +) : JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.sm(), UiStyle.Gap.sm())) { + private val chips = LinkedHashMap() + + init { + border = JBUI.Borders.emptyBottom(UiStyle.Gap.sm()) + isVisible = false + } + + val count: Int get() = chips.size + + @RequiresEdt + fun add(item: PromptAttachment) { + if (chips.containsKey(item.id)) return + val chip = PromptAttachmentChip(project, item, remove = { removed(item) }) + chips[item.id] = chip + add(chip) + sync() + } + + @RequiresEdt + fun remove(item: PromptAttachment) { + val chip = chips.remove(item.id) ?: return + remove(chip) + sync() + } + + @RequiresEdt + fun clear() { + if (chips.isEmpty()) return + chips.clear() + removeAll() + sync() + } + + @RequiresEdt + private fun sync() { + isVisible = chips.isNotEmpty() + revalidate() + repaint() + } +} + +private class PromptAttachmentChip( + project: Project, + item: PromptAttachment, + remove: () -> Unit, +) : AttachmentCard( + AttachmentCardItem(item.name, item.mime, item.url, item.path), + remove = remove, + open = { open(project, item) }, +) { + companion object { + private fun open(project: Project, item: PromptAttachment) { + val path = item.path ?: return + ApplicationManager.getApplication().executeOnPooledThread { + val file = LocalFileSystem.getInstance().refreshAndFindFileByNioFile(path) + ApplicationManager.getApplication().invokeLater { + if (project.isDisposed) return@invokeLater + if (file == null) { + Notification("Kilo Code", KiloBundle.message("prompt.attachment.missing", item.name), NotificationType.WARNING).notify(project) + return@invokeLater + } + FileEditorManager.getInstance(project).openFile(file, true) + } + } + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt index e154d67ccfc..d1fef70d22d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt @@ -5,19 +5,24 @@ import ai.kilocode.client.actions.SendPromptAction import ai.kilocode.client.actions.StopSessionAction import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.ui.ReasoningPicker +import ai.kilocode.client.session.model.PromptAttachment +import ai.kilocode.client.session.model.PromptAttachmentExtractor import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.ui.mode.ModePicker import ai.kilocode.client.session.ui.model.ModelPicker import ai.kilocode.client.ui.HoverIcon -import ai.kilocode.client.ui.RoundedContentPanel import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.iconButton import ai.kilocode.log.ChatLogSummary import ai.kilocode.log.KiloLog +import ai.kilocode.rpc.dto.PromptPartDto import com.intellij.icons.AllIcons import com.intellij.ide.DataManager +import com.intellij.ide.dnd.DnDEvent +import com.intellij.ide.dnd.DnDSupport +import com.intellij.ide.dnd.FileCopyPasteUtil import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.actionSystem.ActionPlaces import com.intellij.openapi.actionSystem.ActionUiKind @@ -35,9 +40,9 @@ import com.intellij.openapi.keymap.KeymapUtil import com.intellij.openapi.project.Project import com.intellij.openapi.util.IconLoader import com.intellij.ui.AnimatedIcon +import com.intellij.ui.JBColor import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.xml.util.XmlStringUtil -import com.intellij.util.ui.JBValue import com.intellij.util.ui.JBDimension import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil @@ -49,10 +54,13 @@ import java.awt.Cursor import java.awt.Graphics import java.awt.Graphics2D import java.awt.RenderingHints +import java.awt.datatransfer.DataFlavor +import java.awt.datatransfer.Transferable import java.awt.event.FocusAdapter import java.awt.event.FocusEvent import java.awt.event.MouseAdapter import java.awt.event.MouseEvent +import java.util.concurrent.Future import javax.swing.Box import javax.swing.BoxLayout import javax.swing.Icon @@ -61,12 +69,12 @@ import javax.swing.JComponent import javax.swing.ScrollPaneConstants /** - * Prompt input panel with borderless IntelliJ editor text field and - * mode/model controls grouped inside one rounded editor-background shell. + * Prompt input panel with a borderless IntelliJ editor text field and + * mode/model controls in the full bottom session area. */ class PromptPanel( private val project: Project, - private val onSend: (String) -> Unit, + private val onSend: (String, List) -> Unit, private val onAbort: () -> Unit, private val onEnhance: (String, (Result) -> Unit) -> Unit, ) : BorderLayoutPanel(), SessionEditorStyleTarget, SendPromptContext { @@ -88,10 +96,23 @@ class PromptPanel( var onReset: () -> Unit = {} var onChange: () -> Unit = {} var onAutoApproveToggle: (Boolean) -> Unit = {} + var onFileDrag: (Boolean) -> Unit = {} private var style = SessionEditorStyle.current() - private val shell = PromptShell() + private val shell = BorderLayoutPanel().apply { + isOpaque = true + border = JBUI.Borders.empty( + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), + ) + } + private val attachments = mutableListOf() + private val strip = PromptAttachmentStrip(project) { removeAttachment(it) } private var bus: MessageBusConnection? = null private var autoApprove = false + private var attachment = true + private var submitting = false private val editor = PromptEditorTextField(project, this).apply { border = JBUI.Borders.empty() @@ -109,15 +130,18 @@ class PromptPanel( ed.scrollPane.viewport.background = style.editorScheme.defaultBackground ed.settings.isUseSoftWraps = true ed.settings.isAdditionalPageAtBottom = false + ed.putUserData(PROMPT_ATTACHMENT_PASTE_HANDLER_KEY, PromptAttachmentPasteHandler { processPaste(it) }) ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + installFileDrop(ed.contentComponent, "editor") + installFileDrop(ed.scrollPane, "scroll") ed.contentComponent.addFocusListener(object : FocusAdapter() { override fun focusGained(e: FocusEvent) { - shell.repaint() + repaint() } override fun focusLost(e: FocusEvent) { - shell.repaint() + repaint() } }) } @@ -166,22 +190,12 @@ class PromptPanel( private var request = 0L override val isSendEnabled: Boolean - get() = ready && !busy && text().isNotEmpty() + get() = ready && !busy && !submitting && (text().isNotEmpty() || attachments.isNotEmpty()) override val isStopEnabled: Boolean get() = busy init { - border = JBUI.Borders.compound( - JBUI.Borders.customLineTop(JBUI.CurrentTheme.ToolWindow.borderColor()), - JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.Prompt.PANEL_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.Prompt.PANEL_HORIZONTAL_PADDING), - JBUI.scale(SessionUiStyle.View.Prompt.PANEL_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.Prompt.PANEL_HORIZONTAL_PADDING), - ), - ) - applyStyle(style) editor.text = "" editor.addDocumentListener(object : DocumentListener { @@ -191,6 +205,7 @@ class PromptPanel( onChange() } }) + shell.add(strip, BorderLayout.NORTH) shell.add(editor, BorderLayout.CENTER) val bar = BorderLayoutPanel().apply { @@ -213,17 +228,31 @@ class PromptPanel( bar.add(button) shell.add(bar, BorderLayout.SOUTH) add(shell, BorderLayout.CENTER) + installFileDrop(shell, "shell") syncTooltip() syncAutoApprove() syncEnhance() } + override fun updateUI() { + super.updateUI() + border = JBUI.Borders.compound( + JBUI.Borders.customLineTop(separator()), + JBUI.Borders.empty(), + ) + } + @RequiresEdt fun setReady(value: Boolean) { ready = value if (!value) invalidateEnhancement() else syncEnhance() } + @RequiresEdt + fun setAttachmentEnabled(value: Boolean) { + attachment = value + } + @RequiresEdt fun setBusy(value: Boolean) { busy = value @@ -270,11 +299,15 @@ class PromptPanel( internal fun buttonForTest(): JButton = button + internal fun attachmentCountForTest(): Int = attachments.size + internal val defaultFocusedComponent: JComponent get() = editor @RequiresEdt override fun applyStyle(style: SessionEditorStyle) { this.style = style + background = style.editorScheme.defaultBackground + shell.background = style.editorScheme.defaultBackground editor.font = style.editorFont editor.getEditor(false)?.let(style::applyToEditor) editor.background = style.editorScheme.defaultBackground @@ -285,9 +318,18 @@ class PromptPanel( @RequiresEdt fun clear() { editor.text = "" + attachments.clear() + strip.clear() syncEditorHeight() } + @RequiresEdt + fun addAttachmentForTest(item: PromptAttachment) { + addAttachment(item) + } + + internal fun processPasteForTest(transferable: Transferable): Future<*> = processPaste(transferable) + @RequiresEdt fun focus() { editor.requestFocusInWindow() @@ -359,12 +401,134 @@ class PromptPanel( private fun submit(src: String) { if (!isSendEnabled) return val txt = text() - LOG.debug { "${ChatLogSummary.prompt(txt)} src=$src busy=$busy" } - if (txt.isNotEmpty()) { - onSend(txt) + val items = attachments.toList() + submitting = true + ApplicationManager.getApplication().executeOnPooledThread { + try { + val files = items.map { it.part() } + ApplicationManager.getApplication().invokeLater { + submitting = false + if (project.isDisposed) return@invokeLater + LOG.debug { "${ChatLogSummary.prompt(promptDto(txt, files))} src=$src busy=$busy" } + onSend(txt, files) + } + } catch (e: Exception) { + ApplicationManager.getApplication().invokeLater { + submitting = false + if (project.isDisposed) return@invokeLater + LOG.warn("kind=prompt-submit src=$src failed message=${e.message}", e) + notify(KiloBundle.message("prompt.attachment.send.failed", e.message ?: e.javaClass.simpleName)) + } + } } } + @RequiresEdt + private fun addAttachment(item: PromptAttachment) { + if (!attachment && PromptAttachmentExtractor.media(item.mime)) { + LOG.debug { "kind=prompt-attachment add name=${item.name} mime=${item.mime} blocked=unsupported-model" } + notify(KiloBundle.message("prompt.attachment.unsupported.model")) + return + } + if (attachments.any { it.id == item.id }) { + LOG.debug { "kind=prompt-attachment add name=${item.name} mime=${item.mime} blocked=duplicate" } + return + } + attachments += item + strip.add(item) + LOG.debug { "kind=prompt-attachment add name=${item.name} mime=${item.mime} count=${attachments.size}" } + onChange() + } + + @RequiresEdt + private fun removeAttachment(item: PromptAttachment) { + if (!attachments.removeIf { it.id == item.id }) return + strip.remove(item) + onChange() + } + + private fun promptDto(text: String, files: List) = ai.kilocode.rpc.dto.PromptDto( + parts = buildList { + text.takeIf { it.isNotBlank() }?.let { add(PromptPartDto(type = "text", text = it)) } + addAll(files) + } + ) + + internal fun installFileDrop(target: JComponent, area: String) { + LOG.debug { "kind=prompt-dnd install area=$area component=${target.javaClass.name}" } + DnDSupport.createBuilder(target) + .enableAsNativeTarget() + .setTargetChecker { event -> + if (!FileCopyPasteUtil.isFileListFlavorAvailable(event)) { + onFileDrag(false) + LOG.debug { "kind=prompt-dnd check area=$area accept=false flavor=false" } + return@setTargetChecker true + } + event.setDropPossible(true) + onFileDrag(true) + LOG.debug { "kind=prompt-dnd check area=$area accept=true flavor=true" } + false + } + .setCleanUpOnLeaveCallback { + onFileDrag(false) + } + .setDropHandlerWithResult { event -> + val start = System.nanoTime() + val files = dropFiles(event) + val ms = elapsedMs(start) + LOG.debug { "kind=prompt-dnd drop area=$area files=${files.size} extractMs=$ms queued=${files.isNotEmpty()}" } + onFileDrag(false) + if (files.isEmpty()) return@setDropHandlerWithResult false + processAttachments("prompt-dnd", area, files, null, ms) + true + } + .install() + } + + private fun processPaste(transferable: Transferable): Future<*> { + return processAttachments("prompt-paste", "editor", null, transferable, 0) + } + + private fun processAttachments( + kind: String, + area: String, + files: List?, + transferable: Transferable?, + sourceMs: Long, + ): Future<*> { + return ApplicationManager.getApplication().executeOnPooledThread { + val start = System.nanoTime() + try { + val list = files ?: transferable?.let { FileCopyPasteUtil.getFileList(it).orEmpty() }.orEmpty() + val image = transferable?.takeIf { list.isEmpty() && it.isDataFlavorSupported(DataFlavor.imageFlavor) } + ?.getTransferData(DataFlavor.imageFlavor) + ?.let(PromptAttachmentExtractor::image) + val items = PromptAttachmentExtractor.files(list) + listOfNotNull(image) + val ms = elapsedMs(start) + LOG.debug { "kind=$kind extract area=$area files=${list.size} image=${image != null} attachments=${items.size} extractMs=$ms sourceMs=$sourceMs" } + if (items.isEmpty()) return@executeOnPooledThread + ApplicationManager.getApplication().invokeLater { + if (project.isDisposed) return@invokeLater + LOG.debug { "kind=$kind attach area=$area files=${list.size} image=${image != null} attachments=${items.size} extractMs=$ms sourceMs=$sourceMs" } + items.forEach(::addAttachment) + } + } catch (e: Exception) { + LOG.warn("kind=$kind extract area=$area failed message=${e.message}", e) + } + } + } + + private fun dropFiles(event: DnDEvent): List { + if (!FileCopyPasteUtil.isFileListFlavorAvailable(event)) return emptyList() + return FileCopyPasteUtil.getFileListFromAttachedObject(event.attachedObject).orEmpty() + } + + private fun elapsedMs(start: Long) = (System.nanoTime() - start) / 1_000_000 + + private fun notify(text: String) { + com.intellij.notification.Notification("Kilo Code", text, com.intellij.notification.NotificationType.WARNING).notify(project) + } + @RequiresEdt private fun bindKeymap() { if (bus != null) return @@ -435,6 +599,8 @@ class PromptPanel( return KiloBundle.message("prompt.placeholder") } + private fun separator() = JBColor.namedColor("EditorTabs.underTabsBorderColor", JBUI.CurrentTheme.EditorTabs.borderColor()) + @RequiresEdt private fun syncEditorHeight() { val count = ApplicationManager.getApplication().runReadAction { editor.document.lineCount } @@ -552,22 +718,4 @@ class PromptPanel( } } - private inner class PromptShell : RoundedContentPanel( - JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), - ) { - private val focus = JBValue.UIInteger("Component.focusWidth", SessionUiStyle.View.Prompt.FOCUS_WIDTH) - - override fun contentColor() = style.editorScheme.defaultBackground - - override fun outlineColor() = if (UIUtil.isFocusAncestor(editor)) { - JBUI.CurrentTheme.Focus.focusColor() - } else { - SessionUiStyle.View.Outline.brightColor() - } - - override fun outlineWidth() = if (UIUtil.isFocusAncestor(editor)) focus.get() else SessionUiStyle.View.Outline.width() - - override fun cornerArc() = JBUI.scale(JBUI.getInt("Button.arc", SessionUiStyle.View.Prompt.CORNER_ARC)) - } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt index 9ffd77482dd..9203771e44c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt @@ -5,6 +5,7 @@ import com.intellij.ui.JBColor import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import java.awt.Color +import java.awt.Insets import javax.swing.border.Border /** Static style tokens owned by the chat session UI. */ @@ -16,7 +17,7 @@ object SessionUiStyle { /** Geometry for the transcript list and its scroll behavior. */ object SessionLayout { const val GAP = 3 - const val TRANSCRIPT_PADDING = 12 + val InnerInsets = Insets(UiStyle.Gap.md(), UiStyle.Gap.md(), UiStyle.Gap.sm(), UiStyle.Gap.sm()) const val TRANSCRIPT_SCROLLBAR_PADDING = 10 const val USER_PROMPT_INDENT = 100 const val SCROLL_INCREMENT = 48 @@ -77,6 +78,31 @@ object SessionUiStyle { const val SHELL_HORIZONTAL_PADDING = 8 } + /** Attachment preview card geometry. */ + object Attachment { + const val CARD_WIDTH = 80 + const val CARD_HEIGHT = 59 + const val CLOSE_SIZE = 18 + const val CORNER_ARC = 8 + } + + /** Full-session file drop overlay geometry and colors. */ + object DropOverlay { + const val CARD_VERTICAL_PADDING = 16 + const val CARD_HORIZONTAL_PADDING = 20 + const val CARD_ARC = 12 + const val LABEL_GAP = 2 + const val ICON_GAP = 10 + private const val SCRIM_ALPHA = 210 + + fun scrim(): Color = JBColor.lazy { + val bg = UiStyle.Colors.bg() + Color(bg.red, bg.green, bg.blue, SCRIM_ALPHA) + } + + fun card(): Color = UiStyle.Colors.contentBackground() + } + /** Reasoning block preview sizing. */ object Reasoning { const val BODY_LINES = 5 @@ -96,16 +122,16 @@ object SessionUiStyle { /** Markdown code block geometry inside assistant messages. */ object Code { - const val BLOCK_GAP = 6 + const val BLOCK_GAP = SessionLayout.GAP const val MIN_ROWS = 1 const val BORDER_WIDTH = 1 const val VIEWPORT_TOP_PADDING = 6 const val VIEWPORT_HORIZONTAL_PADDING = 8 - const val VIEWPORT_BOTTOM_PADDING = 0 + const val VIEWPORT_BOTTOM_PADDING = 6 const val SCROLLBAR_HEIGHT = 12 const val WIDTH_PADDING = 16 - fun topPadding(): Int = VIEWPORT_TOP_PADDING + SCROLLBAR_HEIGHT + fun topPadding(): Int = VIEWPORT_TOP_PADDING } /** Permission session-view command preview limits. */ diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/AttachmentView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/AttachmentView.kt new file mode 100644 index 00000000000..50f07635b1a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/AttachmentView.kt @@ -0,0 +1,79 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.session.model.Content +import ai.kilocode.client.session.model.FileAttachment +import ai.kilocode.client.session.ui.attachment.AttachmentCard +import ai.kilocode.client.session.ui.attachment.AttachmentCardItem +import ai.kilocode.client.session.views.base.PartView +import ai.kilocode.client.ui.UiStyle +import com.intellij.util.ui.JBUI +import java.awt.FlowLayout +import java.net.URI +import java.nio.file.Path + +class AttachmentView( + private var item: FileAttachment, + private val openAttachment: (FileAttachment) -> Unit, +) : PartView() { + constructor( + item: FileAttachment, + openFile: (String) -> Unit, + openUrl: (String) -> Unit, + ) : this(item, { openDefault(it, openFile, openUrl) }) + + override val contentId: String = item.id + private var chip = chip(item) + + init { + layout = FlowLayout(FlowLayout.LEFT, 0, UiStyle.Gap.xs()) + border = JBUI.Borders.empty(0, UiStyle.Gap.pad(), UiStyle.Gap.pad(), UiStyle.Gap.pad()) + add(chip) + } + + override fun update(content: Content) { + if (content !is FileAttachment) return + if (same(content)) { + item = content + return + } + item = content + remove(chip) + chip = chip(content) + add(chip) + revalidate() + repaint() + } + + override fun dumpLabel(): String = "AttachmentView#${item.id}:${name(item)}" + + private fun chip(item: FileAttachment) = AttachmentCard( + AttachmentCardItem(name(item), item.mime, item.url), + open = { openAttachment(item) }, + ) + + private fun same(next: FileAttachment) = item.mime == next.mime && item.url == next.url && item.filename == next.filename + + companion object { + fun openDefault(item: FileAttachment, openFile: (String) -> Unit, openUrl: (String) -> Unit) { + val url = item.url.takeIf { it.isNotBlank() } ?: return + val uri = runCatching { URI.create(url) }.getOrNull() ?: return + if (uri.scheme == "file") { + val path = runCatching { Path.of(uri).toString() }.getOrNull() ?: return + openFile(path) + return + } + openUrl(url) + } + } + + private fun name(item: FileAttachment) = item.filename?.takeIf { it.isNotBlank() } + ?: tail(item.url).takeIf { it.isNotBlank() } + ?: "attachment" + + private fun tail(value: String): String { + val clean = value.trimEnd('/', '\\') + val index = maxOf(clean.lastIndexOf('/'), clean.lastIndexOf('\\')) + if (index < 0) return clean + return clean.substring(index + 1) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt index d0b5e06b445..8506d867923 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.model.Content +import ai.kilocode.client.session.model.FileAttachment import ai.kilocode.client.session.model.Message import ai.kilocode.client.session.model.Reasoning import ai.kilocode.client.session.model.StepFinish @@ -15,6 +16,7 @@ import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.session.ui.style.SessionUiStyle import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer +import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI import java.awt.Graphics import java.awt.Graphics2D @@ -38,6 +40,7 @@ class MessageView( private var style: SessionEditorStyle = SessionEditorStyle.current(), private val openUrl: (String) -> Unit = {}, private val selection: SessionSelection? = null, + private val openAttachment: (String, FileAttachment) -> Unit = { _, item -> AttachmentView.openDefault(item, openFile, openUrl) }, private val resize: ((JComponent, () -> Unit) -> Unit)? = null, private val repo: String? = null, private val hover: ((PartView, Boolean) -> Unit)? = null, @@ -58,6 +61,7 @@ class MessageView( // so snapshot updates can append only deltas. private val aliases = LinkedHashMap() private val sources = LinkedHashMap() + private var attachments: PromptAttachmentView? = null private var hidden: ToolCallRef? = null init { @@ -112,6 +116,11 @@ class MessageView( } val existing = parts[content.id] if (existing != null) { + if (existing is PromptAttachmentView && content is FileAttachment) { + existing.upsert(content) + refresh() + return + } if (ViewFactory.shouldReplace(existing, content)) { replacePart(content, existing) return @@ -126,6 +135,10 @@ class MessageView( } private fun addPart(content: Content) { + if (content is FileAttachment && role == SessionUiStyle.View.Message.USER_ROLE) { + addAttachment(content) + return + } if (content is Reasoning) { val previous = parts.values.lastOrNull() if (previous is ReasoningView) { @@ -143,6 +156,19 @@ class MessageView( add(view) } + @RequiresEdt + private fun addAttachment(content: FileAttachment) { + val view = attachments ?: PromptAttachmentView(msg.info.id) { openAttachment(msg.info.id, it) }.also { + it.resize = resize + it.hover = hover + it.applyStyle(style) + attachments = it + add(it) + } + view.upsert(content) + parts[content.id] = view + } + private fun updateAlias(content: Reasoning, id: String) { val view = parts[id] as? ReasoningView ?: return val prev = sources[content.id].orEmpty() @@ -184,6 +210,14 @@ class MessageView( return } val view = parts.remove(contentId) ?: return + if (view is PromptAttachmentView) { + view.remove(contentId) + if (!view.isEmpty()) { + refresh() + return + } + attachments = null + } aliases.values.removeAll { it == contentId } sources.keys.removeAll { it !in aliases } detach(view) @@ -199,6 +233,7 @@ class MessageView( */ private fun isHidden(content: Content): Boolean { if (content !is Tool) return false + if (role == SessionUiStyle.View.Message.USER_ROLE && content.name == "read") return true if (content.name == "todoread") return true if (content.name == "todowrite" && content.state != ToolExecState.COMPLETED) return true val ref = hidden ?: return false @@ -212,7 +247,7 @@ class MessageView( * Called only when the hidden ref changes to avoid unnecessary rebuilds. */ private fun rebuildParts() { - parts.values.forEach { + parts.values.distinct().forEach { detach(it) remove(it) Disposer.dispose(it) @@ -220,6 +255,7 @@ class MessageView( parts.clear() aliases.clear() sources.clear() + attachments = null for ((_, content) in msg.parts) { if (content is StepFinish) continue if (isHidden(content)) continue @@ -235,9 +271,9 @@ class MessageView( } private fun view(content: Content) = if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) { - ViewFactory.createUser(content, openFile, openUrl, selection, repo) + ViewFactory.createUser(content, openFile, openUrl, selection, repo) { openAttachment(msg.info.id, it) } } else { - ViewFactory.create(content, openFile, openUrl, selection, repo) + ViewFactory.create(content, openFile, openUrl, selection, repo) { openAttachment(msg.info.id, it) } } /** Append a streaming delta to the renderer for [contentId]. */ @@ -288,7 +324,7 @@ class MessageView( val arc = JBUI.scale(JBUI.getInt("Button.arc", SessionUiStyle.View.Prompt.CORNER_ARC)) g2.color = style.editorScheme.defaultBackground g2.fillRoundRect(0, 0, width, height, arc, arc) - g2.color = SessionUiStyle.View.Outline.brightColor() + g2.color = SessionUiStyle.View.Outline.color() val w = width - 1 val h = height - 1 if (w > 0 && h > 0) g2.drawRoundRect(0, 0, w, h, arc, arc) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt index a64af5f93db..421b19f551d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt @@ -40,6 +40,7 @@ class PlanExitView(tool: Tool, openFile: (String) -> Unit, selection: SessionSel } override fun applyStyle(style: SessionEditorStyle) { + md.applyStyle(style) md.font = style.transcriptFont md.codeFont = style.editorFamily md.foreground = style.editorForeground diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptAttachmentView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptAttachmentView.kt new file mode 100644 index 00000000000..a01148a25fc --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptAttachmentView.kt @@ -0,0 +1,133 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.session.model.Content +import ai.kilocode.client.session.model.FileAttachment +import ai.kilocode.client.session.ui.attachment.AttachmentCard +import ai.kilocode.client.session.ui.attachment.AttachmentCardItem +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.base.PartView +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import java.awt.Dimension +import javax.swing.ScrollPaneConstants + +class PromptAttachmentView( + messageId: String, + private val openAttachment: (FileAttachment) -> Unit, +) : PartView() { + override val contentId: String = "attachments:$messageId" + + private val items = LinkedHashMap() + private val cards = LinkedHashMap() + private val row = Stack.horizontal(gap = UiStyle.Gap.sm()) + private val scroll = JBScrollPane(row).apply { + border = null + isOpaque = false + viewport.isOpaque = false + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED + verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER + } + + init { + isOpaque = false + border = JBUI.Borders.empty( + 0, + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), + ) + add(scroll) + } + + fun contains(id: String) = items.containsKey(id) + + fun isEmpty() = items.isEmpty() + + fun ids(): List = items.keys.toList() + + fun scrollPane(): JBScrollPane = scroll + + @RequiresEdt + fun upsert(item: FileAttachment) { + val old = items[item.id] + items[item.id] = item + if (old != null && same(old, item)) return + val next = card(item) + val prev = cards.put(item.id, next) + if (prev == null) { + row.next(next) + refresh() + return + } + val at = row.components.indexOfFirst { it === prev }.takeIf { it >= 0 } ?: return refresh() + row.remove(prev) + row.add(next, at) + refresh() + } + + @RequiresEdt + fun remove(id: String): Boolean { + val item = items.remove(id) ?: return false + cards.remove(item.id)?.let { row.remove(it) } + refresh() + return true + } + + override fun update(content: Content) { + if (content is FileAttachment) upsert(content) + } + + override fun getPreferredSize(): Dimension { + val ins = insets + val pref = scroll.preferredSize + return Dimension(0, pref.height + bar() + ins.top + ins.bottom) + } + + override fun getMinimumSize() = preferredSize + + override fun doLayout() { + val ins = insets + scroll.setBounds( + ins.left, + ins.top, + maxOf(0, width - ins.left - ins.right), + maxOf(0, height - ins.top - ins.bottom), + ) + } + + override fun dispose() { + row.removeAll() + cards.clear() + items.clear() + } + + override fun dumpLabel(): String = "PromptAttachmentView#$contentId[${items.keys.joinToString(",")}]" + + private fun refresh() { + revalidate() + repaint() + } + + private fun card(item: FileAttachment) = AttachmentCard( + AttachmentCardItem(name(item), item.mime, item.url), + open = { openAttachment(item) }, + ) + + private fun same(a: FileAttachment, b: FileAttachment) = a.mime == b.mime && a.url == b.url && a.filename == b.filename + + private fun bar() = scroll.horizontalScrollBar.preferredSize.height + + private fun name(item: FileAttachment) = item.filename?.takeIf { it.isNotBlank() } + ?: tail(item.url).takeIf { it.isNotBlank() } + ?: "attachment" + + private fun tail(value: String): String { + val clean = value.trimEnd('/', '\\') + val index = maxOf(clean.lastIndexOf('/'), clean.lastIndexOf('\\')) + if (index < 0) return clean + return clean.substring(index + 1) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt index 21afb1fcab7..796e18bddbb 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt @@ -317,7 +317,7 @@ class ReasoningBody( private fun reasoningParts(selection: SessionSelection? = null): ReasoningParts { val title = JBLabel(KiloBundle.message("session.part.reasoning")).apply { foreground = UiStyle.Colors.weak() } - val icon = JBLabel(SessionViewIcons.eye).apply { foreground = UiStyle.Colors.weak() } + val icon = JBLabel(SessionViewIcons.brain).apply { foreground = UiStyle.Colors.weak() } val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { isOpaque = false add(icon, BorderLayout.WEST) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionViewIcons.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionViewIcons.kt index c59f5d371fb..4ce4d3b423d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionViewIcons.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionViewIcons.kt @@ -1,15 +1,18 @@ package ai.kilocode.client.session.views import com.intellij.openapi.util.IconLoader +import javax.swing.Icon object SessionViewIcons { val brain = icon("brain") val bubble = icon("bubble-5") val bulletList = icon("bullet-list") val checklist = icon("checklist") - val chevronDown = icon("chevron-down") + val chevronDown: Icon = icon("chevron-down") val chevronLeft = icon("chevron-left") val chevronRight = icon("chevron-right") + val chevronCollapsed: Icon = chevronRight + val chevronExpanded: Icon = chevronDown val code = icon("code") val codeLines = icon("code-lines") val console = icon("console") diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt index fa56f6466c2..8b5c30eee0f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt @@ -4,7 +4,6 @@ import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Text import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.selection.SessionSelection -import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.ui.md.MdView import ai.kilocode.client.ui.md.MdViewFactory @@ -18,7 +17,7 @@ import java.awt.BorderLayout */ open class TextView( text: Text, - transparent: Boolean = false, + transparent: Boolean = true, openUrl: (String) -> Unit = {}, selection: SessionSelection? = null, ) : PartView() { @@ -73,7 +72,7 @@ open class TextView( protected open fun styleFont(style: SessionEditorStyle) = style.transcriptFont - protected open fun styleBackground(style: SessionEditorStyle) = SessionUiStyle.Transcript.bgColor() + protected open fun styleBackground(style: SessionEditorStyle) = style.editorBackground private fun refresh() { revalidate() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt index 459509ffc93..e00944d1282 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session.views +import ai.kilocode.client.session.model.FileAttachment import ai.kilocode.client.session.model.Message import ai.kilocode.client.session.ui.SessionLayoutPanel import ai.kilocode.client.session.ui.style.SessionEditorStyle @@ -27,6 +28,7 @@ class TurnView( private var style: SessionEditorStyle = SessionEditorStyle.current(), private val openUrl: (String) -> Unit = {}, private val selection: SessionSelection? = null, + private val openAttachment: (String, FileAttachment) -> Unit = { _, item -> AttachmentView.openDefault(item, openFile, openUrl) }, private val resize: ((JComponent, () -> Unit) -> Unit)? = null, private val repo: String? = null, private val hover: ((PartView, Boolean) -> Unit)? = null, @@ -42,7 +44,7 @@ class TurnView( /** Add a new [MessageView] for [msg] at the end of this turn. */ fun addMessage(msg: Message): MessageView { - val view = MessageView(msg, openFile, style, openUrl, selection, resize, repo, hover) + val view = MessageView(msg, openFile, style, openUrl, selection, openAttachment, resize, repo, hover) messages[msg.info.id] = view add(view) revalidate() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt index a13dde6e1ce..ba7a8c29dc0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt @@ -6,10 +6,12 @@ import ai.kilocode.client.session.views.question.QuestionResultView import ai.kilocode.client.session.views.tool.GlobToolView import ai.kilocode.client.session.views.tool.ReadToolView import ai.kilocode.client.session.views.tool.SearchToolView +import ai.kilocode.client.session.views.tool.ShellToolView import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.model.Compaction import ai.kilocode.client.session.model.Content +import ai.kilocode.client.session.model.FileAttachment import ai.kilocode.client.session.model.Generic import ai.kilocode.client.session.model.Reasoning import ai.kilocode.client.session.model.StepFinish @@ -31,25 +33,22 @@ object ViewFactory { openFile: (String) -> Unit, ): PartView = create(content, openFile, openUrl = {}, selection = null, repo = null) - fun create( - content: Content, - openFile: (String) -> Unit, - openUrl: (String) -> Unit, - ): PartView = create(content, openFile, openUrl, selection = null, repo = null) - fun create( content: Content, openFile: (String) -> Unit, openUrl: (String) -> Unit = {}, selection: SessionSelection? = null, repo: String? = null, + openAttachment: (FileAttachment) -> Unit = { AttachmentView.openDefault(it, openFile, openUrl) }, ): PartView = when (content) { is Text -> TextView(content, openUrl = openUrl, selection = selection) is Reasoning -> ReasoningView(content, openUrl = openUrl, selection = selection) + is FileAttachment -> AttachmentView(content, openAttachment) is Tool -> when { TodoWriteView.canRender(content) -> TodoWriteView(content) PlanExitView.canRender(content) -> PlanExitView(content, openFile, selection) QuestionResultView.canRender(content) -> QuestionResultView(content, selection) + ShellToolView.canRender(content) -> ShellToolView(content, selection = selection) GlobToolView.canRender(content) -> GlobToolView(content, selection = selection, repo = repo) SearchToolView.canRender(content) -> SearchToolView(content, selection = selection, repo = repo) ReadToolView.canRender(content) -> ReadToolView(content, openFile, selection = selection) @@ -65,21 +64,16 @@ object ViewFactory { openFile: (String) -> Unit, ): PartView = createUser(content, openFile, openUrl = {}, selection = null, repo = null) - fun createUser( - content: Content, - openFile: (String) -> Unit, - openUrl: (String) -> Unit, - ): PartView = createUser(content, openFile, openUrl, selection = null, repo = null) - fun createUser( content: Content, openFile: (String) -> Unit, openUrl: (String) -> Unit = {}, selection: SessionSelection? = null, repo: String? = null, + openAttachment: (FileAttachment) -> Unit = { AttachmentView.openDefault(it, openFile, openUrl) }, ): PartView = when (content) { is Text -> PromptView(content, openUrl = openUrl, selection = selection) - else -> create(content, openFile, openUrl, selection, repo) + else -> create(content, openFile, openUrl, selection, repo, openAttachment) } /** @@ -94,6 +88,8 @@ object ViewFactory { if (view is PlanExitView) return !PlanExitView.canRender(content) if (view !is PlanExitView && PlanExitView.canRender(content)) return true if (view is QuestionResultView) return !QuestionResultView.canRender(content) + if (view is ShellToolView) return !ShellToolView.canRender(content) || QuestionResultView.canRender(content) + if (view !is ShellToolView && ShellToolView.canRender(content)) return true if (view is GlobToolView) return !GlobToolView.canRender(content) || QuestionResultView.canRender(content) if (view !is GlobToolView && GlobToolView.canRender(content)) return true if (view is SearchToolView) return !SearchToolView.canRender(content) || QuestionResultView.canRender(content) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt index ac93b08e834..df67cbbca67 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt @@ -157,7 +157,7 @@ abstract class AbstractSessionPartView( } private fun syncArrow(): Boolean { - val icon = if (isExpanded()) SessionViewIcons.chevronDown else SessionViewIcons.chevronRight + val icon = if (isExpanded()) SessionViewIcons.chevronExpanded else SessionViewIcons.chevronCollapsed if (arrow.icon === icon) return false arrow.icon = icon return true diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt index f1c241141f8..0b50d798269 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt @@ -285,7 +285,7 @@ class QuestionResultView(tool: Tool, private val selection: SessionSelection? = } private fun syncArrow() { - arrow.icon = if (isExpanded()) SessionViewIcons.chevronDown else SessionViewIcons.chevronRight + arrow.icon = if (isExpanded()) SessionViewIcons.chevronExpanded else SessionViewIcons.chevronCollapsed } override fun setHovered(value: Boolean) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt index a2dcc25d158..78ff1af8fae 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt @@ -101,7 +101,7 @@ private fun todoParts(): TodoParts { val glyph = JBLabel(SessionViewIcons.checklist) val title = JBLabel(KiloBundle.message("session.part.todo.title")) val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } - val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { + val center = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply { isOpaque = false add(title, BorderLayout.WEST) add(sub, BorderLayout.CENTER) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt new file mode 100644 index 00000000000..7200420d4ea --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt @@ -0,0 +1,304 @@ +package ai.kilocode.client.session.views.tool + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.model.Content +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.ui.selection.SessionSelection +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.base.SecondarySessionPartView +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.md.MdCodeBlockBorder +import ai.kilocode.client.ui.md.MdCodeBlockFactory +import ai.kilocode.client.ui.md.MdCodeBlockOptions +import ai.kilocode.client.ui.md.MdViewFactory +import ai.kilocode.client.ui.md.hybrid.MdTerminal +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer +import com.intellij.ui.EditorTextField +import com.intellij.ui.components.JBHtmlPane +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import java.awt.Dimension +import javax.swing.JPanel +import javax.swing.ScrollPaneConstants + +class ShellToolView( + tool: Tool, + selection: SessionSelection? = null, + private val parts: ToolParts = toolParts(tool), + private val holder: ShellHolder = ShellHolder(tool, selection), +) : SecondarySessionPartView(parts.header, { holder.body().panel }) { + + override val contentId: String = tool.id + + private var item = tool + private var style = SessionEditorStyle.current() + + init { + holder.parent = this + bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot) + applyStyle(style) + sync() + } + + @RequiresEdt + override fun expand(): Boolean { + val changed = super.expand() + if (!changed) return false + syncBody() + holder.shell?.applyStyle(style) + return true + } + + @RequiresEdt + override fun getPreferredSize(): Dimension { + val size = super.getPreferredSize() + if (!bodyVisible()) return size + val height = row.preferredSize.height + (holder.shell?.panel?.preferredSize?.height ?: 0) + return Dimension(size.width, minOf(size.height, height)) + } + + @RequiresEdt + override fun update(content: Content) { + if (content !is Tool) return + val was = item.name + item = content + var changed = false + if (was != content.name || !canExpand(content)) changed = collapse() || changed + changed = sync() || changed + changed = syncBody() || changed + if (changed) refresh() + } + + @RequiresEdt + fun labelText(): String = listOf(parts.title.text, subtitleText(parts), parts.state.text) + .filter { it.isNotBlank() } + .joinToString(" ") + + @RequiresEdt + fun commandText(): String = command(item) + + @RequiresEdt + fun outputText(): String = clean(output(item)) + + @RequiresEdt + fun errorText(): String = clean(item.error.orEmpty()) + + @RequiresEdt + fun bodyText(): String = ShellContent(item).body + + @RequiresEdt + fun hasToggle(): Boolean = arrow.isVisible + + @RequiresEdt + internal fun bodyCreated() = holder.shell != null + + @RequiresEdt + internal fun bodyVisible() = holder.shell?.panel?.parent === this + + @RequiresEdt + internal fun markdown() = holder.shell?.markdown() ?: ShellContent(item).markdown + + @RequiresEdt + internal fun codeEditors(): List = holder.shell?.codeEditors() ?: emptyList() + + @RequiresEdt + internal fun commandFont() = codeEditors().firstOrNull()?.font ?: style.editorFont + + @RequiresEdt + internal fun titleFont() = parts.title.font + + @RequiresEdt + internal fun subtitleFont() = parts.sub.font + + @RequiresEdt + internal fun subtitleForeground() = parts.sub.foreground + + @RequiresEdt + internal fun stateFont() = parts.state.font + + @RequiresEdt + internal fun controlCount() = if (arrow.isVisible) 1 else 0 + + @RequiresEdt + internal fun mdComponent() = holder.shell?.mdComponent() + + @RequiresEdt + internal fun horizontalPolicy() = holder.shell?.scrolls()?.firstOrNull()?.horizontalScrollBarPolicy + ?: ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + + @RequiresEdt + override fun applyStyle(style: SessionEditorStyle) { + this.style = style + var changed = false + changed = setFont(parts.title, style.boldEditorFont) || changed + changed = setFont(parts.sub, style.transcriptFont) || changed + changed = setFont(parts.link, style.smallEditorFont) || changed + changed = setFont(parts.state, style.smallEditorFont) || changed + holder.shell?.let { changed = it.applyStyle(style) || changed } + if (changed) refresh() + } + + private fun sync(): Boolean { + val expand = canExpand(item) + var changed = false + changed = syncExpandable(expand) || changed + changed = setVisible(parts.state, !expand) || changed + changed = setIcon(parts.glyph, icon(item)) || changed + changed = setForeground(parts.glyph, color(item)) || changed + changed = setText(parts.title, title(item)) || changed + changed = setText(parts.sub, subtitle(item)) || changed + changed = setForeground(parts.title, titleColor(item)) || changed + changed = setForeground(parts.sub, UiStyle.Colors.weak()) || changed + changed = setText(parts.state, stateText(item)) || changed + changed = setForeground(parts.state, color(item)) || changed + return changed + } + + private fun syncBody(): Boolean { + val body = holder.shell ?: return false + return body.update(item) + } + + override fun dumpLabel() = "ShellToolView#$contentId(${labelText()})" + + companion object { + fun canRender(tool: Tool) = tool.name == "bash" + } +} + +class ShellHolder( + private val tool: Tool, + private val selection: SessionSelection?, +) { + var parent: Disposable? = null + var shell: ShellBody? = null + + @RequiresEdt + fun body(): ShellBody { + val current = shell + if (current != null) return current + val owner = parent ?: error("Shell holder has no parent") + return ShellBody(tool, selection, owner).also { + shell = it + Disposer.register(owner, it) + } + } +} + +class ShellBody( + tool: Tool, + selection: SessionSelection?, + parent: Disposable, +) : Disposable { + private val md = MdViewFactory.create( + SessionEditorStyle.current(), + selection, + MdCodeBlockFactory.default( + MdCodeBlockOptions( + border = MdCodeBlockBorder.Bottom, + maxLines = 15, + verticalPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, + editorOnly = true, + ), + ), + ) + val panel = md.component + + init { + Disposer.register(parent, md) + applyStyle(SessionEditorStyle.current()) + update(tool) + } + + @RequiresEdt + fun update(tool: Tool): Boolean { + val content = ShellContent(tool) + if (md.markdown() == content.markdown) return false + md.set(content.markdown) + styleShell() + return true + } + + @RequiresEdt + fun applyStyle(style: SessionEditorStyle): Boolean { + val before = md.font + md.applyStyle(style) + md.font = style.transcriptFont + md.foreground = style.editorForeground + md.background = style.editorBackground + md.preBg = style.editorBackground + md.codeFont = style.editorFamily + md.component.border = JBUI.Borders.empty() + styleShell() + return before != md.font + } + + @RequiresEdt + private fun styleShell() { + val root = md.component as? JPanel ?: return + root.components.filterIsInstance().forEach { + it.border = JBUI.Borders.emptyLeft(JBUI.scale(SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING)) + } + } + + @RequiresEdt + fun markdown() = md.markdown() + + @RequiresEdt + fun mdComponent() = md.component + + @RequiresEdt + fun scrolls(): List = (md.component as? JPanel)?.components?.filterIsInstance() ?: emptyList() + + @RequiresEdt + fun codeEditors(): List = scrolls().mapNotNull { it.viewport.view as? EditorTextField } + + override fun dispose() = Unit +} + +private data class ShellContent( + val command: String, + val output: String, + val error: String, + val rawOutput: String = output, + val rawError: String = error, +) { + constructor(tool: Tool) : this( + command(tool), + clean(output(tool)), + clean(tool.error.orEmpty()), + output(tool), + tool.error.orEmpty(), + ) + + val body: String = listOf(command, output, error).filter { it.isNotBlank() }.joinToString("\n\n") + + val markdown: String = buildString { + section(KiloBundle.message("session.part.tool.shell.command"), command, "shell-command") + section(KiloBundle.message("session.part.tool.shell.output"), rawOutput, outputLang(rawOutput)) + section(KiloBundle.message("session.part.tool.shell.error"), rawError, "ansi-stderr") + } +} + +private fun outputLang(text: String): String = if (MdTerminal.hasAnsi(text)) "ansi-stdout" else "shell-output" + +private fun StringBuilder.section(title: String, text: String, lang: String) { + if (text.isBlank()) return + if (isNotEmpty()) append("\n\n") + val fence = fence(text) + append("**").append(title).append("**\n\n") + append(fence).append(lang).append("\n") + append(text) + if (!text.endsWith('\n')) append('\n') + append(fence) +} + +private fun fence(text: String): String { + val size = Regex("`+").findAll(text).maxOfOrNull { it.value.length } ?: 0 + return "`".repeat(maxOf(3, size + 1)) +} + +private fun clean(text: String): String = MdTerminal.strip(MdTerminal.reduce(text, keepSgr = false)) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt index 81744bfb98b..0dd50b8932e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt @@ -14,6 +14,7 @@ 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 ai.kilocode.cli.KiloCliParser import ai.kilocode.log.KiloLog import com.intellij.openapi.Disposable import com.intellij.openapi.editor.EditorFactory @@ -209,10 +210,10 @@ class ToolBody private constructor( val disposable = Disposer.newDisposable("Tool body") val body = runCatching { val field = ToolField(preview(tool), SessionEditorStyle.current()).also { ed -> - ed.setDisposedWith(disposable) Disposer.register(disposable) { ed.getEditor(false)?.let(EditorFactory.getInstance()::releaseEditor) } + ed.setDisposedWith(disposable) } ToolBody(null, field, pane(field, true), disposable) }.getOrElse { err -> @@ -328,7 +329,7 @@ internal fun toolParts( add(link, LINK_CARD) } val state = JBLabel().apply { foreground = UiStyle.Colors.weak() } - val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { isOpaque = false } + val center = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply { isOpaque = false } val controls = Stack.horizontal() val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { isOpaque = false @@ -364,7 +365,7 @@ internal fun searchParts(count: Int): ToolParts { val state = JBLabel().apply { foreground = UiStyle.Colors.weak() } val stack = Stack.fitHorizontal(UiStyle.Gap.md()).apply { targets.forEach { next(it) } } val target = stack.align(HAlign.TRACK, VAlign.CENTER) - val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { + val center = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply { isOpaque = false minimumSize = JBUI.size(0, minimumSize.height) add(title, BorderLayout.WEST) @@ -539,19 +540,11 @@ internal data class Target( internal fun target(tool: Tool): Target? { val out = output(tool) if (out.isBlank()) return null - val path = tag(out, "path") ?: return null - val type = tag(out, "type") ?: return null + val path = KiloCliParser.tag(out, "path") ?: return null + val type = KiloCliParser.tag(out, "type") ?: return null return Target(path, type.lowercase()) } -private fun tag(text: String, name: String): String? = - Regex("<$name>\\s*([\\s\\S]*?)\\s*") - .find(text) - ?.groupValues - ?.getOrNull(1) - ?.trim() - ?.takeIf { it.isNotBlank() } - private fun shellTitle(tool: Tool): String = tool.input["description"]?.takeIf { it.isNotBlank() } ?: tool.metadata["description"]?.takeIf { it.isNotBlank() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/FilledBadgeIcon.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/FilledBadgeIcon.kt index 11e0e961a6d..8987154fa24 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/FilledBadgeIcon.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/FilledBadgeIcon.kt @@ -4,6 +4,7 @@ import com.intellij.util.ui.JBFont import com.intellij.util.ui.JBUI import java.awt.Color import java.awt.Component +import java.awt.Font import java.awt.Graphics import java.awt.Graphics2D import java.awt.RenderingHints @@ -14,9 +15,9 @@ internal class FilledBadgeIcon( internal val text: String, private val bg: Color, private val fg: Color, + private val font: Font = JBFont.small(), ) : Icon { override fun getIconWidth(): Int { - val font = JBFont.small() val width = font.getStringBounds(text, FontRenderContext(null, true, true)).width.toInt() return width + UiStyle.Gap.lg() * 2 } @@ -31,7 +32,7 @@ internal class FilledBadgeIcon( g2.color = bg g2.fillRoundRect(0, 0, iconWidth, iconHeight, iconHeight, iconHeight) g2.color = fg - g2.font = JBFont.small() + g2.font = font val fm = g2.fontMetrics val base = (iconHeight + fm.ascent - fm.descent) / 2 g2.drawString(text, UiStyle.Gap.lg(), base) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt index 311bee99c90..1ff400346b1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt @@ -108,7 +108,7 @@ open class LayeredOverlayPanel( override fun contains(x: Int, y: Int): Boolean { for (child in components) { - if (child.isVisible && child.bounds.contains(x, y)) return true + if (child.isVisible && child.bounds.contains(x, y) && child.contains(x - child.x, y - child.y)) return true } return false } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt index 76e815e71b0..7230ff2eda5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt @@ -1,6 +1,15 @@ package ai.kilocode.client.ui.md import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.ui.UiStyle +import com.intellij.openapi.editor.DefaultLanguageHighlighterColors +import com.intellij.openapi.editor.HighlighterColors +import com.intellij.openapi.editor.colors.CodeInsightColors +import com.intellij.openapi.editor.colors.ColorKey +import com.intellij.openapi.editor.colors.EditorColors +import com.intellij.openapi.editor.colors.TextAttributesKey +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil import java.awt.Color internal object MdCommon { @@ -33,32 +42,66 @@ internal object MdCommon { if (!opts.opaque) body.add("background: transparent") if (body.isNotEmpty()) rules.append("body { ${body.joinToString("; ")} } ") + rules.append("h1, h2, h3, h4, h5, h6 { color: ${hex(opts.headingFg)} } ") + rules.append("strong, b { color: ${hex(opts.strongFg)} } ") + rules.append("em, i { color: ${hex(opts.emphasisFg)} } ") rules.append("a { color: ${hex(opts.linkColor)} } ") + rules.append("ul, ol { color: ${hex(opts.listMarkerFg)} } ") + rules.append("li { color: ${hex(opts.foreground)} } ") rules.append("tt, code, samp, pre { font-family: '${css(opts.codeFont)}', monospace } ") - rules.append("pre { background: ${hex(opts.preBg)} } ") - rules.append("pre { color: ${hex(opts.preFg)} } ") - rules.append("code { background: ${hex(opts.codeBg)} } ") - rules.append("blockquote { border-left-color: ${hex(opts.quoteBorder)} } ") - rules.append("blockquote { color: ${hex(opts.quoteFg)} } ") + rules.append("code { background: ${hex(opts.codeBg)}; color: ${hex(opts.inlineCodeFg)} } ") + rules.append("pre { background: ${hex(opts.preBg)}; color: ${hex(opts.preFg)}; border-color: ${hex(opts.codeBorder)} } ") + rules.append("pre code { background: ${hex(opts.preBg)}; color: ${hex(opts.preFg)} } ") + rules.append("blockquote { border-left-color: ${hex(opts.quoteBorder)}; color: ${hex(opts.quoteFg)} } ") + rules.append("blockquote p { color: ${hex(opts.quoteFg)} } ") rules.append("th, td { border-color: ${hex(opts.tableBorder)} } ") + rules.append("th { color: ${hex(opts.tableHeaderFg)} } ") + rules.append("hr { border-color: ${hex(opts.hrColor)} } ") return rules.toString().trim() } - fun defaults(style: SessionEditorStyle) = MdStyle( - font = style.transcriptFont, - foreground = com.intellij.util.ui.UIUtil.getLabelForeground(), - background = style.editorScheme.defaultBackground, - linkColor = com.intellij.util.ui.JBUI.CurrentTheme.Link.Foreground.ENABLED, - codeBg = style.editorScheme.defaultBackground, - preBg = style.editorScheme.defaultBackground, - preFg = style.editorScheme.defaultForeground, - codeFont = style.editorFamily, - quoteBorder = com.intellij.ui.JBColor.border(), - quoteFg = com.intellij.util.ui.UIUtil.getContextHelpForeground(), - tableBorder = com.intellij.ui.JBColor.border(), - opaque = true, - ) + fun defaults(style: SessionEditorStyle): MdStyle { + val weak = fg(style, DefaultLanguageHighlighterColors.DOC_COMMENT) + ?: fg(style, DefaultLanguageHighlighterColors.LINE_COMMENT) + ?: UIUtil.getContextHelpForeground() + val border = color(style, EditorColors.PREVIEW_BORDER_COLOR) ?: UiStyle.Colors.contentBorder() + val blockBg = bg(style, DefaultLanguageHighlighterColors.DOC_CODE_BLOCK) ?: style.editorBackground + return MdStyle( + font = style.transcriptFont, + foreground = style.editorForeground, + background = style.editorBackground, + linkColor = fg(style, CodeInsightColors.HYPERLINK_ATTRIBUTES) ?: JBUI.CurrentTheme.Link.Foreground.ENABLED, + codeBg = bg(style, DefaultLanguageHighlighterColors.DOC_CODE_INLINE) + ?: bg(style, DefaultLanguageHighlighterColors.STRING) + ?: style.editorBackground, + preBg = blockBg, + preFg = fg(style, DefaultLanguageHighlighterColors.DOC_CODE_BLOCK) ?: style.editorForeground, + codeFont = style.editorFamily, + quoteBorder = border, + quoteFg = weak, + tableBorder = border, + headingFg = fg(style, CodeInsightColors.HYPERLINK_ATTRIBUTES) ?: style.editorForeground, + strongFg = fg(style, HighlighterColors.TEXT) ?: style.editorForeground, + emphasisFg = weak, + inlineCodeFg = fg(style, DefaultLanguageHighlighterColors.DOC_CODE_INLINE) + ?: fg(style, DefaultLanguageHighlighterColors.STRING) + ?: style.editorForeground, + listMarkerFg = weak, + hrColor = border, + tableHeaderFg = fg(style, HighlighterColors.TEXT) ?: style.editorForeground, + codeBorder = border, + opaque = true, + ) + } + + private fun fg(style: SessionEditorStyle, key: TextAttributesKey): Color? = + style.editorScheme.getAttributes(key)?.foregroundColor + + private fun bg(style: SessionEditorStyle, key: TextAttributesKey): Color? = + style.editorScheme.getAttributes(key)?.backgroundColor + + private fun color(style: SessionEditorStyle, key: ColorKey): Color? = style.editorScheme.getColor(key) } internal data class MdStyle( @@ -73,5 +116,13 @@ internal data class MdStyle( val quoteBorder: Color, val quoteFg: Color, val tableBorder: Color, + val headingFg: Color, + val strongFg: Color, + val emphasisFg: Color, + val inlineCodeFg: Color, + val listMarkerFg: Color, + val hrColor: Color, + val tableHeaderFg: Color, + val codeBorder: Color, val opaque: Boolean, ) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewFactory.kt index 259247aeb68..bcb444d5602 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewFactory.kt @@ -2,14 +2,41 @@ package ai.kilocode.client.ui.md import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.selection.SessionSelection +import javax.swing.ScrollPaneConstants object MdViewFactory { fun create(style: SessionEditorStyle = SessionEditorStyle.current(), selection: SessionSelection? = null): MdView = hybrid(style, selection) + fun create(style: SessionEditorStyle, selection: SessionSelection?, code: MdCodeBlockFactory): MdView = + hybrid(style, selection, code) + fun hybrid(style: SessionEditorStyle = SessionEditorStyle.current(), selection: SessionSelection? = null): MdView = MdViewHybrid(style, selection) + fun hybrid( + style: SessionEditorStyle, + selection: SessionSelection?, + code: MdCodeBlockFactory, + ): MdView = MdViewHybrid(style, selection, code) + + fun hybrid(code: MdCodeBlockFactory): MdView = hybrid(SessionEditorStyle.current(), null, code) + fun html(style: SessionEditorStyle = SessionEditorStyle.current(), selection: SessionSelection? = null): MdView = - MdViewHtmlPane(style, selection) + hybrid(style, selection) +} + +data class MdCodeBlockOptions( + val border: MdCodeBlockBorder = MdCodeBlockBorder.All, + val maxLines: Int? = null, + val verticalPolicy: Int = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, + val editorOnly: Boolean = false, +) + +enum class MdCodeBlockBorder { All, Horizontal, Bottom } + +data class MdCodeBlockFactory(val opts: MdCodeBlockOptions = MdCodeBlockOptions()) { + companion object { + fun default(opts: MdCodeBlockOptions = MdCodeBlockOptions()) = MdCodeBlockFactory(opts) + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHtmlPane.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHtmlPane.kt deleted file mode 100644 index 47756753f7d..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHtmlPane.kt +++ /dev/null @@ -1,344 +0,0 @@ -package ai.kilocode.client.ui.md - -import ai.kilocode.client.session.ui.style.SessionEditorStyle -import ai.kilocode.client.session.ui.selection.SessionSelection -import ai.kilocode.log.KiloLog -import com.intellij.openapi.Disposable -import com.intellij.openapi.util.Disposer -import com.intellij.ui.components.JBHtmlPane -import com.intellij.ui.components.JBHtmlPaneConfiguration -import com.intellij.ui.components.JBHtmlPaneStyleConfiguration -import org.commonmark.ext.autolink.AutolinkExtension -import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension -import org.commonmark.ext.gfm.tables.TablesExtension -import org.commonmark.parser.Parser -import org.commonmark.renderer.html.HtmlRenderer -import java.awt.Color -import java.awt.Font -import javax.swing.JComponent -import javax.swing.event.HyperlinkEvent -import javax.swing.text.html.StyleSheet - -@Suppress("UnstableApiUsage") -internal class MdViewHtmlPane( - style: SessionEditorStyle = SessionEditorStyle.current(), - private var selection: SessionSelection? = null, -) : MdView { - companion object { - private val LOG = KiloLog.create(MdViewHtmlPane::class.java) - } - - private val listeners = mutableListOf() - private val source = StringBuilder() - private var rendered = "" - private var style = style - private var reg: Disposable? = null - private var disposed = false - - private val extensions = listOf( - AutolinkExtension.create(), - TablesExtension.create(), - StrikethroughExtension.create(), - ) - - private val parser: Parser = Parser.builder().extensions(extensions).build() - - private val renderer: HtmlRenderer = HtmlRenderer.builder() - .extensions(extensions) - .escapeHtml(true) - .sanitizeUrls(true) - .build() - - private var fontOverride: Font? = null - private var foregroundOverride: Color? = null - private var backgroundOverride: Color? = null - private var linkColorOverride: Color? = null - private var codeBgOverride: Color? = null - private var preBgOverride: Color? = null - private var preFgOverride: Color? = null - private var codeFontOverride: String? = null - private var quoteBorderOverride: Color? = null - private var quoteFgOverride: Color? = null - private var tableBorderOverride: Color? = null - private var opaqueState = true - - private val pane: JBHtmlPane = JBHtmlPane( - JBHtmlPaneStyleConfiguration { - enableInlineCodeBackground = true - enableCodeBlocksBackground = true - }, - JBHtmlPaneConfiguration { - customStyleSheetProvider { buildOverrideStyleSheet() } - }, - ).apply { - isEditable = false - isOpaque = true - background = opts().background - - addHyperlinkListener { e -> - if (e.eventType != HyperlinkEvent.EventType.ACTIVATED) return@addHyperlinkListener - val href = e.description ?: return@addHyperlinkListener - val pt = (e.inputEvent as? java.awt.event.MouseEvent)?.point - dispatch(MdView.LinkEvent(href, pt)) - } - } - - init { - syncSelection() - } - - override val component: JComponent get() = pane - - override var font: Font - get() = fontOverride ?: opts().font - set(value) { - if (disposed) return - if (fontOverride == value) return - fontOverride = value - markDirty() - } - - override var foreground: Color - get() = foregroundOverride ?: opts().foreground - set(value) { - if (disposed) return - if (foregroundOverride == value) return - foregroundOverride = value - markDirty() - } - - override var background: Color - get() = backgroundOverride ?: opts().background - set(value) { - if (disposed) return - if (backgroundOverride == value) return - backgroundOverride = value - if (opaqueState) pane.background = value - markDirty() - } - - override var linkColor: Color - get() = linkColorOverride ?: opts().linkColor - set(value) { - if (disposed) return - if (linkColorOverride == value) return - linkColorOverride = value - markDirty() - } - - override var codeBg: Color - get() = codeBgOverride ?: opts().codeBg - set(value) { - if (disposed) return - if (codeBgOverride == value) return - codeBgOverride = value - markDirty() - } - - override var preBg: Color - get() = preBgOverride ?: opts().preBg - set(value) { - if (disposed) return - if (preBgOverride == value) return - preBgOverride = value - markDirty() - } - - override var preFg: Color - get() = preFgOverride ?: opts().preFg - set(value) { - if (disposed) return - if (preFgOverride == value) return - preFgOverride = value - markDirty() - } - - override var codeFont: String - get() = codeFontOverride ?: opts().codeFont - set(value) { - if (disposed) return - if (codeFontOverride == value) return - codeFontOverride = value - markDirty() - } - - override var quoteBorder: Color - get() = quoteBorderOverride ?: opts().quoteBorder - set(value) { - if (disposed) return - if (quoteBorderOverride == value) return - quoteBorderOverride = value - markDirty() - } - - override var quoteFg: Color - get() = quoteFgOverride ?: opts().quoteFg - set(value) { - if (disposed) return - if (quoteFgOverride == value) return - quoteFgOverride = value - markDirty() - } - - override var tableBorder: Color - get() = tableBorderOverride ?: opts().tableBorder - set(value) { - if (disposed) return - if (tableBorderOverride == value) return - tableBorderOverride = value - markDirty() - } - - override var opaque: Boolean - get() = opaqueState - set(value) { - if (disposed) return - if (opaqueState == value) return - opaqueState = value - pane.isOpaque = value - if (value) pane.background = background - markDirty() - } - - override fun applyStyle(style: SessionEditorStyle) { - if (disposed) return - this.style = style - selection?.applyStyle(style) - if (opaqueState) pane.background = background - markDirty() - } - - override fun setSelection(selection: SessionSelection?) { - if (disposed) return - if (this.selection === selection) return - reg?.let(Disposer::dispose) - reg = null - this.selection = selection - syncSelection() - } - - override fun resetStyles() { - if (disposed) return - fontOverride = null - foregroundOverride = null - backgroundOverride = null - linkColorOverride = null - codeBgOverride = null - preBgOverride = null - preFgOverride = null - codeFontOverride = null - quoteBorderOverride = null - quoteFgOverride = null - tableBorderOverride = null - opaqueState = true - pane.isOpaque = true - pane.background = background - markDirty() - } - - override fun set(text: String) { - if (disposed) return - if (source.toString() == text) return - source.clear() - source.append(text) - syncHtml() - } - - override fun append(delta: String) { - if (disposed) return - if (delta.isEmpty()) return - source.append(delta) - syncHtml() - } - - override fun clear() { - if (disposed) return - if (source.isEmpty() && rendered.isEmpty() && pane.text.isEmpty()) return - source.clear() - rendered = "" - pane.text = "" - } - - override fun addLinkListener(listener: MdView.LinkListener) { - if (disposed) return - listeners.add(listener) - } - - override fun removeLinkListener(listener: MdView.LinkListener) { - listeners.remove(listener) - } - - override fun markdown(): String = source.toString() - - override fun html(): String = rendered - - override fun overrideSheet(): String = MdCommon.rules(opts()) - - override fun simulateLink(href: String) { - if (disposed) return - dispatch(MdView.LinkEvent(href)) - } - - override fun dispose() { - disposed = true - listeners.clear() - source.clear() - rendered = "" - reg?.let(Disposer::dispose) - reg = null - pane.text = "" - } - - private fun syncSelection() { - reg = selection?.register(pane) - } - - private fun dispatch(event: MdView.LinkEvent) { - for (l in listeners) l.onLink(event) - } - - private fun markDirty() { - if (disposed) return - pane.reloadCssStylesheets() - if (source.isNotEmpty()) syncHtml() - } - - private fun syncHtml() { - if (disposed) return - val body = renderer.render(parser.parse(source.toString())) - if (rendered == body && pane.text == "$body") return - rendered = body - pane.text = "$body" - pane.caretPosition = 0 - } - - private fun buildOverrideStyleSheet(): StyleSheet { - val sheet = StyleSheet() - val rules = overrideSheet() - if (rules.isEmpty()) return sheet - try { - sheet.addRule(rules) - } catch (err: Exception) { - LOG.warn("kind=markdown css=true failed message=${err.message} rules=$rules", err) - } - return sheet - } - - private fun opts(): MdStyle { - val base = MdCommon.defaults(style) - return base.copy( - font = fontOverride ?: base.font, - foreground = foregroundOverride ?: base.foreground, - background = backgroundOverride ?: base.background, - linkColor = linkColorOverride ?: base.linkColor, - codeBg = codeBgOverride ?: base.codeBg, - preBg = preBgOverride ?: base.preBg, - preFg = preFgOverride ?: base.preFg, - codeFont = codeFontOverride ?: base.codeFont, - quoteBorder = quoteBorderOverride ?: base.quoteBorder, - quoteFg = quoteFgOverride ?: base.quoteFg, - tableBorder = tableBorderOverride ?: base.tableBorder, - opaque = opaqueState, - ) - } -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt index cd03b91b5c1..18f20bd6e9c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt @@ -1,946 +1,10 @@ package ai.kilocode.client.ui.md -import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.selection.SessionSelection -import ai.kilocode.client.session.ui.style.SessionUiStyle -import ai.kilocode.log.KiloLog -import com.intellij.openapi.Disposable -import com.intellij.openapi.editor.EditorFactory -import com.intellij.openapi.fileTypes.FileType -import com.intellij.openapi.fileTypes.FileTypeRegistry -import com.intellij.openapi.fileTypes.PlainTextFileType -import com.intellij.openapi.fileTypes.UnknownFileType -import com.intellij.openapi.project.ProjectManager -import com.intellij.openapi.util.Disposer -import com.intellij.ui.components.JBTextArea -import com.intellij.ui.components.JBHtmlPane -import com.intellij.ui.components.JBHtmlPaneConfiguration -import com.intellij.ui.components.JBHtmlPaneStyleConfiguration -import com.intellij.ui.components.JBScrollPane -import com.intellij.util.ui.JBUI -import org.commonmark.ext.autolink.AutolinkExtension -import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension -import org.commonmark.ext.gfm.tables.TablesExtension -import org.commonmark.node.AbstractVisitor -import org.commonmark.node.Block -import org.commonmark.node.Document -import org.commonmark.node.FencedCodeBlock -import org.commonmark.node.IndentedCodeBlock -import org.commonmark.node.Node -import org.commonmark.node.ThematicBreak -import org.commonmark.parser.Parser -import org.commonmark.renderer.html.HtmlRenderer -import java.awt.Color -import java.awt.Dimension -import java.awt.Font -import javax.swing.Box -import javax.swing.BoxLayout -import javax.swing.JComponent -import javax.swing.JPanel -import javax.swing.ScrollPaneConstants -import javax.swing.event.HyperlinkEvent -import javax.swing.text.html.StyleSheet +import ai.kilocode.client.session.ui.style.SessionEditorStyle -@Suppress("UnstableApiUsage") internal class MdViewHybrid( style: SessionEditorStyle = SessionEditorStyle.current(), - private var selection: SessionSelection? = null, -) : MdView { - companion object { - private val LOG = KiloLog.create(MdViewHybrid::class.java) - - private val FILES = mapOf( - "kt" to "kt", - "kotlin" to "kt", - "js" to "js", - "javascript" to "js", - "jsx" to "jsx", - "ts" to "ts", - "typescript" to "ts", - "tsx" to "tsx", - "java" to "java", - "py" to "py", - "python" to "py", - "sh" to "sh", - "bash" to "sh", - "shell" to "sh", - "json" to "json", - "xml" to "xml", - "html" to "html", - "css" to "css", - "md" to "md", - "markdown" to "md", - "yaml" to "yaml", - "yml" to "yaml", - "toml" to "toml", - "go" to "go", - "golang" to "go", - "rs" to "rs", - "rust" to "rs", - "rb" to "rb", - "ruby" to "rb", - "php" to "php", - "swift" to "swift", - "scala" to "scala", - "sql" to "sql", - "dockerfile" to "dockerfile", - "docker" to "dockerfile", - "gradle" to "gradle", - "kts" to "kts", - "c" to "c", - "h" to "h", - "cpp" to "cpp", - "c++" to "cpp", - "cc" to "cc", - "cxx" to "cxx", - "hpp" to "hpp", - "h++" to "hpp", - "cs" to "cs", - "csharp" to "cs", - "c#" to "cs", - "fs" to "fs", - "fsharp" to "fs", - "f#" to "fs", - "ps1" to "ps1", - "powershell" to "ps1", - "pwsh" to "ps1", - "bat" to "bat", - "batch" to "bat", - "cmd" to "bat", - "makefile" to "makefile", - "make" to "makefile", - "terraform" to "tf", - "tf" to "tf", - "hcl" to "hcl", - "vue" to "vue", - "svelte" to "svelte", - "graphql" to "graphql", - "proto" to "proto", - "ini" to "ini", - "properties" to "properties", - "diff" to "diff", - "patch" to "patch", - ) - } - - private val listeners = mutableListOf() - private val source = StringBuilder() - private var style = style - private var rendered = "" - private var disposed = false - private val blocks = mutableListOf() - private var openFence: Fence? = null - private var stale = false - - private val extensions = listOf( - AutolinkExtension.create(), - TablesExtension.create(), - StrikethroughExtension.create(), - ) - - private val parser: Parser = Parser.builder().extensions(extensions).build() - - private val renderer: HtmlRenderer = HtmlRenderer.builder() - .extensions(extensions) - .escapeHtml(true) - .sanitizeUrls(true) - .build() - - private var fontOverride: Font? = null - private var foregroundOverride: Color? = null - private var backgroundOverride: Color? = null - private var linkColorOverride: Color? = null - private var codeBgOverride: Color? = null - private var preBgOverride: Color? = null - private var preFgOverride: Color? = null - private var codeFontOverride: String? = null - private var quoteBorderOverride: Color? = null - private var quoteFgOverride: Color? = null - private var tableBorderOverride: Color? = null - private var opaqueState = true - - private val root = JPanel().apply { - layout = BoxLayout(this, BoxLayout.Y_AXIS) - isOpaque = true - background = opts().background - } - - override val component: JComponent get() = root - - override var font: Font - get() = fontOverride ?: opts().font - set(value) { - if (disposed) return - if (fontOverride == value) return - fontOverride = value - syncStyle() - } - - override var foreground: Color - get() = foregroundOverride ?: opts().foreground - set(value) { - if (disposed) return - if (foregroundOverride == value) return - foregroundOverride = value - syncStyle() - } - - override var background: Color - get() = backgroundOverride ?: opts().background - set(value) { - if (disposed) return - if (backgroundOverride == value) return - backgroundOverride = value - syncStyle() - } - - override var linkColor: Color - get() = linkColorOverride ?: opts().linkColor - set(value) { - if (disposed) return - if (linkColorOverride == value) return - linkColorOverride = value - syncStyle() - } - - override var codeBg: Color - get() = codeBgOverride ?: opts().codeBg - set(value) { - if (disposed) return - if (codeBgOverride == value) return - codeBgOverride = value - syncStyle() - } - - override var preBg: Color - get() = preBgOverride ?: opts().preBg - set(value) { - if (disposed) return - if (preBgOverride == value) return - preBgOverride = value - syncStyle() - } - - override var preFg: Color - get() = preFgOverride ?: opts().preFg - set(value) { - if (disposed) return - if (preFgOverride == value) return - preFgOverride = value - syncStyle() - } - - override var codeFont: String - get() = codeFontOverride ?: opts().codeFont - set(value) { - if (disposed) return - if (codeFontOverride == value) return - codeFontOverride = value - syncStyle() - } - - override var quoteBorder: Color - get() = quoteBorderOverride ?: opts().quoteBorder - set(value) { - if (disposed) return - if (quoteBorderOverride == value) return - quoteBorderOverride = value - syncStyle() - } - - override var quoteFg: Color - get() = quoteFgOverride ?: opts().quoteFg - set(value) { - if (disposed) return - if (quoteFgOverride == value) return - quoteFgOverride = value - syncStyle() - } - - override var tableBorder: Color - get() = tableBorderOverride ?: opts().tableBorder - set(value) { - if (disposed) return - if (tableBorderOverride == value) return - tableBorderOverride = value - syncStyle() - } - - override var opaque: Boolean - get() = opaqueState - set(value) { - if (disposed) return - if (opaqueState == value) return - opaqueState = value - syncStyle() - } - - override fun applyStyle(style: SessionEditorStyle) { - if (disposed) return - this.style = style - selection?.applyStyle(style) - syncStyle() - } - - override fun setSelection(selection: SessionSelection?) { - if (disposed) return - if (this.selection === selection) return - this.selection = selection - clearBlocks() - syncBlocks() - } - - override fun resetStyles() { - if (disposed) return - fontOverride = null - foregroundOverride = null - backgroundOverride = null - linkColorOverride = null - codeBgOverride = null - preBgOverride = null - preFgOverride = null - codeFontOverride = null - quoteBorderOverride = null - quoteFgOverride = null - tableBorderOverride = null - opaqueState = true - syncStyle() - } - - override fun set(text: String) { - if (disposed) return - if (source.toString() == text) return - source.clear() - source.append(text) - syncBlocks() - } - - override fun append(delta: String) { - if (disposed) return - if (delta.isEmpty()) return - val fence = openFence - val view = blocks.lastOrNull() as? CodeView - if (fence != null && view != null && clean(fence.char, delta)) { - source.append(delta) - view.grow(delta) - stale = true - root.revalidate() - root.repaint() - return - } - source.append(delta) - syncBlocks() - } - - override fun clear() { - if (disposed) return - if (source.isEmpty() && rendered.isEmpty() && root.componentCount == 0) return - source.clear() - rendered = "" - openFence = null - stale = false - clearBlocks() - root.revalidate() - root.repaint() - } - - override fun addLinkListener(listener: MdView.LinkListener) { - if (disposed) return - listeners.add(listener) - } - - override fun removeLinkListener(listener: MdView.LinkListener) { - listeners.remove(listener) - } - - override fun markdown(): String = source.toString() - - override fun html(): String { - if (!stale) return rendered - val out = project(source.toString()) - rendered = out.html - openFence = out.open - stale = false - return rendered - } - - override fun overrideSheet(): String = MdCommon.rules(opts()) - - override fun simulateLink(href: String) { - if (disposed) return - dispatch(MdView.LinkEvent(href)) - } - - override fun dispose() { - disposed = true - listeners.clear() - source.clear() - rendered = "" - openFence = null - stale = false - clearBlocks() - } - - private fun syncStyle() { - if (disposed) return - val opts = opts() - root.isOpaque = opts.opaque - if (opts.opaque) root.background = opts.background - for (view in blocks) view.style(opts) - root.revalidate() - root.repaint() - } - - private fun syncBlocks() { - if (disposed) return - val text = source.toString() - val out = project(text) - rendered = out.html - openFence = out.open - stale = false - val next = out.blocks - if (text.isEmpty()) { - openFence = null - clearBlocks() - root.revalidate() - root.repaint() - return - } - sync(next) - root.revalidate() - root.repaint() - } - - private fun clearBlocks() { - blocks.forEach { Disposer.dispose(it.disposable) } - blocks.clear() - root.removeAll() - } - - private fun sync(next: List) { - var at = 0 - while (at < blocks.size && at < next.size) { - val view = blocks[at] - val desc = next[at] - if (!view.compatible(desc)) break - view.update(desc) - at++ - } - removeBlocks(at) - for (desc in next.drop(at)) addBlock(view(desc)) - } - - private fun clean(char: Char, delta: String): Boolean { - if (delta.contains(char)) return false - val start = source.lastIndexOf("\n") + 1 - for (idx in start until source.length) { - if (source[idx] == char) return false - } - return true - } - - private fun removeBlocks(start: Int) { - if (start >= blocks.size) return - val idx = if (start == 0) 0 else start * 2 - 1 - while (root.componentCount > idx) root.remove(root.componentCount - 1) - val stale = blocks.drop(start) - repeat(blocks.size - start) { blocks.removeAt(blocks.lastIndex) } - stale.forEach { Disposer.dispose(it.disposable) } - } - - private fun addGap() { - if (root.componentCount == 0) return - root.add(Box.createVerticalStrut(JBUI.scale(SessionUiStyle.View.Code.BLOCK_GAP))) - } - - private fun addBlock(view: View) { - addGap() - view.component.alignmentX = JComponent.LEFT_ALIGNMENT - blocks.add(view) - root.add(view.component) - } - - private fun view(desc: Desc): View { - val disposable = Disposer.newDisposable("Markdown block") - return when (desc) { - is Desc.Html -> HtmlView(desc, htmlBlock(desc.body, disposable), disposable) - is Desc.Code -> CodeView(desc, codeBlock(desc.text, desc.file, disposable), disposable) - } - } - - private fun htmlBlock(body: String, disposable: Disposable): JBHtmlPane { - val opts = opts() - return JBHtmlPane( - JBHtmlPaneStyleConfiguration { - enableInlineCodeBackground = true - enableCodeBlocksBackground = true - }, - JBHtmlPaneConfiguration { - customStyleSheetProvider { sheet() } - }, - ).apply { - isEditable = false - isOpaque = opts.opaque - background = opts.background - text = "$body" - selection?.register(this, disposable) - addHyperlinkListener { e -> - if (e.eventType != HyperlinkEvent.EventType.ACTIVATED) return@addHyperlinkListener - val href = e.description ?: return@addHyperlinkListener - val pt = (e.inputEvent as? java.awt.event.MouseEvent)?.point - dispatch(MdView.LinkEvent(href, pt)) - } - } - } - - private fun codeBlock(text: String, file: FileType, disposable: Disposable): JBScrollPane { - val opts = opts() - val value = text.trimEnd('\n') - val field = runCatching { - CodeField(file, opts, text).also { ed -> - ed.setDisposedWith(disposable) - Disposer.register(disposable) { - ed.getEditor(false)?.let(EditorFactory.getInstance()::releaseEditor) - } - selection?.register(ed, disposable) - } - }.getOrElse { err -> - LOG.warn("kind=markdown codeEditor=true failed message=${err.message}", err) - textArea(text, opts, disposable) - } - sizeCodeField(field, value) - val pane = object : JBScrollPane(field) { - override fun doLayout() { - super.doLayout() - val view = viewport.view ?: return - val size = viewport.extentSize - if (size.height <= 0 || view.height == size.height) return - view.setSize(view.width.coerceAtLeast(size.width), size.height) - } - } - styleCodePane(pane, opts) - sizeCodePane(pane, field) - return pane - } - - private fun styleCodePane(pane: JBScrollPane, opts: MdStyle) { - pane.apply { - border = JBUI.Borders.customLine(opts.tableBorder, SessionUiStyle.View.Code.BORDER_WIDTH) - viewportBorder = JBUI.Borders.empty( - SessionUiStyle.View.Code.topPadding(), - SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING, - SessionUiStyle.View.Code.VIEWPORT_BOTTOM_PADDING, - SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING, - ) - isOpaque = opts.opaque - background = opts.preBg - viewport.background = opts.preBg - horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED - verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER - isWheelScrollingEnabled = true - setOverlappingScrollBar(false) - horizontalScrollBar.preferredSize = Dimension(0, JBUI.scale(SessionUiStyle.View.Code.SCROLLBAR_HEIGHT)) - horizontalScrollBar.isOpaque = true - verticalScrollBar.preferredSize = JBUI.emptySize() - } - } - - private fun sizeCodeField(component: JComponent, text: String) { - val height = codeHeight(component, text) - val width = codeWidth(component, text) - component.preferredSize = Dimension(width, height) - component.minimumSize = Dimension(0, height) - component.maximumSize = Dimension(Int.MAX_VALUE, height) - } - - private fun sizeCodePane(pane: JBScrollPane, component: JComponent) { - val pad = pane.viewportBorder.getBorderInsets(pane) - val height = component.preferredSize.height + pane.insets.top + pane.insets.bottom + - pad.top + pad.bottom + pane.horizontalScrollBar.preferredSize.height - pane.preferredSize = Dimension(0, height) - pane.minimumSize = Dimension(0, height) - pane.maximumSize = Dimension(Int.MAX_VALUE, height) - } - - private fun codeWidth(component: JComponent, text: String): Int { - val metrics = component.getFontMetrics(component.font) - val width = text.lineSequence().maxOfOrNull { metrics.stringWidth(it) } ?: 0 - return width + JBUI.scale(SessionUiStyle.View.Code.WIDTH_PADDING) - } - - private fun codeHeight(component: JComponent, text: String): Int { - val count = text.lineSequence().count() - val rows = count.coerceAtLeast(SessionUiStyle.View.Code.MIN_ROWS) - val field = component as? CodeField - if (field != null) { - field.ensureWillComputePreferredSize() - val ed = field.getEditor(false) - val line = ed?.lineHeight ?: component.getFontMetrics(component.font).height - return maxOf(field.preferredSize.height, line * rows) - } - val line = component.getFontMetrics(component.font).height - return line * rows - } - - private fun textArea(text: String, opts: MdStyle, disposable: Disposable) = JBTextArea(text.trimEnd('\n')).apply { - isEditable = false - lineWrap = false - styleTextArea(this, opts) - border = JBUI.Borders.empty( - SessionUiStyle.View.Code.VIEWPORT_TOP_PADDING, - SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING, - ) - selection?.register(this, disposable) - } - - private fun styleTextArea(area: JBTextArea, opts: MdStyle) { - area.isOpaque = opts.opaque - area.background = opts.preBg - area.foreground = opts.preFg - area.font = style.editorFont - } - - private inner class CodeField(file: FileType, opts: MdStyle, value: String) : - com.intellij.ui.EditorTextField( - EditorFactory.getInstance().createDocument(value.trimEnd('\n')), - ProjectManager.getInstance().defaultProject, - file, - true, - false, - ) { - init { - setFontInheritedFromLAF(false) - font = style.editorFont - addSettingsProvider { ed -> - style.applyToEditor(ed) - ed.setBorder(JBUI.Borders.empty()) - ed.scrollPane.border = JBUI.Borders.empty() - ed.scrollPane.viewportBorder = JBUI.Borders.empty() - ed.backgroundColor = opts.preBg - ed.scrollPane.background = opts.preBg - ed.scrollPane.viewport.background = opts.preBg - ed.settings.isUseSoftWraps = false - ed.settings.isAdditionalPageAtBottom = false - ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER - ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER - } - } - } - - private fun file(lang: String?): FileType { - val key = lang?.trim()?.split(Regex("\\s+"))?.firstOrNull()?.lowercase().orEmpty() - val ext = FILES[key] ?: return PlainTextFileType.INSTANCE - val type = FileTypeRegistry.getInstance().getFileTypeByExtension(ext) - if (type == UnknownFileType.INSTANCE) return PlainTextFileType.INSTANCE - return type - } - - private fun dispatch(event: MdView.LinkEvent) { - for (l in listeners) l.onLink(event) - } - - private fun sheet(): StyleSheet { - val sheet = StyleSheet() - val rules = overrideSheet() - if (rules.isEmpty()) return sheet - try { - sheet.addRule(rules) - } catch (err: Exception) { - LOG.warn("kind=markdown css=true failed message=${err.message} rules=$rules", err) - } - return sheet - } - - private fun opts(): MdStyle { - val base = MdCommon.defaults(style) - return base.copy( - font = fontOverride ?: base.font, - foreground = foregroundOverride ?: base.foreground, - background = backgroundOverride ?: base.background, - linkColor = linkColorOverride ?: base.linkColor, - codeBg = codeBgOverride ?: base.codeBg, - preBg = preBgOverride ?: base.preBg, - preFg = preFgOverride ?: base.preFg, - codeFont = codeFontOverride ?: base.codeFont, - quoteBorder = quoteBorderOverride ?: base.quoteBorder, - quoteFg = quoteFgOverride ?: base.quoteFg, - tableBorder = tableBorderOverride ?: base.tableBorder, - opaque = opaqueState, - ) - } - - private fun collect(doc: Node): List { - val visitor = Visitor() - doc.accept(visitor) - return visitor.blocks - } - - private fun project(text: String): Projection { - val blocks = mutableListOf() - val html = StringBuilder() - val md = StringBuilder() - val lines = lines(text) - var trailing: Fence? = null - var idx = 0 - - fun flush() { - if (md.isEmpty()) return - val doc = parser.parse(md.toString()) - val descs = collect(doc) - blocks.addAll(descs) - for (desc in descs) { - when (desc) { - is Desc.Html -> html.append(desc.body) - is Desc.Code -> html.append(codeHtml(desc.text)) - } - } - md.clear() - } - - while (idx < lines.size) { - val line = lines[idx] - val open = opener(line.text) - if (open == null) { - val pending = idx == lines.lastIndex && pendingOpener(line.text) - if (pending) { - flush() - blocks.add(Desc.Code("", PlainTextFileType.INSTANCE)) - html.append(codeHtml("")) - } else { - md.append(line.text).append(line.end) - } - idx++ - continue - } - - flush() - idx++ - val code = StringBuilder() - var closed = false - var trimmed = false - while (idx < lines.size) { - val item = lines[idx] - val close = closer(item.text, open) - if (close) { - closed = true - idx++ - break - } - val partial = idx == lines.lastIndex && partialCloser(item.text, open) - if (partial) trimmed = true - if (!partial) code.append(item.text).append(item.end) - idx++ - } - val desc = Desc.Code(code.toString(), file(open.info)) - blocks.add(desc) - html.append(codeHtml(desc.text)) - trailing = if (!closed && !trimmed) open else null - } - - flush() - return Projection(html.toString(), blocks, trailing) - } - - private fun lines(text: String): List { - if (text.isEmpty()) return emptyList() - val lines = mutableListOf() - var start = 0 - while (start < text.length) { - val end = text.indexOf('\n', start) - if (end == -1) { - lines.add(Line(text.substring(start), "")) - break - } - lines.add(Line(text.substring(start, end), "\n")) - start = end + 1 - } - return lines - } - - private fun opener(text: String): Fence? { - val trimmed = text.dropWhile { it == ' ' } - val indent = text.length - trimmed.length - if (indent > 3) return null - val char = trimmed.firstOrNull() ?: return null - if (char != '`' && char != '~') return null - val size = trimmed.takeWhile { it == char }.length - if (size < 3) return null - val info = trimmed.drop(size).trim() - if (char == '`' && info.contains('`')) return null - return Fence(char, size, info) - } - - private fun closer(text: String, fence: Fence): Boolean { - val trimmed = text.dropWhile { it == ' ' } - val indent = text.length - trimmed.length - if (indent > 3) return false - val size = trimmed.takeWhile { it == fence.char }.length - if (size < fence.size) return false - return trimmed.drop(size).isBlank() - } - - private fun pendingOpener(text: String): Boolean { - val trimmed = text.dropWhile { it == ' ' } - val indent = text.length - trimmed.length - if (indent > 3) return false - val char = trimmed.firstOrNull() ?: return false - if (char != '`' && char != '~') return false - val size = trimmed.takeWhile { it == char }.length - if (size !in 1..2) return false - return trimmed.drop(size).isBlank() - } - - private fun partialCloser(text: String, fence: Fence): Boolean { - val trimmed = text.dropWhile { it == ' ' } - val indent = text.length - trimmed.length - if (indent > 3) return false - val size = trimmed.takeWhile { it == fence.char }.length - if (size !in 1 until fence.size) return false - return trimmed.drop(size).isBlank() - } - - private fun codeHtml(text: String): String = "
${escape(text)}
\n" - - private fun escape(text: String): String = text - .replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace("\"", """) - - private sealed class Desc { - data class Html(val body: String) : Desc() - data class Code(val text: String, val file: FileType) : Desc() - } - - private data class Projection(val html: String, val blocks: List, val open: Fence?) - - private data class Line(val text: String, val end: String) - - private data class Fence(val char: Char, val size: Int, val info: String) - - private abstract inner class View( - var desc: Desc, - val component: JComponent, - val disposable: Disposable, - ) { - abstract fun compatible(desc: Desc): Boolean - abstract fun update(desc: Desc) - abstract fun style(opts: MdStyle) - } - - private inner class HtmlView(desc: Desc.Html, private val pane: JBHtmlPane, disposable: Disposable) : - View(desc, pane, disposable) { - override fun compatible(desc: Desc) = desc is Desc.Html - - override fun update(desc: Desc) { - if (this.desc == desc) return - this.desc = desc - pane.text = "${(desc as Desc.Html).body}" - } - - override fun style(opts: MdStyle) { - pane.isOpaque = opts.opaque - pane.background = opts.background - pane.reloadCssStylesheets() - val item = desc as Desc.Html - pane.text = "${item.body}" - } - } - - private inner class CodeView(desc: Desc.Code, private val pane: JBScrollPane, disposable: Disposable) : - View(desc, pane, disposable) { - override fun compatible(desc: Desc) = desc is Desc.Code && (this.desc as Desc.Code).file == desc.file - - override fun update(desc: Desc) { - if (this.desc == desc) return - this.desc = desc - val value = (desc as Desc.Code).text.trimEnd('\n') - val view = pane.viewport.view - when (view) { - is CodeField -> view.text = value - is JBTextArea -> view.text = value - } - if (view is JComponent) { - sizeCodeField(view, value) - sizeCodePane(pane, view) - } - } - - fun grow(delta: String) { - val item = desc as Desc.Code - val next = item.copy(text = item.text + delta) - desc = next - val value = next.text.trimEnd('\n') - val view = pane.viewport.view - when (view) { - is CodeField -> view.text = value - is JBTextArea -> view.text = value - } - if (view is JComponent) { - sizeCodeField(view, value) - sizeCodePane(pane, view) - } - } - - override fun style(opts: MdStyle) { - styleCodePane(pane, opts) - val view = pane.viewport.view - when (view) { - is CodeField -> { - view.font = style.editorFont - view.background = opts.preBg - } - is JBTextArea -> styleTextArea(view, opts) - } - if (view is JComponent) { - val text = when (view) { - is CodeField -> view.text - is JBTextArea -> view.text - else -> "" - } - sizeCodeField(view, text) - sizeCodePane(pane, view) - } - } - } - - private inner class Visitor : AbstractVisitor() { - val blocks = mutableListOf() - private val run = StringBuilder() - - override fun visit(document: Document) { - visitChildren(document) - flush() - } - - override fun visit(code: FencedCodeBlock) { - flush() - blocks.add(Desc.Code(code.literal, file(code.info))) - } - - override fun visit(code: IndentedCodeBlock) { - flush() - blocks.add(Desc.Code(code.literal, file(null))) - } - - private fun flush() { - if (run.isEmpty()) return - blocks.add(Desc.Html(run.toString())) - run.clear() - } - - public override fun visitChildren(parent: Node) { - var child = parent.firstChild - while (child != null) { - val next = child.next - if (child is ThematicBreak) { - child = next - continue - } - if (child is FencedCodeBlock || child is IndentedCodeBlock) child.accept(this) - if (child is Block && child !is FencedCodeBlock && child !is IndentedCodeBlock) run.append(renderer.render(child)) - child = next - } - } - } -} + selection: SessionSelection? = null, + code: MdCodeBlockFactory = MdCodeBlockFactory.default(), +) : ai.kilocode.client.ui.md.hybrid.MdViewHybrid(style, selection, code) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdLanguage.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdLanguage.kt new file mode 100644 index 00000000000..4acb36e8d12 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdLanguage.kt @@ -0,0 +1,78 @@ +package ai.kilocode.client.ui.md.hybrid + +import com.intellij.openapi.fileTypes.FileType +import com.intellij.openapi.fileTypes.FileTypeRegistry +import com.intellij.openapi.fileTypes.PlainTextFileType +import com.intellij.openapi.fileTypes.UnknownFileType + +internal sealed class Kind { + data class Source(val file: FileType) : Kind() + data class Terminal(val stream: Stream, val mode: Mode) : Kind() +} + +internal enum class Stream { Stdout, Stderr } + +internal enum class Mode { Ansi, Shell, Command } + +internal object MdLanguage { + /** Internal terminal fence tags produced by ShellToolView shell transcript markdown. */ + private val terms = mapOf( + "ansi" to Kind.Terminal(Stream.Stdout, Mode.Ansi), + "ansi-stdout" to Kind.Terminal(Stream.Stdout, Mode.Ansi), + "terminal" to Kind.Terminal(Stream.Stdout, Mode.Ansi), + "terminal-output" to Kind.Terminal(Stream.Stdout, Mode.Ansi), + "shell-command" to Kind.Terminal(Stream.Stdout, Mode.Command), + "shell-output" to Kind.Terminal(Stream.Stdout, Mode.Shell), + "ansi-stderr" to Kind.Terminal(Stream.Stderr, Mode.Ansi), + "terminal-error" to Kind.Terminal(Stream.Stderr, Mode.Ansi), + "shell-error" to Kind.Terminal(Stream.Stderr, Mode.Ansi), + ) + + // Alias layer only; canonical extensions fall through to FileTypeRegistry below. + private val files = mapOf( + "kotlin" to "kt", + "javascript" to "js", + "typescript" to "ts", + "python" to "py", + "bash" to "sh", + "shell" to "sh", + "zsh" to "sh", + "shellscript" to "sh", + "markdown" to "md", + "yml" to "yaml", + "golang" to "go", + "rust" to "rs", + "ruby" to "rb", + "docker" to "dockerfile", + "c++" to "cpp", + "h++" to "hpp", + "csharp" to "cs", + "c#" to "cs", + "fsharp" to "fs", + "f#" to "fs", + "powershell" to "ps1", + "pwsh" to "ps1", + "batch" to "bat", + "cmd" to "bat", + "make" to "makefile", + "terraform" to "tf", + ) + + fun kind(lang: String?): Kind { + val key = lang?.trim()?.split(Regex("\\s+"))?.take(2)?.joinToString(" ")?.lowercase().orEmpty() + terms[key]?.let { return it } + if (key == "shell script") return Kind.Source(type("sh")) + val single = key.substringBefore(' ') + terms[single]?.let { return it } + files[key]?.let { return Kind.Source(type(it)) } + files[single]?.let { return Kind.Source(type(it)) } + type(key).takeIf { it != PlainTextFileType.INSTANCE }?.let { return Kind.Source(it) } + return Kind.Source(type(single)) + } + + private fun type(ext: String): FileType { + val type = FileTypeRegistry.getInstance().getFileTypeByExtension(ext) + if (type == UnknownFileType.INSTANCE) return PlainTextFileType.INSTANCE + return type + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdShellHighlight.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdShellHighlight.kt new file mode 100644 index 00000000000..e1754aaf63a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdShellHighlight.kt @@ -0,0 +1,79 @@ +package ai.kilocode.client.ui.md.hybrid + +import com.intellij.openapi.editor.DefaultLanguageHighlighterColors +import com.intellij.openapi.editor.colors.TextAttributesKey + +internal data class ShellRange(val start: Int, val end: Int, val key: TextAttributesKey) + +internal data class ShellDisplay(val text: String, val ranges: List) + +internal object MdShellHighlight { + private val hash = Regex("(?m)^[0-9a-f]{7,40}(?=\\s)") + private val commit = Regex("^[0-9a-f]{7,40}(?:\\s|$)") + private val file = Regex("^\\s+.+\\s+\\|\\s+\\d+") + private val summary = Regex("^\\d+ files? changed(?:,|$)") + private val refs = Regex("\\((?:HEAD|origin|main|master|develop|release|feature|bugfix|hotfix|[^)]+/[^)]+)[^)]*\\)") + private val plus = Regex("\\+{2,}") + private val minus = Regex("-{2,}") + private val insertions = Regex("\\b\\d+ insertions?\\(\\+\\)") + private val deletions = Regex("\\b\\d+ deletions?\\(-\\)") + private val meta = Regex("(?m)^<(?:shell_metadata|/shell_metadata)>$") + private val cut = Regex("(?m)^\\.\\.\\.output truncated\\.\\.\\.$") + private val cmd = Regex("(?m)(^|[|&;]\\s*)([A-Za-z_./~][A-Za-z0-9_./~+-]*)") + private val flag = Regex("(?() + var grouped = false + var stat = false + + for (line in text.lines()) { + val header = commit.containsMatchIn(line) + if (header && grouped && stat && out.lastOrNull()?.isNotEmpty() == true) out.add("") + out.add(line) + if (header) grouped = true + stat = line.isNotBlank() && (file.containsMatchIn(line) || summary.containsMatchIn(line)) + } + + val display = out.joinToString("\n") + return ShellDisplay(display, ranges(display)) + } + + fun command(text: String) = ShellDisplay(text, commandRanges(text)) + + fun ranges(text: String): List = buildList { + fun add(regex: Regex, key: TextAttributesKey) { + regex.findAll(text).forEach { match -> + add(ShellRange(match.range.first, match.range.last + 1, key)) + } + } + + add(hash, DefaultLanguageHighlighterColors.NUMBER) + add(refs, DefaultLanguageHighlighterColors.KEYWORD) + add(insertions, DefaultLanguageHighlighterColors.STRING) + add(deletions, DefaultLanguageHighlighterColors.LINE_COMMENT) + add(plus, DefaultLanguageHighlighterColors.STRING) + add(minus, DefaultLanguageHighlighterColors.LINE_COMMENT) + add(meta, DefaultLanguageHighlighterColors.DOC_COMMENT) + add(cut, DefaultLanguageHighlighterColors.KEYWORD) + } + + private fun commandRanges(text: String): List = buildList { + cmd.findAll(text).forEach { match -> + val group = match.groups[2] ?: return@forEach + add(ShellRange(group.range.first, group.range.last + 1, DefaultLanguageHighlighterColors.FUNCTION_CALL)) + } + flag.findAll(text).forEach { match -> + add(ShellRange(match.range.first, match.range.last + 1, DefaultLanguageHighlighterColors.KEYWORD)) + } + string.findAll(text).forEach { match -> + add(ShellRange(match.range.first, match.range.last + 1, DefaultLanguageHighlighterColors.STRING)) + } + env.findAll(text).forEach { match -> + val group = match.groups[2] ?: return@forEach + add(ShellRange(group.range.first, group.range.last + 1, DefaultLanguageHighlighterColors.STATIC_FIELD)) + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdTerminal.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdTerminal.kt new file mode 100644 index 00000000000..2422c9b9810 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdTerminal.kt @@ -0,0 +1,101 @@ +package ai.kilocode.client.ui.md.hybrid + +import com.intellij.execution.process.AnsiEscapeDecoder +import com.intellij.execution.process.ProcessOutputTypes +import com.intellij.openapi.util.Key + +internal data class Range(val start: Int, val end: Int, val key: Key<*>) + +internal data class Term(val text: String, val ranges: List) + +internal object MdTerminal { + private val ansi = Regex("\\u001B\\[[0-?]*[ -/]*[@-~]") + + fun decode(text: String, stream: Stream): Term { + val out = StringBuilder() + val ranges = mutableListOf() + val key = when (stream) { + Stream.Stdout -> ProcessOutputTypes.STDOUT + Stream.Stderr -> ProcessOutputTypes.STDERR + } + // AnsiEscapeDecoder handles SGR coloring; full terminal emulation is too heavy for inline transcripts. + AnsiEscapeDecoder().escapeText(reduce(text, keepSgr = true), key) { chunk, attrs -> + val start = out.length + out.append(chunk) + val end = out.length + if (start != end) ranges.add(Range(start, end, attrs)) + } + return Term(out.toString().trimEnd('\n'), ranges) + } + + fun split(text: String, delim: Char): List { + val list = mutableListOf() + var start = 0 + while (true) { + val index = text.indexOf(delim, start) + if (index < 0) { + list.add(text.substring(start)) + return list + } + list.add(text.substring(start, index)) + start = index + 1 + } + } + + fun backspace(text: String): String { + val out = StringBuilder() + var idx = 0 + while (idx < text.length) { + val ch = text[idx++] + if (ch == '\b') { + if (out.isNotEmpty()) out.deleteCharAt(out.length - 1) + continue + } + out.append(ch) + } + return out.toString() + } + + fun reduce(text: String, keepSgr: Boolean): String = split(text.replace("\r\n", "\n"), '\n') + .joinToString("\n") { controls(it, keepSgr) } + + fun strip(text: String): String = ansi.replace(text, "") + + fun hasAnsi(text: String): Boolean = ansi.containsMatchIn(text) + + private fun controls(text: String, keepSgr: Boolean): String { + val out = StringBuilder() + val src = text + var idx = 0 + fun esc(): String? { + if (src[idx] != '\u001B') return null + if (idx + 1 >= src.length || src[idx + 1] != '[') { + idx++ + return "" + } + var end = idx + 2 + while (end < src.length && src[end] !in '@'..'~') end++ + if (end >= src.length) { + idx = src.length + return "" + } + val seq = src.substring(idx, end + 1) + idx = end + 1 + return if (keepSgr && seq.endsWith('m')) seq else "" + } + while (idx < src.length) { + val seq = esc() + if (seq != null) { + out.append(seq) + continue + } + when (val ch = src[idx++]) { + '\r' -> out.clear() + '\b' -> if (out.isNotEmpty()) out.deleteCharAt(out.length - 1) + '\t' -> out.append(ch) + else -> if (!ch.isISOControl()) out.append(ch) + } + } + return out.toString() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt new file mode 100644 index 00000000000..d27b2b62327 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt @@ -0,0 +1,1068 @@ +package ai.kilocode.client.ui.md.hybrid + +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.selection.SessionSelection +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.ui.md.MdCodeBlockBorder +import ai.kilocode.client.ui.md.MdCodeBlockFactory +import ai.kilocode.client.ui.md.MdCommon +import ai.kilocode.client.ui.md.MdStyle +import ai.kilocode.client.ui.md.MdView +import ai.kilocode.log.KiloLog +import com.intellij.execution.ui.ConsoleViewContentType +import com.intellij.openapi.Disposable +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.editor.markup.HighlighterLayer +import com.intellij.openapi.editor.markup.HighlighterTargetArea +import com.intellij.openapi.fileTypes.FileType +import com.intellij.openapi.fileTypes.PlainTextFileType +import com.intellij.openapi.project.ProjectManager +import com.intellij.openapi.util.Disposer +import com.intellij.ui.components.JBTextArea +import com.intellij.ui.components.JBHtmlPane +import com.intellij.ui.components.JBHtmlPaneConfiguration +import com.intellij.ui.components.JBHtmlPaneStyleConfiguration +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.JBUI +import org.commonmark.ext.autolink.AutolinkExtension +import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension +import org.commonmark.ext.gfm.tables.TablesExtension +import org.commonmark.node.AbstractVisitor +import org.commonmark.node.Block +import org.commonmark.node.Document +import org.commonmark.node.FencedCodeBlock +import org.commonmark.node.IndentedCodeBlock +import org.commonmark.node.Node +import org.commonmark.node.ThematicBreak +import org.commonmark.parser.Parser +import org.commonmark.renderer.html.HtmlRenderer +import java.awt.Color +import java.awt.Dimension +import java.awt.Font +import javax.swing.Box +import javax.swing.BoxLayout +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.ScrollPaneConstants +import javax.swing.event.HyperlinkEvent +import javax.swing.text.html.StyleSheet + +@Suppress("UnstableApiUsage") +internal open class MdViewHybrid( + style: SessionEditorStyle = SessionEditorStyle.current(), + private var selection: SessionSelection? = null, + private val code: MdCodeBlockFactory = MdCodeBlockFactory.default(), +) : MdView { + companion object { + private val LOG = KiloLog.create(MdViewHybrid::class.java) + } + + private val listeners = mutableListOf() + private val source = StringBuilder() + private var style = style + private var rendered = "" + private var disposed = false + private val blocks = mutableListOf() + private var openFence: Fence? = null + private var stale = false + + private val extensions = listOf( + AutolinkExtension.create(), + TablesExtension.create(), + StrikethroughExtension.create(), + ) + + private val parser: Parser = Parser.builder().extensions(extensions).build() + + private val renderer: HtmlRenderer = HtmlRenderer.builder() + .extensions(extensions) + .escapeHtml(true) + .sanitizeUrls(true) + .build() + + private var fontOverride: Font? = null + private var foregroundOverride: Color? = null + private var backgroundOverride: Color? = null + private var linkColorOverride: Color? = null + private var codeBgOverride: Color? = null + private var preBgOverride: Color? = null + private var preFgOverride: Color? = null + private var codeFontOverride: String? = null + private var quoteBorderOverride: Color? = null + private var quoteFgOverride: Color? = null + private var tableBorderOverride: Color? = null + private var opaqueState = true + + private val root = JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + isOpaque = true + background = opts().background + } + + override val component: JComponent get() = root + + override var font: Font + get() = fontOverride ?: opts().font + set(value) { + if (disposed) return + if (fontOverride == value) return + fontOverride = value + syncStyle() + } + + override var foreground: Color + get() = foregroundOverride ?: opts().foreground + set(value) { + if (disposed) return + if (foregroundOverride == value) return + foregroundOverride = value + syncStyle() + } + + override var background: Color + get() = backgroundOverride ?: opts().background + set(value) { + if (disposed) return + if (backgroundOverride == value) return + backgroundOverride = value + syncStyle() + } + + override var linkColor: Color + get() = linkColorOverride ?: opts().linkColor + set(value) { + if (disposed) return + if (linkColorOverride == value) return + linkColorOverride = value + syncStyle() + } + + override var codeBg: Color + get() = codeBgOverride ?: opts().codeBg + set(value) { + if (disposed) return + if (codeBgOverride == value) return + codeBgOverride = value + syncStyle() + } + + override var preBg: Color + get() = preBgOverride ?: opts().preBg + set(value) { + if (disposed) return + if (preBgOverride == value) return + preBgOverride = value + syncStyle() + } + + override var preFg: Color + get() = preFgOverride ?: opts().preFg + set(value) { + if (disposed) return + if (preFgOverride == value) return + preFgOverride = value + syncStyle() + } + + override var codeFont: String + get() = codeFontOverride ?: opts().codeFont + set(value) { + if (disposed) return + if (codeFontOverride == value) return + codeFontOverride = value + syncStyle() + } + + override var quoteBorder: Color + get() = quoteBorderOverride ?: opts().quoteBorder + set(value) { + if (disposed) return + if (quoteBorderOverride == value) return + quoteBorderOverride = value + syncStyle() + } + + override var quoteFg: Color + get() = quoteFgOverride ?: opts().quoteFg + set(value) { + if (disposed) return + if (quoteFgOverride == value) return + quoteFgOverride = value + syncStyle() + } + + override var tableBorder: Color + get() = tableBorderOverride ?: opts().tableBorder + set(value) { + if (disposed) return + if (tableBorderOverride == value) return + tableBorderOverride = value + syncStyle() + } + + override var opaque: Boolean + get() = opaqueState + set(value) { + if (disposed) return + if (opaqueState == value) return + opaqueState = value + syncStyle() + } + + override fun applyStyle(style: SessionEditorStyle) { + if (disposed) return + this.style = style + selection?.applyStyle(style) + syncStyle() + } + + override fun setSelection(selection: SessionSelection?) { + if (disposed) return + if (this.selection === selection) return + this.selection = selection + clearBlocks() + syncBlocks() + } + + override fun resetStyles() { + if (disposed) return + fontOverride = null + foregroundOverride = null + backgroundOverride = null + linkColorOverride = null + codeBgOverride = null + preBgOverride = null + preFgOverride = null + codeFontOverride = null + quoteBorderOverride = null + quoteFgOverride = null + tableBorderOverride = null + opaqueState = true + syncStyle() + } + + override fun set(text: String) { + if (disposed) return + if (source.toString() == text) return + source.clear() + source.append(text) + syncBlocks() + } + + override fun append(delta: String) { + if (disposed) return + if (delta.isEmpty()) return + val fence = openFence + val view = blocks.lastOrNull() + if (fence != null && view != null && clean(fence.char, delta)) { + source.append(delta) + view.grow(delta) + stale = true + root.revalidate() + root.repaint() + return + } + source.append(delta) + syncBlocks() + } + + override fun clear() { + if (disposed) return + if (source.isEmpty() && rendered.isEmpty() && root.componentCount == 0) return + source.clear() + rendered = "" + openFence = null + stale = false + clearBlocks() + root.revalidate() + root.repaint() + } + + override fun addLinkListener(listener: MdView.LinkListener) { + if (disposed) return + listeners.add(listener) + } + + override fun removeLinkListener(listener: MdView.LinkListener) { + listeners.remove(listener) + } + + override fun markdown(): String = source.toString() + + override fun html(): String { + if (!stale) return rendered + val out = project(source.toString()) + rendered = out.html + openFence = out.open + stale = false + return rendered + } + + override fun overrideSheet(): String = MdCommon.rules(opts()) + + override fun simulateLink(href: String) { + if (disposed) return + dispatch(MdView.LinkEvent(href)) + } + + override fun dispose() { + disposed = true + listeners.clear() + source.clear() + rendered = "" + openFence = null + stale = false + clearBlocks() + } + + private fun syncStyle() { + if (disposed) return + val opts = opts() + root.isOpaque = opts.opaque + if (opts.opaque) root.background = opts.background + for (view in blocks) view.style(opts) + root.revalidate() + root.repaint() + } + + private fun syncBlocks() { + if (disposed) return + val text = source.toString() + val out = project(text) + rendered = out.html + openFence = out.open + stale = false + val next = out.blocks + if (text.isEmpty()) { + openFence = null + clearBlocks() + root.revalidate() + root.repaint() + return + } + sync(next) + root.revalidate() + root.repaint() + } + + private fun clearBlocks() { + blocks.forEach { Disposer.dispose(it.disposable) } + blocks.clear() + root.removeAll() + } + + private fun sync(next: List) { + var at = 0 + while (at < blocks.size && at < next.size) { + val view = blocks[at] + val desc = next[at] + if (!view.compatible(desc)) break + view.update(desc) + at++ + } + removeBlocks(at) + for (desc in next.drop(at)) addBlock(view(desc)) + } + + private fun clean(char: Char, delta: String): Boolean { + if (delta.contains(char)) return false + val start = source.lastIndexOf("\n") + 1 + for (idx in start until source.length) { + if (source[idx] == char) return false + } + return true + } + + private fun removeBlocks(start: Int) { + if (start >= blocks.size) return + val idx = if (start == 0) 0 else start * 2 - 1 + while (root.componentCount > idx) root.remove(root.componentCount - 1) + val stale = blocks.drop(start) + repeat(blocks.size - start) { blocks.removeAt(blocks.lastIndex) } + stale.forEach { Disposer.dispose(it.disposable) } + } + + private fun addGap() { + if (root.componentCount == 0) return + root.add(Box.createVerticalStrut(JBUI.scale(SessionUiStyle.View.Code.BLOCK_GAP))) + } + + private fun addBlock(view: View) { + addGap() + view.component.alignmentX = JComponent.LEFT_ALIGNMENT + blocks.add(view) + root.add(view.component) + } + + private fun view(desc: Desc): View { + val disposable = Disposer.newDisposable("Markdown block") + return when (desc) { + is Desc.Html -> HtmlView(desc, htmlBlock(desc.body, disposable), disposable) + is Desc.Code -> when (val kind = desc.kind) { + is Kind.Source -> CodeView(desc, codeBlock(desc.text, kind.file, disposable), disposable) + is Kind.Terminal -> TermView(desc, terminalBlock(desc.text, kind, disposable), disposable) + } + } + } + + private fun htmlBlock(body: String, disposable: Disposable): JBHtmlPane { + val opts = opts() + return JBHtmlPane( + JBHtmlPaneStyleConfiguration { + enableInlineCodeBackground = true + enableCodeBlocksBackground = true + }, + JBHtmlPaneConfiguration { + customStyleSheetProvider { sheet() } + }, + ).apply { + isEditable = false + isOpaque = opts.opaque + background = opts.background + text = "$body" + selection?.register(this, disposable) + addHyperlinkListener { e -> + if (e.eventType != HyperlinkEvent.EventType.ACTIVATED) return@addHyperlinkListener + val href = e.description ?: return@addHyperlinkListener + val pt = (e.inputEvent as? java.awt.event.MouseEvent)?.point + dispatch(MdView.LinkEvent(href, pt)) + } + } + } + + private fun codeBlock(text: String, file: FileType, disposable: Disposable): JBScrollPane { + val opts = opts() + val value = text.trimEnd('\n') + fun editor(type: FileType) = CodeField(type, opts, text, false).also { ed -> + Disposer.register(disposable) { + ed.getEditor(false)?.let(EditorFactory.getInstance()::releaseEditor) + } + ed.setDisposedWith(disposable) + selection?.register(ed, disposable) + } + val field = runCatching { + editor(file) + }.getOrElse { err -> + LOG.warn("kind=markdown codeEditor=true failed message=${err.message}", err) + if (code.opts.editorOnly) runCatching { + editor(PlainTextFileType.INSTANCE) + }.getOrElse { fallback -> + LOG.warn("kind=markdown codeEditor=true fallback=plain failed message=${fallback.message}", fallback) + throw fallback + } else { + textArea(text, opts, disposable) + } + } + sizeCodeField(field, value) + val pane = object : JBScrollPane(field) { + override fun doLayout() { + super.doLayout() + if (code.opts.verticalPolicy != ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER) return + val view = viewport.view ?: return + val size = viewport.extentSize + if (size.height <= 0 || view.height == size.height) return + view.setSize(view.width.coerceAtLeast(size.width), size.height) + } + } + styleCodePane(pane, opts) + sizeCodePane(pane, field) + return pane + } + + private fun terminalBlock(text: String, kind: Kind.Terminal, disposable: Disposable): JBScrollPane { + val opts = opts() + val term = MdTerminal.decode(text, kind.stream) + val value = shellDisplay(term, kind.mode) + val field = CodeField(PlainTextFileType.INSTANCE, opts, value.text, false).also { ed -> + Disposer.register(disposable) { + ed.getEditor(false)?.let(EditorFactory.getInstance()::releaseEditor) + } + ed.setDisposedWith(disposable) + selection?.register(ed, disposable) + } + sizeCodeField(field, value.text) + val pane = object : JBScrollPane(field) { + override fun doLayout() { + super.doLayout() + if (code.opts.verticalPolicy != ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER) return + val view = viewport.view ?: return + val size = viewport.extentSize + if (size.height <= 0 || view.height == size.height) return + view.setSize(view.width.coerceAtLeast(size.width), size.height) + } + } + styleCodePane(pane, opts) + sizeCodePane(pane, field) + applyTerm(field, term, kind.mode, value) + return pane + } + + private fun styleCodePane(pane: JBScrollPane, opts: MdStyle) { + pane.apply { + val width = SessionUiStyle.View.Code.BORDER_WIDTH + border = when (code.opts.border) { + MdCodeBlockBorder.All -> JBUI.Borders.customLine(opts.codeBorder, width) + MdCodeBlockBorder.Horizontal -> JBUI.Borders.customLine(opts.codeBorder, width, 0, width, 0) + MdCodeBlockBorder.Bottom -> JBUI.Borders.customLine(opts.codeBorder, 0, 0, width, 0) + } + viewportBorder = JBUI.Borders.empty( + SessionUiStyle.View.Code.topPadding(), + SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING, + SessionUiStyle.View.Code.VIEWPORT_BOTTOM_PADDING, + SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING, + ) + isOpaque = true + background = opts.preBg + viewport.isOpaque = true + viewport.background = opts.preBg + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED + verticalScrollBarPolicy = code.opts.verticalPolicy + isWheelScrollingEnabled = true + setOverlappingScrollBar(false) + horizontalScrollBar.preferredSize = Dimension(0, JBUI.scale(SessionUiStyle.View.Code.SCROLLBAR_HEIGHT)) + horizontalScrollBar.isOpaque = true + if (code.opts.verticalPolicy == ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER) { + verticalScrollBar.preferredSize = JBUI.emptySize() + } + } + } + + private fun sizeCodeField(component: JComponent, text: String) { + val height = codeHeight(component, text) + val width = codeWidth(component, text) + component.preferredSize = Dimension(width, height) + component.minimumSize = Dimension(0, height) + component.maximumSize = Dimension(Int.MAX_VALUE, height) + } + + private fun sizeCodePane(pane: JBScrollPane, component: JComponent) { + val pad = pane.viewportBorder.getBorderInsets(pane) + val text = when (component) { + is CodeField -> component.text + is JBTextArea -> component.text + else -> "" + } + val content = visibleCodeHeight(component, text) + val height = content + pane.insets.top + pane.insets.bottom + + pad.top + pad.bottom + pane.horizontalScrollBar.preferredSize.height + pane.preferredSize = Dimension(0, height) + pane.minimumSize = Dimension(0, height) + pane.maximumSize = Dimension(Int.MAX_VALUE, height) + } + + private fun codeWidth(component: JComponent, text: String): Int { + val metrics = component.getFontMetrics(component.font) + val width = text.lineSequence().maxOfOrNull { metrics.stringWidth(it) } ?: 0 + return width + JBUI.scale(SessionUiStyle.View.Code.WIDTH_PADDING) + } + + private fun codeHeight(component: JComponent, text: String): Int { + val count = text.lineSequence().count() + val rows = count.coerceAtLeast(SessionUiStyle.View.Code.MIN_ROWS) + val field = component as? CodeField + if (field != null) { + field.ensureWillComputePreferredSize() + val ed = field.getEditor(false) + val line = ed?.lineHeight ?: component.getFontMetrics(component.font).height + return maxOf(field.preferredSize.height, line * rows) + } + val line = component.getFontMetrics(component.font).height + return line * rows + } + + private fun visibleCodeHeight(component: JComponent, text: String): Int { + val max = code.opts.maxLines ?: return component.preferredSize.height + val count = text.lineSequence().count() + val rows = count.coerceAtLeast(SessionUiStyle.View.Code.MIN_ROWS).coerceAtMost(max) + val field = component as? CodeField + if (field != null) { + field.ensureWillComputePreferredSize() + val ed = field.getEditor(false) + val line = ed?.lineHeight ?: component.getFontMetrics(component.font).height + return line * rows + } + val line = component.getFontMetrics(component.font).height + return line * rows + } + + private fun textArea(text: String, opts: MdStyle, disposable: Disposable) = JBTextArea(text.trimEnd('\n')).apply { + isEditable = false + lineWrap = false + styleTextArea(this, opts) + border = JBUI.Borders.empty( + SessionUiStyle.View.Code.VIEWPORT_TOP_PADDING, + SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING, + ) + selection?.register(this, disposable) + } + + private fun styleTextArea(area: JBTextArea, opts: MdStyle) { + area.isOpaque = true + area.background = opts.preBg + area.foreground = opts.preFg + area.font = style.editorFont + } + + private inner class CodeField(file: FileType, opts: MdStyle, value: String, val soft: Boolean) : + com.intellij.ui.EditorTextField( + EditorFactory.getInstance().createDocument(value.trimEnd('\n')), + ProjectManager.getInstance().defaultProject, + file, + true, + false, + ) { + init { + setFontInheritedFromLAF(false) + font = style.editorFont + addSettingsProvider { ed -> + style.applyToEditor(ed) + ed.setBorder(JBUI.Borders.empty()) + ed.scrollPane.border = JBUI.Borders.empty() + ed.scrollPane.viewportBorder = JBUI.Borders.empty() + ed.backgroundColor = opts.preBg + ed.scrollPane.background = opts.preBg + ed.scrollPane.isOpaque = true + ed.scrollPane.viewport.isOpaque = true + ed.scrollPane.viewport.background = opts.preBg + ed.settings.isUseSoftWraps = soft + ed.settings.isAdditionalPageAtBottom = false + ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER + } + } + } + + private fun shellDisplay(term: Term, mode: Mode): ShellDisplay { + if (mode == Mode.Shell) return MdShellHighlight.project(term.text) + if (mode == Mode.Command) return MdShellHighlight.command(term.text) + return ShellDisplay(term.text, emptyList()) + } + + private fun applyTerm(field: CodeField, term: Term, mode: Mode, display: ShellDisplay = shellDisplay(term, mode)) { + val editor = field.getEditor(true) ?: return + editor.markupModel.removeAllHighlighters() + if (mode == Mode.Shell || mode == Mode.Command) { + applyShell(field, display) + return + } + val size = editor.document.textLength + for (range in term.ranges) { + val start = range.start.coerceAtMost(size) + val end = range.end.coerceAtMost(size) + if (start >= end) continue + val type = ConsoleViewContentType.getConsoleViewType(range.key) + val key = type.attributesKey + if (key != null) { + editor.markupModel.addRangeHighlighter( + key, + start, + end, + HighlighterLayer.SYNTAX + 1, + HighlighterTargetArea.EXACT_RANGE, + ) + } else { + editor.markupModel.addRangeHighlighter( + start, + end, + HighlighterLayer.SYNTAX + 1, + type.attributes, + HighlighterTargetArea.EXACT_RANGE, + ) + } + } + } + + private fun applyShell(field: CodeField, display: ShellDisplay) { + val editor = field.getEditor(false) ?: return + val size = editor.document.textLength + for (range in display.ranges) { + val start = range.start.coerceAtMost(size) + val end = range.end.coerceAtMost(size) + if (start >= end) continue + editor.markupModel.addRangeHighlighter( + range.key, + start, + end, + HighlighterLayer.SYNTAX + 1, + HighlighterTargetArea.EXACT_RANGE, + ) + } + } + + private fun dispatch(event: MdView.LinkEvent) { + for (l in listeners) l.onLink(event) + } + + private fun sheet(): StyleSheet { + val sheet = StyleSheet() + val rules = overrideSheet() + if (rules.isEmpty()) return sheet + try { + sheet.addRule(rules) + } catch (err: Exception) { + LOG.warn("kind=markdown css=true failed message=${err.message} rules=$rules", err) + } + return sheet + } + + private fun opts(): MdStyle { + val base = MdCommon.defaults(style) + return base.copy( + font = fontOverride ?: base.font, + foreground = foregroundOverride ?: base.foreground, + background = backgroundOverride ?: base.background, + linkColor = linkColorOverride ?: base.linkColor, + codeBg = codeBgOverride ?: base.codeBg, + preBg = preBgOverride ?: base.preBg, + preFg = preFgOverride ?: base.preFg, + codeFont = codeFontOverride ?: base.codeFont, + quoteBorder = quoteBorderOverride ?: base.quoteBorder, + quoteFg = quoteFgOverride ?: base.quoteFg, + tableBorder = tableBorderOverride ?: base.tableBorder, + opaque = opaqueState, + ) + } + + private fun collect(doc: Node): List { + val visitor = Visitor() + doc.accept(visitor) + return visitor.blocks + } + + private fun project(text: String): Projection { + val blocks = mutableListOf() + val html = StringBuilder() + val md = StringBuilder() + val lines = lines(text) + var trailing: Fence? = null + var idx = 0 + + fun flush() { + if (md.isEmpty()) return + val doc = parser.parse(md.toString()) + val descs = collect(doc) + blocks.addAll(descs) + for (desc in descs) { + when (desc) { + is Desc.Html -> html.append(desc.body) + is Desc.Code -> html.append(codeHtml(desc.text)) + } + } + md.clear() + } + + while (idx < lines.size) { + val line = lines[idx] + val open = opener(line.text) + if (open == null) { + val pending = idx == lines.lastIndex && pendingOpener(line.text) + if (pending) { + flush() + blocks.add(Desc.Code("", Kind.Source(PlainTextFileType.INSTANCE))) + html.append(codeHtml("")) + } else { + md.append(line.text).append(line.end) + } + idx++ + continue + } + + flush() + idx++ + val code = StringBuilder() + var closed = false + var trimmed = false + while (idx < lines.size) { + val item = lines[idx] + val close = closer(item.text, open) + if (close) { + closed = true + idx++ + break + } + val partial = idx == lines.lastIndex && partialCloser(item.text, open) + if (partial) trimmed = true + if (!partial) code.append(item.text).append(item.end) + idx++ + } + val desc = Desc.Code(code.toString(), MdLanguage.kind(open.info)) + blocks.add(desc) + html.append(codeHtml(desc.text)) + trailing = if (!closed && !trimmed) open else null + } + + flush() + return Projection(html.toString(), blocks, trailing) + } + + private fun lines(text: String): List { + if (text.isEmpty()) return emptyList() + val lines = mutableListOf() + var start = 0 + while (start < text.length) { + val end = text.indexOf('\n', start) + if (end == -1) { + lines.add(Line(text.substring(start), "")) + break + } + lines.add(Line(text.substring(start, end), "\n")) + start = end + 1 + } + return lines + } + + private fun opener(text: String): Fence? { + val trimmed = text.dropWhile { it == ' ' } + val indent = text.length - trimmed.length + if (indent > 3) return null + val char = trimmed.firstOrNull() ?: return null + if (char != '`' && char != '~') return null + val size = trimmed.takeWhile { it == char }.length + if (size < 3) return null + val info = trimmed.drop(size).trim() + if (char == '`' && info.contains('`')) return null + return Fence(char, size, info) + } + + private fun closer(text: String, fence: Fence): Boolean { + val trimmed = text.dropWhile { it == ' ' } + val indent = text.length - trimmed.length + if (indent > 3) return false + val size = trimmed.takeWhile { it == fence.char }.length + if (size < fence.size) return false + return trimmed.drop(size).isBlank() + } + + private fun pendingOpener(text: String): Boolean { + val trimmed = text.dropWhile { it == ' ' } + val indent = text.length - trimmed.length + if (indent > 3) return false + val char = trimmed.firstOrNull() ?: return false + if (char != '`' && char != '~') return false + val size = trimmed.takeWhile { it == char }.length + if (size !in 1..2) return false + return trimmed.drop(size).isBlank() + } + + private fun partialCloser(text: String, fence: Fence): Boolean { + val trimmed = text.dropWhile { it == ' ' } + val indent = text.length - trimmed.length + if (indent > 3) return false + val size = trimmed.takeWhile { it == fence.char }.length + if (size !in 1 until fence.size) return false + return trimmed.drop(size).isBlank() + } + + private fun codeHtml(text: String): String = "
${escape(text)}
\n" + + private fun escape(text: String): String = text + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + + private sealed class Desc { + data class Html(val body: String) : Desc() + data class Code(val text: String, val kind: Kind) : Desc() + } + + private data class Projection(val html: String, val blocks: List, val open: Fence?) + + private data class Line(val text: String, val end: String) + + private data class Fence(val char: Char, val size: Int, val info: String) + + private abstract inner class View( + var desc: Desc, + val component: JComponent, + val disposable: Disposable, + ) { + abstract fun compatible(desc: Desc): Boolean + abstract fun update(desc: Desc) + abstract fun style(opts: MdStyle) + open fun grow(delta: String) = Unit + } + + private inner class HtmlView(desc: Desc.Html, private val pane: JBHtmlPane, disposable: Disposable) : + View(desc, pane, disposable) { + override fun compatible(desc: Desc) = desc is Desc.Html + + override fun update(desc: Desc) { + if (this.desc == desc) return + this.desc = desc + pane.text = "${(desc as Desc.Html).body}" + } + + override fun style(opts: MdStyle) { + pane.isOpaque = opts.opaque + pane.background = opts.background + pane.reloadCssStylesheets() + val item = desc as Desc.Html + pane.text = "${item.body}" + } + } + + private inner class CodeView(desc: Desc.Code, private val pane: JBScrollPane, disposable: Disposable) : + View(desc, pane, disposable) { + override fun compatible(desc: Desc) = desc is Desc.Code && (this.desc as Desc.Code).kind == desc.kind + + override fun update(desc: Desc) { + if (this.desc == desc) return + this.desc = desc + val value = (desc as Desc.Code).text.trimEnd('\n') + val view = pane.viewport.view + when (view) { + is CodeField -> view.text = value + is JBTextArea -> view.text = value + } + if (view is JComponent) { + sizeCodeField(view, value) + sizeCodePane(pane, view) + } + } + + override fun grow(delta: String) { + val item = desc as Desc.Code + val next = item.copy(text = item.text + delta) + desc = next + val value = next.text.trimEnd('\n') + val view = pane.viewport.view + when (view) { + is CodeField -> view.text = value + is JBTextArea -> view.text = value + } + if (view is JComponent) { + sizeCodeField(view, value) + sizeCodePane(pane, view) + } + } + + override fun style(opts: MdStyle) { + styleCodePane(pane, opts) + val view = pane.viewport.view + when (view) { + is CodeField -> { + view.font = style.editorFont + view.background = opts.preBg + view.getEditor(false)?.let { ed -> + style.applyToEditor(ed) + ed.setBorder(JBUI.Borders.empty()) + ed.scrollPane.border = JBUI.Borders.empty() + ed.scrollPane.viewportBorder = JBUI.Borders.empty() + ed.backgroundColor = opts.preBg + ed.scrollPane.background = opts.preBg + ed.scrollPane.isOpaque = true + ed.scrollPane.viewport.isOpaque = true + ed.scrollPane.viewport.background = opts.preBg + ed.settings.isUseSoftWraps = view.soft + ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER + } + } + is JBTextArea -> styleTextArea(view, opts) + } + if (view is JComponent) { + val text = when (view) { + is CodeField -> view.text + is JBTextArea -> view.text + else -> "" + } + sizeCodeField(view, text) + sizeCodePane(pane, view) + } + } + } + + private inner class TermView(desc: Desc.Code, private val pane: JBScrollPane, disposable: Disposable) : + View(desc, pane, disposable) { + override fun compatible(desc: Desc) = desc is Desc.Code && (this.desc as Desc.Code).kind == desc.kind + + override fun update(desc: Desc) { + if (this.desc == desc) return + this.desc = desc + val item = desc as Desc.Code + val kind = item.kind as Kind.Terminal + val term = MdTerminal.decode(item.text, kind.stream) + val value = shellDisplay(term, kind.mode) + val view = pane.viewport.view as? CodeField ?: return + view.text = value.text + sizeCodeField(view, value.text) + sizeCodePane(pane, view) + applyTerm(view, term, kind.mode, value) + } + + override fun style(opts: MdStyle) { + styleCodePane(pane, opts) + val view = pane.viewport.view as? CodeField ?: return + val item = desc as Desc.Code + val kind = item.kind as Kind.Terminal + view.font = style.editorFont + view.background = opts.preBg + view.getEditor(false)?.let { ed -> + style.applyToEditor(ed) + ed.setBorder(JBUI.Borders.empty()) + ed.scrollPane.border = JBUI.Borders.empty() + ed.scrollPane.viewportBorder = JBUI.Borders.empty() + ed.backgroundColor = opts.preBg + ed.scrollPane.background = opts.preBg + ed.scrollPane.isOpaque = true + ed.scrollPane.viewport.isOpaque = true + ed.scrollPane.viewport.background = opts.preBg + ed.settings.isUseSoftWraps = view.soft + ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER + } + val term = MdTerminal.decode(item.text, kind.stream) + val value = shellDisplay(term, kind.mode) + if (view.text != value.text) view.text = value.text + sizeCodeField(view, value.text) + sizeCodePane(pane, view) + applyTerm(view, term, kind.mode, value) + } + + override fun grow(delta: String) { + val item = desc as Desc.Code + update(item.copy(text = item.text + delta)) + } + } + + private inner class Visitor : AbstractVisitor() { + val blocks = mutableListOf() + private val run = StringBuilder() + + override fun visit(document: Document) { + visitChildren(document) + flush() + } + + override fun visit(code: FencedCodeBlock) { + flush() + blocks.add(Desc.Code(code.literal, MdLanguage.kind(code.info))) + } + + override fun visit(code: IndentedCodeBlock) { + flush() + blocks.add(Desc.Code(code.literal, MdLanguage.kind(null))) + } + + private fun flush() { + if (run.isEmpty()) return + blocks.add(Desc.Html(run.toString())) + run.clear() + } + + public override fun visitChildren(parent: Node) { + var child = parent.firstChild + while (child != null) { + val next = child.next + if (child is ThematicBreak) { + child = next + continue + } + if (child is FencedCodeBlock || child is IndentedCodeBlock) child.accept(this) + if (child is Block && child !is FencedCodeBlock && child !is IndentedCodeBlock) run.append(renderer.render(child)) + child = next + } + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloEditorKind.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloEditorKind.kt new file mode 100644 index 00000000000..e3276f68529 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloEditorKind.kt @@ -0,0 +1,13 @@ +package ai.kilocode.client.vfs + +import com.intellij.openapi.Disposable +import com.intellij.openapi.project.Project +import com.intellij.util.concurrency.annotations.RequiresEdt +import javax.swing.JComponent + +interface KiloEditorKind : KiloVirtualFileKind { + @RequiresEdt + fun createContent(project: Project, file: KiloVirtualFile, parent: Disposable): JComponent + + fun preferredFocus(component: JComponent): JComponent? = null +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloEditorKindRegistry.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloEditorKindRegistry.kt new file mode 100644 index 00000000000..207e79da489 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloEditorKindRegistry.kt @@ -0,0 +1,22 @@ +package ai.kilocode.client.vfs + +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import java.util.concurrent.ConcurrentHashMap + +@Service(Service.Level.APP) +class KiloEditorKindRegistry { + private val kinds = ConcurrentHashMap() + + fun register(kind: KiloEditorKind) { + kinds[kind.id] = kind + service().register(kind) + } + + fun unregister(id: String) { + kinds.remove(id) + service().unregister(id) + } + + fun get(id: String): KiloEditorKind? = kinds[id] +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloFileEditor.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloFileEditor.kt new file mode 100644 index 00000000000..1b0dc0f7605 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloFileEditor.kt @@ -0,0 +1,28 @@ +package ai.kilocode.client.vfs + +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.util.concurrency.annotations.RequiresEdt +import javax.swing.JComponent + +class KiloFileEditor( + private val project: Project, + private val file: VirtualFile, + private val kilo: KiloVirtualFile, + private val kind: KiloEditorKind, +) : KiloFileEditorBase() { + private val ui: JComponent by lazy { kind.createContent(project, kilo, this) } + + @RequiresEdt + override fun getComponent(): JComponent = ui + + override fun getPreferredFocusedComponent(): JComponent? = kind.preferredFocus(ui) + override fun getName(): String = kind.title(kilo.path.params) + override fun getFile(): VirtualFile = file + override fun isValid(): Boolean = super.isValid() && kilo.isValid + + override fun dispose() { + KiloVirtualFileSystem.getInstance().release(kilo.path) + super.dispose() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloFileEditorBase.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloFileEditorBase.kt new file mode 100644 index 00000000000..4fc5b296c66 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloFileEditorBase.kt @@ -0,0 +1,34 @@ +package ai.kilocode.client.vfs + +import com.intellij.openapi.fileEditor.FileEditor +import com.intellij.openapi.fileEditor.FileEditorState +import com.intellij.openapi.fileEditor.FileEditorStateLevel +import com.intellij.openapi.util.CheckedDisposable +import com.intellij.openapi.util.UserDataHolderBase +import java.beans.PropertyChangeListener +import java.beans.PropertyChangeSupport + +abstract class KiloFileEditorBase : UserDataHolderBase(), FileEditor, CheckedDisposable { + private var disposed = false + private val support = PropertyChangeSupport(this) + + override fun isDisposed(): Boolean = disposed + + override fun dispose() { + disposed = true + } + + override fun isValid(): Boolean = !disposed + + override fun addPropertyChangeListener(listener: PropertyChangeListener) { + support.addPropertyChangeListener(listener) + } + + override fun removePropertyChangeListener(listener: PropertyChangeListener) { + support.removePropertyChangeListener(listener) + } + + override fun getState(level: FileEditorStateLevel): FileEditorState = FileEditorState.INSTANCE + override fun setState(state: FileEditorState) {} + override fun isModified(): Boolean = false +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloFileEditorProvider.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloFileEditorProvider.kt new file mode 100644 index 00000000000..1bcbbbcb27e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloFileEditorProvider.kt @@ -0,0 +1,46 @@ +package ai.kilocode.client.vfs + +import ai.kilocode.client.session.ui.attachment.ensureAttachmentEditorKind +import com.intellij.openapi.components.service +import com.intellij.openapi.fileEditor.FileEditor +import com.intellij.openapi.fileEditor.FileEditorPolicy +import com.intellij.openapi.fileEditor.FileEditorProvider +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.vfs.VirtualFile + +class KiloFileEditorProvider : FileEditorProvider, DumbAware { + override fun accept(project: Project, file: VirtualFile): Boolean { + ensureAttachmentEditorKind() + val path = path(file) ?: return false + return service().get(path.kind) != null + } + + override fun acceptRequiresReadAction(): Boolean = false + + override fun createEditor(project: Project, file: VirtualFile): FileEditor { + ensureAttachmentEditorKind() + val path = path(file) ?: error("Invalid Kilo virtual file: ${file.path}") + val kilo = file as? KiloVirtualFile ?: KiloVirtualFile(path) + val kind = service().get(kilo.path.kind) ?: error("Unknown Kilo editor kind: ${kilo.path.kind}") + return KiloFileEditor(project, file, kilo, kind) + } + + override fun disposeEditor(editor: FileEditor) { + Disposer.dispose(editor) + } + + override fun getEditorTypeId(): String = EDITOR_TYPE_ID + override fun getPolicy(): FileEditorPolicy = FileEditorPolicy.HIDE_OTHER_EDITORS + + companion object { + const val EDITOR_TYPE_ID = "KiloVfsEditor" + + private fun path(file: VirtualFile): KiloPath? { + if (file is KiloVirtualFile) return file.path + if (file.fileSystem.protocol != KiloVirtualFileSystem.PROTOCOL && !file.url.startsWith("${KiloVirtualFileSystem.PROTOCOL}://")) return null + return KiloVirtualFileSystem.decode(file.path) ?: KiloVirtualFileSystem.decode(file.url) + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloPath.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloPath.kt new file mode 100644 index 00000000000..a2773afa1b6 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloPath.kt @@ -0,0 +1,16 @@ +package ai.kilocode.client.vfs + +import kotlinx.serialization.Serializable + +@Serializable +data class KiloPath( + val kind: String, + val params: Map = emptyMap(), +) { + fun canonical(): KiloPath = copy(params = canonicalParams(params)) +} + +internal fun canonicalParams(params: Map): Map { + if (params.size < 2) return params + return params.toSortedMap() +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVfsManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVfsManager.kt new file mode 100644 index 00000000000..b23d0f69a05 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVfsManager.kt @@ -0,0 +1,40 @@ +package ai.kilocode.client.vfs + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.FileEditorProvider +import com.intellij.openapi.project.Project +import com.intellij.util.concurrency.annotations.RequiresEdt + +@Service(Service.Level.PROJECT) +class KiloVfsManager(private val project: Project) { + @RequiresEdt + fun open(kind: String, params: Map = emptyMap(), focus: Boolean = true): Boolean { + val file = file(kind, params) ?: return false + if (ApplicationManager.getApplication().isUnitTestMode) { + file.putUserData(FileEditorProvider.KEY, KiloFileEditorProvider()) + } + FileEditorManager.getInstance(project).openFile(file, focus) + return true + } + + @RequiresEdt + fun close(kind: String, params: Map = emptyMap()) { + val file = file(kind, params) ?: return + FileEditorManager.getInstance(project).closeFile(file) + KiloVirtualFileSystem.getInstance().release(file.path) + } + + @RequiresEdt + fun updatePresentation(kind: String, params: Map = emptyMap()) { + val file = file(kind, params) ?: return + FileEditorManager.getInstance(project).updateFilePresentation(file) + } + + private fun file(kind: String, params: Map): KiloVirtualFile? { + val path = KiloPath(kind, params) + val fs = KiloVirtualFileSystem.getInstance() + return fs.refreshAndFindFileByPath(fs.getPath(path)) as? KiloVirtualFile + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVirtualFile.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVirtualFile.kt new file mode 100644 index 00000000000..01b14336dd7 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVirtualFile.kt @@ -0,0 +1,58 @@ +@file:Suppress("LeakingThis") + +package ai.kilocode.client.vfs + +import com.intellij.openapi.components.service +import com.intellij.openapi.fileEditor.FileEditorManagerKeys +import com.intellij.openapi.fileTypes.FileType +import com.intellij.openapi.fileTypes.FileTypes +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.VirtualFilePathWrapper +import com.intellij.openapi.vfs.VirtualFileWithoutContent +import java.io.InputStream +import java.io.OutputStream + +class KiloVirtualFile( + val path: KiloPath, +) : VirtualFile(), + VirtualFileWithoutContent, + VirtualFilePathWrapper { + init { + putUserData(FileEditorManagerKeys.FORBID_TAB_SPLIT, true) + } + + override fun getFileSystem(): KiloVirtualFileSystem = KiloVirtualFileSystem.getInstance() + override fun getFileType(): FileType = FileTypes.UNKNOWN + override fun getPath(): String = fileSystem.getPath(path) + override fun getUrl(): String = "${fileSystem.protocol}://$path" + override fun getName(): String = kind()?.title(path.params) ?: path.kind + override fun getPresentableName(): String = name + override fun getPresentablePath(): String = kind()?.presentablePath(path.params) ?: name + override fun enforcePresentableName(): Boolean = true + override fun isValid(): Boolean = kind()?.isValid(path.params) == true + override fun isWritable(): Boolean = false + override fun isDirectory(): Boolean = false + override fun getParent(): VirtualFile? = null + override fun getChildren(): Array = emptyArray() + override fun getLength(): Long = 0 + override fun getTimeStamp(): Long = 0 + override fun getModificationStamp(): Long = 0 + override fun refresh(asynchronous: Boolean, recursive: Boolean, postRunnable: Runnable?) { + postRunnable?.run() + } + + override fun contentsToByteArray(): ByteArray = throw UnsupportedOperationException() + override fun getInputStream(): InputStream = throw UnsupportedOperationException() + override fun getOutputStream(requestor: Any?, newModificationStamp: Long, newTimeStamp: Long): OutputStream = + throw UnsupportedOperationException() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is KiloVirtualFile) return false + return path == other.path + } + + override fun hashCode(): Int = path.hashCode() + + private fun kind(): KiloVirtualFileKind? = service().get(path.kind) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVirtualFileKind.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVirtualFileKind.kt new file mode 100644 index 00000000000..0dc059356b3 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVirtualFileKind.kt @@ -0,0 +1,15 @@ +package ai.kilocode.client.vfs + +import javax.swing.Icon + +interface KiloVirtualFileKind { + val id: String + + fun title(params: Map): String + + fun icon(params: Map): Icon? = null + + fun presentablePath(params: Map): String = title(params) + + fun isValid(params: Map): Boolean = true +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVirtualFileKindRegistry.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVirtualFileKindRegistry.kt new file mode 100644 index 00000000000..89af66be9e0 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVirtualFileKindRegistry.kt @@ -0,0 +1,19 @@ +package ai.kilocode.client.vfs + +import com.intellij.openapi.components.Service +import java.util.concurrent.ConcurrentHashMap + +@Service(Service.Level.APP) +class KiloVirtualFileKindRegistry { + private val kinds = ConcurrentHashMap() + + fun register(kind: KiloVirtualFileKind) { + kinds[kind.id] = kind + } + + fun unregister(id: String) { + kinds.remove(id) + } + + fun get(id: String): KiloVirtualFileKind? = kinds[id] +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVirtualFileSystem.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVirtualFileSystem.kt new file mode 100644 index 00000000000..c87aa9e1911 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVirtualFileSystem.kt @@ -0,0 +1,85 @@ +package ai.kilocode.client.vfs + +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.vfs.NonPhysicalFileSystem +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.VirtualFileListener +import com.intellij.openapi.vfs.VirtualFilePathWrapper +import com.intellij.openapi.vfs.VirtualFileSystem +import java.net.URLDecoder +import java.nio.charset.StandardCharsets +import java.util.concurrent.ConcurrentHashMap +import kotlinx.serialization.json.Json + +class KiloVirtualFileSystem : VirtualFileSystem(), NonPhysicalFileSystem { + private val files = ConcurrentHashMap() + + fun getPath(path: KiloPath): String = json.encodeToString(KiloPath.serializer(), path.canonical()) + + fun findOrCreateFile(path: KiloPath): VirtualFile? { + service().get(path.kind) ?: return null + return files.computeIfAbsent(path.canonical()) { KiloVirtualFile(it) } + } + + fun release(path: KiloPath) { + files.remove(path.canonical()) + } + + override fun findFileByPath(path: String): VirtualFile? { + val parsed = decode(path) ?: return null + return findOrCreateFile(parsed) + } + + override fun refreshAndFindFileByPath(path: String): VirtualFile? = findFileByPath(path) + + override fun extractPresentableUrl(path: String): String { + return (refreshAndFindFileByPath(path) as? VirtualFilePathWrapper)?.presentablePath ?: path + } + + override fun refresh(asynchronous: Boolean) {} + + override fun getProtocol(): String = PROTOCOL + + override fun addVirtualFileListener(listener: VirtualFileListener) {} + + override fun removeVirtualFileListener(listener: VirtualFileListener) {} + override fun isReadOnly(): Boolean = true + override fun deleteFile(requestor: Any?, file: VirtualFile) = unsupported() + override fun moveFile(requestor: Any?, file: VirtualFile, newParent: VirtualFile) = unsupported() + override fun renameFile(requestor: Any?, file: VirtualFile, newName: String) = unsupported() + override fun createChildFile(requestor: Any?, file: VirtualFile, name: String): VirtualFile = unsupported() + override fun createChildDirectory(requestor: Any?, file: VirtualFile, name: String): VirtualFile = unsupported() + override fun copyFile(requestor: Any?, file: VirtualFile, newParent: VirtualFile, copyName: String): VirtualFile = unsupported() + + private fun unsupported(): Nothing = throw UnsupportedOperationException("Kilo virtual files are read-only") + + companion object { + const val PROTOCOL = "kilo" + + private val json = Json + private val log = logger() + private val local = KiloVirtualFileSystem() + + fun getInstance(): KiloVirtualFileSystem = local + + fun decode(path: String): KiloPath? { + return try { + val raw = raw(path) ?: return null + json.decodeFromString(KiloPath.serializer(), raw).canonical() + } catch (err: Exception) { + log.warn("Cannot deserialize $path", err) + null + } + } + + private fun raw(path: String): String? { + if (path.startsWith("{")) return path + if (!path.startsWith("$PROTOCOL://")) return null + val raw = path.substringAfter("://") + if (raw.startsWith("{")) return raw + if (!raw.startsWith("%7B", ignoreCase = true)) return null + return URLDecoder.decode(raw, StandardCharsets.UTF_8) + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/remove-hover.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/remove-hover.svg new file mode 100644 index 00000000000..437e5035017 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/remove-hover.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/remove-hover_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/remove-hover_dark.svg new file mode 100644 index 00000000000..e604cb68874 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/remove-hover_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/remove.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/remove.svg new file mode 100644 index 00000000000..fe390112568 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/remove.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/remove_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/remove_dark.svg new file mode 100644 index 00000000000..a00b482d888 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/remove_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml index de1f491c9a6..2c8207d401e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml +++ b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml @@ -9,6 +9,7 @@ + + + option(label: String): T where T : AbstractButton = findAll(ui).first { it.actionCommand == label } - private fun toolView(mid: String, pid: String): ToolView { + private fun toolView(mid: String, pid: String): JComponent { val messages = find(ui) - return messages.findMessage(mid)?.part(pid) as? ToolView + val view = messages.findMessage(mid)?.part(pid) + return when (view) { + is ShellToolView -> view + is ToolView -> view + else -> null + } ?: error("missing tool $mid/$pid\n${messages.dumpDetailed()}") } + private fun bodyVisible(view: JComponent): Boolean = when (view) { + is ShellToolView -> view.bodyVisible() + is ToolView -> view.bodyVisible() + else -> false + } + + private fun toggle(view: JComponent) = when (view) { + is ShellToolView -> view.toggle() + is ToolView -> view.toggle() + else -> Unit + } + private fun visibleY(component: JComponent): Int = SwingUtilities.convertPoint(component, Point(0, 0), scrollComponent()).y diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt index fc91e303e1f..9fa3063df02 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt @@ -10,6 +10,7 @@ import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.ui.ConnectionPanel import ai.kilocode.client.session.ui.empty.EmptySessionPanel import ai.kilocode.client.session.ui.LoadingPanel +import ai.kilocode.client.session.ui.SessionDropOverlay import ai.kilocode.client.session.ui.prompt.PromptPanel import ai.kilocode.client.session.ui.account.SessionAccountOverlay import ai.kilocode.client.session.ui.SessionMessageListPanel @@ -56,6 +57,59 @@ class SessionUiLayoutTest : SessionUiTestBase() { assertEquals(listOf(connection, prompt), stack.components.toList()) } + fun `test drop overlay is attached under root overlay layer`() { + val root = find(ui) + val drop = find(ui) + + assertSame(root.overlay, drop.parent) + assertTrue(drop.isVisible) + assertFalse(drop.contains(1, 1)) + assertFalse(root.blocker.components.contains(drop)) + } + + fun `test drop overlay is visual only and not native file drop target`() { + val drop = find(ui) + + assertNull(drop.dropTarget) + } + + fun `test prompt file drag leave does not immediately hide drop overlay`() { + val prompt = find(ui) + val drop = find(ui) + val card = dropCard(drop) + + layout() + prompt.onFileDrag(true) + assertFalse(drop.contains(1, 1)) + assertTrue(card.isVisible) + + prompt.onFileDrag(false) + assertFalse(drop.contains(1, 1)) + assertTrue(card.isVisible) + + prompt.onFileDrag(true) + drop.setActive(false) + } + + fun `test drop overlay covers full session after layout`() { + val root = find(ui) + val drop = find(ui) + + layout() + + assertEquals(java.awt.Rectangle(0, 0, root.overlay.width, root.overlay.height), drop.bounds) + } + + fun `test drop overlay is above account and scroll overlays`() { + val root = find(ui) + val drop = find(ui) + val account = find(ui) + val jump = jumpButton() + + assertTrue(root.overlay.getComponentZOrder(drop) < root.overlay.getComponentZOrder(account)) + assertTrue(root.overlay.getComponentZOrder(drop) < root.overlay.getComponentZOrder(jump)) + } + fun `test active views are children of message list panel`() { ui = newUi(id = "ses_test") settle() @@ -465,4 +519,11 @@ class SessionUiLayoutTest : SessionUiTestBase() { assertEquals(top, overlay.y) assertEquals(root.overlay.width - overlay.width - right, overlay.x) } + + private fun dropCard(drop: SessionDropOverlay) = drop.components + .single() + .let { it as javax.swing.JComponent } + .components + .single() + .let { it as javax.swing.JComponent } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt index 6e5d17d406c..ab1ea65096b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt @@ -133,6 +133,27 @@ class SessionModelTest : BasePlatformTestCase() { assertTrue(events.single() is SessionModelEvent.ContentUpdated) } + fun `test updateContent ignores new empty text content`() { + model.addMessage(msg("m1", "user")) + events.clear() + + model.updateContent("m1", part("p1", "m1", "text", text = " ")) + + assertNull(model.message("m1")!!.parts["p1"]) + assertTrue(events.isEmpty()) + } + + fun `test updateContent removes existing text when it becomes empty`() { + model.addMessage(msg("m1", "user")) + model.updateContent("m1", part("p1", "m1", "text", text = "visible")) + events.clear() + + model.updateContent("m1", part("p1", "m1", "text", text = "")) + + assertNull(model.message("m1")!!.parts["p1"]) + assertEquals("ContentRemoved m1/p1", events.single().toString()) + } + fun `test updateContent reasoning creates Reasoning content`() { model.addMessage(msg("m1", "assistant")) @@ -153,6 +174,31 @@ class SessionModelTest : BasePlatformTestCase() { assertTrue(p.done) } + fun `test updateContent file creates attachment content and updates metadata`() { + model.addMessage(msg("m1", "user")) + events.clear() + + model.updateContent("m1", filePart("f1", "m1", "image/png", "file:///tmp/a.png", "a.png")) + + val file = model.message("m1")!!.parts["f1"] as FileAttachment + assertEquals("image/png", file.mime) + assertEquals("file:///tmp/a.png", file.url) + assertEquals("a.png", file.filename) + assertEquals("ContentAdded m1/f1", events.single().toString()) + + model.updateContent("m1", filePart("f1", "m1", "application/pdf", "file:///tmp/b.pdf", "b.pdf")) + + assertSame(file, model.message("m1")!!.parts["f1"]) + assertEquals("application/pdf", file.mime) + assertEquals("file:///tmp/b.pdf", file.url) + assertEquals("b.pdf", file.filename) + assertTrue(events.any { it.toString() == "ContentUpdated m1/f1" }) + assertModel(""" + user#m1 + file#f1 application/pdf b.pdf + """) + } + fun `test updateContent tool creates Tool content and tracks state`() { model.addMessage(msg("m1", "assistant")) @@ -848,12 +894,18 @@ class SessionModelTest : BasePlatformTestCase() { tokens: TokensDto? = null, todos: List = emptyList(), todoView: TodoViewDto? = null, + mime: String? = null, + url: String? = null, + filename: String? = null, ) = PartDto( id = id, sessionID = "ses", messageID = mid, type = type, text = text, + mime = mime, + url = url, + filename = filename, tool = tool, state = state, title = title, @@ -869,6 +921,15 @@ class SessionModelTest : BasePlatformTestCase() { tokens = tokens, ) + private fun filePart(id: String, mid: String, mime: String, url: String, filename: String) = part( + id = id, + mid = mid, + type = "file", + mime = mime, + url = url, + filename = filename, + ) + private fun question(id: String) = Question( id = id, items = listOf( diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt index 2edb90556de..0d6bb6b29b0 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt @@ -2,22 +2,49 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.model.PromptAttachment +import ai.kilocode.client.session.ui.attachment.AttachmentCard +import ai.kilocode.client.session.ui.attachment.AttachmentCardItem +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.ui.prompt.PROMPT_ATTACHMENT_PASTE_HANDLER_KEY +import ai.kilocode.client.session.ui.prompt.PromptAttachmentPasteHandler +import ai.kilocode.client.session.ui.prompt.PromptAttachmentPasteProvider import ai.kilocode.client.session.ui.prompt.PromptDataKeys import ai.kilocode.client.session.ui.prompt.PromptPanel import com.intellij.icons.AllIcons import com.intellij.notification.Notification import com.intellij.notification.Notifications +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.actionSystem.DataContext import com.intellij.openapi.actionSystem.DataSink import com.intellij.openapi.actionSystem.UiDataProvider import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.editor.actions.PasteAction import com.intellij.openapi.keymap.KeymapUtil +import com.intellij.testFramework.PlatformTestUtil import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.AnimatedIcon import com.intellij.ui.EditorTextField +import com.intellij.ui.components.JBLabel +import com.intellij.util.Producer import com.intellij.util.ui.EmptyIcon +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil import kotlinx.coroutines.CancellationException import java.awt.Container +import java.awt.datatransfer.DataFlavor +import java.awt.datatransfer.StringSelection +import java.awt.datatransfer.Transferable +import java.awt.event.MouseEvent +import java.awt.image.BufferedImage +import java.io.ByteArrayOutputStream +import java.io.File +import java.util.Base64 +import javax.imageio.ImageIO import javax.swing.JButton +import javax.swing.ImageIcon import javax.swing.SwingUtilities @Suppress("UnstableApiUsage") @@ -25,7 +52,7 @@ class PromptPanelTest : BasePlatformTestCase() { fun `test prompt input uses editor font settings`() { val style = SessionEditorStyle.current() - val panel = PromptPanel(project, {}, {}, { _, _ -> }) + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) val font = panel.inputFont() assertEquals(style.editorFamily, font.name) @@ -34,13 +61,13 @@ class PromptPanelTest : BasePlatformTestCase() { fun `test prompt input uses editor background`() { val style = SessionEditorStyle.current() - val panel = PromptPanel(project, {}, {}, { _, _ -> }) + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) assertEquals(style.editorScheme.defaultBackground, panel.defaultFocusedComponent.background) } fun `test applyStyle updates prompt input and height`() { - val panel = PromptPanel(project, {}, {}, { _, _ -> }) + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) val style = SessionEditorStyle.create(family = "Courier New", size = 26) panel.applyStyle(style) @@ -51,7 +78,7 @@ class PromptPanelTest : BasePlatformTestCase() { } fun `test prompt editor grows when lines are added`() { - val panel = PromptPanel(project, {}, {}, { _, _ -> }) + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) val editor = panel.defaultFocusedComponent as EditorTextField val min = editor.preferredSize.height @@ -61,7 +88,7 @@ class PromptPanelTest : BasePlatformTestCase() { } fun `test prompt editor shrinks when lines are removed`() { - val panel = PromptPanel(project, {}, {}, { _, _ -> }) + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) val editor = panel.defaultFocusedComponent as EditorTextField val min = editor.preferredSize.height @@ -74,7 +101,7 @@ class PromptPanelTest : BasePlatformTestCase() { } fun `test prompt editor shrinks after clear`() { - val panel = PromptPanel(project, {}, {}, { _, _ -> }) + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) val editor = panel.defaultFocusedComponent as EditorTextField val min = editor.preferredSize.height @@ -86,8 +113,124 @@ class PromptPanelTest : BasePlatformTestCase() { assertEquals(min, editor.preferredSize.height) } + fun `test attachment only prompt can send`() { + var sent = false + val panel = PromptPanel(project, { text, files -> + sent = text.isBlank() && files.single().url == "file:///tmp/a.png" + }, {}, { _, _ -> }) + panel.setReady(true) + + panel.addAttachmentForTest(PromptAttachment("a", "a.png", "image/png", "file:///tmp/a.png")) + panel.send() + waitForSend { sent } + + assertTrue(sent) + } + + fun `test clear removes attachments`() { + val panel = PromptPanel(project, { _, _ -> }, {}, { _, _ -> }) + + panel.addAttachmentForTest(PromptAttachment("a", "a.txt", "text/plain", "file:///tmp/a.txt")) + assertEquals(1, panel.attachmentCountForTest()) + + panel.clear() + + assertEquals(0, panel.attachmentCountForTest()) + } + + fun `test removed attachment can be added again`() { + val item = PromptAttachment("a", "a.txt", "text/plain", "file:///tmp/a.txt") + val panel = PromptPanel(project, { _, _ -> }, {}, { _, _ -> }) + + panel.addAttachmentForTest(item) + attachmentRemoveButton(panel, item).doClick() + panel.addAttachmentForTest(item) + + assertEquals(1, panel.attachmentCountForTest()) + } + + fun `test attachment card is compact icon only with tooltip metadata and hover remove`() { + val item = PromptAttachment("a", "a.txt", "text/plain", "file:///tmp/a%20b.txt") + val panel = PromptPanel(project, { _, _ -> }, {}, { _, _ -> }) + + panel.addAttachmentForTest(item) + + val button = attachmentRemoveButton(panel, item) + val card = attachmentCard(panel) + + assertFalse(button.isVisible) + assertTrue(card.toolTipText.contains("a.txt")) + assertTrue(card.toolTipText.contains("text/plain")) + assertTrue(card.toolTipText.contains("/tmp/a b.txt")) + assertFalse(card.toolTipText.contains("file:///")) + assertTrue(card.toolTipText.startsWith("")) + assertTrue(card.toolTipText.contains("Name: a.txt
Type: text/plain
Location: /tmp/a b.txt")) + assertFalse(labels(card).any { it.text == "a.txt" || it.text == "text/plain" || it.text == "/tmp/a b.txt" }) + assertTrue(components(card).filterIsInstance().any { it !== button && it.toolTipText == card.toolTipText }) + assertEquals(JBUI.scale(SessionUiStyle.View.Attachment.CARD_WIDTH), card.preferredSize.width) + assertEquals(JBUI.scale(SessionUiStyle.View.Attachment.CARD_HEIGHT), card.preferredSize.height) + assertEquals(0, card.getComponentZOrder(button)) + + val label = labels(card).first() + label.dispatchEvent(MouseEvent(label, MouseEvent.MOUSE_ENTERED, System.currentTimeMillis(), 0, 1, 1, 0, false)) + + assertTrue(button.isVisible) + val icon = button.icon + button.dispatchEvent(MouseEvent(button, MouseEvent.MOUSE_ENTERED, System.currentTimeMillis(), 0, 1, 1, 0, false)) + assertNotSame(icon, button.icon) + button.dispatchEvent(MouseEvent(button, MouseEvent.MOUSE_EXITED, System.currentTimeMillis(), 0, 1, 1, 0, false)) + assertSame(icon, button.icon) + } + + fun `test attachment tooltip hides embedded binary content`() { + val item = PromptAttachment("a", "a.png", "image/png", "data:image/png;base64,aGVsbG8=") + val panel = PromptPanel(project, { _, _ -> }, {}, { _, _ -> }) + + panel.addAttachmentForTest(item) + + val tip = attachmentCard(panel).toolTipText + + assertTrue(tip.contains("Name: a.png")) + assertTrue(tip.contains("Type: image/png")) + assertTrue(tip.contains("Location: ${KiloBundle.message("prompt.attachment.embedded")}")) + assertFalse(tip.contains("data:image/png")) + assertFalse(tip.contains("base64")) + assertFalse(tip.contains("aGVsbG8=")) + } + + fun `test attachment child click opens item`() { + var opened = false + val card = AttachmentCard( + AttachmentCardItem("a.txt", "text/plain", "file:///tmp/a.txt"), + open = { opened = true }, + ) + + val label = labels(card).first() + label.dispatchEvent(MouseEvent(label, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), 0, 1, 1, 1, false)) + + assertTrue(opened) + } + + fun `test attachment card previews embedded image data`() { + val image = BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB) + val out = ByteArrayOutputStream() + ImageIO.write(image, "png", out) + val card = AttachmentCard( + AttachmentCardItem("a.png", "image/png", "data:image/png;base64,${Base64.getEncoder().encodeToString(out.toByteArray())}"), + ) + + card.addNotify() + repeat(20) { + UIUtil.dispatchAllInvocationEvents() + if (labels(card).any { it.icon is ImageIcon }) return@repeat + Thread.sleep(20) + } + + assertTrue(labels(card).any { it.icon is ImageIcon }) + } + fun `test reasoning picker hides when variants are empty`() { - val panel = PromptPanel(project, {}, {}, { _, _ -> }) + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) panel.reasoning.setItems(emptyList()) @@ -95,7 +238,7 @@ class PromptPanelTest : BasePlatformTestCase() { } fun `test reasoning picker shows selected variant`() { - val panel = PromptPanel(project, {}, {}, { _, _ -> }) + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) panel.reasoning.setItems(listOf(ReasoningPicker.Item("low", "Low"), ReasoningPicker.Item("high", "High")), "high") @@ -119,7 +262,7 @@ class PromptPanelTest : BasePlatformTestCase() { } fun `test reset visibility can be toggled`() { - val panel = PromptPanel(project, {}, {}, { _, _ -> }) + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) panel.setResetVisible(true) @@ -127,7 +270,7 @@ class PromptPanelTest : BasePlatformTestCase() { } fun `test prompt editor exposes send context`() { - val panel = PromptPanel(project, {}, {}, { _, _ -> }) + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) val sink = TestSink() (panel.defaultFocusedComponent as UiDataProvider).uiDataSnapshot(sink) @@ -136,7 +279,7 @@ class PromptPanelTest : BasePlatformTestCase() { } fun `test prompt button exposes send context`() { - val panel = PromptPanel(project, {}, {}, { _, _ -> }) + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) val sink = TestSink() (panel.buttonForTest() as UiDataProvider).uiDataSnapshot(sink) @@ -144,8 +287,106 @@ class PromptPanelTest : BasePlatformTestCase() { assertSame(panel, sink.send) } + fun `test prompt paste provider invokes registered handler`() { + val editor = createEditor() + val item = FileListTransferable(listOf(File.createTempFile("kilo-paste", ".txt"))) + var seen: Transferable? = null + editor.putUserData(PROMPT_ATTACHMENT_PASTE_HANDLER_KEY, PromptAttachmentPasteHandler { seen = it }) + + try { + PromptAttachmentPasteProvider().performPaste(pasteContext(editor, item)) + + assertSame(item, seen) + } finally { + EditorFactory.getInstance().releaseEditor(editor) + } + } + + fun `test file list paste adds attachment`() { + val panel = PromptPanel(project, { _, _ -> }, {}, { _, _ -> }) + val file = File.createTempFile("kilo-paste", ".txt") + file.writeText("hello") + + PlatformTestUtil.waitForFuture(panel.processPasteForTest(FileListTransferable(listOf(file)))) + UIUtil.dispatchAllInvocationEvents() + + assertEquals(1, panel.attachmentCountForTest()) + } + + fun `test frontend file attachment defers data url encoding until send`() { + val file = File.createTempFile("kilo-paste", ".txt") + file.writeText("hello") + + val item = ai.kilocode.client.session.model.PromptAttachmentExtractor.files(listOf(file)).single() + + assertTrue(item.url.startsWith("file://")) + assertTrue(item.part().url.orEmpty().startsWith("data:text/plain;base64,")) + } + + fun `test pasted frontend file sends data url payload`() { + var sent: ai.kilocode.rpc.dto.PromptPartDto? = null + val panel = PromptPanel(project, { _, files -> sent = files.single() }, {}, { _, _ -> }) + val file = File.createTempFile("kilo-paste", ".txt") + file.writeText("hello") + panel.setReady(true) + + PlatformTestUtil.waitForFuture(panel.processPasteForTest(FileListTransferable(listOf(file)))) + UIUtil.dispatchAllInvocationEvents() + panel.send() + waitForSend { sent != null } + + val item = sent!! + assertEquals("text/plain", item.mime) + assertTrue(item.url.orEmpty().startsWith("data:text/plain;base64,")) + assertFalse(item.url.orEmpty().startsWith("file://")) + } + + fun `test raw image paste adds attachment`() { + val panel = PromptPanel(project, { _, _ -> }, {}, { _, _ -> }) + val image = BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB) + + PlatformTestUtil.waitForFuture(panel.processPasteForTest(ImageTransferable(image))) + UIUtil.dispatchAllInvocationEvents() + + assertEquals(1, panel.attachmentCountForTest()) + } + + fun `test file paste ignores companion image flavor`() { + val panel = PromptPanel(project, { _, _ -> }, {}, { _, _ -> }) + val file = File.createTempFile("kilo-paste", ".png") + file.writeBytes(byteArrayOf()) + val image = BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB) + + PlatformTestUtil.waitForFuture(panel.processPasteForTest(FileImageTransferable(listOf(file), image))) + UIUtil.dispatchAllInvocationEvents() + + assertEquals(1, panel.attachmentCountForTest()) + } + + fun `test normal text paste is not intercepted`() { + val editor = createEditor() + val provider = PromptAttachmentPasteProvider() + editor.putUserData(PROMPT_ATTACHMENT_PASTE_HANDLER_KEY, PromptAttachmentPasteHandler {}) + + try { + assertFalse(provider.isPasteEnabled(pasteContext(editor, StringSelection("hello")))) + } finally { + EditorFactory.getInstance().releaseEditor(editor) + } + } + + fun `test disabled media model blocks pasted image`() { + val panel = PromptPanel(project, { _, _ -> }, {}, { _, _ -> }) + panel.setAttachmentEnabled(false) + + PlatformTestUtil.waitForFuture(panel.processPasteForTest(ImageTransferable(BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB)))) + UIUtil.dispatchAllInvocationEvents() + + assertEquals(0, panel.attachmentCountForTest()) + } + fun `test prompt button switches between send and stop state`() { - val panel = PromptPanel(project, {}, {}, { _, _ -> }) + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) assertEquals(KeymapUtil.createTooltipText("Send", "Kilo.SendPrompt"), panel.buttonForTest().toolTipText) assertFalse(panel.isStopEnabled) @@ -157,7 +398,7 @@ class PromptPanelTest : BasePlatformTestCase() { } fun `test auto approve button toggles and updates tooltip`() { - val panel = PromptPanel(project, {}, {}, { _, _ -> }) + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) val button = autoApproveButton(panel) var seen: Boolean? = null panel.onAutoApproveToggle = { seen = it } @@ -188,7 +429,7 @@ class PromptPanelTest : BasePlatformTestCase() { } fun `test auto approve and enhance buttons sit next to send button`() { - val panel = PromptPanel(project, {}, {}, { _, _ -> }) + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) val auto = autoApproveButton(panel) val enhance = enhanceButton(panel) val send = panel.buttonForTest() @@ -202,7 +443,7 @@ class PromptPanelTest : BasePlatformTestCase() { } fun `test enhance button follows connection and busy state`() { - val panel = PromptPanel(project, {}, {}, { _, _ -> }) + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) val enhance = enhanceButton(panel) assertFalse(enhance.isEnabled) @@ -220,7 +461,7 @@ class PromptPanelTest : BasePlatformTestCase() { fun `test enhance button rewrites active draft`() { var seen: String? = null var complete: ((Result) -> Unit)? = null - val panel = PromptPanel(project, {}, {}, { text, done -> + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { text, done -> seen = text complete = done }) @@ -249,7 +490,7 @@ class PromptPanelTest : BasePlatformTestCase() { fun `test edit while enhancing ignores stale completion`() { var complete: ((Result) -> Unit)? = null - val panel = PromptPanel(project, {}, {}, { _, done -> complete = done }) + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, done -> complete = done }) val editor = panel.defaultFocusedComponent as EditorTextField val enhance = enhanceButton(panel) panel.setReady(true) @@ -273,7 +514,7 @@ class PromptPanelTest : BasePlatformTestCase() { ApplicationManager.getApplication().messageBus.connect(testRootDisposable).subscribe(Notifications.TOPIC, listener) project.messageBus.connect(testRootDisposable).subscribe(Notifications.TOPIC, listener) var complete: ((Result) -> Unit)? = null - val panel = PromptPanel(project, {}, {}, { _, done -> complete = done }) + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, done -> complete = done }) val editor = panel.defaultFocusedComponent as EditorTextField val enhance = enhanceButton(panel) panel.setReady(true) @@ -290,7 +531,7 @@ class PromptPanelTest : BasePlatformTestCase() { fun `test empty enhancement inserts explanation without request`() { var requests = 0 - val panel = PromptPanel(project, {}, {}, { _, _ -> requests++ }) + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> requests++ }) val editor = panel.defaultFocusedComponent as EditorTextField panel.setReady(true) @@ -301,7 +542,7 @@ class PromptPanelTest : BasePlatformTestCase() { } fun `test pickers belong to rounded shell`() { - val panel = PromptPanel(project, {}, {}, { _, _ -> }) + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) val shell = panel.shellForTest() assertTrue(SwingUtilities.isDescendingFrom(panel.mode, shell)) @@ -319,6 +560,25 @@ class PromptPanelTest : BasePlatformTestCase() { } } + private fun attachmentRemoveButton(panel: PromptPanel, item: PromptAttachment): JButton { + val name = KiloBundle.message("prompt.attachment.remove", item.name) + return buttons(panel).first { it.accessibleContext.accessibleName == name } + } + + private fun attachmentCard(root: java.awt.Component): AttachmentCard { + fun visit(node: java.awt.Component): AttachmentCard? { + if (node is AttachmentCard) return node + if (node is Container) { + for (child in node.components) { + val card = visit(child) + if (card != null) return card + } + } + return null + } + return visit(root)!! + } + private fun enhanceButton(panel: PromptPanel): JButton { val name = KiloBundle.message("prompt.action.enhance") return buttons(panel).first { it.accessibleContext.accessibleName == name } @@ -334,6 +594,83 @@ class PromptPanelTest : BasePlatformTestCase() { return out } + private fun labels(root: java.awt.Component): List { + return components(root).filterIsInstance() + } + + private fun components(root: java.awt.Component): List { + val out = mutableListOf() + fun visit(node: java.awt.Component) { + out.add(node) + if (node is Container) node.components.forEach(::visit) + } + visit(root) + return out + } + + private fun createEditor(): Editor { + val factory = EditorFactory.getInstance() + return factory.createEditor(factory.createDocument(""), project) + } + + private fun waitForSend(done: () -> Boolean) { + repeat(50) { + UIUtil.dispatchAllInvocationEvents() + if (done()) return + Thread.sleep(20) + } + } + + private fun pasteContext(editor: Editor, item: Transferable) = DataContext { id -> + when (id) { + CommonDataKeys.EDITOR.name -> editor + PasteAction.TRANSFERABLE_PROVIDER.name -> Producer { item } + else -> null + } + } + + private class FileListTransferable(private val files: List) : Transferable { + override fun getTransferDataFlavors(): Array = arrayOf(DataFlavor.javaFileListFlavor) + + override fun isDataFlavorSupported(flavor: DataFlavor): Boolean = flavor == DataFlavor.javaFileListFlavor + + override fun getTransferData(flavor: DataFlavor): Any { + if (!isDataFlavorSupported(flavor)) throw java.awt.datatransfer.UnsupportedFlavorException(flavor) + return files + } + } + + private class ImageTransferable(private val image: BufferedImage) : Transferable { + override fun getTransferDataFlavors(): Array = arrayOf(DataFlavor.imageFlavor) + + override fun isDataFlavorSupported(flavor: DataFlavor): Boolean = flavor == DataFlavor.imageFlavor + + override fun getTransferData(flavor: DataFlavor): Any { + if (!isDataFlavorSupported(flavor)) throw java.awt.datatransfer.UnsupportedFlavorException(flavor) + return image + } + } + + private class FileImageTransferable( + private val files: List, + private val image: BufferedImage, + ) : Transferable { + override fun getTransferDataFlavors(): Array = arrayOf( + DataFlavor.javaFileListFlavor, + DataFlavor.imageFlavor, + ) + + override fun isDataFlavorSupported(flavor: DataFlavor): Boolean { + return flavor == DataFlavor.javaFileListFlavor || flavor == DataFlavor.imageFlavor + } + + override fun getTransferData(flavor: DataFlavor): Any { + if (flavor == DataFlavor.javaFileListFlavor) return files + if (flavor == DataFlavor.imageFlavor) return image + throw java.awt.datatransfer.UnsupportedFlavorException(flavor) + } + } + private class TestSink : DataSink { var send: Any? = null diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionRootPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionRootPanelTest.kt index f7e69f4ca46..6f8be31660e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionRootPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionRootPanelTest.kt @@ -1,7 +1,10 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.ui.UiStyle +import com.intellij.icons.AllIcons import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBFont import com.intellij.util.ui.components.BorderLayoutPanel import java.awt.Dimension import java.awt.Rectangle @@ -71,6 +74,63 @@ class SessionRootPanelTest : BasePlatformTestCase() { assertTrue(child.laid) } + fun `test drop overlay starts visible but never captures hit tests`() { + val drop = SessionDropOverlay().apply { + setSize(200, 100) + } + val card = dropCard(drop) + + assertTrue(drop.isVisible) + assertFalse(drop.contains(50, 50)) + assertFalse(card.isVisible) + + drop.setActive(true) + assertFalse(drop.contains(50, 50)) + assertTrue(card.isVisible) + + drop.setActive(false) + assertFalse(drop.contains(50, 50)) + assertFalse(card.isVisible) + } + + fun `test drop overlay can fill root overlay bounds`() { + val root = SessionRootPanel().apply { + setSize(400, 260) + } + val drop = SessionDropOverlay() + + root.addOverlay(drop) { pane, _ -> + Rectangle(0, 0, pane.width, pane.height) + } + root.doLayout() + + assertEquals(Rectangle(0, 0, 400, 260), drop.bounds) + } + + fun `test drop overlay labels use platform heading fonts`() { + val drop = SessionDropOverlay() + val labels = dropLabels(drop) + + assertEquals("Drop files here", labels[0].text) + assertEquals(JBFont.h0(), labels[0].font) + assertEquals("to add them to the prompt", labels[1].text) + assertEquals(JBFont.h2(), labels[1].font) + assertEquals(AllIcons.Actions.Download.iconWidth * 3, labels[2].icon.iconWidth) + assertEquals(AllIcons.Actions.Download.iconHeight * 3, labels[2].icon.iconHeight) + } + + fun `test drop overlay is registered in overlay layer not blocker`() { + val root = SessionRootPanel() + val drop = SessionDropOverlay() + + root.addOverlay(drop) { pane, _ -> + Rectangle(0, 0, pane.width, pane.height) + } + + assertSame(root.overlay, drop.parent) + assertFalse(root.blocker.components.contains(drop)) + } + fun `test setBlocked makes blocker visible and setBlocked false hides it`() { val root = SessionRootPanel().apply { setSize(200, 100) } root.doLayout() @@ -135,4 +195,18 @@ class SessionRootPanelTest : BasePlatformTestCase() { super.doLayout() } } + + private fun dropCard(drop: SessionDropOverlay) = drop.components + .single() + .let { it as javax.swing.JComponent } + .components + .single() + .let { it as javax.swing.JComponent } + + private fun dropLabels(drop: SessionDropOverlay): List { + val stack = dropCard(drop).components.single() as javax.swing.JComponent + return stack.components + .map { it as javax.swing.JComponent } + .map { it.components.single() as JBLabel } + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionSelectionCopyTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionSelectionCopyTest.kt index a266bc91432..da2e4952e21 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionSelectionCopyTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionSelectionCopyTest.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.session.SessionUiTestBase +import ai.kilocode.client.session.views.tool.ShellToolView import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.PartDto @@ -73,20 +74,26 @@ class SessionSelectionCopyTest : SessionUiTestBase() { output = text, ), )) - for (view in toolViews(ui)) view.expand() + for (view in toolViews(ui)) expand(view) layout() return textComponent(text) } - private fun toolViews(root: Container): List { - val out = mutableListOf() - if (root is ToolView) out.add(root) + private fun toolViews(root: Container): List { + val out = mutableListOf() + if (root is ShellToolView || root is ToolView) out.add(root) for (child in root.components) { if (child is Container) out.addAll(toolViews(child)) } return out } + private fun expand(view: Container) = when (view) { + is ShellToolView -> view.expand() + is ToolView -> view.expand() + else -> false + } + private fun copyProvider(): CopyProvider? { val sink = CopySink() (ui as UiDataProvider).uiDataSnapshot(sink) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt index 880899b007e..5bb1692ed34 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt @@ -2,7 +2,15 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionState +import ai.kilocode.client.session.ui.attachment.AttachmentCard +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.AttachmentView +import ai.kilocode.client.session.views.PromptAttachmentView +import ai.kilocode.client.session.views.tool.ReadToolView import ai.kilocode.client.session.views.TextView +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.views.tool.ShellToolView +import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageTimeDto import ai.kilocode.rpc.dto.MessageWithPartsDto @@ -10,6 +18,11 @@ import ai.kilocode.rpc.dto.PartDto import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.ui.JBUI +import java.awt.Container +import java.awt.event.MouseEvent +import javax.swing.JButton +import javax.swing.ScrollPaneConstants /** * Integration test: mutate [SessionModel] directly on the EDT and verify @@ -77,14 +90,19 @@ class SessionUiUpdateTest : BasePlatformTestCase() { // ------ tool lifecycle ------ - fun `test tool state transitions are reflected in ToolView`() { + fun `test tool state transitions are reflected in tool view`() { model.upsertMessage(msg("a1", "assistant")) model.updateContent("a1", toolPart("t1", "a1", "bash", "pending")) model.updateContent("a1", toolPart("t1", "a1", "bash", "running")) model.updateContent("a1", toolPart("t1", "a1", "bash", "completed")) - val tv = panel.findMessage("a1")!!.part("t1") as ai.kilocode.client.session.views.tool.ToolView - assertFalse(tv.labelText().contains("Running")) + val view = panel.findMessage("a1")!!.part("t1") + val label = when (view) { + is ShellToolView -> view.labelText() + is ToolView -> view.labelText() + else -> error("unexpected tool view ${view?.javaClass?.name}") + } + assertFalse(label.contains("Running")) } fun `test read tool renders as ReadToolView`() { @@ -154,6 +172,223 @@ class SessionUiUpdateTest : BasePlatformTestCase() { assertNull(gv.border) } + fun `test assistant file part renders as attachment view`() { + model.upsertMessage(msg("a1", "assistant")) + model.updateContent( + "a1", + PartDto( + id = "f1", + sessionID = "ses", + messageID = "a1", + type = "file", + mime = "image/png", + url = "file:///tmp/a.png", + filename = "a.png", + ), + ) + + val view = panel.findMessage("a1")!!.part("f1") + assertTrue(view is AttachmentView) + assertEquals("AttachmentView#f1:a.png", view!!.dumpLabel()) + assertNotNull(find(view, AttachmentCard::class.java)) + assertFalse(buttons(view).any { it.accessibleContext.accessibleName == KiloBundle.message("prompt.attachment.remove", "a.png") }) + } + + fun `test user file part renders as prompt attachment strip`() { + model.upsertMessage(msg("u1", "user")) + model.updateContent( + "u1", + PartDto( + id = "f1", + sessionID = "ses", + messageID = "u1", + type = "file", + mime = "image/png", + url = "file:///tmp/a.png", + filename = "a.png", + ), + ) + + val view = panel.findMessage("u1")!!.part("f1") + assertTrue(view is PromptAttachmentView) + assertEquals("PromptAttachmentView#attachments:u1[f1]", view!!.dumpLabel()) + assertNotNull(find(view, AttachmentCard::class.java)) + assertFalse(buttons(view).any { it.accessibleContext.accessibleName == KiloBundle.message("prompt.attachment.remove", "a.png") }) + } + + fun `test user text and attachments share one prompt container`() { + val opened = mutableListOf() + val item = SessionMessageListPanel(model, parent, openFile = {}, openAttachment = { _, it -> opened.add(it.url) }) + model.upsertMessage(msg("u1", "user")) + model.updateContent("u1", part("p1", "u1", "text", text = "look at this")) + model.updateContent( + "u1", + PartDto( + id = "f1", + sessionID = "ses", + messageID = "u1", + type = "file", + mime = "image/png", + url = "data:image/png;base64,aGVsbG8=", + filename = "a.png", + ), + ) + model.updateContent( + "u1", + PartDto( + id = "f2", + sessionID = "ses", + messageID = "u1", + type = "file", + mime = "text/plain", + url = "data:text/plain;base64,aGVsbG8=", + filename = "note.txt", + ), + ) + + val msg = item.findMessage("u1")!! + val attachment = msg.part("f1")!! + val other = msg.part("f2")!! + + assertSame(msg, attachment.parent) + assertSame(attachment, other) + assertEquals(listOf("p1", "f1", "f2"), msg.partIds()) + assertEquals(1, msg.components.filterIsInstance().size) + assertEquals(2, findAll(attachment, AttachmentCard::class.java).size) + + val cards = findAll(attachment, AttachmentCard::class.java) + for (card in cards) { + card.dispatchEvent(MouseEvent(card, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), 0, 1, 1, 1, false)) + } + + assertEquals(listOf("data:image/png;base64,aGVsbG8=", "data:text/plain;base64,aGVsbG8="), opened) + } + + fun `test empty sanitized user text does not create prompt panel`() { + model.upsertMessage(msg("u1", "user")) + model.updateContent("u1", part("p1", "u1", "text", text = "read these screenshots")) + model.updateContent("u1", part("p2", "u1", "text", text = " ")) + model.updateContent( + "u1", + PartDto( + id = "f1", + sessionID = "ses", + messageID = "u1", + type = "file", + mime = "image/png", + url = "data:image/png;base64,aGVsbG8=", + filename = "a.png", + ), + ) + + val msg = panel.findMessage("u1")!! + + assertNull(msg.part("p2")) + assertEquals(listOf("p1", "f1"), msg.partIds()) + assertEquals(1, msg.components.filterIsInstance().size) + assertEquals(1, msg.components.filterIsInstance().size) + } + + fun `test prompt text panel is removed when content becomes empty`() { + model.upsertMessage(msg("u1", "user")) + model.updateContent("u1", part("p1", "u1", "text", text = "visible")) + + assertNotNull(panel.findMessage("u1")!!.part("p1")) + + model.updateContent("u1", part("p1", "u1", "text", text = "")) + + val msg = panel.findMessage("u1")!! + assertNull(msg.part("p1")) + assertTrue(msg.partIds().isEmpty()) + assertEquals(0, msg.components.filterIsInstance().size) + } + + fun `test user attachment strip scrolls horizontally only`() { + model.upsertMessage(msg("u1", "user")) + for (i in 1..8) { + model.updateContent( + "u1", + PartDto( + id = "f$i", + sessionID = "ses", + messageID = "u1", + type = "file", + mime = "image/png", + url = "file:///tmp/$i.png", + filename = "$i.png", + ), + ) + } + + val view = panel.findMessage("u1")!!.part("f1") as PromptAttachmentView + val height = view.preferredSize.height + val pane = view.scrollPane() + + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, pane.horizontalScrollBarPolicy) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, pane.verticalScrollBarPolicy) + assertEquals(0, view.insets.top) + assertEquals(JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), view.insets.bottom) + assertEquals( + JBUI.scale(SessionUiStyle.View.Attachment.CARD_HEIGHT) + + pane.horizontalScrollBar.preferredSize.height + + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), + height, + ) + + model.updateContent( + "u1", + PartDto( + id = "f9", + sessionID = "ses", + messageID = "u1", + type = "file", + mime = "image/png", + url = "file:///tmp/9.png", + filename = "9.png", + ), + ) + + assertEquals(height, view.preferredSize.height) + assertEquals((1..9).map { "f$it" }, view.ids()) + } + + fun `test user read tool payload is hidden but assistant read tool renders`() { + model.upsertMessage(msg("u1", "user")) + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("u1", toolPart("ut1", "u1", "read", "completed")) + model.updateContent("a1", toolPart("at1", "a1", "read", "completed")) + + val user = panel.findMessage("u1")!! + val assistant = panel.findMessage("a1")!! + + assertTrue(user.partIds().isEmpty()) + assertNull(user.part("ut1")) + assertTrue(assistant.part("at1") is ReadToolView) + } + + fun `test transcript attachment click delegates to attachment opener`() { + val opened = mutableListOf>() + val item = SessionMessageListPanel(model, parent, openFile = {}, openAttachment = { msg, it -> opened.add(msg to it.url) }) + model.upsertMessage(msg("u1", "user")) + model.updateContent( + "u1", + PartDto( + id = "f1", + sessionID = "ses", + messageID = "u1", + type = "file", + mime = "text/plain", + url = "data:text/plain;base64,aGVsbG8=", + filename = "note.txt", + ), + ) + + val card = find(item.findMessage("u1")!!.part("f1")!!, AttachmentCard::class.java)!! + card.dispatchEvent(MouseEvent(card, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), 0, 1, 1, 1, false)) + + assertEquals(listOf("u1" to "data:text/plain;base64,aGVsbG8="), opened) + } + // ------ silent part types ------ fun `test step markers are not rendered in panel`() { @@ -225,4 +460,35 @@ class SessionUiUpdateTest : BasePlatformTestCase() { private fun toolPart(id: String, mid: String, tool: String, state: String) = PartDto( id = id, sessionID = "ses", messageID = mid, type = "tool", tool = tool, state = state, ) + + private fun find(root: java.awt.Component, type: Class): T? { + if (type.isInstance(root)) return type.cast(root) + if (root is Container) { + for (child in root.components) { + val found = find(child, type) + if (found != null) return found + } + } + return null + } + + private fun findAll(root: java.awt.Component, type: Class): List { + val out = mutableListOf() + fun visit(node: java.awt.Component) { + if (type.isInstance(node)) out.add(type.cast(node)) + if (node is Container) node.components.forEach(::visit) + } + visit(root) + return out + } + + private fun buttons(root: java.awt.Component): List { + val out = mutableListOf() + fun visit(node: java.awt.Component) { + if (node is JButton) out.add(node) + if (node is Container) node.components.forEach(::visit) + } + visit(root) + return out + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentEditorKindTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentEditorKindTest.kt new file mode 100644 index 00000000000..b18e32a35d1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentEditorKindTest.kt @@ -0,0 +1,236 @@ +package ai.kilocode.client.session.ui.attachment + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloSessionService +import ai.kilocode.client.session.model.FileAttachment +import ai.kilocode.client.testing.FakeAppRpcApi +import ai.kilocode.client.testing.FakeSessionRpcApi +import ai.kilocode.client.vfs.KiloPath +import ai.kilocode.client.vfs.KiloVirtualFile +import ai.kilocode.client.vfs.KiloVirtualFileSystem +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.MessageTimeDto +import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.PartDto +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.service +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.vfs.VirtualFileManager +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.testFramework.replaceService +import com.intellij.util.ui.UIUtil +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking + +class AttachmentEditorKindTest : BasePlatformTestCase() { + fun testAttachmentParamsUseStableIdentityFields() { + val item = FileAttachment("part1").apply { + mime = "text/plain" + url = "data:text/plain;base64,aGVsbG8=" + filename = "note.txt" + } + + val params = attachmentParams("ses1", "msg1", item, "note.txt", "/repo") + val path = KiloPath(AttachmentEditorKind.ID, params).canonical() + val json = KiloVirtualFileSystem.getInstance().getPath(path) + val decoded = KiloVirtualFileSystem.decode(json) + + assertEquals(path, decoded) + assertEquals(AttachmentEditorKind.ID, path.kind) + assertEquals("ses1", params["sessionId"]) + assertEquals("msg1", params["messageId"]) + assertEquals("part1", params["partId"]) + assertFalse(params["attachmentKey"].isNullOrBlank()) + assertEquals("note.txt", params["filename"]) + assertEquals("text/plain", params["mime"]) + assertEquals("/repo", params["directory"]) + assertFalse(json.contains("projectHash", ignoreCase = true)) + assertFalse(json.contains("launch", ignoreCase = true)) + assertFalse(json.contains("time", ignoreCase = true)) + assertFalse(json.contains("random", ignoreCase = true)) + } + + fun testSameParamsMapToSameVirtualPath() { + val params = linkedMapOf( + "sessionId" to "ses1", + "messageId" to "msg1", + "partId" to "part1", + "attachmentKey" to "key1", + "filename" to "note.txt", + "mime" to "text/plain", + "directory" to "/repo", + ) + + val one = KiloVirtualFileSystem.getInstance().getPath(KiloPath(AttachmentEditorKind.ID, params)) + val two = KiloVirtualFileSystem.getInstance().getPath(KiloPath(AttachmentEditorKind.ID, params.toList().reversed().toMap())) + + assertEquals(one, two) + assertFalse(one.contains("/system/kilo/editors")) + assertFalse(one.contains("kiloattachment")) + } + + fun testDuplicatePartAttachmentsMapToDistinctVirtualFiles() { + val first = FileAttachment("part1").apply { + mime = "text/plain" + url = "data:text/plain;base64,b25l" + filename = "note.txt" + } + val second = FileAttachment("part1").apply { + mime = "text/plain" + url = "data:text/plain;base64,dHdv" + filename = "note.txt" + } + val one = attachmentParams("ses1", "msg1", first, "note.txt", "/repo") + val two = attachmentParams("ses1", "msg1", second, "note.txt", "/repo") + + assertFalse(one == two) + assertFalse(one["attachmentKey"] == two["attachmentKey"]) + assertFalse(KiloPath(AttachmentEditorKind.ID, one) == KiloPath(AttachmentEditorKind.ID, two)) + } + + fun testVirtualFilesAreExcludedFromEditorHistory() { + ensureAttachmentEditorKind() + val file = KiloVirtualFile(KiloPath(AttachmentEditorKind.ID, mapOf( + "directory" to "/repo", + "sessionId" to "ses1", + "messageId" to "msg1", + "partId" to "part1", + "filename" to "note.txt", + ))) + + assertNull(VirtualFileManager.getInstance().findFileByUrl(file.url)) + } + + @Suppress("UnstableApiUsage") + fun testFetchUsesAttachmentKeyBeforeDuplicatePartId() { + val cs = CoroutineScope(SupervisorJob()) + val app = FakeAppRpcApi() + val rpc = FakeSessionRpcApi() + app.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + val first = PartDto( + id = "part1", + sessionID = "ses1", + messageID = "msg1", + type = "file", + mime = "text/plain", + url = "data:text/plain;base64,b25l", + filename = "note.txt", + ) + val second = first.copy(url = "data:text/plain;base64,dHdv") + rpc.history.add(MessageWithPartsDto( + info = MessageDto( + id = "msg1", + sessionID = "ses1", + role = "user", + time = MessageTimeDto(created = 0.0), + ), + parts = listOf(first, second), + )) + ApplicationManager.getApplication().replaceService(KiloAppService::class.java, KiloAppService(cs, app), testRootDisposable) + project.replaceService(KiloSessionService::class.java, KiloSessionService(project, cs, rpc), testRootDisposable) + val item = FileAttachment("part1").apply { + mime = "text/plain" + url = second.url.orEmpty() + filename = "note.txt" + } + val results = mutableListOf() + val parent = Disposer.newDisposable() + + try { + KiloAttachmentEditorService(project, cs).load(ref("ses1", "msg1", item, "note.txt", "/repo"), parent) { + results.add(it) + } + + waitFor { results.any { it is AttachmentData.Text } } + assertTrue(results.any { it is AttachmentData.Connecting }) + val data = results.last { it is AttachmentData.Text } as AttachmentData.Text + assertEquals("two", data.text) + assertEquals(1, rpc.attachmentParts.size) + assertEquals("msg1", rpc.attachmentParts.single().messageId) + assertEquals(0, rpc.historyCalls) + } finally { + Disposer.dispose(parent) + cs.cancel() + } + } + + @Suppress("UnstableApiUsage") + fun testLoadShowsConnectionFailedUntilRetryBecomesReady() = runBlocking { + val cs = CoroutineScope(SupervisorJob()) + val app = FakeAppRpcApi() + val rpc = FakeSessionRpcApi() + val part = PartDto( + id = "part1", + sessionID = "ses1", + messageID = "msg1", + type = "file", + mime = "text/plain", + url = "data:text/plain;base64,b2s=", + filename = "note.txt", + ) + rpc.history.add(MessageWithPartsDto( + info = MessageDto( + id = "msg1", + sessionID = "ses1", + role = "user", + time = MessageTimeDto(created = 0.0), + ), + parts = listOf(part), + )) + app.state.value = KiloAppStateDto(KiloAppStatusDto.ERROR) + ApplicationManager.getApplication().replaceService(KiloAppService::class.java, KiloAppService(cs, app), testRootDisposable) + project.replaceService(KiloSessionService::class.java, KiloSessionService(project, cs, rpc), testRootDisposable) + val item = FileAttachment("part1").apply { + mime = part.mime.orEmpty() + url = part.url.orEmpty() + filename = part.filename.orEmpty() + } + val results = mutableListOf() + val parent = Disposer.newDisposable() + + try { + KiloAttachmentEditorService(project, cs).load(ref("ses1", "msg1", item, "note.txt", "/repo"), parent) { + results.add(it) + } + + waitFor { results.any { it is AttachmentData.ConnectionFailed } } + assertTrue(results.any { it is AttachmentData.Connecting }) + assertTrue(results.any { it is AttachmentData.ConnectionFailed }) + + app.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + + waitFor { results.any { it is AttachmentData.Text } } + val data = results.last { it is AttachmentData.Text } as AttachmentData.Text + assertEquals("ok", data.text) + } finally { + Disposer.dispose(parent) + cs.cancel() + } + } + + private fun waitFor(done: () -> Boolean) { + val until = System.currentTimeMillis() + 5_000 + while (!done() && System.currentTimeMillis() < until) { + UIUtil.dispatchAllInvocationEvents() + Thread.sleep(50) + } + assertTrue(done()) + } + + private fun ref(session: String, message: String, item: FileAttachment, name: String, dir: String): AttachmentRef { + val params = attachmentParams(session, message, item, name, dir) + return AttachmentRef( + directory = params.getValue("directory"), + sessionId = params.getValue("sessionId"), + messageId = params.getValue("messageId"), + partId = params.getValue("partId"), + attachmentKey = params["attachmentKey"], + filename = params.getValue("filename"), + mime = params.getValue("mime"), + ) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt index 599f2ebed09..2709b6bbbe1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt @@ -17,11 +17,14 @@ import ai.kilocode.rpc.dto.PartTimeDto import ai.kilocode.rpc.dto.ProviderDto import ai.kilocode.rpc.dto.TodoDto import ai.kilocode.rpc.dto.TokensDto +import com.intellij.icons.AllIcons import com.intellij.ide.util.PropertiesComponent +import java.awt.Cursor import java.awt.Color import java.awt.Point import java.awt.event.MouseEvent import java.awt.event.MouseWheelEvent +import java.awt.image.BufferedImage import javax.swing.UIManager class SessionHeaderPanelTest : SessionControllerTestBase() { @@ -59,6 +62,7 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { assertFalse(panel.isExpanded()) assertEquals("Generated title", panel.titleText()) assertEquals("$0.07", panel.costText()) + assertEquals("$0.07 spent in this session", panel.costTip()) assertEquals("1%", panel.contextText()) assertEquals("Tokens 13.7K 2.5K cache write 25 cache read 75", panel.tokenText()) assertEquals("Tokens used by the latest assistant response: input, output, cache writes, and cache reads.", panel.tokenTip()) @@ -73,7 +77,33 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { List(panel.foregrounds().size) { style.editorForeground }, panel.foregrounds(), ) - assertNotNull(panel.expandButton().icon) + assertEquals("", panel.expandButton().text) + assertSame(AllIcons.General.ChevronRight, panel.expandButton().icon) + assertEquals(Cursor.HAND_CURSOR, panel.expandButton().cursor.type) + assertNotSame(panel.compactButton().parent, panel.expandButton().parent) + } + + fun `test header has editor tab bottom separator`() { + val old = UIManager.getColor("EditorTabs.underTabsBorderColor") + val color = Color(12, 34, 56) + + try { + UIManager.put("EditorTabs.underTabsBorderColor", color) + val c = promptedHeader() + val panel = SessionHeaderPanel(c, parent) + val ins = panel.border.getBorderInsets(panel) + val img = BufferedImage(10, 10, BufferedImage.TYPE_INT_ARGB) + val g = img.createGraphics() + + panel.border.paintBorder(panel, g, 0, 0, img.width, img.height) + g.dispose() + + assertEquals(0, ins.top) + assertEquals(1, ins.bottom) + assertEquals(color.rgb, img.getRGB(5, img.height - 1)) + } finally { + UIManager.put("EditorTabs.underTabsBorderColor", old) + } } fun `test compact button follows eligibility and invokes controller`() { @@ -96,7 +126,7 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { val c = promptedHeader() val panel = SessionHeaderPanel(c, parent) - panel.expandButton().doClick() + click(panel.expandButton()) assertTrue(panel.isExpanded()) assertTrue(panel.todoVisible()) assertFalse(panel.todoListVisible()) @@ -184,7 +214,7 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { val bar = panel.contextBar() assertFalse(panel.isExpanded()) - panel.expandButton().doClick() + click(panel.expandButton()) assertTrue(panel.isExpanded()) assertSame(body, panel.bodyPanel()) @@ -261,7 +291,7 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { assertNull(panel.timelineToolTip()) assertEquals(-1, panel.timelineHover()) - panel.expandButton().doClick() + click(panel.expandButton()) assertFalse(panel.isExpanded()) assertSame(body, panel.bodyPanel()) @@ -297,26 +327,33 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { assertFalse(panel.isExpanded()) assertEquals("Show session metrics", panel.expandTip()) + assertEquals("", panel.expandButton().text) + assertSame(AllIcons.General.ChevronRight, panel.expandButton().icon) + assertNotSame(panel.compactButton().parent, panel.expandButton().parent) - panel.expandButton().doClick() + click(panel.expandButton()) emit(ChatEventDto.SessionUpdated("ses_test", session("ses_test", title = "New title"))) assertTrue(panel.isExpanded()) assertEquals("Hide session metrics", panel.expandTip()) + assertEquals("", panel.expandButton().text) + assertSame(AllIcons.General.ChevronDown, panel.expandButton().icon) - panel.expandButton().doClick() + click(panel.expandButton()) emit(ChatEventDto.MessageUpdated("ses_test", assistant(cost = 0.2))) assertFalse(panel.isExpanded()) assertEquals("Show session metrics", panel.expandTip()) + assertEquals("", panel.expandButton().text) + assertSame(AllIcons.General.ChevronRight, panel.expandButton().icon) } fun `test collapse persists and new header starts collapsed`() { val c = promptedHeader() val panel = SessionHeaderPanel(c, parent) - panel.expandButton().doClick() - panel.expandButton().doClick() + click(panel.expandButton()) + click(panel.expandButton()) assertFalse(panel.isExpanded()) assertFalse(PropertiesComponent.getInstance().getBoolean(SessionHeaderPanel.EXPANDED_KEY, true)) @@ -334,7 +371,7 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { assertFalse(panel.isExpanded()) - panel.expandButton().doClick() + click(panel.expandButton()) assertTrue(panel.isExpanded()) assertTrue(PropertiesComponent.getInstance().getBoolean(SessionHeaderPanel.EXPANDED_KEY, false)) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt index 93a3d2c173a..9e6845f53c7 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt @@ -3,8 +3,16 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.toolKind +import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.views.tool.ToolView +import ai.kilocode.client.ui.md.MdView +import com.intellij.openapi.editor.colors.CodeInsightColors +import com.intellij.openapi.editor.colors.EditorColorsManager +import com.intellij.openapi.editor.colors.EditorColorsScheme +import com.intellij.openapi.editor.markup.TextAttributes import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.Color +import java.awt.Font @Suppress("UnstableApiUsage") class PlanExitViewTest : BasePlatformTestCase() { @@ -43,8 +51,28 @@ class PlanExitViewTest : BasePlatformTestCase() { assertEquals(listOf(".kilo/plans/my%20plan.md"), opened) } + fun `test applyStyle refreshes nested markdown role colors`() { + val view = PlanExitView(tool(ToolExecState.COMPLETED)) {} + val scheme = EditorColorsManager.getInstance().globalScheme.clone() as EditorColorsScheme + scheme.setAttributes( + CodeInsightColors.HYPERLINK_ATTRIBUTES, + TextAttributes(Color(0x77, 0x88, 0x99), null, null, null, Font.PLAIN), + ) + val style = SessionEditorStyle.create(scheme = scheme) + + view.applyStyle(style) + + assertTrue(md(view).overrideSheet().contains("a { color: #778899")) + } + private fun tool(state: ToolExecState) = Tool("prt_plan", "plan_exit", toolKind("plan_exit")).apply { this.state = state output = "Plan is ready at .kilo/plans/x.md. Ending planning turn." } + + private fun md(view: PlanExitView): MdView { + val field = PlanExitView::class.java.getDeclaredField("md") + field.isAccessible = true + return field.get(view) as MdView + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt index 5c4bb2947a9..868ba5a4cdc 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt @@ -13,6 +13,8 @@ import java.awt.Component import java.awt.Container import java.awt.event.MouseEvent import java.awt.image.BufferedImage +import javax.swing.Icon +import javax.swing.JLabel import javax.swing.JPanel import javax.swing.border.Border @@ -131,6 +133,24 @@ class QuestionResultViewTest : BasePlatformTestCase() { assertFalse("Should be collapsed after second toggle", view.isExpanded()) } + fun `test toggle uses right and down chevron icons`() { + val view = QuestionResultView(completedTool( + input = mapOf("questions" to """[{"question":"Q1"}]"""), + metadata = mapOf("answers" to """[["A1"]]"""), + )) + + assertTrue(icons(view).contains(SessionViewIcons.chevronCollapsed)) + assertTrue(icons(view).contains(SessionViewIcons.chevronRight)) + val closed = SessionViewIcons.chevronCollapsed + + view.toggle() + + assertTrue(icons(view).contains(SessionViewIcons.chevronExpanded)) + assertTrue(icons(view).contains(SessionViewIcons.chevronDown)) + assertEquals(closed.iconWidth, SessionViewIcons.chevronExpanded.iconWidth) + assertEquals(closed.iconHeight, SessionViewIcons.chevronExpanded.iconHeight) + } + fun `test hover only changes header background`() { val view = QuestionResultView(completedTool( input = mapOf("questions" to """[{"question":"Q1"}]"""), @@ -310,4 +330,15 @@ class QuestionResultViewTest : BasePlatformTestCase() { assertEquals(rgb, Color(image.getRGB(2, 4), true).rgb) } + private fun icons(component: Component): List { + val found = mutableListOf() + collect(component, found) + return found + } + + private fun collect(component: Component, found: MutableList) { + if (component is JLabel) component.icon?.let(found::add) + if (component is Container) component.components.forEach { collect(it, found) } + } + } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt index c09062ec808..e718bdcc8b6 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt @@ -10,6 +10,8 @@ import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import java.awt.Component import java.awt.Container +import javax.swing.Icon +import javax.swing.JLabel import javax.swing.JPanel import javax.swing.ScrollPaneConstants @@ -177,6 +179,14 @@ class ReasoningViewTest : BasePlatformTestCase() { assertTrue(font.size < style.editorSize) } + fun `test reasoning header uses brain icon`() { + val view = ReasoningView(reasoning("p1", done = true, text = "one")) + val icons = icons(view) + + assertTrue(icons.contains(SessionViewIcons.brain)) + assertFalse(icons.contains(SessionViewIcons.eye)) + } + fun `test applyStyle updates reasoning in place`() { val view = ReasoningView(reasoning("p1", done = true, text = "one\ntwo\nthree\nfour")) val component = view.md.component @@ -285,4 +295,15 @@ class ReasoningViewTest : BasePlatformTestCase() { } error("scroll not found") } + + private fun icons(component: Component): List { + val found = mutableListOf() + collect(component, found) + return found + } + + private fun collect(component: Component, found: MutableList) { + if (component is JLabel) component.icon?.let(found::add) + if (component is Container) component.components.forEach { collect(it, found) } + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SearchToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SearchToolViewTest.kt index eea06ce279f..e9b090bee9b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SearchToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SearchToolViewTest.kt @@ -9,8 +9,10 @@ import ai.kilocode.client.session.views.tool.GlobToolView import ai.kilocode.client.session.views.tool.ReadToolView import ai.kilocode.client.session.views.tool.SearchToolView import ai.kilocode.client.session.views.tool.ToolView +import ai.kilocode.client.ui.UiStyle import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.BorderLayout import java.awt.Container import java.awt.Dimension import javax.swing.ScrollPaneConstants @@ -100,6 +102,14 @@ class SearchToolViewTest : BasePlatformTestCase() { assertEquals(style.regularFont, view.targetFont(1)) } + fun `test search header title target gap uses standard medium gap`() { + val view = SearchToolView(tool().also { + it.input = mapOf("pattern" to "TODO", "include" to "*.kt") + }) + + assertEquals(UiStyle.Gap.md(), (view.centerComponent().layout as BorderLayout).hgap) + } + fun `test completed search starts collapsed and expands output`() { val view = track(SearchToolView(tool().also { it.output = "src/A.kt:1:class A" })) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt new file mode 100644 index 00000000000..8046cfe3d35 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt @@ -0,0 +1,389 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.model.ToolExecState +import ai.kilocode.client.session.model.toolKind +import ai.kilocode.client.session.ui.selection.SessionSelection +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.tool.ShellToolView +import ai.kilocode.client.session.views.tool.ToolView +import ai.kilocode.client.ui.UiStyle +import com.intellij.execution.process.ProcessOutputTypes +import com.intellij.execution.ui.ConsoleViewContentType +import com.intellij.openapi.editor.DefaultLanguageHighlighterColors +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBHtmlPane +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import javax.swing.ScrollPaneConstants + +@Suppress("UnstableApiUsage") +class ShellToolViewTest : BasePlatformTestCase() { + private val views = mutableListOf() + + override fun tearDown() { + try { + views.forEach(Disposer::dispose) + views.clear() + } finally { + super.tearDown() + } + } + + fun `test command only shell renders markdown`() { + val view = track(ShellToolView(tool().also { it.input = mapOf("command" to "pwd") })) + + assertTrue(view.hasToggle()) + assertFalse(view.bodyCreated()) + assertEquals("pwd", view.bodyText()) + view.toggle() + + assertEquals("**Command**\n\n```shell-command\npwd\n```", view.markdown()) + assertEquals(listOf("pwd"), view.codeTexts()) + } + + fun `test output only shell renders markdown`() { + val view = track(ShellToolView(tool().also { it.output = "done" })) + + assertEquals("done", view.outputText()) + view.toggle() + + assertEquals("**Output**\n\n```shell-output\ndone\n```", view.markdown()) + assertEquals(listOf("done"), view.codeTexts()) + } + + fun `test command and output render sections in order`() { + val view = track(ShellToolView(tool().also { + it.input = mapOf("command" to "git status", "description" to "Check status") + it.output = "clean" + })) + + assertTrue(view.labelText().contains("Shell")) + assertTrue(view.labelText().contains("Check status")) + assertEquals("git status", view.commandText()) + assertEquals("clean", view.outputText()) + assertEquals("git status\n\nclean", view.bodyText()) + view.toggle() + + assertEquals( + "**Command**\n\n```shell-command\ngit status\n```\n\n**Output**\n\n```shell-output\nclean\n```", + view.markdown(), + ) + assertEquals(listOf("git status", "clean"), view.codeTexts()) + } + + fun `test ansi escapes are preserved in markdown and decoded in output`() { + val view = track(ShellToolView(tool().also { it.output = "\u001B[32mgreen\u001B[0m line" })) + + assertEquals("green line", view.outputText()) + view.toggle() + + assertTrue(view.markdown().contains("\u001B[32mgreen\u001B[0m line")) + assertEquals(listOf("green line"), view.codeTexts()) + assertTrue(view.codeEditors().single().getEditor(true)!!.markupModel.allHighlighters.isNotEmpty()) + } + + fun `test carriage return frames keep last non-empty value`() { + val view = track(ShellToolView(tool().also { + it.output = "progress 1\rprogress 2\rprogress done\nstdout line\n" + })) + + assertEquals("progress done\nstdout line\n", view.outputText()) + view.toggle() + + assertEquals(listOf("progress done\nstdout line"), view.codeTexts()) + } + + fun `test output backspaces clean visible text`() { + val view = track(ShellToolView(tool().also { it.output = "abc\b\bd" })) + + assertEquals("ad", view.outputText()) + view.toggle() + + assertEquals(listOf("ad"), view.codeTexts()) + } + + fun `test clean output delegates terminal reducer`() { + val view = track(ShellToolView(tool().also { + it.output = "\u001B[31mspin 1\u001B[0m\r\u001B[32mspin 2\u001B[0m\nabc\bd\u001B[K" + })) + + assertEquals("spin 2\nabd", view.outputText()) + assertEquals("spin 2\nabd", view.bodyText()) + } + + fun `test output backticks use longer markdown fence`() { + val view = track(ShellToolView(tool().also { it.output = "before\n```\nafter" })) + + view.toggle() + + assertTrue(view.markdown().contains("````shell-output\nbefore\n```\nafter\n````")) + assertEquals(listOf("before\n```\nafter"), view.codeTexts()) + } + + fun `test error section uses shell error text`() { + val view = track(ShellToolView(tool(ToolExecState.ERROR).also { + it.input = mapOf("command" to "fail") + it.error = "boom" + })) + + assertEquals("boom", view.errorText()) + assertTrue(view.labelText().contains("Error")) + view.toggle() + + assertEquals( + "**Command**\n\n```shell-command\nfail\n```\n\n**Error**\n\n```ansi-stderr\nboom\n```", + view.markdown(), + ) + assertEquals(listOf("fail", "boom"), view.codeTexts()) + val error = view.codeEditors().last().getEditor(true)!! + val expected = ConsoleViewContentType.getConsoleViewType(ProcessOutputTypes.STDERR).attributesKey + assertEquals(expected, error.markupModel.allHighlighters.single().textAttributesKey) + } + + fun `test body is created lazily and reused`() { + val view = track(ShellToolView(tool().also { + it.input = mapOf("command" to "pwd") + it.output = "/tmp" + })) + + assertFalse(view.bodyCreated()) + view.toggle() + val body = view.mdComponent() + val cmd = view.codeEditors().first() + val out = view.codeEditors().last() + view.toggle() + view.toggle() + + assertSame(body, view.mdComponent()) + assertSame(cmd, view.codeEditors().first()) + assertSame(out, view.codeEditors().last()) + assertTrue(view.bodyVisible()) + } + + fun `test collapsed update does not create body`() { + val view = track(ShellToolView(tool(ToolExecState.RUNNING).also { + it.input = mapOf("command" to "pwd") + it.output = "/tmp" + })) + + view.update(tool().also { + it.input = mapOf("command" to "pwd") + it.output = "/home" + }) + + assertFalse(view.bodyCreated()) + assertEquals("pwd\n\n/home", view.bodyText()) + } + + fun `test update after first expand changes existing markdown body`() { + val view = track(ShellToolView(tool(ToolExecState.RUNNING).also { + it.input = mapOf("command" to "pwd") + it.output = "/tmp" + })) + + view.toggle() + view.toggle() + val body = view.mdComponent() + val cmd = view.codeEditors().first() + val out = view.codeEditors().last() + view.update(tool().also { + it.input = mapOf("command" to "pwd") + it.output = "/home" + }) + + assertTrue(view.bodyCreated()) + assertFalse(view.bodyVisible()) + assertSame(body, view.mdComponent()) + assertSame(cmd, view.codeEditors().first()) + assertSame(out, view.codeEditors().last()) + assertEquals(listOf("pwd", "/home"), view.codeTexts()) + } + + fun `test applyStyle updates fonts in place`() { + val view = track(ShellToolView(tool().also { it.output = "done" })) + val style = SessionEditorStyle.create(family = "Courier New", size = 25) + view.toggle() + val editor = view.codeEditors().single() + + view.applyStyle(style) + + assertSame(editor, view.codeEditors().single()) + assertEquals(style.editorFont.name, view.commandFont().name) + assertEquals(style.editorSize, view.commandFont().size) + assertEquals(style.transcriptFont.name, view.titleFont().name) + assertTrue(view.titleFont().isBold) + assertEquals(style.transcriptFont.name, view.subtitleFont().name) + assertEquals(style.transcriptFont.size, view.subtitleFont().size) + assertFalse(view.subtitleFont().isBold) + assertEquals(UiStyle.Colors.weak().rgb, view.subtitleForeground().rgb) + assertTrue(view.stateFont().size < style.editorSize) + } + + fun `test selection registers shell markdown editors`() { + val selection = SessionSelection() + val view = track(ShellToolView(tool().also { it.input = mapOf("command" to "pwd") }, selection)) + view.toggle() + val editor = view.codeEditors().single().getEditor(true) + editor?.selectionModel?.setSelection(0, 3) + + assertEquals("pwd", selection.selectedText()) + Disposer.dispose(selection) + } + + fun `test shell view uses editor backed markdown code blocks`() { + val style = SessionEditorStyle.current() + val view = track(ShellToolView(tool().also { it.output = "done" })) + view.toggle() + + assertEquals(style.editorFont.name, view.commandFont().name) + assertEquals(1, view.codeEditors().size) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, view.horizontalPolicy()) + assertTrue(view.preferredSize.height > 0) + } + + fun `test plain git output receives shell output highlighters`() { + val output = """ + 475ab514 (HEAD -> main, origin/main, origin/HEAD) Bump kotlinSerialization from 1.10.0 to 1.11.0 + gradle/libs.versions.toml | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + e8b9785 Add second change + packages/kilo-jetbrains/frontend/src/main/kotlin/App.kt | 14 ++++++++++---- + 1 file changed, 10 insertions(+), 4 deletions(-) + """.trimIndent() + val display = """ + 475ab514 (HEAD -> main, origin/main, origin/HEAD) Bump kotlinSerialization from 1.10.0 to 1.11.0 + gradle/libs.versions.toml | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + + e8b9785 Add second change + packages/kilo-jetbrains/frontend/src/main/kotlin/App.kt | 14 ++++++++++---- + 1 file changed, 10 insertions(+), 4 deletions(-) + """.trimIndent() + val view = track(ShellToolView(tool().also { it.output = output })) + + view.toggle() + val editor = view.codeEditors().single().getEditor(true)!! + + assertTrue(view.markdown().contains("```shell-output\n$output\n```")) + assertEquals(display, view.codeTexts().single()) + assertTrue(editor.markupModel.allHighlighters.size >= 4) + } + + fun `test command receives shell command highlighters`() { + val view = track(ShellToolView(tool().also { + it.input = mapOf("command" to "git log -30 --oneline --decorate") + })) + + view.toggle() + val field = view.codeEditors().single() + val editor = field.getEditor(true)!! + val spans = editor.markupModel.allHighlighters.map { + field.text.substring(it.startOffset, it.endOffset) to it.textAttributesKey + } + + assertTrue(view.markdown().contains("```shell-command\ngit log -30 --oneline --decorate\n```")) + assertTrue(spans.contains("git" to DefaultLanguageHighlighterColors.FUNCTION_CALL)) + assertTrue(spans.contains("-30" to DefaultLanguageHighlighterColors.KEYWORD)) + assertTrue(spans.contains("--oneline" to DefaultLanguageHighlighterColors.KEYWORD)) + assertTrue(spans.contains("--decorate" to DefaultLanguageHighlighterColors.KEYWORD)) + } + + fun `test shell labels align with code text and code blocks use bottom border only`() { + val view = track(ShellToolView(tool().also { + it.input = mapOf("command" to "pwd") + it.output = "/tmp" + })) + view.toggle() + val root = view.mdComponent()!! + val ins = root.border?.getBorderInsets(root) + val labels = root.components.filterIsInstance() + val panes = root.components.filterIsInstance() + + assertEquals(0, ins?.left ?: 0) + assertEquals(2, labels.size) + assertEquals(2, panes.size) + labels.forEach { + val label = it.border?.getBorderInsets(it) + assertEquals(JBUI.scale(SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING), label?.left ?: 0) + assertEquals(0, label?.right ?: 0) + } + panes.forEach { + val pane = it.border.getBorderInsets(it) + assertEquals(0, pane.top) + assertEquals(SessionUiStyle.View.Code.BORDER_WIDTH, pane.bottom) + assertEquals(0, pane.left) + assertEquals(0, pane.right) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, it.verticalScrollBarPolicy) + assertEquals(root.background.rgb, it.background.rgb) + assertEquals(root.background.rgb, it.viewport.background.rgb) + } + } + + fun `test shell code blocks are editor backed and capped to fifteen lines`() { + val output = (1..30).joinToString("\n") { "line $it" } + val view = track(ShellToolView(tool().also { it.output = output })) + view.toggle() + val root = view.mdComponent()!! + val pane = root.components.filterIsInstance().single() + val editor = view.codeEditors().single() + val nested = editor.getEditor(true)!!.scrollPane + val line = editor.getEditor(true)!!.lineHeight + val chrome = pane.insets.top + pane.insets.bottom + + pane.viewportBorder.getBorderInsets(pane).top + pane.viewportBorder.getBorderInsets(pane).bottom + + pane.horizontalScrollBar.preferredSize.height + + assertEquals(output, editor.text) + assertEquals(1, view.codeEditors().size) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, pane.verticalScrollBarPolicy) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, pane.horizontalScrollBarPolicy) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, nested.verticalScrollBarPolicy) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, nested.horizontalScrollBarPolicy) + assertFalse(editor.getEditor(true)!!.settings.isUseSoftWraps) + assertTrue(pane.preferredSize.height <= line * 15 + chrome) + assertTrue(editor.preferredSize.height > pane.preferredSize.height - chrome) + assertTrue(pane.preferredSize.height < editor.preferredSize.height + chrome) + } + + fun `test view factory routes bash and replaces generic views`() { + val bash = tool() + val other = Tool("p1", "mystery", toolKind("mystery")).also { it.state = ToolExecState.COMPLETED } + + assertTrue(ViewFactory.create(bash, openFile = {}) is ShellToolView) + assertTrue(ViewFactory.shouldReplace(ToolView(bash), bash)) + assertTrue(ViewFactory.shouldReplace(ShellToolView(bash), other)) + assertFalse(ViewFactory.shouldReplace(ShellToolView(bash), bash)) + } + + fun `test shell editors are disposed after churn`() { + val base = EditorFactory.getInstance().allEditors.size + + repeat(40) { i -> + val view = ShellToolView(tool().also { + it.input = mapOf("command" to "log $i") + it.output = (1..20).joinToString("\n") { line -> "line $i/$line" } + }) + view.toggle() + view.codeEditors().forEach { it.getEditor(true) } + Disposer.dispose(view) + } + UIUtil.dispatchAllInvocationEvents() + + assertEquals(base, EditorFactory.getInstance().allEditors.size) + } + + private fun ShellToolView.codeTexts() = codeEditors().map { it.text } + + private fun tool(state: ToolExecState = ToolExecState.COMPLETED) = Tool("p1", "bash", toolKind("bash")).also { + it.state = state + } + + private fun track(view: ShellToolView): ShellToolView { + views.add(view) + return view + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolBodyStressTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolBodyStressTest.kt index 3b5171f4f3a..f2595428c43 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolBodyStressTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolBodyStressTest.kt @@ -5,6 +5,7 @@ import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.toolKind import ai.kilocode.client.session.views.tool.GlobToolView import ai.kilocode.client.session.views.tool.SearchToolView +import ai.kilocode.client.session.views.tool.ShellToolView import ai.kilocode.client.session.views.tool.ToolView import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.util.Disposer @@ -14,7 +15,7 @@ import com.intellij.util.ui.UIUtil @Suppress("UnstableApiUsage") class ToolBodyStressTest : BasePlatformTestCase() { - fun `test expanded tool body editors are disposed after churn`() { + fun `test expanded generic tool body editors are disposed after churn`() { val base = EditorFactory.getInstance().allEditors.size repeat(60) { i -> @@ -28,6 +29,20 @@ class ToolBodyStressTest : BasePlatformTestCase() { assertEquals(base, EditorFactory.getInstance().allEditors.size) } + fun `test expanded shell tool editors are disposed after churn`() { + val base = EditorFactory.getInstance().allEditors.size + + repeat(60) { i -> + val view = ShellToolView(shell(i)) + view.toggle() + view.codeEditors().forEach { it.getEditor(true) } + Disposer.dispose(view) + } + drainEdt() + + assertEquals(base, EditorFactory.getInstance().allEditors.size) + } + fun `test expanded search tool editors are disposed after churn`() { val base = EditorFactory.getInstance().allEditors.size @@ -47,7 +62,12 @@ class ToolBodyStressTest : BasePlatformTestCase() { assertEquals(base, EditorFactory.getInstance().allEditors.size) } - private fun tool(index: Int) = Tool("p$index", "bash", toolKind("bash")).also { + private fun tool(index: Int) = Tool("p$index", "mystery", toolKind("mystery")).also { + it.state = ToolExecState.COMPLETED + it.output = (1..20).joinToString("\n") { line -> "line $index/$line" } + } + + private fun shell(index: Int) = Tool("p$index", "bash", toolKind("bash")).also { it.state = ToolExecState.COMPLETED it.input = mapOf("command" to "log $index") it.output = (1..20).joinToString("\n") { line -> "line $index/$line" } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt index 1de7afe7eeb..1834157ce6b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt @@ -8,8 +8,10 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.base.SecondarySessionPartView import ai.kilocode.client.session.views.tool.ToolView +import ai.kilocode.client.ui.UiStyle import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.BorderLayout import java.awt.Color import java.awt.image.BufferedImage import javax.swing.JPanel @@ -257,6 +259,12 @@ class ToolViewTest : BasePlatformTestCase() { assertSmallEditorFont(view.stateFont(), style) } + fun `test tool header title subtitle gap uses standard medium gap`() { + val view = track(ToolView(tool("p1", "bash", ToolExecState.COMPLETED).also { it.output = "done" })) + + assertEquals(UiStyle.Gap.md(), centerGap(view)) + } + fun `test applyStyle updates tool fonts in place`() { val view = ToolView(tool("p1", "bash", ToolExecState.COMPLETED)) val style = SessionEditorStyle.create(family = "Courier New", size = 25) @@ -381,6 +389,13 @@ class ToolViewTest : BasePlatformTestCase() { assertTrue(font.size < style.editorSize) } + private fun centerGap(view: ToolView): Int { + val row = view.components.filterIsInstance().single() + val header = (row.layout as BorderLayout).getLayoutComponent(BorderLayout.CENTER) as JPanel + val center = (header.layout as BorderLayout).getLayoutComponent(BorderLayout.CENTER) as JPanel + return (center.layout as BorderLayout).hgap + } + private fun paint(border: Border): Color { val image = BufferedImage(3, 3, BufferedImage.TYPE_INT_ARGB) val item = JPanel() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt index 7a03cb52083..0ede3a0aa29 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt @@ -11,6 +11,7 @@ import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageTimeDto import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.util.ui.JBUI +import java.awt.image.BufferedImage import javax.swing.JComponent import javax.swing.JPanel import javax.swing.RepaintManager @@ -110,6 +111,16 @@ class TurnViewTest : BasePlatformTestCase() { assertFalse(mv.isOpaque) } + fun `test user message uses standard outline color`() { + val mv = MessageView(msg("u1", "user"), openFile) + mv.setSize(120, 48) + val image = BufferedImage(120, 48, BufferedImage.TYPE_INT_ARGB) + + mv.paint(image.createGraphics()) + + assertEquals(SessionUiStyle.View.Outline.color().rgb, image.getRGB(60, 0)) + } + fun `test assistant message remains borderless`() { val mv = MessageView(msg("a1", "assistant"), openFile) val ins = mv.border.getBorderInsets(mv) @@ -140,14 +151,14 @@ class TurnViewTest : BasePlatformTestCase() { assertFalse((mv.part("p1") as TextView).contentOpaque()) } - fun `test assistant text view remains opaque`() { + fun `test assistant text view is transparent`() { val mv = MessageView(msg("a1", "assistant"), openFile) val text = ai.kilocode.client.session.model.Text("p1") text.content.append("hello") mv.upsertPart(text) - assertTrue((mv.part("p1") as TextView).contentOpaque()) + assertFalse((mv.part("p1") as TextView).contentOpaque()) } fun `test upsertPart updates existing part rather than adding duplicate`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt index bc8e1e9ed18..6fb2cde06f5 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt @@ -2,11 +2,13 @@ package ai.kilocode.client.session.views.base import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.SessionViewIcons import com.intellij.testFramework.fixtures.BasePlatformTestCase import java.awt.Color import java.awt.Component import java.awt.event.MouseEvent import java.awt.image.BufferedImage +import javax.swing.Icon import javax.swing.JLabel import javax.swing.JPanel import javax.swing.border.Border @@ -43,6 +45,22 @@ class AbstractSessionPartViewTest : BasePlatformTestCase() { assertSame(view, content.parent) } + fun `test toggle uses right and down chevron icons`() { + val view = TestView(content = JLabel("body")) + + assertSame(SessionViewIcons.chevronCollapsed, view.arrowIcon()) + assertSame(SessionViewIcons.chevronRight, view.arrowIcon()) + val closed = view.arrowIcon() + + view.toggle() + + assertSame(SessionViewIcons.chevronExpanded, view.arrowIcon()) + assertSame(SessionViewIcons.chevronDown, view.arrowIcon()) + assertNotSame(closed, view.arrowIcon()) + assertEquals(closed.iconWidth, view.arrowIcon().iconWidth) + assertEquals(closed.iconHeight, view.arrowIcon().iconHeight) + } + fun `test non expandable hides content`() { val content = JLabel("body") val view = TestView(content = content, expanded = true) @@ -96,6 +114,7 @@ class AbstractSessionPartViewTest : BasePlatformTestCase() { override val contentId = "test" override fun update(content: Content) {} fun arrowVisible() = arrow.isVisible + fun arrowIcon(): Icon = arrow.icon } private fun TestView.component(index: Int): Component = components[index] diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt index 444eda3bfec..578988381d2 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt @@ -5,10 +5,13 @@ import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.toolKind import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.views.base.PrimarySessionPartView +import ai.kilocode.client.ui.UiStyle import ai.kilocode.rpc.dto.TodoDto import ai.kilocode.rpc.dto.TodoViewDto import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.BorderLayout import java.awt.Color +import javax.swing.JPanel @Suppress("UnstableApiUsage") class TodoWriteViewTest : BasePlatformTestCase() { @@ -55,6 +58,14 @@ class TodoWriteViewTest : BasePlatformTestCase() { assertEquals(style.editorForeground, view.rowForeground(1)) } + fun `test todo header title subtitle gap uses standard medium gap`() { + val view = TodoWriteView(tool("todowrite", ToolExecState.COMPLETED).also { + it.todos = listOf(TodoDto("Next", "pending", "medium")) + }) + + assertEquals(UiStyle.Gap.md(), centerGap(view)) + } + fun `test compact view renders hidden labels and visible rows`() { val view = TodoWriteView(tool("todowrite", ToolExecState.COMPLETED).also { it.todos = listOf( @@ -94,5 +105,12 @@ class TodoWriteViewTest : BasePlatformTestCase() { assertTrue(view.rowText(0).contains("New")) } + private fun centerGap(view: TodoWriteView): Int { + val row = view.components.filterIsInstance().first() + val header = (row.layout as BorderLayout).getLayoutComponent(BorderLayout.CENTER) as JPanel + val center = (header.layout as BorderLayout).getLayoutComponent(BorderLayout.CENTER) as JPanel + return (center.layout as BorderLayout).hgap + } + private fun tool(name: String, state: ToolExecState) = Tool("p1", name, toolKind(name)).also { it.state = state } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/tool/KiloCliParserTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/tool/KiloCliParserTest.kt new file mode 100644 index 00000000000..43df4520ac3 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/tool/KiloCliParserTest.kt @@ -0,0 +1,27 @@ +package ai.kilocode.client.session.views.tool + +import ai.kilocode.cli.KiloCliParser +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class KiloCliParserTest { + @Test + fun `tag extracts trimmed tool xml value`() { + val text = """ + + /tmp/example.txt + + file + """.trimIndent() + + assertEquals("/tmp/example.txt", KiloCliParser.tag(text, "path")) + assertEquals("file", KiloCliParser.tag(text, "type")) + } + + @Test + fun `tag returns null for blank or missing value`() { + assertNull(KiloCliParser.tag(" ", "path")) + assertNull(KiloCliParser.tag("file", "path")) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt index ece8e520c45..eac63e23a1c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt @@ -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 @@ -46,6 +47,8 @@ class FakeSessionRpcApi : KiloSessionRpcApi { /** Message history returned by [messages]. */ val history = mutableListOf() var historyGate: CompletableDeferred? = null + var historyCalls = 0 + private set /** Recent sessions returned by [recent]. */ val recent = mutableListOf() @@ -82,6 +85,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi { var enhanceGate: CompletableDeferred? = null var enhanceThrows: Exception? = null val prompts = mutableListOf>() + val attachmentParts = mutableListOf() val aborts = mutableListOf>() val compacts = mutableListOf>() val configs = mutableListOf>() @@ -101,6 +105,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi { private set data class CloudCall(val directory: String, val cursor: String?, val limit: Int, val gitUrl: String?) + data class AttachmentCall(val id: String, val directory: String, val messageId: String, val partId: String, val attachmentKey: String?) // --- Implementation --- @@ -202,10 +207,25 @@ class FakeSessionRpcApi : KiloSessionRpcApi { override suspend fun messages(id: String, directory: String): List { assertNotEdt("messages") + historyCalls++ historyGate?.await() return history.toList() } + override suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto? { + assertNotEdt("attachmentPart") + attachmentParts.add(AttachmentCall(id, directory, messageId, partId, attachmentKey)) + historyGate?.await() + return history + .firstOrNull { it.info.id == messageId } + ?.parts + ?.firstOrNull { + if (it.type != "file") return@firstOrNull false + if (!attachmentKey.isNullOrBlank()) key(it.id, it.filename.orEmpty(), it.url.orEmpty()) == attachmentKey + else it.id == partId + } + } + override suspend fun events(id: String, directory: String): Flow { assertNotEdt("events") return eventFlow?.invoke(id, directory) ?: events @@ -245,4 +265,10 @@ class FakeSessionRpcApi : KiloSessionRpcApi { assertNotEdt("pendingQuestions") return pendingQuestionList.toList() } + + private fun key(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) } + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdTerminalTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdTerminalTest.kt new file mode 100644 index 00000000000..c7766d764b1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdTerminalTest.kt @@ -0,0 +1,26 @@ +package ai.kilocode.client.ui.md + +import ai.kilocode.client.ui.md.hybrid.MdTerminal +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class MdTerminalTest : BasePlatformTestCase() { + fun `test split preserves trailing empty segment`() { + assertEquals(listOf("one", "two", ""), MdTerminal.split("one\ntwo\n", '\n')) + } + + fun `test reduce collapses carriage frames and backspaces`() { + assertEquals("done\nab", MdTerminal.reduce("step 1\rstep 2\rdone\nabc\b", keepSgr = false)) + } + + fun `test reduce keeps only sgr escapes when requested`() { + val text = "\u001B[32mgreen\u001B[0m\u001B[K" + + assertEquals("\u001B[32mgreen\u001B[0m", MdTerminal.reduce(text, keepSgr = true)) + assertEquals("green", MdTerminal.reduce(text, keepSgr = false)) + } + + fun `test strip removes ansi escapes`() { + assertEquals("green", MdTerminal.strip("\u001B[32mgreen\u001B[0m")) + assertTrue(MdTerminal.hasAnsi("\u001B[32mgreen\u001B[0m")) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewFactoryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewFactoryTest.kt index 59d82645613..2ecfdd04f1d 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewFactoryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewFactoryTest.kt @@ -12,8 +12,8 @@ class MdViewFactoryTest : BasePlatformTestCase() { assertInstanceOf(MdViewFactory.hybrid(), MdViewHybrid::class.java) } - fun `test html returns html pane renderer`() { - assertInstanceOf(MdViewFactory.html(), MdViewHtmlPane::class.java) + fun `test html returns hybrid renderer`() { + assertInstanceOf(MdViewFactory.html(), MdViewHybrid::class.java) } fun `test create applies supplied session style`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt index 104b5ab88d3..6a6a4ce3ad5 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt @@ -2,18 +2,29 @@ package ai.kilocode.client.ui.md import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle +import com.intellij.execution.process.ProcessOutputTypes +import com.intellij.execution.ui.ConsoleViewContentType +import com.intellij.openapi.editor.DefaultLanguageHighlighterColors +import com.intellij.openapi.editor.HighlighterColors +import com.intellij.openapi.editor.colors.CodeInsightColors +import com.intellij.openapi.editor.colors.EditorColors +import com.intellij.openapi.editor.colors.EditorColorsManager +import com.intellij.openapi.editor.colors.EditorColorsScheme import com.intellij.openapi.fileTypes.FileType import com.intellij.openapi.fileTypes.FileTypeRegistry import com.intellij.openapi.fileTypes.PlainTextFileType import com.intellij.openapi.fileTypes.UnknownFileType +import com.intellij.openapi.editor.markup.TextAttributes import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.EditorTextField import com.intellij.ui.components.JBHtmlPane import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import java.awt.BorderLayout import java.awt.Color +import java.awt.Font import javax.swing.Box import javax.swing.JPanel import javax.swing.ScrollPaneConstants @@ -42,6 +53,13 @@ class MdViewHybridTest : BasePlatformTestCase() { assertEquals("hello **world**", view.markdown()) } + fun `test root has no renderer owned left inset`() { + val ins = view.component.border?.getBorderInsets(view.component) + + assertEquals(0, ins?.left ?: 0) + assertEquals(0, ins?.right ?: 0) + } + fun `test append renders accumulated source`() { view.append("hello ") view.append("**world**") @@ -52,9 +70,12 @@ class MdViewHybridTest : BasePlatformTestCase() { fun `test fenced code block shows horizontal scrollbar as needed`() { view.set("```kotlin\nval value = 1\n```") val pane = scrolls().single() + val editor = editors().single().getEditor(true)!! assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, pane.horizontalScrollBarPolicy) assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, pane.verticalScrollBarPolicy) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, editor.scrollPane.horizontalScrollBarPolicy) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, editor.scrollPane.verticalScrollBarPolicy) assertTrue(pane.isWheelScrollingEnabled) assertTrue(pane.horizontalScrollBar.preferredSize.height > 0) assertTrue(pane.horizontalScrollBar.isOpaque) @@ -62,6 +83,26 @@ class MdViewHybridTest : BasePlatformTestCase() { assertEquals(0, pane.verticalScrollBar.preferredSize.width) } + fun `test transparent markdown keeps code block background opaque`() { + view.opaque = false + + view.set("```kotlin\nval value = 1\n```") + val pane = scrolls().single() + val editor = editors().single().getEditor(true)!! + val bg = view.preBg + + assertFalse(view.component.isOpaque) + assertTrue(pane.isOpaque) + assertTrue(pane.viewport.isOpaque) + assertTrue(editor.scrollPane.isOpaque) + assertTrue(editor.scrollPane.viewport.isOpaque) + assertEquals(bg.rgb, pane.background.rgb) + assertEquals(bg.rgb, pane.viewport.background.rgb) + assertEquals(bg.rgb, editor.backgroundColor.rgb) + assertEquals(bg.rgb, editor.scrollPane.background.rgb) + assertEquals(bg.rgb, editor.scrollPane.viewport.background.rgb) + } + fun `test fenced code block preserves multiline editor text and height`() { view.set("```kotlin\nval one = 1\nval two = 2\nval three = 3\n```") val pane = scrolls().single() @@ -76,22 +117,25 @@ class MdViewHybridTest : BasePlatformTestCase() { assertTrue(pane.preferredSize.height >= line * 3) } - fun `test fenced code block horizontal scrollbar has no bottom padding`() { + fun `test fenced code block has symmetric content padding with horizontal scrollbar`() { view.set("```kotlin\n${"x".repeat(500)}\n```") val pane = scrolls().single() val pad = pane.viewportBorder.getBorderInsets(pane) + assertEquals(SessionUiStyle.View.Code.VIEWPORT_TOP_PADDING, pad.top) assertEquals(SessionUiStyle.View.Code.VIEWPORT_BOTTOM_PADDING, pad.bottom) + assertEquals(pad.top, pad.bottom) assertTrue(pane.horizontalScrollBar.preferredSize.height > 0) } - fun `test short code block top padding balances hidden scrollbar space`() { + fun `test short code block keeps symmetric content padding`() { view.set("```text\n[ALICE, ANNA]\n```") val pane = scrolls().single() val pad = pane.viewportBorder.getBorderInsets(pane) - assertTrue(pad.top > pane.horizontalScrollBar.preferredSize.height) + assertEquals(SessionUiStyle.View.Code.VIEWPORT_TOP_PADDING, pad.top) assertEquals(SessionUiStyle.View.Code.VIEWPORT_BOTTOM_PADDING, pad.bottom) + assertEquals(pad.top, pad.bottom) } fun `test fenced code block height is not capped`() { @@ -104,6 +148,36 @@ class MdViewHybridTest : BasePlatformTestCase() { assertTrue(pane.preferredSize.height >= line * 24) } + fun `test custom code block options cap editor height and enable vertical scrolling`() { + Disposer.dispose(view) + view = MdViewFactory.hybrid( + code = MdCodeBlockFactory.default( + MdCodeBlockOptions( + border = MdCodeBlockBorder.Horizontal, + maxLines = 3, + verticalPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, + editorOnly = true, + ), + ), + ) + val code = (1..12).joinToString("\n") { "val value$it = $it" } + view.set("```kotlin\n$code\n```") + val pane = scrolls().single() + val editor = editors().single() + val ins = pane.border.getBorderInsets(pane) + val chrome = pane.insets.top + pane.insets.bottom + + pane.viewportBorder.getBorderInsets(pane).top + pane.viewportBorder.getBorderInsets(pane).bottom + + pane.horizontalScrollBar.preferredSize.height + + assertEquals(SessionUiStyle.View.Code.BORDER_WIDTH, ins.top) + assertEquals(SessionUiStyle.View.Code.BORDER_WIDTH, ins.bottom) + assertEquals(0, ins.left) + assertEquals(0, ins.right) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, pane.verticalScrollBarPolicy) + assertTrue(editor.preferredSize.height > pane.preferredSize.height - chrome) + assertTrue(pane.preferredSize.height < editor.preferredSize.height + chrome) + } + fun `test fenced code block lays out to full editor height`() { val code = (1..30).joinToString("\n") { "val value$it = $it" } view.set("```kotlin\n$code\n```") @@ -399,6 +473,56 @@ class MdViewHybridTest : BasePlatformTestCase() { assertSame(type("js"), editors().single().fileType) } + fun `test shell code fence resolves shell file type`() { + view.set("```shell\necho hi\n```") + + assertSame(type("sh"), editors().single().fileType) + } + + fun `test shell command code fence renders terminal semantic highlighters`() { + view.set("```shell-command\ngit log -30 --oneline --decorate\n```") + val field = editors().single() + val editor = field.getEditor(true)!! + val spans = editor.markupModel.allHighlighters.map { + field.text.substring(it.startOffset, it.endOffset) to it.textAttributesKey + } + + assertSame(PlainTextFileType.INSTANCE, field.fileType) + assertEquals("git log -30 --oneline --decorate", field.text) + assertTrue(spans.contains("git" to DefaultLanguageHighlighterColors.FUNCTION_CALL)) + assertTrue(spans.contains("-30" to DefaultLanguageHighlighterColors.KEYWORD)) + assertTrue(spans.contains("--oneline" to DefaultLanguageHighlighterColors.KEYWORD)) + assertTrue(spans.contains("--decorate" to DefaultLanguageHighlighterColors.KEYWORD)) + assertFalse(editor.settings.isUseSoftWraps) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, scrolls().single().horizontalScrollBarPolicy) + } + + fun `test streaming shell command fence preserves terminal editor component`() { + view.append("```shell-command\ngit") + val pane = scrolls().single() + val field = editors().single() + val editor = field.getEditor(true)!! + + view.append(" status --short\n") + + assertSame(pane, scrolls().single()) + assertSame(field, editors().single()) + assertEquals("git status --short", field.text) + assertTrue(editor.markupModel.allHighlighters.map { + field.text.substring(it.startOffset, it.endOffset) to it.textAttributesKey + }.contains("git" to DefaultLanguageHighlighterColors.FUNCTION_CALL)) + } + + fun `test shell script aliases resolve shell file type`() { + view.set("```shell script\necho hi\n```") + + assertSame(type("sh"), editors().single().fileType) + + view.set("```zsh\necho hi\n```") + + assertSame(type("sh"), editors().single().fileType) + } + fun `test fenced code block ignores whitespace metadata`() { view.set("```json title=\"sample.json\"\n{\"value\":1}\n```") @@ -409,6 +533,14 @@ class MdViewHybridTest : BasePlatformTestCase() { view.set("```yaml\nvalue: 1\n```") assertSame(type("yaml"), editors().single().fileType) + + view.set("```golang\nfmt.Println(1)\n```") + + assertSame(type("go"), editors().single().fileType) + + view.set("```pwsh\nWrite-Host hi\n```") + + assertSame(type("ps1"), editors().single().fileType) } fun `test unknown fenced code language uses plain text`() { @@ -423,6 +555,107 @@ class MdViewHybridTest : BasePlatformTestCase() { assertSame(PlainTextFileType.INSTANCE, editors().single().fileType) } + fun `test ansi stdout aliases render terminal plain text`() { + listOf("ansi", "ansi-stdout", "terminal-output").forEach { lang -> + view.set("```$lang\n\u001B[32mgreen\u001B[0m\n```") + + assertSame(PlainTextFileType.INSTANCE, editors().single().fileType) + assertEquals("green", editors().single().text) + assertTrue(editors().single().getEditor(true)!!.markupModel.allHighlighters.isNotEmpty()) + } + } + + fun `test shell output renders plain text with semantic highlighters`() { + val output = """ + 475ab514 (HEAD -> main, origin/main, origin/HEAD) Bump kotlinSerialization from 1.10.0 to 1.11.0 + gradle/libs.versions.toml | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + e8b9785 Add second change + packages/kilo-jetbrains/frontend/src/main/kotlin/App.kt | 14 ++++++++++---- + 1 file changed, 10 insertions(+), 4 deletions(-) + """.trimIndent() + val display = """ + 475ab514 (HEAD -> main, origin/main, origin/HEAD) Bump kotlinSerialization from 1.10.0 to 1.11.0 + gradle/libs.versions.toml | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + + e8b9785 Add second change + packages/kilo-jetbrains/frontend/src/main/kotlin/App.kt | 14 ++++++++++---- + 1 file changed, 10 insertions(+), 4 deletions(-) + """.trimIndent() + + view.set("```shell-output\n$output\n```") + val pane = scrolls().single() + val field = editors().single() + val editor = field.getEditor(true)!! + val spans = editor.markupModel.allHighlighters.map { + field.text.substring(it.startOffset, it.endOffset) to it.textAttributesKey + } + + assertSame(PlainTextFileType.INSTANCE, field.fileType) + assertEquals("```shell-output\n$output\n```", view.markdown()) + assertEquals(display, field.text) + assertTrue(spans.contains("475ab514" to DefaultLanguageHighlighterColors.NUMBER)) + assertTrue(spans.contains("(HEAD -> main, origin/main, origin/HEAD)" to DefaultLanguageHighlighterColors.KEYWORD)) + assertTrue(spans.contains("1 insertion(+)" to DefaultLanguageHighlighterColors.STRING)) + assertTrue(spans.contains("1 deletion(-)" to DefaultLanguageHighlighterColors.LINE_COMMENT)) + assertTrue(spans.contains("++++++++++" to DefaultLanguageHighlighterColors.STRING)) + assertTrue(spans.contains("----" to DefaultLanguageHighlighterColors.LINE_COMMENT)) + assertFalse(editor.settings.isUseSoftWraps) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, pane.horizontalScrollBarPolicy) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, editor.scrollPane.horizontalScrollBarPolicy) + } + + fun `test ansi stderr aliases render terminal plain text`() { + listOf("ansi-stderr", "terminal-error", "shell-error").forEach { lang -> + view.set("```$lang\nboom\n```") + val editor = editors().single().getEditor(true)!! + val expected = ConsoleViewContentType.getConsoleViewType(ProcessOutputTypes.STDERR).attributesKey + + assertSame(PlainTextFileType.INSTANCE, editors().single().fileType) + assertEquals("boom", editors().single().text) + assertEquals(expected, editor.markupModel.allHighlighters.single().textAttributesKey) + } + } + + fun `test terminal block updates retained editor without soft wraps`() { + view.set("```ansi-stdout\none\n```") + val pane = scrolls().single() + val field = editors().single() + val editor = field.getEditor(true)!! + + view.set("```ansi-stdout\ntwo\n```") + + assertSame(pane, scrolls().single()) + assertSame(field, editors().single()) + assertEquals("two", field.text) + assertFalse(editor.settings.isUseSoftWraps) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, pane.horizontalScrollBarPolicy) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, editor.scrollPane.horizontalScrollBarPolicy) + } + + fun `test terminal and source plain text blocks are incompatible`() { + view.set("```text\none\n```") + val source = editors().single().getEditor(true)!! + + view.set("```ansi-stdout\none\n```") + drainEdt() + + assertTrue(source.isDisposed) + assertEquals("one", editors().single().text) + } + + fun `test ansi and shell output blocks are incompatible`() { + view.set("```ansi-stdout\none\n```") + val ansi = editors().single().getEditor(true)!! + + view.set("```shell-output\none\n```") + drainEdt() + + assertTrue(ansi.isDisposed) + assertEquals("one", editors().single().text) + } + fun `test fenced code block width is bounded and boxed`() { view.set("```kotlin\n${"x".repeat(500)}\n```") val pane = scrolls().single() @@ -517,6 +750,30 @@ class MdViewHybridTest : BasePlatformTestCase() { assertTrue(pane.text.contains("hello")) } + fun `test applyStyle updates retained code editor scheme and background`() { + view.set("```kotlin\nval value = 1\n```") + val pane = scrolls().single() + val field = editors().single() + val editor = field.getEditor(true)!! + val style = customStyle() + + view.applyStyle(style) + + assertSame(pane, scrolls().single()) + assertSame(field, editors().single()) + assertEquals( + Color(0xDD, 0xEE, 0xFF).rgb, + editor.colorsScheme.getAttributes(DefaultLanguageHighlighterColors.DOC_CODE_BLOCK).foregroundColor.rgb, + ) + assertEquals(Color(0x44, 0x55, 0x66).rgb, editor.backgroundColor.rgb) + assertEquals(Color(0x44, 0x55, 0x66).rgb, pane.background.rgb) + assertEquals(Color(0x44, 0x55, 0x66).rgb, pane.viewport.background.rgb) + assertEquals(Color(0x44, 0x55, 0x66).rgb, editor.scrollPane.background.rgb) + assertEquals(Color(0x44, 0x55, 0x66).rgb, editor.scrollPane.viewport.background.rgb) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, editor.scrollPane.horizontalScrollBarPolicy) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, editor.scrollPane.verticalScrollBarPolicy) + } + fun `test resetStyles keeps content rendered`() { view.set("hello **world**") view.font = view.font.deriveFont(25f) @@ -562,4 +819,26 @@ class MdViewHybridTest : BasePlatformTestCase() { private fun drainEdt() { UIUtil.dispatchAllInvocationEvents() } + + private fun customStyle(): SessionEditorStyle { + val scheme = EditorColorsManager.getInstance().globalScheme.clone() as EditorColorsScheme + scheme.setAttributes( + HighlighterColors.TEXT, + TextAttributes(Color(0x10, 0x20, 0x30), Color(0x01, 0x02, 0x03), null, null, Font.PLAIN), + ) + scheme.setAttributes( + DefaultLanguageHighlighterColors.DOC_CODE_INLINE, + TextAttributes(Color(0xAA, 0xBB, 0xCC), Color(0x11, 0x22, 0x33), null, null, Font.PLAIN), + ) + scheme.setAttributes( + DefaultLanguageHighlighterColors.DOC_CODE_BLOCK, + TextAttributes(Color(0xDD, 0xEE, 0xFF), Color(0x44, 0x55, 0x66), null, null, Font.PLAIN), + ) + scheme.setAttributes( + CodeInsightColors.HYPERLINK_ATTRIBUTES, + TextAttributes(Color(0x77, 0x88, 0x99), null, null, null, Font.PLAIN), + ) + scheme.setColor(EditorColors.PREVIEW_BORDER_COLOR, Color(0x22, 0x33, 0x44)) + return SessionEditorStyle.create(scheme = scheme, family = "Courier New", size = 21) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewLoggingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewLoggingTest.kt index f5d698cf10e..3b8c743fdd3 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewLoggingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewLoggingTest.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.ui.md +import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase @Suppress("UnstableApiUsage") @@ -7,11 +8,14 @@ class MdViewLoggingTest : BasePlatformTestCase() { fun `test invalid font family does not throw while building override sheet`() { val view = MdViewFactory.html() + try { + view.codeFont = "broken'font" + view.set("`x`") - view.codeFont = "broken'font" - view.set("`x`") - - assertTrue(view.overrideSheet().contains("broken\\'font")) - assertTrue(view.html().contains("")) + assertTrue(view.overrideSheet().contains("broken\\'font")) + assertTrue(view.html().contains("")) + } finally { + Disposer.dispose(view) + } } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt index 9a2a736b21c..39109bc8bbb 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt @@ -1,6 +1,14 @@ package ai.kilocode.client.ui.md import ai.kilocode.client.session.ui.style.SessionEditorStyle +import com.intellij.openapi.editor.DefaultLanguageHighlighterColors +import com.intellij.openapi.editor.HighlighterColors +import com.intellij.openapi.editor.colors.CodeInsightColors +import com.intellij.openapi.editor.colors.EditorColors +import com.intellij.openapi.editor.colors.EditorColorsManager +import com.intellij.openapi.editor.colors.EditorColorsScheme +import com.intellij.openapi.editor.markup.TextAttributes +import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase import java.awt.Color import java.awt.Font @@ -21,6 +29,14 @@ class MdViewTest : BasePlatformTestCase() { view = MdViewFactory.html() } + override fun tearDown() { + try { + if (this::view.isInitialized) Disposer.dispose(view) + } finally { + super.tearDown() + } + } + // ---- set ---- fun `test set stores source`() { @@ -221,6 +237,45 @@ class MdViewTest : BasePlatformTestCase() { assertTrue(view.overrideSheet().contains("${style.editorSize}pt")) } + fun `test override sheet includes markdown role color rules`() { + val sheet = view.overrideSheet() + + assertTrue(sheet.contains("h1, h2, h3, h4, h5, h6")) + assertTrue(sheet.contains("strong, b")) + assertTrue(sheet.contains("em, i")) + assertTrue(sheet.contains("ul, ol")) + assertTrue(sheet.contains("li { color:")) + assertTrue(sheet.contains("blockquote")) + assertTrue(sheet.contains("th, td")) + assertTrue(sheet.contains("th { color:")) + assertTrue(sheet.contains("hr {")) + assertTrue(sheet.contains("pre code")) + } + + fun `test override sheet separates table and code block borders`() { + view.tableBorder = Color(0x12, 0x34, 0x56) + val sheet = view.overrideSheet() + val pre = sheet.substringAfter("pre {").substringBefore("} pre code") + val cells = sheet.substringAfter("th, td {").substringBefore("}") + + assertTrue(cells.contains("#123456")) + assertFalse(pre.contains("#123456")) + assertTrue(pre.contains("border-color:")) + } + + fun `test applyStyle derives markdown colors from editor scheme`() { + val style = customStyle() + + view.applyStyle(style) + val sheet = view.overrideSheet() + + assertTrue(sheet.contains("a { color: #778899")) + assertTrue(sheet.contains("code { background: #112233; color: #aabbcc")) + assertTrue(sheet.contains("pre { background: #445566; color: #ddeeff; border-color: #223344")) + assertTrue(sheet.contains("blockquote { border-left-color: #223344; color: #334455")) + assertTrue(sheet.contains("th, td { border-color: #223344")) + } + // ---- style overrides appear in override sheet when set ---- fun `test foreground override appears in override sheet`() { @@ -433,4 +488,30 @@ class MdViewTest : BasePlatformTestCase() { assertTrue(view.html().contains("")) } + private fun customStyle(): SessionEditorStyle { + val scheme = EditorColorsManager.getInstance().globalScheme.clone() as EditorColorsScheme + scheme.setAttributes( + HighlighterColors.TEXT, + TextAttributes(Color(0x10, 0x20, 0x30), Color(0x01, 0x02, 0x03), null, null, Font.PLAIN), + ) + scheme.setAttributes( + DefaultLanguageHighlighterColors.DOC_COMMENT, + TextAttributes(Color(0x33, 0x44, 0x55), null, null, null, Font.PLAIN), + ) + scheme.setAttributes( + DefaultLanguageHighlighterColors.DOC_CODE_INLINE, + TextAttributes(Color(0xAA, 0xBB, 0xCC), Color(0x11, 0x22, 0x33), null, null, Font.PLAIN), + ) + scheme.setAttributes( + DefaultLanguageHighlighterColors.DOC_CODE_BLOCK, + TextAttributes(Color(0xDD, 0xEE, 0xFF), Color(0x44, 0x55, 0x66), null, null, Font.PLAIN), + ) + scheme.setAttributes( + CodeInsightColors.HYPERLINK_ATTRIBUTES, + TextAttributes(Color(0x77, 0x88, 0x99), null, null, null, Font.PLAIN), + ) + scheme.setColor(EditorColors.PREVIEW_BORDER_COLOR, Color(0x22, 0x33, 0x44)) + return SessionEditorStyle.create(scheme = scheme, family = "Courier New", size = 21) + } + } diff --git a/packages/kilo-jetbrains/package.json b/packages/kilo-jetbrains/package.json index 3712c8291b3..40dd7d8b8fd 100644 --- a/packages/kilo-jetbrains/package.json +++ b/packages/kilo-jetbrains/package.json @@ -8,7 +8,7 @@ "test": "./gradlew test", "test:ci": "bun script/test-ci.ts" }, - "version": "7.3.45", + "version": "7.3.46", "dependencies": {}, "devDependencies": {}, "peerDependencies": {} diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/cli/KiloCliParser.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/cli/KiloCliParser.kt new file mode 100644 index 00000000000..417c80037a8 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/cli/KiloCliParser.kt @@ -0,0 +1,18 @@ +package ai.kilocode.cli + +import java.util.concurrent.ConcurrentHashMap + +object KiloCliParser { + private val tags = ConcurrentHashMap() + + fun tag(text: String, name: String): String? = + tags.computeIfAbsent(name) { + val tag = Regex.escape(it) + Regex("<$tag>\\s*([\\s\\S]*?)\\s*") + } + .find(text) + ?.groupValues + ?.getOrNull(1) + ?.trim() + ?.takeIf { it.isNotBlank() } +} diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt index 664f5e22e57..5283789459c 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt @@ -50,14 +50,23 @@ object ChatLogSummary { fun prompt(prompt: PromptDto): String { val out = mutableListOf() - val text = prompt.parts.joinToString("\n") { it.text } + val text = prompt.parts.mapNotNull { it.text }.joinToString("\n") + val files = prompt.parts.filter { it.type == "file" } out += "kind=prompt" out += "parts=${prompt.parts.size}" out += "chars=${text.length}" + if (files.isNotEmpty()) out += "attachments=${files.size}" + files.count { it.mime?.startsWith("image/") == true || it.mime == "application/pdf" } + .takeIf { it > 0 } + ?.let { out += "media=$it" } prompt.parts.map { it.type } .distinct() .takeIf { it.isNotEmpty() } ?.let { out += "types=${it.joinToString(",")}" } + files.mapNotNull { it.mime ?: it.type } + .distinct() + .takeIf { it.isNotEmpty() } + ?.let { out += "attachmentTypes=${it.joinToString(",")}" } prompt.agent?.takeIf { it.isNotBlank() }?.let { out += "agent=$it" } model(prompt.providerID, prompt.modelID)?.let { out += "model=$it" } prompt.variant?.takeIf { it.isNotBlank() }?.let { out += "variant=$it" } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt index c6fc43f9202..fe53f8c35ee 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt @@ -8,6 +8,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 @@ -86,6 +87,9 @@ interface KiloSessionRpcApi : RemoteApi { /** Load message history for a session. */ suspend fun messages(id: String, directory: String): List + /** Load one attachment part from a session without returning full history to the frontend. */ + suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto? + /** Subscribe to streaming chat events for a specific session. */ suspend fun events(id: String, directory: String): Flow diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt index e5c85cb0ab2..21bf20897b0 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt @@ -72,6 +72,9 @@ data class PartDto( val reason: String? = null, val cost: Double? = null, val tokens: TokensDto? = null, + val mime: String? = null, + val url: String? = null, + val filename: String? = null, ) @Serializable @@ -96,7 +99,10 @@ data class PromptDto( @Serializable data class PromptPartDto( val type: String, - val text: String, + val text: String? = null, + val mime: String? = null, + val url: String? = null, + val filename: String? = null, ) // --- Streaming Events --- diff --git a/packages/kilo-telemetry/package.json b/packages/kilo-telemetry/package.json index c1de2bbebeb..7085b54750d 100644 --- a/packages/kilo-telemetry/package.json +++ b/packages/kilo-telemetry/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-telemetry", - "version": "7.3.45", + "version": "7.3.46", "type": "module", "license": "MIT", "description": "Telemetry for Kilo CLI - PostHog analytics integration", diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index eacff9a4ddd..b68c0fcf20b 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-ui", - "version": "7.3.45", + "version": "7.3.46", "type": "module", "license": "MIT", "exports": { diff --git a/packages/kilo-vscode/.vscodeignore b/packages/kilo-vscode/.vscodeignore index a1779eec27d..b8e0d54b256 100644 --- a/packages/kilo-vscode/.vscodeignore +++ b/packages/kilo-vscode/.vscodeignore @@ -25,3 +25,6 @@ AGENTS.md # Include bin/ directory for CLI binary (production) !bin/** + +# Include WAV assets used by extension-host notification playback +!audio-wav/** diff --git a/packages/kilo-vscode/CHANGELOG.md b/packages/kilo-vscode/CHANGELOG.md index 3411fdbfb83..d7c983f454c 100644 --- a/packages/kilo-vscode/CHANGELOG.md +++ b/packages/kilo-vscode/CHANGELOG.md @@ -1,5 +1,21 @@ # kilo-code +## 7.3.46 + +### Patch Changes + +- [#11184](https://github.com/Kilo-Org/kilocode/pull/11184) [`adf03a9`](https://github.com/Kilo-Org/kilocode/commit/adf03a98245e8877c580cb1f77a7e0ea4f0af61d) - Support model-specific reasoning overrides for task subagents, including custom subagents with their own model and variant settings. + +- [#11238](https://github.com/Kilo-Org/kilocode/pull/11238) [`fb2db2e`](https://github.com/Kilo-Org/kilocode/commit/fb2db2e0182b637d92d3158da9dbea953974cf71) - Allow reasoning to be removed from custom provider models after it has been enabled. + +- [#11232](https://github.com/Kilo-Org/kilocode/pull/11232) [`f326be4`](https://github.com/Kilo-Org/kilocode/commit/f326be4b6c9d5af9d944c8e5e7f9c524c173052f) - Clarify potential loop permission prompts and auto-approval rules with localized tool names. + +- [#11183](https://github.com/Kilo-Org/kilocode/pull/11183) [`1fd0960`](https://github.com/Kilo-Org/kilocode/commit/1fd0960cbcc82cb78a17c62332c08c5a7ba7d7c4) - Restore reverted sessions on the first Redo click. + +- [#11169](https://github.com/Kilo-Org/kilocode/pull/11169) [`e2939c7`](https://github.com/Kilo-Org/kilocode/commit/e2939c7557a08e2377c2dbbc0433f4817f4a121f) - Route question responses to the worktree where the question was created. + +- [#11240](https://github.com/Kilo-Org/kilocode/pull/11240) [`f820e57`](https://github.com/Kilo-Org/kilocode/commit/f820e57bab6c1ddd26f73964160bee7134488b96) - Prevent skill removal from recursively deleting working directories. + ## 7.3.45 ### Minor Changes diff --git a/packages/kilo-vscode/audio-wav/alert-01.wav b/packages/kilo-vscode/audio-wav/alert-01.wav new file mode 100644 index 00000000000..061fdb53c3a Binary files /dev/null and b/packages/kilo-vscode/audio-wav/alert-01.wav differ diff --git a/packages/kilo-vscode/audio-wav/alert-02.wav b/packages/kilo-vscode/audio-wav/alert-02.wav new file mode 100644 index 00000000000..b73daff94a8 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/alert-02.wav differ diff --git a/packages/kilo-vscode/audio-wav/alert-03.wav b/packages/kilo-vscode/audio-wav/alert-03.wav new file mode 100644 index 00000000000..7ad929468c1 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/alert-03.wav differ diff --git a/packages/kilo-vscode/audio-wav/alert-04.wav b/packages/kilo-vscode/audio-wav/alert-04.wav new file mode 100644 index 00000000000..b99b52738a4 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/alert-04.wav differ diff --git a/packages/kilo-vscode/audio-wav/alert-05.wav b/packages/kilo-vscode/audio-wav/alert-05.wav new file mode 100644 index 00000000000..5580a3f3ed1 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/alert-05.wav differ diff --git a/packages/kilo-vscode/audio-wav/alert-06.wav b/packages/kilo-vscode/audio-wav/alert-06.wav new file mode 100644 index 00000000000..48dbd0dc870 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/alert-06.wav differ diff --git a/packages/kilo-vscode/audio-wav/alert-07.wav b/packages/kilo-vscode/audio-wav/alert-07.wav new file mode 100644 index 00000000000..ed04924bec9 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/alert-07.wav differ diff --git a/packages/kilo-vscode/audio-wav/alert-08.wav b/packages/kilo-vscode/audio-wav/alert-08.wav new file mode 100644 index 00000000000..f9de3611dd4 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/alert-08.wav differ diff --git a/packages/kilo-vscode/audio-wav/alert-09.wav b/packages/kilo-vscode/audio-wav/alert-09.wav new file mode 100644 index 00000000000..842417b962a Binary files /dev/null and b/packages/kilo-vscode/audio-wav/alert-09.wav differ diff --git a/packages/kilo-vscode/audio-wav/alert-10.wav b/packages/kilo-vscode/audio-wav/alert-10.wav new file mode 100644 index 00000000000..f9a80aaddb3 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/alert-10.wav differ diff --git a/packages/kilo-vscode/audio-wav/bip-bop-01.wav b/packages/kilo-vscode/audio-wav/bip-bop-01.wav new file mode 100644 index 00000000000..eb259650ce1 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/bip-bop-01.wav differ diff --git a/packages/kilo-vscode/audio-wav/bip-bop-02.wav b/packages/kilo-vscode/audio-wav/bip-bop-02.wav new file mode 100644 index 00000000000..44c5ea0d5c5 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/bip-bop-02.wav differ diff --git a/packages/kilo-vscode/audio-wav/bip-bop-03.wav b/packages/kilo-vscode/audio-wav/bip-bop-03.wav new file mode 100644 index 00000000000..88899675b73 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/bip-bop-03.wav differ diff --git a/packages/kilo-vscode/audio-wav/bip-bop-04.wav b/packages/kilo-vscode/audio-wav/bip-bop-04.wav new file mode 100644 index 00000000000..319ab26d71f Binary files /dev/null and b/packages/kilo-vscode/audio-wav/bip-bop-04.wav differ diff --git a/packages/kilo-vscode/audio-wav/bip-bop-05.wav b/packages/kilo-vscode/audio-wav/bip-bop-05.wav new file mode 100644 index 00000000000..f9db904297d Binary files /dev/null and b/packages/kilo-vscode/audio-wav/bip-bop-05.wav differ diff --git a/packages/kilo-vscode/audio-wav/bip-bop-06.wav b/packages/kilo-vscode/audio-wav/bip-bop-06.wav new file mode 100644 index 00000000000..dd561c3e14d Binary files /dev/null and b/packages/kilo-vscode/audio-wav/bip-bop-06.wav differ diff --git a/packages/kilo-vscode/audio-wav/bip-bop-07.wav b/packages/kilo-vscode/audio-wav/bip-bop-07.wav new file mode 100644 index 00000000000..9ccf4984680 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/bip-bop-07.wav differ diff --git a/packages/kilo-vscode/audio-wav/bip-bop-08.wav b/packages/kilo-vscode/audio-wav/bip-bop-08.wav new file mode 100644 index 00000000000..ec87ee8ce17 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/bip-bop-08.wav differ diff --git a/packages/kilo-vscode/audio-wav/bip-bop-09.wav b/packages/kilo-vscode/audio-wav/bip-bop-09.wav new file mode 100644 index 00000000000..1a11d73ecc9 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/bip-bop-09.wav differ diff --git a/packages/kilo-vscode/audio-wav/bip-bop-10.wav b/packages/kilo-vscode/audio-wav/bip-bop-10.wav new file mode 100644 index 00000000000..b4d5e705a0c Binary files /dev/null and b/packages/kilo-vscode/audio-wav/bip-bop-10.wav differ diff --git a/packages/kilo-vscode/audio-wav/nope-01.wav b/packages/kilo-vscode/audio-wav/nope-01.wav new file mode 100644 index 00000000000..3fa989aaaa2 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/nope-01.wav differ diff --git a/packages/kilo-vscode/audio-wav/nope-02.wav b/packages/kilo-vscode/audio-wav/nope-02.wav new file mode 100644 index 00000000000..10948cf9667 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/nope-02.wav differ diff --git a/packages/kilo-vscode/audio-wav/nope-03.wav b/packages/kilo-vscode/audio-wav/nope-03.wav new file mode 100644 index 00000000000..dbfb6eeb6e5 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/nope-03.wav differ diff --git a/packages/kilo-vscode/audio-wav/nope-04.wav b/packages/kilo-vscode/audio-wav/nope-04.wav new file mode 100644 index 00000000000..dd1893e4b2a Binary files /dev/null and b/packages/kilo-vscode/audio-wav/nope-04.wav differ diff --git a/packages/kilo-vscode/audio-wav/nope-05.wav b/packages/kilo-vscode/audio-wav/nope-05.wav new file mode 100644 index 00000000000..562e49f9576 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/nope-05.wav differ diff --git a/packages/kilo-vscode/audio-wav/nope-06.wav b/packages/kilo-vscode/audio-wav/nope-06.wav new file mode 100644 index 00000000000..7192f7a31c8 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/nope-06.wav differ diff --git a/packages/kilo-vscode/audio-wav/nope-07.wav b/packages/kilo-vscode/audio-wav/nope-07.wav new file mode 100644 index 00000000000..f7d760c2a61 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/nope-07.wav differ diff --git a/packages/kilo-vscode/audio-wav/nope-08.wav b/packages/kilo-vscode/audio-wav/nope-08.wav new file mode 100644 index 00000000000..7ee5c72b48a Binary files /dev/null and b/packages/kilo-vscode/audio-wav/nope-08.wav differ diff --git a/packages/kilo-vscode/audio-wav/nope-09.wav b/packages/kilo-vscode/audio-wav/nope-09.wav new file mode 100644 index 00000000000..254a083320a Binary files /dev/null and b/packages/kilo-vscode/audio-wav/nope-09.wav differ diff --git a/packages/kilo-vscode/audio-wav/nope-10.wav b/packages/kilo-vscode/audio-wav/nope-10.wav new file mode 100644 index 00000000000..9c43e5765ed Binary files /dev/null and b/packages/kilo-vscode/audio-wav/nope-10.wav differ diff --git a/packages/kilo-vscode/audio-wav/nope-11.wav b/packages/kilo-vscode/audio-wav/nope-11.wav new file mode 100644 index 00000000000..934bdd42e9a Binary files /dev/null and b/packages/kilo-vscode/audio-wav/nope-11.wav differ diff --git a/packages/kilo-vscode/audio-wav/nope-12.wav b/packages/kilo-vscode/audio-wav/nope-12.wav new file mode 100644 index 00000000000..ed0b49189a3 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/nope-12.wav differ diff --git a/packages/kilo-vscode/audio-wav/staplebops-01.wav b/packages/kilo-vscode/audio-wav/staplebops-01.wav new file mode 100644 index 00000000000..4c429b74612 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/staplebops-01.wav differ diff --git a/packages/kilo-vscode/audio-wav/staplebops-02.wav b/packages/kilo-vscode/audio-wav/staplebops-02.wav new file mode 100644 index 00000000000..6ccc3f4f469 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/staplebops-02.wav differ diff --git a/packages/kilo-vscode/audio-wav/staplebops-03.wav b/packages/kilo-vscode/audio-wav/staplebops-03.wav new file mode 100644 index 00000000000..e9ce09bb54c Binary files /dev/null and b/packages/kilo-vscode/audio-wav/staplebops-03.wav differ diff --git a/packages/kilo-vscode/audio-wav/staplebops-04.wav b/packages/kilo-vscode/audio-wav/staplebops-04.wav new file mode 100644 index 00000000000..042e3f93e97 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/staplebops-04.wav differ diff --git a/packages/kilo-vscode/audio-wav/staplebops-05.wav b/packages/kilo-vscode/audio-wav/staplebops-05.wav new file mode 100644 index 00000000000..194f85d02c6 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/staplebops-05.wav differ diff --git a/packages/kilo-vscode/audio-wav/staplebops-06.wav b/packages/kilo-vscode/audio-wav/staplebops-06.wav new file mode 100644 index 00000000000..0a7bca02a4b Binary files /dev/null and b/packages/kilo-vscode/audio-wav/staplebops-06.wav differ diff --git a/packages/kilo-vscode/audio-wav/staplebops-07.wav b/packages/kilo-vscode/audio-wav/staplebops-07.wav new file mode 100644 index 00000000000..3fc74be97a4 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/staplebops-07.wav differ diff --git a/packages/kilo-vscode/audio-wav/yup-01.wav b/packages/kilo-vscode/audio-wav/yup-01.wav new file mode 100644 index 00000000000..2361194deb2 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/yup-01.wav differ diff --git a/packages/kilo-vscode/audio-wav/yup-02.wav b/packages/kilo-vscode/audio-wav/yup-02.wav new file mode 100644 index 00000000000..acf914a6491 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/yup-02.wav differ diff --git a/packages/kilo-vscode/audio-wav/yup-03.wav b/packages/kilo-vscode/audio-wav/yup-03.wav new file mode 100644 index 00000000000..58865b47fc3 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/yup-03.wav differ diff --git a/packages/kilo-vscode/audio-wav/yup-04.wav b/packages/kilo-vscode/audio-wav/yup-04.wav new file mode 100644 index 00000000000..08f474cc771 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/yup-04.wav differ diff --git a/packages/kilo-vscode/audio-wav/yup-05.wav b/packages/kilo-vscode/audio-wav/yup-05.wav new file mode 100644 index 00000000000..8837d4040a8 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/yup-05.wav differ diff --git a/packages/kilo-vscode/audio-wav/yup-06.wav b/packages/kilo-vscode/audio-wav/yup-06.wav new file mode 100644 index 00000000000..4c65ec82a51 Binary files /dev/null and b/packages/kilo-vscode/audio-wav/yup-06.wav differ diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 6941d574acd..876b6f9e9f4 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -2,7 +2,7 @@ "name": "kilo-code", "displayName": "Kilo Code: AI Coding Agent, Copilot, and Autocomplete", "description": "Open Source AI coding agent that generates code from natural language, automates tasks, and runs terminal commands. Features inline autocomplete, browser automation, automated refactoring, and custom modes for planning, coding, and debugging. Supports 500+ AI models including Claude (Anthropic), Gemini, Grok, GPT, Codex and GLM.", - "version": "7.3.45", + "version": "7.3.46", "icon": "assets/icons/logo-outline-black.png", "galleryBanner": { "color": "#FFFFFF", @@ -943,47 +943,64 @@ "default": false, "description": "Run browser automation in headless mode (no visible window). When disabled, you can watch the agent interact with the browser." }, - "kilo-code.new.notifications.agent": { + "kilo-code.new.attention.enabled": { "type": "boolean", - "default": true, - "description": "Show notification when agent completes a task" + "default": false, + "description": "Play sounds when sessions complete, error, or need input" }, - "kilo-code.new.notifications.permissions": { - "type": "boolean", - "default": true, - "description": "Show notification on permission requests" - }, - "kilo-code.new.notifications.errors": { - "type": "boolean", - "default": true, - "description": "Show notification on errors" - }, - "kilo-code.new.sounds.agent": { + "kilo-code.new.attention.sound": { "type": "string", "default": "default", "enum": [ "default", - "none" + "system", + "alert-01", + "alert-02", + "alert-03", + "alert-04", + "alert-05", + "alert-06", + "alert-07", + "alert-08", + "alert-09", + "alert-10", + "bip-bop-01", + "bip-bop-02", + "bip-bop-03", + "bip-bop-04", + "bip-bop-05", + "bip-bop-06", + "bip-bop-07", + "bip-bop-08", + "bip-bop-09", + "bip-bop-10", + "staplebops-01", + "staplebops-02", + "staplebops-03", + "staplebops-04", + "staplebops-05", + "staplebops-06", + "staplebops-07", + "nope-01", + "nope-02", + "nope-03", + "nope-04", + "nope-05", + "nope-06", + "nope-07", + "nope-08", + "nope-09", + "nope-10", + "nope-11", + "nope-12", + "yup-01", + "yup-02", + "yup-03", + "yup-04", + "yup-05", + "yup-06" ], - "description": "Sound to play when agent completes" - }, - "kilo-code.new.sounds.permissions": { - "type": "string", - "default": "default", - "enum": [ - "default", - "none" - ], - "description": "Sound to play on permission requests" - }, - "kilo-code.new.sounds.errors": { - "type": "string", - "default": "default", - "enum": [ - "default", - "none" - ], - "description": "Sound to play on errors" + "description": "Use different sounds for completion, input, and errors, or choose one sound for every event" }, "kilo-code.new.showTaskTimeline": { "type": "boolean", @@ -1083,6 +1100,7 @@ "@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:", diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index b117b8c14ee..0426e3c9de6 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -11,6 +11,7 @@ import type { Config, } from "@kilocode/sdk/v2/client" import { type KiloConnectionService, ServerStartupError } from "./services/cli-backend" +import { previewSound } from "./services/attention" import type { EditorContext, IndexingStatus } from "./services/cli-backend/types" import { FileIgnoreController } from "./services/autocomplete/shims/FileIgnoreController" import { ChatTextAreaAutocomplete } from "./services/autocomplete/chat-autocomplete/ChatTextAreaAutocomplete" @@ -53,7 +54,7 @@ import { getWorkspaceRoot } from "./review-utils" import { createMarketplaceRemover, removeAgent, removeMcp } from "./kilo-provider/remove-config-item" import type { RemoteStatusService } from "./services/RemoteStatusService" import { resolveProjectDirectory } from "./project-directory" -import { getBusySessionCount, seedSessionStatuses } from "./session-status" +import { seedSessionStatuses } from "./session-status" import { normalizeEnhancePromptErrorMessage } from "./enhance-prompt-error" import { retry } from "./services/cli-backend/retry" import { slimInfo, slimPart, slimParts } from "./kilo-provider/slim-metadata" @@ -139,7 +140,9 @@ import { completeProviderOAuth as completeOAuthAction, disconnectProvider as disconnectProviderAction, saveCustomProvider as saveCustomProviderAction, + resolveStoredKey, } from "./provider-actions" +import type { StoredProviderKey } from "./provider-actions" import { fetchOpenAIModels, FetchModelsError } from "./shared/fetch-models" import type { Agent } from "@kilocode/sdk/v2/client" import { configFeatures } from "./features" @@ -196,6 +199,7 @@ type LegacySyncEvent = properties: Extract["data"] } | { + source: "sync" id: string type: "session.updated" properties: Extract["data"] @@ -206,20 +210,30 @@ type LegacySyncEvent = properties: Extract["data"] } -type ProviderEvent = Event | LegacySyncEvent +type FullSessionUpdatedEvent = { + id: string + type: "session.updated" + properties: { sessionID: string; info: Session } +} + +type ProviderEvent = Event | LegacySyncEvent | FullSessionUpdatedEvent function isLegacySyncEvent(event: ProviderEvent): event is LegacySyncEvent { + if (event.type === "session.updated") return "source" in event && event.source === "sync" return ( event.type === "message.updated" || event.type === "message.removed" || event.type === "message.part.updated" || event.type === "message.part.removed" || event.type === "session.created" || - event.type === "session.updated" || event.type === "session.deleted" ) } +function isFullSessionUpdatedEvent(event: ProviderEvent): event is FullSessionUpdatedEvent { + return event.type === "session.updated" && !isLegacySyncEvent(event) +} + function unwrapSyncEvent(event: GlobalEvent["payload"]): ProviderEvent | undefined { if (event.type !== "sync") return event @@ -235,7 +249,7 @@ function unwrapSyncEvent(event: GlobalEvent["payload"]): ProviderEvent | undefin case "session.created.1": return { id: event.id, type: "session.created", properties: event.data } case "session.updated.1": - return { id: event.id, type: "session.updated", properties: event.data } + return { source: "sync", id: event.id, type: "session.updated", properties: event.data } case "session.deleted.1": return { id: event.id, type: "session.deleted", properties: event.data } default: @@ -257,6 +271,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private readonly extensionVersion = vscode.extensions.getExtension("kilocode.kilo-code")?.packageJSON?.version ?? "unknown" private cachedProvidersMessage: unknown = null + /** + * Provider API keys retained extension-side for authenticated model + * fetches (#10139). Keys are stripped before provider data reaches the + * webview, so fetch requests for an existing provider carry a providerID + * and the key is resolved here. Refreshed on every provider fetch. + */ + private storedProviderKeys: Record = {} /** Coalesce provider refreshes — at most one follow-up rerun when a request lands mid-flight. */ private providersRefresh: Promise | null = null private providersQueued = false @@ -1093,6 +1114,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper case "requestNotificationSettings": this.sendNotificationSettings() break + case "testNotification": + previewSound(message.sound) + break case "requestTimelineSetting": this.sendTimelineSetting() break @@ -1808,12 +1832,16 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return } try { - const { response, authMethods, authStates } = await fetchProviderData(client, this.getWorkspaceDirectory()) + const { response, authMethods, authStates, storedKeys } = await fetchProviderData( + client, + this.getWorkspaceDirectory(), + ) if (generation !== this.providersGeneration || client !== this.client) { if (!this.providersQueued) return generation = this.providersGeneration continue } + this.storedProviderKeys = storedKeys const settings = vscode.workspace.getConfiguration("kilo-code.new.model") const message = { type: "providersLoaded", @@ -1900,7 +1928,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const rid = typeof msg.requestId === "string" ? msg.requestId : "" const url = typeof msg.baseURL === "string" ? msg.baseURL : "" if (!rid || !url) return - const key = typeof msg.apiKey === "string" ? msg.apiKey : undefined + const key = + typeof msg.apiKey === "string" ? msg.apiKey : resolveStoredKey(this.storedProviderKeys, msg.providerID, url) const headers = msg.headers && typeof msg.headers === "object" ? (msg.headers as Record) : undefined try { const models = await fetchOpenAIModels({ baseURL: url, apiKey: key, headers }) @@ -2327,21 +2356,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.connectionService.notifyNotificationDismissed(notificationId) } - /** - * Read notification/sound settings from VS Code config and push to webview. - */ + /** Read attention settings from VS Code config and push to webview. */ private sendNotificationSettings(): void { - const notifications = vscode.workspace.getConfiguration("kilo-code.new.notifications") - const sounds = vscode.workspace.getConfiguration("kilo-code.new.sounds") + const attention = vscode.workspace.getConfiguration("kilo-code.new.attention") this.postMessage({ type: "notificationSettingsLoaded", settings: { - notifyAgent: notifications.get("agent", true), - notifyPermissions: notifications.get("permissions", true), - notifyErrors: notifications.get("errors", true), - soundAgent: sounds.get("agent", "default"), - soundPermissions: sounds.get("permissions", "default"), - soundErrors: sounds.get("errors", "default"), + attentionEnabled: attention.get("enabled", false), + attentionSound: attention.get("sound", "default"), }, }) } @@ -2358,11 +2380,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.postMessage(getWorkStylePayload()) } - /** Returns the number of sessions currently in "busy" state. */ - private getBusySessionCount(): number { - return getBusySessionCount(this.sessionStatusMap) - } - private async handleUpdateConfig( partial: Partial, project: Partial = {}, @@ -2377,7 +2394,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const refreshProviders = partial.provider !== undefined || partial.disabled_providers !== undefined || - partial.enabled_providers !== undefined + partial.enabled_providers !== undefined || + partial.hide_prompt_training_models !== undefined const refreshAgents = partial.default_agent !== undefined || partial.agent !== undefined || @@ -3102,7 +3120,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Drop session events from other projects before any tracking logic. // This must come first: the trackedSessionIds guard below would otherwise // let a foreign session through if it was accidentally tracked. - if (!isLegacySyncEvent(event) && isEventFromForeignProject(event, this.projectID)) return + if ( + !isLegacySyncEvent(event) && + !isFullSessionUpdatedEvent(event) && + isEventFromForeignProject(event, this.projectID) + ) + return if ( this.projectID && (event.type === "session.created" || event.type === "session.updated") && @@ -3183,7 +3206,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.trackedSessionIds.add(event.properties.info.id) } if (event.type === "session.updated" && this.currentSession?.id === event.properties.sessionID) { - this.setCurrentSession(applySessionPatch(this.currentSession, event.properties.info)) + const session = isLegacySyncEvent(event) + ? applySessionPatch(this.currentSession, event.properties.info) + : event.properties.info + this.setCurrentSession(session) this.contextSessionID = event.properties.sessionID } @@ -3226,7 +3252,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const msg = isLegacySyncEvent(event) ? this.mapSyncEventToWebviewMessage(event) - : mapSSEEventToWebviewMessage(event, sessionID) + : isFullSessionUpdatedEvent(event) + ? { type: "sessionUpdated" as const, session: this.sessionToWebview(event.properties.info) } + : mapSSEEventToWebviewMessage(event, sessionID) if (!msg) return if (msg.type === "partUpdated") { this.streams.push({ ...msg, part: this.slimPart(msg.part) }) diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index 9d19f9aab36..35dc3073d2b 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -14,6 +14,7 @@ import { KiloConnectionService } from "./services/cli-backend" import { registerAutocompleteProvider } from "./services/autocomplete" import { ensureBackendForAutocomplete } from "./services/autocomplete/ensure-backend" import { AutocompleteServiceManager } from "./services/autocomplete/AutocompleteServiceManager" +import { AttentionService } from "./services/attention" import { BrowserAutomationService } from "./services/browser-automation" import { TelemetryEventName, TelemetryProxy } from "./services/telemetry" import { registerCommitMessageService } from "./services/commit-message" @@ -48,6 +49,7 @@ export function activate(context: vscode.ExtensionContext) { // Create shared connection service (one server for all webviews) const connectionService = new KiloConnectionService(context) + const attention = new AttentionService(connectionService) let restore = context.workspaceState.get(RESTORE_KEY) ?? {} const remember = (patch: RestoreState) => { const next = { ...restore, ...patch } @@ -525,6 +527,7 @@ export function activate(context: vscode.ExtensionContext) { dispose: () => { shuttingDown = true unsubscribeStateChange() + attention.dispose() browserAutomationService.dispose() provider.dispose() connectionService.dispose() diff --git a/packages/kilo-vscode/src/kiloclaw/KiloClawProvider.ts b/packages/kilo-vscode/src/kiloclaw/KiloClawProvider.ts index b6530ccc70a..5d00764d52f 100644 --- a/packages/kilo-vscode/src/kiloclaw/KiloClawProvider.ts +++ b/packages/kilo-vscode/src/kiloclaw/KiloClawProvider.ts @@ -399,7 +399,7 @@ export class KiloClawProvider implements vscode.Disposable { return false } - this.attachEventHandlers(events, chat) + this.attachEventHandlers(events) this.subscribeSandboxContext() return true } @@ -511,7 +511,7 @@ export class KiloClawProvider implements vscode.Disposable { this.subscribedConversationContext = null } - private attachEventHandlers(events: EventServiceClient, _chat: KiloChatClient): void { + private attachEventHandlers(events: EventServiceClient): void { // Reset on reconnect — the event stream may have missed events while // disconnected, so refetch authoritative state. const offReconnect = events.onReconnect(() => { diff --git a/packages/kilo-vscode/src/kiloclaw/kilo-chat-client.ts b/packages/kilo-vscode/src/kiloclaw/kilo-chat-client.ts index 5583b32af51..8d402dafe81 100644 --- a/packages/kilo-vscode/src/kiloclaw/kilo-chat-client.ts +++ b/packages/kilo-vscode/src/kiloclaw/kilo-chat-client.ts @@ -89,10 +89,6 @@ export class KiloChatClient { }) } - getConversation(conversationId: string): Promise { - return this.request(`/v1/conversations/${conversationId}`) - } - createConversation(req: { sandboxId: string title?: string diff --git a/packages/kilo-vscode/src/kiloclaw/token-manager.ts b/packages/kilo-vscode/src/kiloclaw/token-manager.ts index 745b9c7c3f8..93020e67d67 100644 --- a/packages/kilo-vscode/src/kiloclaw/token-manager.ts +++ b/packages/kilo-vscode/src/kiloclaw/token-manager.ts @@ -26,11 +26,6 @@ export class TokenManager { constructor(private readonly getClient: () => KiloClient | null) {} - /** Latest resolved token info (may be stale). Used for URL extraction. */ - peek(): ChatToken | null { - return this.cached - } - /** Drop the cached token; next `get` will refetch. */ clear(): void { this.cached = null diff --git a/packages/kilo-vscode/src/provider-actions.ts b/packages/kilo-vscode/src/provider-actions.ts index cae5ba1e0c8..32623f5a643 100644 --- a/packages/kilo-vscode/src/provider-actions.ts +++ b/packages/kilo-vscode/src/provider-actions.ts @@ -18,6 +18,12 @@ import { configFeatures } from "./features" */ type AuthState = "api" | "oauth" | "wellknown" +/** API key retained extension-side for authenticated model fetches (#10139). */ +export interface StoredProviderKey { + key: string + baseURL: string +} + function disabledWithout(list: string[] | undefined, id: string) { return (list ?? []).filter((item) => item !== id) } @@ -59,17 +65,43 @@ export async function fetchProviderData(client: KiloClient, dir: string) { authRequest, ]) const authStates: Record = {} + const storedKeys: Record = {} const all = response.all.map((item) => { const raw = item as Record if (typeof raw.id === "string" && typeof raw.key === "string" && raw.key) { authStates[raw.id] = "api" + // Retain the key on the extension side so model fetches for an existing + // provider can authenticate without the webview ever seeing the secret + // (#10139). Only providers with a configured baseURL are retained — the + // fetch handler requires a URL match before applying a stored key. + const options = record(raw.options) ? raw.options : undefined + const baseURL = options && typeof options.baseURL === "string" ? options.baseURL : undefined + if (baseURL) storedKeys[raw.id] = { key: raw.key, baseURL } } if (!("key" in raw)) return item const next = { ...raw } delete next.key return next as (typeof response.all)[number] }) - return { response: { ...response, all }, authMethods, authStates } + return { response: { ...response, all }, authMethods, authStates, storedKeys } +} + +/** + * Resolve the stored API key for a model fetch on an existing provider. + * The key is only applied when the requested URL matches the provider's + * configured baseURL, so a stored secret can never be redirected to a + * different host (e.g. after the user edits the URL field). + */ +export function resolveStoredKey( + storedKeys: Record, + providerID: unknown, + url: string, +): string | undefined { + if (typeof providerID !== "string" || !providerID) return undefined + const stored = storedKeys[providerID] + if (!stored) return undefined + const normalize = (value: string) => value.trim().replace(/\/+$/, "") + return normalize(stored.baseURL) === normalize(url) ? stored.key : undefined } export function buildActionContext( diff --git a/packages/kilo-vscode/src/services/attention/index.ts b/packages/kilo-vscode/src/services/attention/index.ts new file mode 100644 index 00000000000..a621bf6e22b --- /dev/null +++ b/packages/kilo-vscode/src/services/attention/index.ts @@ -0,0 +1 @@ +export { AttentionService, previewSound } from "./service" diff --git a/packages/kilo-vscode/src/services/attention/service.ts b/packages/kilo-vscode/src/services/attention/service.ts new file mode 100644 index 00000000000..2e4c8a9982d --- /dev/null +++ b/packages/kilo-vscode/src/services/attention/service.ts @@ -0,0 +1,121 @@ +import * as vscode from "vscode" +import type { TuiAttentionSoundName } from "@kilocode/plugin/tui" +import type { SSEPayload } from "../cli-backend/sdk-sse-adapter" +import type { KiloConnectionService } from "../cli-backend/connection-service" +import { playSound, resolveSoundID } from "./sound" + +type Sync = Extract +type Question = Extract +type Permission = Extract +type Status = Extract +type Error = Extract + +export function previewSound(value: string) { + void playSound("default", resolveSoundID(value)) +} + +export class AttentionService implements vscode.Disposable { + private readonly active = new Set() + private readonly errored = new Set() + private readonly questions = new Set() + private readonly permissions = new Set() + private readonly parents = new Map() + private readonly unsubscribeEvent: () => void + private readonly unsubscribeState: () => void + + constructor(connection: KiloConnectionService) { + this.unsubscribeEvent = connection.onEvent((event) => this.handle(event)) + this.unsubscribeState = connection.onStateChange((state) => { + if (state === "error" || state === "disconnected") this.reset() + }) + } + + dispose() { + this.unsubscribeEvent() + this.unsubscribeState() + this.reset() + this.parents.clear() + } + + private handle(event: SSEPayload) { + if (event.type === "sync") return this.sync(event) + if (event.type === "question.asked" || event.type === "question.replied" || event.type === "question.rejected") { + return this.question(event) + } + if (event.type === "permission.asked" || event.type === "permission.replied") return this.permission(event) + if (event.type === "session.status") return this.status(event) + if (event.type === "session.error") return this.error(event) + } + + private sync(event: Sync) { + if (event.name === "session.created.1") { + this.parents.set(event.data.sessionID, event.data.info.parentID) + return + } + if (event.name === "session.updated.1") { + if (event.data.info.parentID !== undefined) { + this.parents.set(event.data.sessionID, event.data.info.parentID ?? undefined) + } + return + } + if (event.name !== "session.deleted.1") return + this.parents.delete(event.data.sessionID) + this.active.delete(event.data.sessionID) + this.errored.delete(event.data.sessionID) + } + + private question(event: Question) { + if (event.type !== "question.asked") { + this.questions.delete(event.properties.requestID) + return + } + if (this.questions.has(event.properties.id)) return + this.questions.add(event.properties.id) + this.notify("question") + } + + private permission(event: Permission) { + if (event.type !== "permission.asked") { + this.permissions.delete(event.properties.requestID) + return + } + if (this.permissions.has(event.properties.id)) return + this.permissions.add(event.properties.id) + this.notify("permission") + } + + private status(event: Status) { + const sessionID = event.properties.sessionID + if (event.properties.status.type === "busy" || event.properties.status.type === "retry") { + this.active.add(sessionID) + this.errored.delete(sessionID) + return + } + if (event.properties.status.type !== "idle") return + if (!this.active.has(sessionID)) return + this.active.delete(sessionID) + if (this.errored.delete(sessionID)) return + this.notify(this.parents.get(sessionID) ? "subagent_done" : "done") + } + + private error(event: Error) { + const sessionID = event.properties.sessionID + if (!sessionID || !this.active.has(sessionID)) return + this.errored.add(sessionID) + this.notify("error") + } + + private notify(sound: TuiAttentionSoundName) { + const config = vscode.workspace.getConfiguration("kilo-code.new.attention") + if (!config.get("enabled", false)) return + const selected = resolveSoundID(config.get("sound", "default")) + void playSound(sound, selected) + } + + private reset() { + this.active.clear() + this.errored.clear() + this.questions.clear() + this.permissions.clear() + } +} diff --git a/packages/kilo-vscode/src/services/attention/sound.ts b/packages/kilo-vscode/src/services/attention/sound.ts new file mode 100644 index 00000000000..df8ceff1a53 --- /dev/null +++ b/packages/kilo-vscode/src/services/attention/sound.ts @@ -0,0 +1,163 @@ +import * as fs from "fs" +import * as path from "path" +import type { TuiAttentionSoundName } from "@kilocode/plugin/tui" +import { exec } from "../../util/process" + +export const CustomSoundIDs = [ + "alert-01", + "alert-02", + "alert-03", + "alert-04", + "alert-05", + "alert-06", + "alert-07", + "alert-08", + "alert-09", + "alert-10", + "bip-bop-01", + "bip-bop-02", + "bip-bop-03", + "bip-bop-04", + "bip-bop-05", + "bip-bop-06", + "bip-bop-07", + "bip-bop-08", + "bip-bop-09", + "bip-bop-10", + "staplebops-01", + "staplebops-02", + "staplebops-03", + "staplebops-04", + "staplebops-05", + "staplebops-06", + "staplebops-07", + "nope-01", + "nope-02", + "nope-03", + "nope-04", + "nope-05", + "nope-06", + "nope-07", + "nope-08", + "nope-09", + "nope-10", + "nope-11", + "nope-12", + "yup-01", + "yup-02", + "yup-03", + "yup-04", + "yup-05", + "yup-06", +] as const + +export type CustomSoundID = (typeof CustomSoundIDs)[number] +export type AttentionSoundID = "default" | "system" | CustomSoundID + +const ids = new Set(CustomSoundIDs) +const files: Record = { + default: "bip-bop-01", + question: "bip-bop-03", + permission: "staplebops-06", + error: "nope-03", + done: "bip-bop-01", + subagent_done: "yup-01", +} + +const root = path.join(__dirname, "../audio-wav") +let chain = Promise.resolve(false) +let queued = 0 +const limit = 3 + +export function resolveSoundID(value: string | undefined): AttentionSoundID { + if (value === "system" || value === "default" || (value && ids.has(value))) return value as AttentionSoundID + return "default" +} + +async function run(commands: Array<{ cmd: string; args: string[]; env?: NodeJS.ProcessEnv }>) { + for (const command of commands) { + const ok = await exec(command.cmd, command.args, command.env ? { env: command.env } : {}).then( + () => true, + (error) => { + console.debug("[Kilo New] notification sound command failed", { cmd: command.cmd, error }) + return false + }, + ) + if (ok) return true + } + return false +} + +function systemCommands(): Array<{ cmd: string; args: string[] }> { + if (process.platform === "darwin") return [{ cmd: "osascript", args: ["-e", "beep"] }] + if (process.platform === "linux") { + return [ + { cmd: "canberra-gtk-play", args: ["-i", "message-new-instant"] }, + { cmd: "paplay", args: ["/usr/share/sounds/freedesktop/stereo/message.oga"] }, + ] + } + if (process.platform === "win32") { + return [ + { + cmd: "powershell", + args: ["-NoProfile", "-NonInteractive", "-Command", "[System.Media.SystemSounds]::Exclamation.Play()"], + }, + ] + } + return [] +} + +function fileCommands(file: string): Array<{ cmd: string; args: string[]; env?: NodeJS.ProcessEnv }> { + if (process.platform === "darwin") { + return [ + { cmd: "afplay", args: [file] }, + { cmd: "play", args: [file] }, + ] + } + if (process.platform === "linux") { + return [ + { cmd: "aplay", args: [file] }, + { cmd: "paplay", args: [file] }, + { cmd: "play", args: [file] }, + ] + } + if (process.platform === "win32") { + return [ + { + cmd: "powershell", + args: [ + "-NoProfile", + "-NonInteractive", + "-Command", + "$sound = [System.Media.SoundPlayer]::new($env:KILO_SOUND_PATH); $sound.PlaySync(); $sound.Dispose()", + ], + env: { ...process.env, KILO_SOUND_PATH: file }, + }, + ] + } + return [] +} + +async function perform(name: TuiAttentionSoundName, selected: AttentionSoundID, dir: string) { + if (selected === "system") return run(systemCommands()) + const id = selected === "default" ? files[name] : selected + const file = path.resolve(dir, `${id}.wav`) + if (!file.startsWith(`${path.resolve(dir)}${path.sep}`)) return false + if (!fs.existsSync(file)) { + console.warn("[Kilo New] notification sound is missing", { file }) + return false + } + const ok = await run(fileCommands(file)) + if (ok) console.debug("[Kilo New] notification sound played", { name, selected }) + return ok +} + +export async function playSound(name: TuiAttentionSoundName, selected: AttentionSoundID = "default", dir = root) { + if (queued >= limit) return false + queued += 1 + const task = chain.catch(() => false).then(() => perform(name, selected, dir)) + chain = task.finally(() => { + queued -= 1 + }) + return task +} diff --git a/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts b/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts index 5c24286e782..7a2eeaebbaf 100644 --- a/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts +++ b/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts @@ -257,37 +257,6 @@ export class AutocompleteServiceManager { return Date.now() < snoozeUntil } - /** - * Get remaining snooze time in seconds - */ - public getSnoozeRemainingSeconds(): number { - const snoozeUntil = this.settings?.snoozeUntil - if (!snoozeUntil) { - return 0 - } - const remaining = Math.max(0, Math.ceil((snoozeUntil - Date.now()) / 1000)) - return remaining - } - - /** - * Snooze autocomplete for a specified number of seconds - */ - public async snooze(seconds: number): Promise { - if (this.snoozeTimer) { - clearTimeout(this.snoozeTimer) - this.snoozeTimer = null - } - - const snoozeUntil = Date.now() + seconds * 1000 - await writeSettings({ snoozeUntil }) - - this.snoozeTimer = setTimeout(() => { - void this.unsnooze() - }, seconds * 1000) - - await this.load() - } - /** * Cancel snooze and re-enable autocomplete */ diff --git a/packages/kilo-vscode/src/services/autocomplete/__tests__/AutocompleteServiceManager.spec.ts b/packages/kilo-vscode/src/services/autocomplete/__tests__/AutocompleteServiceManager.spec.ts index 1244b25b9ec..5fc37a76bf8 100644 --- a/packages/kilo-vscode/src/services/autocomplete/__tests__/AutocompleteServiceManager.spec.ts +++ b/packages/kilo-vscode/src/services/autocomplete/__tests__/AutocompleteServiceManager.spec.ts @@ -84,10 +84,6 @@ vi.mock("../classic-auto-complete/AutocompleteInlineCompletionProvider", () => { public setModel(id: string) { this.modelId = id } - public getModelId(): string { - return this.modelId - } - constructor(..._args: any[]) {} } return { AutocompleteInlineCompletionProvider } @@ -317,21 +313,5 @@ describe("AutocompleteServiceManager (less mocked logic)", () => { expect(manager.isSnoozed()).toBe(true) }) - - it("getSnoozeRemainingSeconds() returns 0 when not snoozed", async () => { - const manager = await createManager() - ;(manager as any).settings = {} - - expect(manager.getSnoozeRemainingSeconds()).toBe(0) - }) - - it("getSnoozeRemainingSeconds() returns a positive number when snoozed", async () => { - const manager = await createManager() - ;(manager as any).settings = { snoozeUntil: Date.now() + 30_000 } - - const remaining = manager.getSnoozeRemainingSeconds() - expect(remaining).toBeGreaterThan(0) - expect(remaining).toBeLessThanOrEqual(30) - }) }) }) diff --git a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts index 6e6eef24c77..cc38a899e43 100644 --- a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts +++ b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts @@ -233,10 +233,6 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple this.contextProvider.modelId = modelId } - public getModelId(): string { - return this.contextProvider.modelId - } - private processSuggestion( suggestionText: string, prefix: string, diff --git a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/ErrorBackoff.ts b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/ErrorBackoff.ts index 0d211aea235..622398b3136 100644 --- a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/ErrorBackoff.ts +++ b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/ErrorBackoff.ts @@ -133,13 +133,6 @@ export class ErrorBackoff { return false } - /** - * Whether a fatal (non-retriable) error is active — credits depleted, auth invalid, etc. - */ - isFatal(): boolean { - return this.fatal !== null - } - /** * The HTTP status code of the fatal error, or null. */ diff --git a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/__tests__/AutocompleteContextProvider.test.ts b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/__tests__/AutocompleteContextProvider.test.ts index fc75473d7d1..d91c07fc41e 100644 --- a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/__tests__/AutocompleteContextProvider.test.ts +++ b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/__tests__/AutocompleteContextProvider.test.ts @@ -55,9 +55,7 @@ vi.mock("../../continuedev/core/autocomplete/snippets/getAllSnippets", () => ({ rootPathSnippets: [], recentlyEditedRangeSnippets: [], recentlyVisitedRangesSnippets: [], - diffSnippets: [], clipboardSnippets: [], - ideSnippets: [], staticSnippet: [], }), })) @@ -133,9 +131,7 @@ describe("AutocompleteContextProvider", () => { rootPathSnippets: [], recentlyEditedRangeSnippets: [], recentlyVisitedRangesSnippets: [], - diffSnippets: [], clipboardSnippets: [], - ideSnippets: [], staticSnippet: [], }) @@ -178,9 +174,7 @@ describe("AutocompleteContextProvider", () => { rootPathSnippets: [], recentlyEditedRangeSnippets: [], recentlyVisitedRangesSnippets: [], - diffSnippets: [], clipboardSnippets: [], - ideSnippets: [], staticSnippet: [], }) @@ -275,9 +269,7 @@ describe("AutocompleteContextProvider", () => { rootPathSnippets: [], recentlyEditedRangeSnippets: [], recentlyVisitedRangesSnippets: [], - diffSnippets: [], clipboardSnippets: [], - ideSnippets: [], staticSnippet: [], }) @@ -318,12 +310,6 @@ describe("AutocompleteContextProvider", () => { rootPathSnippets: [], recentlyEditedRangeSnippets: [], recentlyVisitedRangesSnippets: [], - diffSnippets: [ - { - content: "diff content", - type: AutocompleteSnippetType.Diff, - }, - ], clipboardSnippets: [ { content: "clipboard content", @@ -331,14 +317,12 @@ describe("AutocompleteContextProvider", () => { copiedAt: "2024-01-01", }, ], - ideSnippets: [], staticSnippet: [], }) const { getSnippets } = await import("../../continuedev/core/autocomplete/templating/filtering") ;(getSnippets as any).mockImplementation((_helper: any, payload: any) => [ ...payload.recentlyOpenedFileSnippets, - ...payload.diffSnippets, ...payload.clipboardSnippets, ]) @@ -357,11 +341,9 @@ describe("AutocompleteContextProvider", () => { result.snippetsWithUris.some((s) => "filepath" in s && s.filepath && s.filepath.includes("blocked.ts")), ).toBe(false) // But should contain snippets without file paths - expect(result.snippetsWithUris).toHaveLength(2) - expect(result.snippetsWithUris[0].content).toBe("diff content") - expect(result.snippetsWithUris[0].type).toBe(AutocompleteSnippetType.Diff) - expect(result.snippetsWithUris[1].content).toBe("clipboard content") - expect(result.snippetsWithUris[1].type).toBe(AutocompleteSnippetType.Clipboard) + expect(result.snippetsWithUris).toHaveLength(1) + expect(result.snippetsWithUris[0].content).toBe("clipboard content") + expect(result.snippetsWithUris[0].type).toBe(AutocompleteSnippetType.Clipboard) }) it("should allow all files when no ignore controller is provided", async () => { @@ -389,9 +371,7 @@ describe("AutocompleteContextProvider", () => { rootPathSnippets: [], recentlyEditedRangeSnippets: [], recentlyVisitedRangesSnippets: [], - diffSnippets: [], clipboardSnippets: [], - ideSnippets: [], staticSnippet: [], }) diff --git a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/getProcessedSnippets.ts b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/getProcessedSnippets.ts index 68b874a0b17..0e3900983e6 100644 --- a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/getProcessedSnippets.ts +++ b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/getProcessedSnippets.ts @@ -4,7 +4,6 @@ import { VsCodeIde } from "../continuedev/core/vscode-test-harness/src/VSCodeIde import { AutocompleteInput } from "../types" import { HelperVars } from "../continuedev/core/autocomplete/util/HelperVars" import { getAllSnippetsWithoutRace } from "../continuedev/core/autocomplete/snippets/getAllSnippets" -import { getDefinitionsFromLsp } from "../continuedev/core/vscode-test-harness/src/autocomplete/lsp" import { DEFAULT_AUTOCOMPLETE_OPTS } from "../continuedev/core/util/parameters" import { getSnippets } from "../continuedev/core/autocomplete/templating/filtering" import { FileIgnoreController } from "../shims/FileIgnoreController" @@ -97,7 +96,6 @@ export async function getProcessedSnippets( const snippetPayload = await getAllSnippetsWithoutRace({ helper, ide, - getDefinitionsFromLsp, contextRetrievalService: contextService, }) diff --git a/packages/kilo-vscode/src/services/autocomplete/context/VisibleCodeTracker.ts b/packages/kilo-vscode/src/services/autocomplete/context/VisibleCodeTracker.ts index dfe2b6a8f24..f1be8972335 100644 --- a/packages/kilo-vscode/src/services/autocomplete/context/VisibleCodeTracker.ts +++ b/packages/kilo-vscode/src/services/autocomplete/context/VisibleCodeTracker.ts @@ -24,8 +24,6 @@ import { extractDiffInfo as _extractDiffInfo } from "./visible-code-utils" const GIT_SCHEMES = ["git", "gitfs", "file", "vscode-remote"] export class VisibleCodeTracker { - private lastContext: VisibleCodeContext | null = null - constructor( private workspacePath: string, private ignoreController: FileIgnoreController | null = null, @@ -101,19 +99,10 @@ export class VisibleCodeTracker { }) } - this.lastContext = { + return { timestamp: Date.now(), editors: editorInfos, } - - return this.lastContext - } - - /** - * Returns the last captured context, or null if never captured. - */ - public getLastContext(): VisibleCodeContext | null { - return this.lastContext } /** diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/constants/AutocompleteLanguageInfo.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/constants/AutocompleteLanguageInfo.ts index 02f14eb85aa..aee32df9230 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/constants/AutocompleteLanguageInfo.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/constants/AutocompleteLanguageInfo.ts @@ -1,6 +1,15 @@ import { getUriFileExtension } from "../../util/uri" import { BracketMatchingService } from "../filtering/BracketMatchingService" -import { CharacterFilter, LineFilter } from "../filtering/streamTransforms/lineStream" + +type LineStream = AsyncGenerator +type LineFilter = (args: { lines: LineStream; fullStop: () => void }) => LineStream +type CharacterFilter = (args: { + chars: AsyncGenerator + prefix: string + suffix: string + filepath: string + multiline: boolean +}) => AsyncGenerator export interface AutocompleteLanguageInfo { /** diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/context/ranking/index.test.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/context/ranking/index.test.ts deleted file mode 100644 index a092a205af4..00000000000 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/context/ranking/index.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { describe, expect, it } from "vitest" -import { rankAndOrderSnippets, fillPromptWithSnippets } from "./index" -import { RankedSnippet } from "../../types" - -// vibecoded -describe("rankAndOrderSnippets", () => { - it("should rank and order snippets by similarity to cursor context", () => { - // Create a simple mock HelperVars with only the required properties - const mockHelper = { - fullPrefix: "function calculateTotal(items) {\n const total = items.reduce(", - fullSuffix: ", 0);\n return total;\n}", - options: { - slidingWindowSize: 50, - slidingWindowPrefixPercentage: 0.5, - }, - } as any - - // Create test snippets with different levels of similarity to the cursor context - const snippets: RankedSnippet[] = [ - { - filepath: "utils.ts", - range: { - start: { line: 10, character: 0 }, - end: { line: 12, character: 0 }, - }, - contents: "// Helper function for database queries\nfunction queryDb() {}", - }, - { - filepath: "math.ts", - range: { - start: { line: 5, character: 0 }, - end: { line: 7, character: 0 }, - }, - contents: "// Array reduce function\nconst sum = items.reduce((acc, item) => acc + item, 0);", - }, - { - filepath: "helpers.ts", - range: { - start: { line: 20, character: 0 }, - end: { line: 22, character: 0 }, - }, - contents: - "// Calculate total with reduce\nfunction calculateSum(items) {\n return items.reduce((a, b) => a + b);\n}", - }, - ] - - const result = rankAndOrderSnippets(snippets, mockHelper) - - // Verify the result has the expected structure - expect(result).toHaveLength(3) - - // All snippets should have scores assigned - expect(result.every((s) => typeof s.score === "number")).toBe(true) - - // All snippets should have required properties - result.forEach((snippet) => { - expect(snippet.filepath).toBeDefined() - expect(snippet.range).toBeDefined() - expect(snippet.contents).toBeDefined() - expect(snippet.score).toBeGreaterThanOrEqual(0) - }) - - // Scores should be in ascending order (lower scores = better matches) - for (let i = 0; i < result.length - 1; i++) { - expect(result[i].score).toBeLessThanOrEqual(result[i + 1].score) - } - }) -}) -// vibecoded - -describe("fillPromptWithSnippets", () => { - it("should fill token budget with snippets until limit is reached", () => { - // Create snippets with required properties (including score) - const snippets: Required[] = [ - { - filepath: "math.ts", - range: { - start: { line: 1, character: 0 }, - end: { line: 3, character: 0 }, - }, - contents: "function add(a, b) { return a + b; }", - score: 0.1, - }, - { - filepath: "utils.ts", - range: { - start: { line: 5, character: 0 }, - end: { line: 7, character: 0 }, - }, - contents: "function multiply(x, y) { return x * y; }", - score: 0.2, - }, - { - filepath: "helpers.ts", - range: { - start: { line: 10, character: 0 }, - end: { line: 15, character: 0 }, - }, - contents: "function calculateSum(items) {\n return items.reduce((acc, item) => acc + item, 0);\n}", - score: 0.3, - }, - ] - - // Set token limit to include first 2 snippets but not the third - // Using a model name and a reasonable token limit - const maxSnippetTokens = 25 - const modelName = "gpt-3.5-turbo" - - const result = fillPromptWithSnippets(snippets, maxSnippetTokens, modelName) - - // Verify that we got fewer snippets than we started with - expect(result.length).toBeLessThan(snippets.length) - expect(result.length).toBeGreaterThan(0) - - // Verify all returned snippets are from the original list - result.forEach((snippet) => { - expect(snippets).toContainEqual(snippet) - }) - - // Verify snippets maintain their order (first snippets are kept) - for (let i = 0; i < result.length; i++) { - expect(result[i]).toBe(snippets[i]) - } - }) -}) diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/context/ranking/index.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/context/ranking/index.ts index d0115a12f20..79095acb40d 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/context/ranking/index.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/context/ranking/index.ts @@ -1,133 +1,9 @@ -import { RangeInFileWithContents } from "../../../" -import { countTokens } from "../../../llm/countTokens" -import { RankedSnippet } from "../../types" -import { HelperVars } from "../../util/HelperVars" - const rx = /[\s.,/#!$%^&*;:{}=\-_`~()[\]]/g + export function getSymbolsForSnippet(snippet: string): Set { const symbols = snippet .split(rx) - .map((s) => s.trim()) - .filter((s) => s !== "") + .map((symbol) => symbol.trim()) + .filter((symbol) => symbol !== "") return new Set(symbols) } - -/** - * Calculate similarity as number of shared symbols divided by total number of unique symbols between both. - */ -function jaccardSimilarity(a: string, b: string): number { - const aSet = getSymbolsForSnippet(a) - const bSet = getSymbolsForSnippet(b) - const union = new Set([...aSet, ...bSet]).size - - // Avoid division by zero - if (union === 0) { - return 0 - } - - let intersection = 0 - for (const symbol of aSet) { - if (bSet.has(symbol)) { - intersection++ - } - } - - return intersection / union -} - -/** - * Rank code snippets to be used in tab-autocomplete prompt. Returns a sorted version of the snippet array. - */ -export function rankAndOrderSnippets(ranges: RankedSnippet[], helper: HelperVars): Required[] { - //MINIMAL_REPO - this isn't actually used in continue - const windowAroundCursor = - helper.fullPrefix.slice(-helper.options.slidingWindowSize * helper.options.slidingWindowPrefixPercentage) + - helper.fullSuffix.slice(helper.options.slidingWindowSize * (1 - helper.options.slidingWindowPrefixPercentage)) - - const snippets: Required[] = ranges.map((snippet) => ({ - score: snippet.score ?? jaccardSimilarity(snippet.contents, windowAroundCursor), - ...snippet, - })) - const uniqueSnippets = deduplicateSnippets(snippets) - return uniqueSnippets.sort((a, b) => a.score - b.score) -} - -/** - * Deduplicate code snippets by merging overlapping ranges into a single range. - */ -function deduplicateSnippets(snippets: Required[]): Required[] { - // Group by file - const fileGroups: { - [key: string]: Required[] - } = {} - for (const snippet of snippets) { - if (!fileGroups[snippet.filepath]) { - fileGroups[snippet.filepath] = [] - } - fileGroups[snippet.filepath].push(snippet) - } - - // Merge overlapping ranges - const allRanges = [] - for (const file of Object.keys(fileGroups)) { - allRanges.push(...mergeSnippetsByRange(fileGroups[file])) - } - return allRanges -} - -function mergeSnippetsByRange(snippets: Required[]): Required[] { - if (snippets.length <= 1) { - return snippets - } - - const sorted = snippets.sort((a, b) => a.range.start.line - b.range.start.line) - const merged: Required[] = [] - - while (sorted.length > 0) { - const next = sorted.shift()! - const last = merged[merged.length - 1] - if (merged.length > 0 && last.range.end.line >= next.range.start.line) { - // Merge with previous snippet - last.score = Math.max(last.score, next.score) - try { - last.range.end = next.range.end - } catch (e) { - console.log("Error merging ranges", e) - } - last.contents = mergeOverlappingRangeContents(last, next) - } else { - merged.push(next) - } - } - - return merged -} - -function mergeOverlappingRangeContents(first: RangeInFileWithContents, second: RangeInFileWithContents): string { - const firstLines = first.contents.split("\n") - const numOverlapping = first.range.end.line - second.range.start.line - return `${firstLines.slice(-numOverlapping).join("\n")}\n${second.contents}` -} - -/** - * Fill the allowed space with snippets. - * It is assumed that the snippets are sorted by score. - */ -export function fillPromptWithSnippets( //MINIMAL_REPO - this isn't actually used in continue - snippets: Required[], - maxSnippetTokens: number, - modelName: string, -): Required[] { - let tokensRemaining = maxSnippetTokens - const keptSnippets: Required[] = [] - for (let i = 0; i < snippets.length; i++) { - const snippet = snippets[i] - const tokenCount = countTokens(snippet.contents, modelName) - if (tokensRemaining - tokenCount >= 0) { - tokensRemaining -= tokenCount - keptSnippets.push(snippet) - } - } - - return keptSnippets -} diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/context/static-context/StaticContextService.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/context/static-context/StaticContextService.ts index 3a3609ccaf2..894205a7d51 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/context/static-context/StaticContextService.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/context/static-context/StaticContextService.ts @@ -48,31 +48,6 @@ export class StaticContextService { }) } - public static formatAutocompleteStaticSnippet(ctx: StaticContext): string { - let output = `AutocompleteStaticSnippet:\n` - output += ` holeType: ${ctx.holeType}\n` - - output += ` relevantTypes:\n` - if (ctx.relevantTypes.size === 0) { - output += ` (none)\n` - } else { - ctx.relevantTypes.forEach((types, filepath) => { - output += ` ${filepath}: [${types.join(", ")}]\n` - }) - } - - output += ` relevantHeaders:\n` - if (ctx.relevantHeaders.size === 0) { - output += ` (none)\n` - } else { - ctx.relevantHeaders.forEach((headers, filepath) => { - output += ` ${filepath}: [${headers.join(", ")}]\n` - }) - } - - return output - } - public async getContext(helper: HelperVars): Promise { const tsFiles = await this.getTypeScriptFilesFromWorkspaces(helper.workspaceUris) // Get the three contexts holeContext, relevantTypes, relevantHeaders. diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/filtering/BracketMatchingService.test.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/filtering/BracketMatchingService.test.ts index 6a205b77e51..bce4ab033c6 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/filtering/BracketMatchingService.test.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/filtering/BracketMatchingService.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect, beforeEach } from "vitest" -import { BracketMatchingService, BRACKETS, BRACKETS_REVERSE } from "./BracketMatchingService" +import { beforeEach, describe, expect, it } from "vitest" +import { BRACKETS, BRACKETS_REVERSE, BracketMatchingService } from "./BracketMatchingService" describe("BracketMatchingService", () => { let service: BracketMatchingService @@ -8,300 +8,93 @@ describe("BracketMatchingService", () => { service = new BracketMatchingService() }) - describe("BRACKETS constants", () => { - it("should have correct opening-to-closing bracket mappings", () => { - expect(BRACKETS["("]).toBe(")") - expect(BRACKETS["{"]).toBe("}") - expect(BRACKETS["["]).toBe("]") - }) - - it("should have correct closing-to-opening bracket mappings", () => { - expect(BRACKETS_REVERSE[")"]).toBe("(") - expect(BRACKETS_REVERSE["}"]).toBe("{") - expect(BRACKETS_REVERSE["]"]).toBe("[") - }) + it("defines matching bracket pairs", () => { + expect(BRACKETS).toEqual({ "(": ")", "{": "}", "[": "]" }) + expect(BRACKETS_REVERSE).toEqual({ ")": "(", "}": "{", "]": "[" }) }) - describe("handleAcceptedCompletion", () => { - it("should track unmatched opening brackets from completion", () => { - service.handleAcceptedCompletion("function test() {", "test.ts") - // Internal state should track the unmatched opening brackets - // We can verify this by checking behavior in subsequent calls - }) + async function* stream(chunks: string[]): AsyncGenerator { + for (const chunk of chunks) yield chunk + } - it("should handle matched bracket pairs correctly", () => { - service.handleAcceptedCompletion("function test() { return 1; }", "test.ts") - // All brackets are matched, so stack should be empty - }) + async function collect(gen: AsyncGenerator): Promise { + const chunks: string[] = [] + for await (const chunk of gen) chunks.push(chunk) + return chunks.join("") + } - it("should handle multiple unmatched opening brackets", () => { - service.handleAcceptedCompletion("if (condition) { while (true) {", "test.ts") - // Should track both unmatched { brackets - }) - - it("should handle nested bracket structures", () => { - service.handleAcceptedCompletion("arr[0] = { key: [1, 2]", "test.ts") - // Should track unmatched { and [ - }) - - it("should stop tracking when encountering unmatched closing bracket", () => { - service.handleAcceptedCompletion("function test() { } }", "test.ts") - // Should stop when encountering the extra closing brace - }) - - it("should handle different bracket types", () => { - service.handleAcceptedCompletion("const obj = { arr: [1, (2", "test.ts") - // Should track {, [, and ( - }) - - it("should reset state for each new completion", () => { - service.handleAcceptedCompletion("function test() {", "test.ts") - service.handleAcceptedCompletion("class MyClass {", "test.ts") - // Second call should reset state from first call - }) - - it("should update filepath tracking", () => { - service.handleAcceptedCompletion("function a() {", "file1.ts") - service.handleAcceptedCompletion("function b() {", "file2.ts") - // Should track that we're now in file2.ts - }) - - it("should handle empty completion string", () => { - service.handleAcceptedCompletion("", "test.ts") - // Should not throw and should have empty stack - }) - - it("should handle completion with only text and no brackets", () => { - service.handleAcceptedCompletion("const x = 5;", "test.ts") - // Should complete successfully with empty bracket stack - }) - - it("should handle complex nested structure", () => { - service.handleAcceptedCompletion("obj = { a: [1, { b: (x", "test.ts") - // Should track {, [, {, ( - }) + it("allows a matching single-line closing bracket", async () => { + const result = service.stopOnUnmatchedClosingBracket( + stream(["x + 1)"]), + "const result = calculate(", + ");", + "test.ts", + false, + ) + expect(await collect(result)).toBe("x + 1)") }) - describe("stopOnUnmatchedClosingBracket", () => { - // Helper function to create async generator from array - async function* arrayToAsyncGen(arr: string[]): AsyncGenerator { - for (const item of arr) { - yield item - } - } + it("stops at an unmatched single-line closing bracket", async () => { + const result = service.stopOnUnmatchedClosingBracket( + stream(["x + 1))"]), + "const result = calculate(", + "", + "test.ts", + false, + ) + expect(await collect(result)).toBe("x + 1)") + }) - // Helper to collect all values from async generator - async function collectAll(gen: AsyncGenerator): Promise { - const results: string[] = [] - for await (const item of gen) { - results.push(item) - } - return results - } + it("tracks brackets opened by the current stream", async () => { + const result = service.stopOnUnmatchedClosingBracket( + stream(["function test() {", "\n return 1;", "\n}"]), + "", + "", + "test.ts", + true, + ) + expect(await collect(result)).toBe("function test() {\n return 1;\n}") + }) - describe("multiline mode", () => { - it("should allow closing brackets that match previous completion", async () => { - service.handleAcceptedCompletion("function test() {", "test.ts") - const stream = arrayToAsyncGen(["\n return 1;\n}"]) - const filtered = service.stopOnUnmatchedClosingBracket(stream, "function test() ", "", "test.ts", true) - const result = await collectAll(filtered) - expect(result.join("")).toBe("\n return 1;\n}") - }) + it("stops at an unmatched multiline closing bracket", async () => { + const result = service.stopOnUnmatchedClosingBracket( + stream(["function test() {\n return 1;\n}\n}"]), + "", + "", + "test.ts", + true, + ) + expect(await collect(result)).toBe("function test() {\n return 1;\n}\n") + }) - it("should not use previous completion state from different file", async () => { - service.handleAcceptedCompletion("function test() {", "file1.ts") - const stream = arrayToAsyncGen(["}"]) - const filtered = service.stopOnUnmatchedClosingBracket(stream, "", "", "file2.ts", true) - const result = await collectAll(filtered) - // Different file so stack is empty, but '}' is in whitespace section - // Whitespace section (closing brackets before non-whitespace) yields without checking - // Since '}' doesn't match /[^\s\)\}\]]/, it's all whitespace/closing brackets - // So entire chunk is yielded and loop continues to end - expect(result.join("")).toBe("}") - }) + it("handles nested bracket types", async () => { + const result = service.stopOnUnmatchedClosingBracket(stream(["arr[i][j]"]), "const val = ", ";", "test.ts", false) + expect(await collect(result)).toBe("arr[i][j]") + }) - it("should stop on unmatched closing bracket in multiline", async () => { - const stream = arrayToAsyncGen(["function test() {\n return 1;\n}\n}"]) - const filtered = service.stopOnUnmatchedClosingBracket(stream, "", "", "test.ts", true) - const result = await collectAll(filtered) - // The chunk contains one complete function and one extra '}' - // Processing char by char: '{' at position 16 opens, '}' at position 32 closes (stack empty) - // '\n' at position 33, then '}' at position 34 is unmatched (stack empty) - // Yields chunk.slice(0, 34) which includes the newline after the first } - expect(result.join("")).toBe("function test() {\n return 1;\n}\n") - }) + it("uses closing brackets from a whitespace-prefixed suffix", async () => { + const result = service.stopOnUnmatchedClosingBracket(stream(["1, 2, 3)"]), "func(", " )", "test.ts", false) + expect(await collect(result)).toBe("1, 2, 3)") + }) - it("should handle multiple chunks in stream", async () => { - const stream = arrayToAsyncGen(["function", " test()", " {", "\n return", " 1;", "\n}"]) - const filtered = service.stopOnUnmatchedClosingBracket(stream, "", "", "test.ts", true) - const result = await collectAll(filtered) - expect(result.join("")).toBe("function test() {\n return 1;\n}") - }) - }) + it("stops suffix bracket parsing at other content", async () => { + const result = service.stopOnUnmatchedClosingBracket(stream(["x)"]), "func(", ") {", "test.ts", false) + expect(await collect(result)).toBe("x") + }) - describe("single-line mode", () => { - it("should allow completing brackets from current line", async () => { - const stream = arrayToAsyncGen(["x + 1)"]) - const filtered = service.stopOnUnmatchedClosingBracket( - stream, - "const result = calculate(", - ");", - "test.ts", - false, - ) - const result = await collectAll(filtered) - expect(result.join("")).toBe("x + 1)") - }) + it("handles a closing bracket at a chunk boundary", async () => { + const result = service.stopOnUnmatchedClosingBracket( + stream(["return 1", ";", "\n", "}", "extra"]), + "function test() {", + "", + "test.ts", + true, + ) + expect(await collect(result)).toBe("return 1;\n") + }) - it("should handle bracket in suffix that gets overwritten", async () => { - const stream = arrayToAsyncGen(["1, 2, 3)"]) - const filtered = service.stopOnUnmatchedClosingBracket(stream, "array.push(", ");", "test.ts", false) - const result = await collectAll(filtered) - // Should allow the closing paren because suffix has one - expect(result.join("")).toBe("1, 2, 3)") - }) - - it("should stop on unmatched closing bracket in single-line", async () => { - const stream = arrayToAsyncGen(["x + 1))"]) - const filtered = service.stopOnUnmatchedClosingBracket( - stream, - "const result = calculate(", - "", - "test.ts", - false, - ) - const result = await collectAll(filtered) - expect(result.join("")).toBe("x + 1)") - }) - - it("should handle multiple bracket pairs on current line", async () => { - const stream = arrayToAsyncGen(['" + y + ")])']) - const filtered = service.stopOnUnmatchedClosingBracket( - stream, - 'array.push({ key: getValue("x', - "", - "test.ts", - false, - ) - const result = await collectAll(filtered) - // Current line has: { ( ( - // Stream closes: ) ] ) - // First ) matches third (, second ] doesn't match second ( (expects }), stops before ] - expect(result.join("")).toBe('" + y + ")') - }) - }) - - describe("edge cases", () => { - it("should handle empty stream", async () => { - const stream = arrayToAsyncGen([]) - const filtered = service.stopOnUnmatchedClosingBracket(stream, "", "", "test.ts", true) - const result = await collectAll(filtered) - expect(result).toEqual([]) - }) - - it("should handle stream with only whitespace before brackets", async () => { - const stream = arrayToAsyncGen([" \n }"]) - const filtered = service.stopOnUnmatchedClosingBracket(stream, "function test() {", "", "test.ts", true) - const result = await collectAll(filtered) - expect(result.join("")).toBe(" \n }") - }) - - it("should allow closing brackets before non-whitespace content", async () => { - const stream = arrayToAsyncGen([")\n const x = 1;"]) - const filtered = service.stopOnUnmatchedClosingBracket(stream, "function test(", "", "test.ts", true) - const result = await collectAll(filtered) - // In multiline mode with no previous completion, stack starts empty but prefix has '(' - // Actually, prefix is NOT processed in multiline mode (only previous completion state) - // Whitespace section: searches for /[^\s\)\}\]]/, finds 'c' at index 7 - // Yields ')\n ' (everything before 'c'), then processes 'const x = 1;' - // 'const x = 1;' has no brackets, yields entire remaining chunk - expect(result.join("")).toBe(")\n const x = 1;") - }) - - it("should handle mixed bracket types correctly", async () => { - const stream = arrayToAsyncGen(["]})"]) - const filtered = service.stopOnUnmatchedClosingBracket(stream, "obj = { arr: [{ key: val", "", "test.ts", true) - const result = await collectAll(filtered) - expect(result.join("")).toBe("]})") - }) - - it("should handle suffix with spaces before closing bracket", async () => { - const stream = arrayToAsyncGen(["1, 2, 3)"]) - const filtered = service.stopOnUnmatchedClosingBracket(stream, "func(", " )", "test.ts", false) - const result = await collectAll(filtered) - // Spaces in suffix should be ignored, bracket should be added to stack - expect(result.join("")).toBe("1, 2, 3)") - }) - - it("should stop when suffix parsing ends at non-bracket", async () => { - const stream = arrayToAsyncGen(["x)"]) - const filtered = service.stopOnUnmatchedClosingBracket(stream, "func(", ") {", "test.ts", false) - const result = await collectAll(filtered) - // In single-line mode, current line is 'func() {' - // Stack from current line: ( opens, ) closes (matches), { opens -> stack = ['{'] - // Suffix ') {': unshift adds '(' to FRONT of stack -> stack = ['(', '{'] - // Stream 'x)': ')' is closing bracket - // stack.pop() removes from END, returns '{', BRACKETS['{'] = '}', char = ')' - // '}' !== ')' so condition is true, stops and yields 'x' - expect(result.join("")).toBe("x") - }) - - it("should handle chunk boundary on closing bracket", async () => { - const stream = arrayToAsyncGen(["return 1", ";", "\n", "}", "extra"]) - const filtered = service.stopOnUnmatchedClosingBracket(stream, "function test() {", "", "test.ts", true) - const result = await collectAll(filtered) - // In multiline mode without previous completion state, stack starts empty - // Prefix doesn't add to stack in multiline mode - // First chunk 'return 1' has no brackets, yielded - // Second chunk ';' has no brackets, yielded - // Third chunk '\n' is whitespace with no brackets, still in whitespace section - // Fourth chunk '}' is closing bracket in whitespace section, but stack is empty so stops immediately - expect(result.join("")).toBe("return 1;\n") - }) - - it("should handle nested brackets in stream", async () => { - const stream = arrayToAsyncGen(["arr[i][j]"]) - const filtered = service.stopOnUnmatchedClosingBracket(stream, "const val = ", ";", "test.ts", false) - const result = await collectAll(filtered) - expect(result.join("")).toBe("arr[i][j]") - }) - - it("should handle unmatched opening brackets in stream", async () => { - const stream = arrayToAsyncGen(["arr[index"]) - const filtered = service.stopOnUnmatchedClosingBracket(stream, "", "", "test.ts", true) - const result = await collectAll(filtered) - expect(result.join("")).toBe("arr[index") - }) - }) - - describe("state persistence across completions", () => { - it("should use state from previous completion in same file", async () => { - service.handleAcceptedCompletion("if (cond) {\n while (true) {", "test.ts") - const stream = arrayToAsyncGen(["\n doWork();\n }\n}"]) - const filtered = service.stopOnUnmatchedClosingBracket(stream, "", "", "test.ts", true) - const result = await collectAll(filtered) - expect(result.join("")).toBe("\n doWork();\n }\n}") - }) - - it("should not use state from previous file", async () => { - service.handleAcceptedCompletion("if (cond) {", "file1.ts") - const stream = arrayToAsyncGen(["}"]) - const filtered = service.stopOnUnmatchedClosingBracket(stream, "", "", "file2.ts", true) - const result = await collectAll(filtered) - // Different file so stack is empty, but '}' is in whitespace section - // Since '}' doesn't match /[^\s\)\}\]]/, entire chunk yielded without bracket checking - expect(result.join("")).toBe("}") - }) - - it("should clear state when switching files", async () => { - service.handleAcceptedCompletion("function a() {", "file1.ts") - service.handleAcceptedCompletion("function b() {", "file2.ts") - const stream = arrayToAsyncGen(["\n return;\n}"]) - const filtered = service.stopOnUnmatchedClosingBracket(stream, "", "", "file2.ts", true) - const result = await collectAll(filtered) - // Should only allow one closing brace from file2's state - expect(result.join("")).toBe("\n return;\n}") - }) - }) + it("handles an empty stream", async () => { + const result = service.stopOnUnmatchedClosingBracket(stream([]), "", "", "test.ts", true) + expect(await collect(result)).toBe("") }) }) diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/filtering/BracketMatchingService.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/filtering/BracketMatchingService.ts index 8ad73dda990..f46f287c1ca 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/filtering/BracketMatchingService.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/filtering/BracketMatchingService.ts @@ -13,61 +13,27 @@ export const BRACKETS_REVERSE: { [key: string]: string } = { * But sometimes we started the pair in a previous autocomplete suggestion */ export class BracketMatchingService { - private openingBracketsFromLastCompletion: string[] = [] - private lastCompletionFile: string | undefined = undefined - - handleAcceptedCompletion(completion: string, filepath: string) { - this.openingBracketsFromLastCompletion = [] - const stack: string[] = [] - - for (let i = 0; i < completion.length; i++) { - const char = completion[i] - if (Object.keys(BRACKETS).includes(char)) { - // It's an opening bracket - stack.push(char) - } else if (Object.values(BRACKETS).includes(char)) { - // It's a closing bracket - if (stack.length === 0 || BRACKETS[stack.pop()!] !== char) { - break - } - } - } - - // Any remaining opening brackets in the stack are uncompleted - this.openingBracketsFromLastCompletion = stack - this.lastCompletionFile = filepath - } - async *stopOnUnmatchedClosingBracket( stream: AsyncGenerator, prefix: string, suffix: string, - filepath: string, + _filepath: string, multiline: boolean, // Whether this is a multiline completion or not ): AsyncGenerator { - let stack: string[] = [] - if (multiline) { - // Add opening brackets from the previous response - if (this.lastCompletionFile === filepath) { - stack = [...this.openingBracketsFromLastCompletion] - } else { - this.lastCompletionFile = undefined - } - } else { + const stack: string[] = [] + if (!multiline) { // If single line completion, then allow completing bracket pairs that are // started on the current line but not finished on the current line - if (!multiline) { - const currentLine = (prefix.split("\n").pop() ?? "") + (suffix.split("\n")[0] ?? "") - for (let i = 0; i < currentLine.length; i++) { - const char = currentLine[i] - if (Object.keys(BRACKETS).includes(char)) { - // It's an opening bracket - stack.push(char) - } else if (Object.values(BRACKETS).includes(char)) { - // It's a closing bracket - if (stack.length === 0 || BRACKETS[stack.pop()!] !== char) { - break - } + const currentLine = (prefix.split("\n").pop() ?? "") + (suffix.split("\n")[0] ?? "") + for (let i = 0; i < currentLine.length; i++) { + const char = currentLine[i] + if (Object.keys(BRACKETS).includes(char)) { + // It's an opening bracket + stack.push(char) + } else if (Object.values(BRACKETS).includes(char)) { + // It's a closing bracket + if (stack.length === 0 || BRACKETS[stack.pop()!] !== char) { + break } } } diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/filtering/streamTransforms/lineStream.test.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/filtering/streamTransforms/lineStream.test.ts deleted file mode 100644 index c89b6bddf35..00000000000 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/filtering/streamTransforms/lineStream.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { beforeEach, describe, expect, it, Mock, vi } from "vitest" - -import { - avoidPathLine, - avoidEmptyComments, - streamWithNewLines, - lineIsRepeated, - stopAtSimilarLine, - stopAtLines, - LINES_TO_STOP_AT, - PREFIXES_TO_SKIP, - skipPrefixes, - stopAtRepeatingLines, -} from "./lineStream" - -describe("lineStream (production-used subset)", () => { - let mockFullStop: Mock - - async function getLineGenerator(lines: any[]) { - return (async function* () { - for (const line of lines) { - yield line - } - })() - } - - async function getFilteredLines(results: AsyncGenerator) { - const output: string[] = [] - for await (const line of results) { - output.push(line) - } - return output - } - - beforeEach(() => { - mockFullStop = vi.fn() - }) - - describe("avoidPathLine", () => { - it("filters out '// Path: ...' lines", async () => { - const linesGenerator = await getLineGenerator(["// Path: src/index.ts", "const x = 5;", "//", "console.log(x);"]) - const result = avoidPathLine(linesGenerator, "//") - const filteredLines = await getFilteredLines(result) - expect(filteredLines).toEqual(["const x = 5;", "//", "console.log(x);"]) - }) - }) - - describe("avoidEmptyComments", () => { - it("filters out empty comment-only lines", async () => { - const linesGenerator = await getLineGenerator(["// Path: src/index.ts", "const x = 5;", "//", "console.log(x);"]) - const result = avoidEmptyComments(linesGenerator, "//") - const filteredLines = await getFilteredLines(result) - expect(filteredLines).toEqual(["// Path: src/index.ts", "const x = 5;", "console.log(x);"]) - }) - }) - - describe("streamWithNewLines", () => { - it("adds newline separators between lines", async () => { - const linesGenerator = await getLineGenerator(["line1", "line2", "line3"]) - const result = streamWithNewLines(linesGenerator) - const filteredLines = await getFilteredLines(result) - expect(filteredLines).toEqual(["line1", "\n", "line2", "\n", "line3"]) - }) - }) - - describe("lineIsRepeated", () => { - it("returns true for similar lines", () => { - expect(lineIsRepeated("const x = 5;", "const x = 6;")).toBe(true) - }) - - it("returns false for different lines", () => { - expect(lineIsRepeated("const x = 5;", "let y = 10;")).toBe(false) - }) - - it("returns false for short lines", () => { - expect(lineIsRepeated("x=5", "x=6")).toBe(false) - }) - }) - - describe("stopAtSimilarLine", () => { - it("stops at the exact same line", async () => { - const lineToTest = "const x = 6" - const linesGenerator = await getLineGenerator(["console.log();", "const y = () => {};", lineToTest]) - - const result = stopAtSimilarLine(linesGenerator, lineToTest, mockFullStop) - const filteredLines = await getFilteredLines(result) - - expect(filteredLines).toEqual(["console.log();", "const y = () => {};"]) - expect(mockFullStop).toHaveBeenCalledTimes(1) - }) - - it("stops at a similar line", async () => { - const lineToTest = "const x = 6;" - const linesGenerator = await getLineGenerator(["console.log();", "const y = () => {};", lineToTest]) - - const result = stopAtSimilarLine(linesGenerator, "a" + lineToTest, mockFullStop) - const filteredLines = await getFilteredLines(result) - - expect(filteredLines).toEqual(["console.log();", "const y = () => {};"]) - expect(mockFullStop).toHaveBeenCalledTimes(1) - }) - - it("continues on bracket-ending lines", async () => { - const linesGenerator = await getLineGenerator([" if (x > 0) {", " console.log(x);", " }"]) - - const result = stopAtSimilarLine(linesGenerator, "}", mockFullStop) - const filteredLines = await getFilteredLines(result) - - expect(filteredLines).toEqual([" if (x > 0) {", " console.log(x);", " }"]) - expect(mockFullStop).toHaveBeenCalledTimes(0) - }) - }) - - describe("stopAtLines", () => { - it("stops at specified lines", async () => { - const linesGenerator = await getLineGenerator([ - "const x = 5;", - "let y = 10;", - LINES_TO_STOP_AT[0], - "const z = 15;", - ]) - - const result = stopAtLines(linesGenerator, mockFullStop) - const filteredLines = await getFilteredLines(result) - - expect(filteredLines).toEqual(["const x = 5;", "let y = 10;"]) - expect(mockFullStop).toHaveBeenCalledTimes(1) - }) - - it("stops when stop phrase has leading whitespace", async () => { - const linesGenerator = await getLineGenerator([ - "const x = 5;", - "let y = 10;", - ` ${LINES_TO_STOP_AT[0]}`, - "const z = 15;", - ]) - - const result = stopAtLines(linesGenerator, mockFullStop) - const filteredLines = await getFilteredLines(result) - - expect(filteredLines).toEqual(["const x = 5;", "let y = 10;"]) - expect(mockFullStop).toHaveBeenCalledTimes(1) - }) - }) - - describe("skipPrefixes", () => { - it("skips configured prefixes on the first line", async () => { - const linesGenerator = await getLineGenerator([`${PREFIXES_TO_SKIP[0]}const x = 5;`, "let y = 10;"]) - - const result = skipPrefixes(linesGenerator) - const filteredLines = await getFilteredLines(result) - - expect(filteredLines).toEqual(["const x = 5;", "let y = 10;"]) - }) - }) - - describe("stopAtRepeatingLines", () => { - it("yields non-repeating lines and does not stop prematurely", async () => { - const linesGenerator = await getLineGenerator(["a", "b", "c", "d", "e"]) - - const result = stopAtRepeatingLines(linesGenerator as any, mockFullStop) - const filteredLines = await getFilteredLines(result as any) - - expect(filteredLines).toEqual(["a", "b", "c", "d", "e"]) - expect(mockFullStop).not.toHaveBeenCalled() - }) - - it("stops when a line repeats 3 times consecutively", async () => { - const linesGenerator = await getLineGenerator(["x", "x", "x", "x", "after"]) - - const result = stopAtRepeatingLines(linesGenerator as any, mockFullStop) - const filteredLines = await getFilteredLines(result as any) - - // Only the first of the repeating lines is yielded - expect(filteredLines).toEqual(["x"]) - expect(mockFullStop).toHaveBeenCalledTimes(1) - }) - }) -}) diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/filtering/streamTransforms/lineStream.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/filtering/streamTransforms/lineStream.ts deleted file mode 100644 index a4cc5e15265..00000000000 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/filtering/streamTransforms/lineStream.ts +++ /dev/null @@ -1,219 +0,0 @@ -import type { LineStream } from "../../../diff/util" -import { lineIsRepeated } from "../../util/textSimilarity" - -export { lineIsRepeated } - -export type LineFilter = (args: { lines: LineStream; fullStop: () => void }) => LineStream - -export type CharacterFilter = (args: { - chars: AsyncGenerator - prefix: string - suffix: string - filepath: string - multiline: boolean -}) => AsyncGenerator - -const BRACKET_ENDING_CHARS = [")", "]", "}", ";"] -export const PREFIXES_TO_SKIP = [""] -export const LINES_TO_STOP_AT = ["# End of file.", "", "```"] - -function isBracketEnding(line: string): boolean { - return line - .trim() - .split("") - .some((char) => BRACKET_ENDING_CHARS.includes(char)) -} - -/** - * Validate whether a stop pattern in a line is in a valid context (not inside quotes or identifiers) - * and capture the text before the pattern. - * Internal helper for stopAtLines. - */ -function validatePatternInLine( - line: string, - pattern: string, -): { - isValid: boolean - patternIndex: number - beforePattern: string -} { - const patternIndex = line.indexOf(pattern) - - if (patternIndex === -1) { - return { isValid: false, patternIndex: -1, beforePattern: "" } - } - - // If preceded by a non-whitespace, treat as part of an identifier - if (patternIndex > 0) { - const charBefore = line[patternIndex - 1] - if (charBefore && !charBefore.match(/\s/)) { - return { isValid: false, patternIndex, beforePattern: "" } - } - } - - const beforePattern = line.substring(0, patternIndex) - const singleQuotes = (beforePattern.match(/'/g) || []).length - const doubleQuotes = (beforePattern.match(/"/g) || []).length - - // Odd number of quotes before the pattern - likely inside quotes - if (singleQuotes % 2 !== 0 || doubleQuotes % 2 !== 0) { - return { isValid: false, patternIndex, beforePattern } - } - - return { isValid: true, patternIndex, beforePattern } -} - -/** - * Filter out lines starting with "// Path: " which models sometimes echo. - */ -export async function* avoidPathLine(stream: LineStream, comment?: string): LineStream { - for await (const line of stream) { - if (comment && line.startsWith(`${comment} Path: `)) { - continue - } - yield line - } -} - -/** - * Filter out empty comment-only lines. - */ -export async function* avoidEmptyComments(stream: LineStream, comment?: string): LineStream { - for await (const line of stream) { - if (!comment || line.trim() !== comment) { - yield line - } - } -} - -/** - * Insert "\n" separators between streamed lines. - */ -export async function* streamWithNewLines(stream: LineStream): LineStream { - let firstLine = true - for await (const nextLine of stream) { - if (!firstLine) { - yield "\n" - } - firstLine = false - yield nextLine - } -} - -/** - * Yield until a line equals or is very similar to the provided line, then call fullStop. - * If the provided line ends with a bracket/semicolon, allow exact trimmed matches to pass through. - */ -export async function* stopAtSimilarLine( - stream: LineStream, - line: string, - fullStop: () => void, -): AsyncGenerator { - const trimmedLine = line.trim() - const lineIsBracketEnding = isBracketEnding(trimmedLine) - - for await (const nextLine of stream) { - if (trimmedLine === "") { - yield nextLine - continue - } - - if (lineIsBracketEnding && trimmedLine === nextLine.trim()) { - yield nextLine - continue - } - - if (nextLine === line) { - fullStop() - break - } - - if (lineIsRepeated(nextLine, trimmedLine)) { - fullStop() - break - } - - yield nextLine - } -} - -/** - * Yield until any of the stop phrases is encountered in a valid context, then call fullStop. - */ -export async function* stopAtLines( - stream: LineStream, - fullStop: () => void, - linesToStopAt: string[] = LINES_TO_STOP_AT, -): LineStream { - for await (const line of stream) { - let shouldStop = false - - for (const stopAt of linesToStopAt) { - if (line.includes(stopAt)) { - const validation = validatePatternInLine(line, stopAt) - if (!validation.isValid) { - continue - } - - const trimmedLine = line.trimStart() - if (trimmedLine.startsWith(stopAt)) { - shouldStop = true - break - } else { - const contentBeforeStopPhrase = validation.beforePattern.trimEnd() - if (contentBeforeStopPhrase.length < validation.beforePattern.length) { - shouldStop = true - break - } - } - } - } - - if (shouldStop) { - fullStop() - break - } - yield line - } -} - -/** - * On the first line only, strip any configured prefix (e.g. ""). - */ -export async function* skipPrefixes(lines: LineStream): LineStream { - let isFirstLine = true - for await (const line of lines) { - if (isFirstLine) { - const match = PREFIXES_TO_SKIP.find((prefix) => line.startsWith(prefix)) - if (match) { - yield line.slice(match.length) - continue - } - isFirstLine = false - } - yield line - } -} - -/** - * Yield lines until a line repeats 3 times consecutively. Only the first of the repeats is yielded. - */ -export async function* stopAtRepeatingLines(lines: LineStream, fullStop: () => void): LineStream { - let previousLine: string | undefined - let repeatCount = 0 - const MAX_REPEATS = 3 - - for await (const line of lines) { - if (line === previousLine) { - repeatCount++ - if (repeatCount === MAX_REPEATS) { - fullStop() - return - } - } else { - yield line - repeatCount = 1 - } - previousLine = line - } -} diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/snippets/getAllSnippets.test.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/snippets/getAllSnippets.test.ts index c47bcf76619..93abefa3cd0 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/snippets/getAllSnippets.test.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/snippets/getAllSnippets.test.ts @@ -1,450 +1,111 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" -import { getAllSnippets, getAllSnippetsWithoutRace } from "./getAllSnippets" +import { beforeEach, describe, expect, it, vi } from "vitest" +import type { ContextRetrievalService } from "../context/ContextRetrievalService" import { AutocompleteSnippetType } from "../types" import type { HelperVars } from "../util/HelperVars" import type { IDE } from "../../index" -import type { GetLspDefinitionsFunction } from "../types" -import type { ContextRetrievalService } from "../context/ContextRetrievalService" +import { getAllSnippetsWithoutRace } from "./getAllSnippets" -describe("getAllSnippets", () => { - let mockHelper: HelperVars - let mockIde: IDE - let mockGetDefinitionsFromLsp: GetLspDefinitionsFunction - let mockContextRetrievalService: ContextRetrievalService +describe("getAllSnippetsWithoutRace", () => { + let helper: HelperVars + let ide: IDE + let context: ContextRetrievalService beforeEach(() => { - // Create mock helper with minimal required properties - mockHelper = { + helper = { input: { filepath: "/test/file.ts", - recentlyEditedRanges: [ - { - filepath: "/test/recent.ts", - lines: ["const x = 1;", "const y = 2;"], - }, - ], + recentlyEditedRanges: [{ filepath: "/test/recent.ts", lines: ["const x = 1;"] }], recentlyVisitedRanges: [ - { - filepath: "/test/visited.ts", - content: "visited content", - type: AutocompleteSnippetType.Code, - }, + { filepath: "/test/visited.ts", content: "visited", type: AutocompleteSnippetType.Code }, ], }, filepath: "/test/file.ts", - fullPrefix: "const result = ", - fullSuffix: ";", - lang: "typescript", options: { - onlyMyCode: false, useRecentlyEdited: true, useRecentlyOpened: true, experimental_enableStaticContextualization: false, }, - } as any - - // Create mock IDE - mockIde = { - getWorkspaceDirs: vi.fn().mockResolvedValue(["/test"]), - getClipboardContent: vi.fn().mockResolvedValue({ - text: "clipboard content", - copiedAt: "2024-01-01T00:00:00.000Z", - }), + } as HelperVars + ide = { + getClipboardContent: vi.fn().mockResolvedValue({ text: "clipboard", copiedAt: "2024-01-01" }), readFile: vi.fn().mockResolvedValue("file content"), - } as any - - // Create mock LSP function - mockGetDefinitionsFromLsp = vi.fn().mockResolvedValue([]) - - // Create mock context retrieval service - mockContextRetrievalService = { + } as unknown as IDE + context = { getRootPathSnippets: vi.fn().mockResolvedValue([]), getSnippetsFromImportDefinitions: vi.fn().mockResolvedValue([]), getStaticContextSnippets: vi.fn().mockResolvedValue([]), - } as any + } as unknown as ContextRetrievalService }) - afterEach(() => { - vi.clearAllMocks() - }) + it("collects every active snippet source", async () => { + const result = await getAllSnippetsWithoutRace({ helper, ide, contextRetrievalService: context }) - describe("getAllSnippets with race conditions", () => { - it("should return all snippet types", async () => { - const result = await getAllSnippets({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, - }) - - expect(result).toHaveProperty("rootPathSnippets") - expect(result).toHaveProperty("importDefinitionSnippets") - expect(result).toHaveProperty("ideSnippets") - expect(result).toHaveProperty("recentlyEditedRangeSnippets") - expect(result).toHaveProperty("diffSnippets") - expect(result).toHaveProperty("clipboardSnippets") - expect(result).toHaveProperty("recentlyVisitedRangesSnippets") - expect(result).toHaveProperty("recentlyOpenedFileSnippets") - expect(result).toHaveProperty("staticSnippet") - }) - - it("should collect recently edited snippets synchronously", async () => { - const result = await getAllSnippets({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, - }) - - expect(result.recentlyEditedRangeSnippets).toHaveLength(1) - expect(result.recentlyEditedRangeSnippets[0]).toEqual({ - filepath: "/test/recent.ts", - content: "const x = 1;\nconst y = 2;", - type: AutocompleteSnippetType.Code, - }) - }) - - it("should pass through recently visited ranges", async () => { - const result = await getAllSnippets({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, - }) - - expect(result.recentlyVisitedRangesSnippets).toEqual(mockHelper.input.recentlyVisitedRanges) - }) - - it("should timeout slow snippet sources after default 100ms", async () => { - // Mock a slow service that takes 200ms - mockContextRetrievalService.getRootPathSnippets = vi.fn().mockImplementation( - () => - new Promise((resolve) => { - setTimeout( - () => - resolve([ - { - filepath: "/slow.ts", - content: "slow", - type: AutocompleteSnippetType.Code, - }, - ]), - 200, - ) - }), - ) - - const startTime = Date.now() - const result = await getAllSnippets({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, - }) - const duration = Date.now() - startTime - - // Should timeout and return empty array, not wait 200ms - expect(result.rootPathSnippets).toEqual([]) - expect(duration).toBeLessThan(150) // Some buffer for timing - }) - - it("should return results from fast sources even if other sources are slow", async () => { - // Mock one fast and one slow source - mockContextRetrievalService.getRootPathSnippets = vi.fn().mockResolvedValue([ - { - filepath: "/fast.ts", - content: "fast", - type: AutocompleteSnippetType.Code, - }, - ]) - mockContextRetrievalService.getSnippetsFromImportDefinitions = vi - .fn() - .mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve([]), 200))) - - const result = await getAllSnippets({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, - }) - - // Fast source should return results - expect(result.rootPathSnippets).toHaveLength(1) - expect(result.rootPathSnippets[0].content).toBe("fast") - - // Slow source should timeout and return empty - expect(result.importDefinitionSnippets).toEqual([]) - }) - - it("should collect clipboard snippets", async () => { - const result = await getAllSnippets({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, - }) - - expect(result.clipboardSnippets).toHaveLength(1) - expect(result.clipboardSnippets[0]).toEqual({ - content: "clipboard content", - copiedAt: "2024-01-01T00:00:00.000Z", - type: AutocompleteSnippetType.Clipboard, - }) - }) - - it("should handle empty results from snippet sources", async () => { - // All sources return empty - mockContextRetrievalService.getRootPathSnippets = vi.fn().mockResolvedValue([]) - mockContextRetrievalService.getSnippetsFromImportDefinitions = vi.fn().mockResolvedValue([]) - mockIde.getClipboardContent = vi.fn().mockResolvedValue({ - text: "", - copiedAt: "2024-01-01T00:00:00.000Z", - }) - - const result = await getAllSnippets({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, - }) - - expect(result.rootPathSnippets).toEqual([]) - expect(result.importDefinitionSnippets).toEqual([]) - expect(result.clipboardSnippets).toHaveLength(1) // Still returns clipboard snippet - }) - - it("should return empty array for IDE snippets when disabled", async () => { - const result = await getAllSnippets({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, - }) - - // IDE_SNIPPETS_ENABLED is false in the implementation - expect(result.ideSnippets).toEqual([]) - expect(mockGetDefinitionsFromLsp).not.toHaveBeenCalled() - }) - - it("should return empty array for diff snippets (temporarily disabled)", async () => { - const result = await getAllSnippets({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, - }) - - expect(result.diffSnippets).toEqual([]) - }) - - it("should handle option useRecentlyEdited = false", async () => { - mockHelper.options.useRecentlyEdited = false - - const result = await getAllSnippets({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, - }) - - expect(result.recentlyEditedRangeSnippets).toEqual([]) - }) - - it("should handle option useRecentlyOpened = false", async () => { - mockHelper.options.useRecentlyOpened = false - - const result = await getAllSnippets({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, - }) - - expect(result.recentlyOpenedFileSnippets).toEqual([]) - }) - - it("should collect static context snippets when experimental flag is enabled", async () => { - mockHelper.options.experimental_enableStaticContextualization = true - mockContextRetrievalService.getStaticContextSnippets = vi.fn().mockResolvedValue([ - { - filepath: "/static.ts", - content: "static", - type: AutocompleteSnippetType.Static, - }, - ]) - - const result = await getAllSnippets({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, - }) - - expect(result.staticSnippet).toHaveLength(1) - expect(result.staticSnippet[0].content).toBe("static") - }) - - it("should return empty static snippets when experimental flag is disabled", async () => { - mockHelper.options.experimental_enableStaticContextualization = false - - const result = await getAllSnippets({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, - }) - - expect(result.staticSnippet).toEqual([]) - expect(mockContextRetrievalService.getStaticContextSnippets).not.toHaveBeenCalled() + expect(result).toEqual({ + rootPathSnippets: [], + importDefinitionSnippets: [], + recentlyEditedRangeSnippets: [ + { filepath: "/test/recent.ts", content: "const x = 1;", type: AutocompleteSnippetType.Code }, + ], + recentlyVisitedRangesSnippets: [ + { filepath: "/test/visited.ts", content: "visited", type: AutocompleteSnippetType.Code }, + ], + clipboardSnippets: [{ content: "clipboard", copiedAt: "2024-01-01", type: AutocompleteSnippetType.Clipboard }], + recentlyOpenedFileSnippets: [], + staticSnippet: [], }) }) - describe("error handling", () => { - it("should propagate errors from context retrieval service", async () => { - mockContextRetrievalService.getRootPathSnippets = vi.fn().mockRejectedValue(new Error("Service error")) + it("honors disabled recent-context sources", async () => { + helper.options.useRecentlyEdited = false + helper.options.useRecentlyOpened = false - // Errors are not caught by racePromise - they propagate if they occur before timeout - await expect( - getAllSnippets({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, + const result = await getAllSnippetsWithoutRace({ helper, ide, contextRetrievalService: context }) + expect(result.recentlyEditedRangeSnippets).toEqual([]) + expect(result.recentlyOpenedFileSnippets).toEqual([]) + }) + + it("collects import definitions and enabled static context", async () => { + helper.options.experimental_enableStaticContextualization = true + context.getSnippetsFromImportDefinitions = vi + .fn() + .mockResolvedValue([{ filepath: "/import.ts", content: "imported", type: AutocompleteSnippetType.Code }]) + context.getStaticContextSnippets = vi + .fn() + .mockResolvedValue([{ filepath: "/static.ts", content: "static", type: AutocompleteSnippetType.Static }]) + + const result = await getAllSnippetsWithoutRace({ helper, ide, contextRetrievalService: context }) + expect(result.importDefinitionSnippets[0]?.content).toBe("imported") + expect(result.staticSnippet[0]?.content).toBe("static") + }) + + it("propagates clipboard failures", async () => { + ide.getClipboardContent = vi.fn().mockRejectedValue(new Error("Clipboard error")) + + await expect(getAllSnippetsWithoutRace({ helper, ide, contextRetrievalService: context })).rejects.toThrow( + "Clipboard error", + ) + }) + + it("waits for active context sources without a collector timeout", async () => { + context.getRootPathSnippets = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + setTimeout( + () => resolve([{ filepath: "/slow.ts", content: "slow", type: AutocompleteSnippetType.Code }]), + 120, + ) }), - ).rejects.toThrow("Service error") - }) + ) - it("should propagate errors from IDE clipboard", async () => { - mockIde.getClipboardContent = vi.fn().mockRejectedValue(new Error("Clipboard error")) - - // Errors are not caught by racePromise - they propagate if they occur before timeout - await expect( - getAllSnippets({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, - }), - ).rejects.toThrow("Clipboard error") - }) - - it("should pass through null from snippet sources", async () => { - mockContextRetrievalService.getRootPathSnippets = vi.fn().mockResolvedValue(null as any) - - const result = await getAllSnippets({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, - }) - - // racePromise returns null if the promise resolves to null (not converted to []) - expect(result.rootPathSnippets).toBeNull() - }) + const result = await getAllSnippetsWithoutRace({ helper, ide, contextRetrievalService: context }) + expect(result.rootPathSnippets[0]?.content).toBe("slow") }) - describe("getAllSnippetsWithoutRace", () => { - it("should wait for all promises without timeout", async () => { - // Mock a slow service that takes 200ms - mockContextRetrievalService.getRootPathSnippets = vi.fn().mockImplementation( - () => - new Promise((resolve) => { - setTimeout( - () => - resolve([ - { - filepath: "/slow.ts", - content: "slow", - type: AutocompleteSnippetType.Code, - }, - ]), - 200, - ) - }), - ) + it("propagates context retrieval failures", async () => { + context.getRootPathSnippets = vi.fn().mockRejectedValue(new Error("Service error")) - const result = await getAllSnippetsWithoutRace({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, - }) - - // Should wait and get results, not timeout - expect(result.rootPathSnippets).toHaveLength(1) - expect(result.rootPathSnippets[0].content).toBe("slow") - }) - - it("should return all snippet types without racing", async () => { - const result = await getAllSnippetsWithoutRace({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, - }) - - expect(result).toHaveProperty("rootPathSnippets") - expect(result).toHaveProperty("importDefinitionSnippets") - expect(result).toHaveProperty("ideSnippets") - expect(result).toHaveProperty("recentlyEditedRangeSnippets") - expect(result).toHaveProperty("diffSnippets") - expect(result).toHaveProperty("clipboardSnippets") - expect(result).toHaveProperty("recentlyVisitedRangesSnippets") - expect(result).toHaveProperty("recentlyOpenedFileSnippets") - expect(result).toHaveProperty("staticSnippet") - }) - - it("should handle errors without race timeout", async () => { - mockContextRetrievalService.getRootPathSnippets = vi.fn().mockRejectedValue(new Error("Service error")) - - // Should propagate error since no timeout - await expect( - getAllSnippetsWithoutRace({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, - }), - ).rejects.toThrow("Service error") - }) - }) - - describe("parallel execution", () => { - it("should execute all snippet collections in parallel", async () => { - const executionOrder: string[] = [] - - mockContextRetrievalService.getRootPathSnippets = vi.fn().mockImplementation(async () => { - executionOrder.push("rootPath-start") - await new Promise((resolve) => setTimeout(resolve, 50)) - executionOrder.push("rootPath-end") - return [] - }) - - mockContextRetrievalService.getSnippetsFromImportDefinitions = vi.fn().mockImplementation(async () => { - executionOrder.push("import-start") - await new Promise((resolve) => setTimeout(resolve, 50)) - executionOrder.push("import-end") - return [] - }) - - mockIde.getClipboardContent = vi.fn().mockImplementation(async () => { - executionOrder.push("clipboard-start") - await new Promise((resolve) => setTimeout(resolve, 50)) - executionOrder.push("clipboard-end") - return { text: "test", copiedAt: "2024-01-01T00:00:00.000Z" } - }) - - await getAllSnippets({ - helper: mockHelper, - ide: mockIde, - getDefinitionsFromLsp: mockGetDefinitionsFromLsp, - contextRetrievalService: mockContextRetrievalService, - }) - - // All should start before any complete (parallel execution) - expect(executionOrder[0]).toBe("rootPath-start") - expect(executionOrder[1]).toBe("import-start") - expect(executionOrder[2]).toBe("clipboard-start") - }) + await expect(getAllSnippetsWithoutRace({ helper, ide, contextRetrievalService: context })).rejects.toThrow( + "Service error", + ) }) }) diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/snippets/getAllSnippets.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/snippets/getAllSnippets.ts index b2adc447fb7..be208b4f931 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/snippets/getAllSnippets.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/snippets/getAllSnippets.ts @@ -1,215 +1,89 @@ import { IDE } from "../../index" -import { findUriInDirs } from "../../util/uri" import { ContextRetrievalService } from "../context/ContextRetrievalService" -import { GetLspDefinitionsFunction } from "../types" import { HelperVars } from "../util/HelperVars" import { openedFilesLruCache } from "../util/openedFilesLruCache" - import { AutocompleteClipboardSnippet, AutocompleteCodeSnippet, - AutocompleteDiffSnippet, AutocompleteSnippetType, AutocompleteStaticSnippet, } from "../types" -const IDE_SNIPPETS_ENABLED = false // ideSnippets is not used, so it's temporarily disabled - export interface SnippetPayload { rootPathSnippets: AutocompleteCodeSnippet[] importDefinitionSnippets: AutocompleteCodeSnippet[] - ideSnippets: AutocompleteCodeSnippet[] recentlyEditedRangeSnippets: AutocompleteCodeSnippet[] recentlyVisitedRangesSnippets: AutocompleteCodeSnippet[] - diffSnippets: AutocompleteDiffSnippet[] clipboardSnippets: AutocompleteClipboardSnippet[] recentlyOpenedFileSnippets: AutocompleteCodeSnippet[] staticSnippet: AutocompleteStaticSnippet[] } -function racePromise(promise: Promise, timeout = 100): Promise { - const timeoutPromise = new Promise((resolve) => { - setTimeout(() => resolve([]), timeout) - }) - - return Promise.race([promise, timeoutPromise]) -} - -// Some IDEs might have special ways of finding snippets (e.g. JetBrains and VS Code have different "LSP-equivalent" systems, -// or they might separately track recently edited ranges) -async function getIdeSnippets( - helper: HelperVars, - ide: IDE, - getDefinitionsFromLsp: GetLspDefinitionsFunction, -): Promise { - const ideSnippets = await getDefinitionsFromLsp( - helper.input.filepath, - helper.fullPrefix + helper.fullSuffix, - helper.fullPrefix.length, - ide, - helper.lang, - ) - - if (helper.options.onlyMyCode) { - const workspaceDirs = await ide.getWorkspaceDirs() - - return ideSnippets.filter((snippet) => - workspaceDirs.some((dir) => !!findUriInDirs(snippet.filepath, [dir]).foundInDir), - ) - } - - return ideSnippets -} - function getSnippetsFromRecentlyEditedRanges(helper: HelperVars): AutocompleteCodeSnippet[] { - if (helper.options.useRecentlyEdited === false) { - return [] - } + if (helper.options.useRecentlyEdited === false) return [] - return helper.input.recentlyEditedRanges.map((range) => { - return { - filepath: range.filepath, - content: range.lines.join("\n"), - type: AutocompleteSnippetType.Code, - } - }) + return helper.input.recentlyEditedRanges.map((range) => ({ + filepath: range.filepath, + content: range.lines.join("\n"), + type: AutocompleteSnippetType.Code, + })) } const getClipboardSnippets = async (ide: IDE): Promise => { const content = await ide.getClipboardContent() - - return [content].map((item) => { - return { - content: item.text, - copiedAt: item.copiedAt, + return [ + { + content: content.text, + copiedAt: content.copiedAt, type: AutocompleteSnippetType.Clipboard, - } - }) + }, + ] } const getSnippetsFromRecentlyOpenedFiles = async (helper: HelperVars, ide: IDE): Promise => { - if (helper.options.useRecentlyOpened === false) { - return [] - } + if (helper.options.useRecentlyOpened === false) return [] try { - const currentFileUri = `${helper.filepath}` - - // Get all file URIs excluding the current file - const fileUrisToRead = [...openedFilesLruCache.entriesDescending()] - .filter(([fileUri, _]) => fileUri !== currentFileUri) - .map(([fileUri, _]) => fileUri) - - // Create an array of promises that each read a file with timeout - const fileReadPromises = fileUrisToRead.map((fileUri) => { - // Create a promise that resolves to a snippet or null - const readPromise = new Promise((resolve) => { + const current = `${helper.filepath}` + const uris = [...openedFilesLruCache.entriesDescending()].filter(([uri]) => uri !== current).map(([uri]) => uri) + const reads = uris.map((uri) => { + const read = new Promise((resolve) => { ide - .readFile(fileUri) - .then((fileContent) => { - if (!fileContent || fileContent.trim() === "") { + .readFile(uri) + .then((content) => { + if (!content || content.trim() === "") { resolve(null) return } - - resolve({ - filepath: fileUri, - content: fileContent, - type: AutocompleteSnippetType.Code, - }) + resolve({ filepath: uri, content, type: AutocompleteSnippetType.Code }) }) - .catch((e) => { - console.error(`Failed to read file ${fileUri}:`, e) + .catch((err) => { + console.error(`Failed to read file ${uri}:`, err) resolve(null) }) }) - // Cut off at 80ms via racing promises - return Promise.race([readPromise, new Promise((resolve) => setTimeout(() => resolve(null), 80))]) + return Promise.race([read, new Promise((resolve) => setTimeout(() => resolve(null), 80))]) }) - - // Execute all file reads in parallel - const results = await Promise.all(fileReadPromises) - - // Filter out null results + const results = await Promise.all(reads) return results.filter(Boolean) as AutocompleteCodeSnippet[] - } catch (e) { - console.error("Error processing opened files cache:", e) + } catch (err) { + console.error("Error processing opened files cache:", err) return [] } } -export const getAllSnippets = async ({ - helper, - ide, - getDefinitionsFromLsp, - contextRetrievalService, -}: { - helper: HelperVars - ide: IDE - getDefinitionsFromLsp: GetLspDefinitionsFunction - contextRetrievalService: ContextRetrievalService -}): Promise => { - const recentlyEditedRangeSnippets = getSnippetsFromRecentlyEditedRanges(helper) - - const [ - rootPathSnippets, - importDefinitionSnippets, - ideSnippets, - diffSnippets, - clipboardSnippets, - recentlyOpenedFileSnippets, - staticSnippet, - ] = await Promise.all([ - racePromise(contextRetrievalService.getRootPathSnippets(helper)), - racePromise(contextRetrievalService.getSnippetsFromImportDefinitions(helper)), - IDE_SNIPPETS_ENABLED ? racePromise(getIdeSnippets(helper, ide, getDefinitionsFromLsp)) : [], - [], // racePromise(getDiffSnippets(ide)) // temporarily disabled, see https://github.com/continuedev/continue/pull/5882, - racePromise(getClipboardSnippets(ide)), - racePromise(getSnippetsFromRecentlyOpenedFiles(helper, ide)), // giving this one a little more time to complete - helper.options.experimental_enableStaticContextualization - ? racePromise(contextRetrievalService.getStaticContextSnippets(helper)) - : [], - ]) - - return { - rootPathSnippets, - importDefinitionSnippets, - ideSnippets, - recentlyEditedRangeSnippets, - diffSnippets, - clipboardSnippets, - recentlyVisitedRangesSnippets: helper.input.recentlyVisitedRanges, - recentlyOpenedFileSnippets, - staticSnippet, - } -} - export const getAllSnippetsWithoutRace = async ({ helper, ide, - getDefinitionsFromLsp, contextRetrievalService, }: { helper: HelperVars ide: IDE - getDefinitionsFromLsp: GetLspDefinitionsFunction contextRetrievalService: ContextRetrievalService }): Promise => { - const recentlyEditedRangeSnippets = getSnippetsFromRecentlyEditedRanges(helper) - - const [ - rootPathSnippets, - importDefinitionSnippets, - ideSnippets, - diffSnippets, - clipboardSnippets, - recentlyOpenedFileSnippets, - staticSnippet, - ] = await Promise.all([ + const [root, imports, clipboard, opened, staticSnippet] = await Promise.all([ contextRetrievalService.getRootPathSnippets(helper), contextRetrievalService.getSnippetsFromImportDefinitions(helper), - IDE_SNIPPETS_ENABLED ? getIdeSnippets(helper, ide, getDefinitionsFromLsp) : [], - [], // racePromise(getDiffSnippets(ide)) // temporarily disabled, see https://github.com/continuedev/continue/pull/5882, getClipboardSnippets(ide), getSnippetsFromRecentlyOpenedFiles(helper, ide), helper.options.experimental_enableStaticContextualization @@ -218,14 +92,12 @@ export const getAllSnippetsWithoutRace = async ({ ]) return { - rootPathSnippets, - importDefinitionSnippets, - ideSnippets, - recentlyEditedRangeSnippets, - diffSnippets, - clipboardSnippets, + rootPathSnippets: root, + importDefinitionSnippets: imports, + recentlyEditedRangeSnippets: getSnippetsFromRecentlyEditedRanges(helper), recentlyVisitedRangesSnippets: helper.input.recentlyVisitedRanges, - recentlyOpenedFileSnippets, + clipboardSnippets: clipboard, + recentlyOpenedFileSnippets: opened, staticSnippet, } } diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/templating/AutocompleteTemplate.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/templating/AutocompleteTemplate.ts index 229fdbf5e35..4904928e4af 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/templating/AutocompleteTemplate.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/templating/AutocompleteTemplate.ts @@ -5,7 +5,7 @@ import { CompletionOptions } from "../../index.js" import { getLastNUriRelativePathParts, getShortestUniqueRelativeUriPaths } from "../../util/uri.js" -import { AutocompleteSnippet, AutocompleteSnippetType } from "../types.js" +import { AutocompleteSnippet } from "../types.js" type TemplateRenderer = ( prefix: string, @@ -49,13 +49,7 @@ const codestralMultifileFimTemplate: AutocompleteTemplate = { ) const otherFiles = snippets - .map((snippet, i) => { - if (snippet.type === AutocompleteSnippetType.Diff) { - return snippet.content - } - - return `+++++ ${getFileName(relativePaths[i])} \n${snippet.content}` - }) + .map((snippet, i) => `+++++ ${getFileName(relativePaths[i])} \n${snippet.content}`) .join("\n\n") return [`${otherFiles}\n\n+++++ ${getFileName(relativePaths[relativePaths.length - 1])}\n${prefix}`, suffix] @@ -93,13 +87,7 @@ const mercuryMultifileFimTemplate: AutocompleteTemplate = { ) const otherFiles = snippets - .map((snippet, i) => { - if (snippet.type === AutocompleteSnippetType.Diff) { - return snippet.content - } - - return `<|file_sep|>${getFileName(relativePaths[i])} \n${snippet.content}` - }) + .map((snippet, i) => `<|file_sep|>${getFileName(relativePaths[i])} \n${snippet.content}`) .join("\n\n") return [ diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/templating/__tests__/formatOpenedFilesContext.test.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/templating/__tests__/formatOpenedFilesContext.test.ts index b54a1e28714..33d3c9c9173 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/templating/__tests__/formatOpenedFilesContext.test.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/templating/__tests__/formatOpenedFilesContext.test.ts @@ -4,7 +4,7 @@ */ import { describe, expect, test } from "vitest" -import { AutocompleteCodeSnippet, AutocompleteDiffSnippet, AutocompleteSnippetType } from "../../types" +import { AutocompleteCodeSnippet, AutocompleteSnippetType } from "../../types" import { HelperVars } from "../../util/HelperVars" import { formatOpenedFilesContext } from "../formatOpenedFilesContext" @@ -25,11 +25,6 @@ describe("formatOpenedFilesContext main function tests", () => { content, }) - const createDiffSnippet = (content: string): AutocompleteDiffSnippet => ({ - type: AutocompleteSnippetType.Diff, - content, - }) - test("should return empty array when no snippets are provided", () => { const result = formatOpenedFilesContext([], 1000, mockHelper, [], TOKEN_BUFFER) expect(result).toEqual([]) @@ -99,7 +94,7 @@ describe("formatOpenedFilesContext main function tests", () => { createCodeSnippet("file2.ts", "content of file 2"), ] - const alreadyAddedSnippets = [createDiffSnippet("diff content")] + const alreadyAddedSnippets = [createCodeSnippet("added.ts", "added content")] const result = formatOpenedFilesContext(snippets, 1000, mockHelper, alreadyAddedSnippets, TOKEN_BUFFER) diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/templating/filtering.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/templating/filtering.ts index c4d0941a7a0..772f2f2ccc1 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/templating/filtering.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/templating/filtering.ts @@ -44,7 +44,6 @@ export const getSnippets = (helper: HelperVars, payload: SnippetPayload): Autoco clipboard: payload.clipboardSnippets, recentlyVisitedRanges: payload.recentlyVisitedRangesSnippets, recentlyEditedRanges: payload.recentlyEditedRangeSnippets, - diff: payload.diffSnippets, recentlyOpenedFiles: payload.recentlyOpenedFileSnippets, base: shuffleArray( filterSnippetsAlreadyInCaretWindow( @@ -88,13 +87,6 @@ export const getSnippets = (helper: HelperVars, payload: SnippetPayload): Autoco defaultPriority: 4, snippets: payload.recentlyEditedRangeSnippets, }, - { - key: "diff", - enabledOrPriority: helper.options.experimental_includeDiff, - defaultPriority: 5, - snippets: payload.diffSnippets, - // TODO: diff is commonly too large, thus anything lower in priority is not included. - }, { key: "base", enabledOrPriority: true, diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/types.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/types.ts index 276440cb333..92fd0dfa20a 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/types.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/autocomplete/types.ts @@ -1,9 +1,7 @@ -import { IDE, RangeInFileWithContents } from "../index" -import { AutocompleteLanguageInfo } from "./constants/AutocompleteLanguageInfo" +import { RangeInFileWithContents } from "../index" export enum AutocompleteSnippetType { Code = "code", - Diff = "diff", Clipboard = "clipboard", Static = "static", } @@ -18,10 +16,6 @@ export interface AutocompleteCodeSnippet extends BaseAutocompleteSnippet { type: AutocompleteSnippetType.Code } -export interface AutocompleteDiffSnippet extends BaseAutocompleteSnippet { - type: AutocompleteSnippetType.Diff -} - export interface AutocompleteClipboardSnippet extends BaseAutocompleteSnippet { type: AutocompleteSnippetType.Clipboard copiedAt: string @@ -32,20 +26,8 @@ export interface AutocompleteStaticSnippet extends BaseAutocompleteSnippet { filepath: string } -export type AutocompleteSnippet = - | AutocompleteCodeSnippet - | AutocompleteDiffSnippet - | AutocompleteClipboardSnippet - | AutocompleteStaticSnippet +export type AutocompleteSnippet = AutocompleteCodeSnippet | AutocompleteClipboardSnippet | AutocompleteStaticSnippet export type RankedSnippet = RangeInFileWithContents & { score?: number } - -export type GetLspDefinitionsFunction = ( - filepath: string, - contents: string, - cursorIndex: number, - ide: IDE, - lang: AutocompleteLanguageInfo, -) => Promise diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/diff/util.test.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/diff/util.test.ts deleted file mode 100644 index 58c569c8b1f..00000000000 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/diff/util.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -// Generated by continue - -import { describe, expect, it, vi } from "vitest" -import { ChatMessage } from "../index" -import { generateLines, streamLines } from "./util" - -describe("streamLines", () => { - it("should split chunks into lines correctly", async () => { - async function* streamCompletion(): AsyncGenerator { - yield "line1\nline" - yield "2\nline3\n" - yield "line4" - } - - const resultLines: string[] = [] - for await (const line of streamLines(streamCompletion())) { - resultLines.push(line) - } - - expect(resultLines).toEqual(["line1", "line2", "line3", "line4"]) - }) - - it("should handle ChatMessage chunks", async () => { - const messageChunk1: ChatMessage = { - role: "assistant", - content: "line1\nline", - } - const messageChunk2: ChatMessage = { - role: "assistant", - content: "2\nline3\n", - } - const messageChunk3: ChatMessage = { - role: "assistant", - content: "line4", - } - - // const spy = vi.spyOn(messageContentModule, "renderChatMessage"); - - async function* streamCompletion(): AsyncGenerator { - yield messageChunk1 - yield messageChunk2 - yield messageChunk3 - } - - const resultLines: string[] = [] - for await (const line of streamLines(streamCompletion())) { - resultLines.push(line) - } - - expect(resultLines).toEqual(["line1", "line2", "line3", "line4"]) - // expect(spy).toHaveBeenCalledTimes(3); - }) - - it("should log lines if log parameter is true", async () => { - const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}) - - async function* streamCompletion(): AsyncGenerator { - yield "line1\nline2\n" - yield "line3" - } - - const resultLines: string[] = [] - for await (const line of streamLines(streamCompletion(), true)) { - resultLines.push(line) - } - - expect(resultLines).toEqual(["line1", "line2", "line3"]) - expect(consoleSpy).toHaveBeenCalledWith("Streamed lines: ", "line1\nline2\nline3") - - consoleSpy.mockRestore() - }) -}) - -describe("generateLines", () => { - it("should yield the lines provided in the array", async () => { - const lines = ["line1", "line2", "line3"] - const resultLines: string[] = [] - - for await (const line of generateLines(lines)) { - resultLines.push(line) - } - - expect(resultLines).toEqual(lines) - }) -}) diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/diff/util.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/diff/util.ts deleted file mode 100644 index d5da1720c3c..00000000000 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/diff/util.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { ChatMessage } from "../index.js" -import { renderChatMessage } from "../util/messageContent.js" - -export type LineStream = AsyncGenerator - -/** - * Convert a stream of arbitrary chunks to a stream of lines - */ -export async function* streamLines( - streamCompletion: AsyncGenerator, - log: boolean = false, -): LineStream { - const allLines = [] - let buffer = "" - - try { - for await (const update of streamCompletion) { - const chunk = typeof update === "string" ? update : renderChatMessage(update) - buffer += chunk - const lines = buffer.split("\n") - buffer = lines.pop() ?? "" - for (const line of lines) { - yield line - allLines.push(line) - } - } - if (buffer.length > 0) { - yield buffer - allLines.push(buffer) - } - } finally { - if (log) { - console.log("Streamed lines: ", allLines.join("\n")) - } - } -} - -export async function* generateLines(lines: T[]): AsyncGenerator { - for (const line of lines) { - yield line - } -} diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/index.d.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/index.d.ts index 5585fe8a087..66b868d0c6d 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/index.d.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/index.d.ts @@ -531,7 +531,6 @@ export interface TabAutocompleteOptions { experimental_includeClipboard: boolean | number experimental_includeRecentlyVisitedRanges: boolean | number experimental_includeRecentlyEditedRanges: boolean | number - experimental_includeDiff: boolean | number experimental_enableStaticContextualization: boolean } diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/util/messageContent.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/util/messageContent.ts deleted file mode 100644 index 1b5ed9fa83e..00000000000 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/util/messageContent.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ChatMessage, MessageContent, TextMessagePart } from "../index" - -function stripImages(messageContent: MessageContent): string { - if (typeof messageContent === "string") { - return messageContent - } - - return messageContent - .filter((part) => part.type === "text") - .map((part) => (part as TextMessagePart).text) - .join("\n") -} - -export function renderChatMessage(message: ChatMessage): string { - switch (message?.role) { - case "user": - case "assistant": - case "system": - return stripImages(message.content) - default: - return "" - } -} diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/util/parameters.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/util/parameters.ts index 18ee68de274..403127b0808 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/util/parameters.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/util/parameters.ts @@ -25,6 +25,5 @@ export const DEFAULT_AUTOCOMPLETE_OPTS: TabAutocompleteOptions = { experimental_includeClipboard: false, experimental_includeRecentlyVisitedRanges: true, experimental_includeRecentlyEditedRanges: true, - experimental_includeDiff: true, experimental_enableStaticContextualization: false, } diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/util/ranges.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/util/ranges.ts index 08598703daa..abafd75ae9a 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/util/ranges.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/util/ranges.ts @@ -13,34 +13,3 @@ export function getRangeInString(content: string, range: Range): string { return [firstLine, ...middleLines, lastLine].join("\n") } - -export function intersection(a: Range, b: Range): Range | null { - const startLine = Math.max(a.start.line, b.start.line) - const endLine = Math.min(a.end.line, b.end.line) - - if (startLine > endLine) { - return null - } - - if (startLine === endLine) { - const startCharacter = Math.max(a.start.character, b.start.character) - const endCharacter = Math.min(a.end.character, b.end.character) - - if (startCharacter > endCharacter) { - return null - } - - return { - start: { line: startLine, character: startCharacter }, - end: { line: endLine, character: endCharacter }, - } - } - - const startCharacter = startLine === a.start.line ? a.start.character : b.start.character - const endCharacter = endLine === a.end.line ? a.end.character : b.end.character - - return { - start: { line: startLine, character: startCharacter }, - end: { line: endLine, character: endCharacter }, - } -} diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/util/treeSitter.test.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/util/treeSitter.test.ts deleted file mode 100644 index 76e58188f4c..00000000000 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/util/treeSitter.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from "vitest" -import { getSymbolsForFile } from "./treeSitter" - -// vibecoded -describe("getSymbolsForFile", () => { - it("should extract symbols from Python code", async () => { - const filepath = "test.py" - const contents = `def greet(name): - return f"Hello, {name}!" - -class Calculator: - def add(self, a, b): - return a + b -` - - const symbols = await getSymbolsForFile(filepath, contents) - - // Verify we get symbols - expect(symbols).toBeDefined() - expect(symbols!.length).toBeGreaterThan(0) - - // Verify function symbol - const greetSymbol = symbols?.find((s) => s.name === "greet") - expect(greetSymbol).toBeDefined() - expect(greetSymbol?.type).toBe("function_definition") - expect(greetSymbol?.filepath).toBe(filepath) - expect(greetSymbol?.range.start.line).toBe(0) - expect(greetSymbol?.content).toContain("def greet") - - // Verify class symbol - const calculatorSymbol = symbols?.find((s) => s.name === "Calculator") - expect(calculatorSymbol).toBeDefined() - expect(calculatorSymbol?.type).toBe("class_definition") - expect(calculatorSymbol?.content).toContain("class Calculator") - }) -}) diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/util/treeSitter.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/util/treeSitter.ts index a8aa4961f69..a3afdd03a2f 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/util/treeSitter.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/util/treeSitter.ts @@ -6,7 +6,6 @@ type Language = Parser.Language type SyntaxNode = Parser.SyntaxNode type Query = Parser.Query type Tree = Parser.Tree -import { SymbolWithRange } from ".." import { getUriFileExtension } from "./uri" export enum LanguageName { @@ -259,87 +258,3 @@ async function loadLanguageForFileExt(fileExtension: string): Promise return await Language.load(wasmPath) } - -// See https://tree-sitter.github.io/tree-sitter/using-parsers -const GET_SYMBOLS_FOR_NODE_TYPES: SyntaxNode["type"][] = [ - "class_declaration", - "class_definition", - "function_item", // function name = first "identifier" child - "function_definition", - "method_declaration", // method name = first "identifier" child - "method_definition", - "generator_function_declaration", - // property_identifier - // field_declaration - // "arrow_function", -] - -export async function getSymbolsForFile(filepath: string, contents: string): Promise { - //MINIMAL_REPO - continue doesn't use this in autocomplete - const parser = await getParserForFile(filepath) - if (!parser) { - return - } - - let tree: Tree | null - try { - tree = parser.parse(contents) - } catch { - console.log(`Error parsing file: ${filepath}`) - return - } - - if (!tree) { - console.log(`Failed to parse file: ${filepath}`) - return - } - // console.log(`file: ${filepath}`); - - // Function to recursively find all named nodes (classes and functions) - const symbols: SymbolWithRange[] = [] - function findNamedNodesRecursive(node: SyntaxNode) { - // console.log(`node: ${node.type}, ${node.text}`); - if (GET_SYMBOLS_FOR_NODE_TYPES.includes(node.type)) { - // console.log(`parent: ${node.type}, ${node.text.substring(0, 200)}`); - // node.children.forEach((child) => { - // console.log(`child: ${child.type}, ${child.text}`); - // }); - - // Empirically, the actual name is the last identifier in the node - // Especially with languages where return type is declared before the name - // TODO use findLast in newer version of node target - let identifier: SyntaxNode | undefined = undefined - for (let i = node.children.length - 1; i >= 0; i--) { - const child = node.children[i] - if (child && (child.type === "identifier" || child.type === "property_identifier")) { - identifier = child - break - } - } - - if (identifier?.text) { - symbols.push({ - filepath, - type: node.type, - name: identifier.text, - range: { - start: { - character: node.startPosition.column, - line: node.startPosition.row, - }, - end: { - character: node.endPosition.column + 1, - line: node.endPosition.row + 1, - }, - }, - content: node.text, - }) - } - } - node.children.forEach((child) => { - if (child) findNamedNodesRecursive(child) - }) - } - findNamedNodesRecursive(tree.rootNode) - return symbols -} diff --git a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/vscode-test-harness/src/autocomplete/lsp.ts b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/vscode-test-harness/src/autocomplete/lsp.ts index a037487f2d4..c13ba3a0289 100644 --- a/packages/kilo-vscode/src/services/autocomplete/continuedev/core/vscode-test-harness/src/autocomplete/lsp.ts +++ b/packages/kilo-vscode/src/services/autocomplete/continuedev/core/vscode-test-harness/src/autocomplete/lsp.ts @@ -1,22 +1,6 @@ -import { AutocompleteLanguageInfo } from "../../../autocomplete/constants/AutocompleteLanguageInfo" -import { AutocompleteCodeSnippet, AutocompleteSnippetType } from "../../../autocomplete/types" -import { GetLspDefinitionsFunction } from "../../../autocomplete/types" -import { getAst, getTreePathAtCursor } from "../../../autocomplete/util/ast" -import { intersection } from "../../../util/ranges" - import * as vscode from "vscode" -import type { DocumentSymbol, IDE, Range, RangeInFile, RangeInFileWithContents, SignatureHelp } from "../../../" -import type Parser from "web-tree-sitter" -type SyntaxNode = Parser.SyntaxNode -const FUNCTION_BLOCK_NODE_TYPES = ["block", "statement_block"] -const FUNCTION_DECLARATION_NODE_TYPEs = [ - "method_definition", - "function_definition", - "function_item", - "function_declaration", - "method_declaration", -] +import type { DocumentSymbol, RangeInFile, SignatureHelp } from "../../../" type GotoProviderName = | "vscode.executeDefinitionProvider" @@ -120,283 +104,6 @@ export async function executeGotoProvider(input: GotoInput): Promise boolean, firstN?: number): SyntaxNode[] { - let matchingNodes: SyntaxNode[] = [] - - if (firstN && firstN <= 0) { - return [] - } - - // Check if the current node's type is in the list of types we're interested in - if (predicate(node)) { - matchingNodes.push(node) - } - - // Recursively search for matching types in all children of the current node - for (const child of node.children) { - if (!child) continue - matchingNodes = matchingNodes.concat( - findChildren(child, predicate, firstN ? firstN - matchingNodes.length : undefined), - ) - } - - return matchingNodes -} - -function findTypeIdentifiers(node: SyntaxNode): SyntaxNode[] { - return findChildren( - node, - (childNode) => - childNode.type === "type_identifier" || - (["ERROR"].includes(childNode.parent?.type ?? "") && - childNode.type === "identifier" && - childNode.text[0].toUpperCase() === childNode.text[0]), - ) -} - -async function crawlTypes( - rif: RangeInFile | RangeInFileWithContents, - ide: IDE, - depth: number = 1, - results: RangeInFileWithContents[] = [], - searchedLabels: Set = new Set(), -): Promise { - // Get the file contents if not already attached - const contents = isRifWithContents(rif) ? rif.contents : await ide.readFile(rif.filepath) - - // Parse AST - const ast = await getAst(rif.filepath, contents) - if (!ast) { - return results - } - const astLineCount = ast.rootNode.text.split("\n").length - - // Find type identifiers - const identifierNodes = findTypeIdentifiers(ast.rootNode).filter((node) => !searchedLabels.has(node.text)) - // Don't search for the same type definition more than once - // We deduplicate below to be sure, but this saves calls to the LSP - identifierNodes.forEach((node) => searchedLabels.add(node.text)) - - // Use LSP to get the definitions of those types - const definitions = [] - - for (const node of identifierNodes) { - const [typeDef] = await executeGotoProvider({ - uri: vscode.Uri.parse(rif.filepath), - // TODO: tree-sitter is zero-indexed, but there seems to be an off-by-one - // error at least with the .ts parser sometimes - line: rif.range.start.line + Math.min(node.startPosition.row, astLineCount - 1), - character: rif.range.start.character + node.startPosition.column, - name: "vscode.executeDefinitionProvider", - }) - - if (!typeDef) { - definitions.push(undefined) - continue - } - - const contents = await ide.readRangeInFile(typeDef.filepath, typeDef.range) - - definitions.push({ - ...typeDef, - contents, - }) - } - - // TODO: Filter out if not in our code? - - // Filter out duplicates - for (const definition of definitions) { - if ( - !definition || - results.some( - (result) => result.filepath === definition.filepath && intersection(result.range, definition.range) !== null, - ) - ) { - continue // ;) - } - results.push(definition) - } - - // Recurse - if (depth > 0) { - for (const result of [...results]) { - await crawlTypes(result, ide, depth - 1, results, searchedLabels) - } - } - - return results -} - -async function getDefinitionsForNode( - uri: vscode.Uri, - node: SyntaxNode, - ide: IDE, - lang: AutocompleteLanguageInfo, -): Promise { - const ranges: (RangeInFile | RangeInFileWithContents)[] = [] - switch (node.type) { - case "call_expression": { - // function call -> function definition - const [funDef] = await executeGotoProvider({ - uri, - line: node.startPosition.row, - character: node.startPosition.column, - name: "vscode.executeDefinitionProvider", - }) - if (!funDef) { - return [] - } - - // Don't display a function of more than 15 lines - // We can of course do something smarter here eventually - let funcText = await ide.readRangeInFile(funDef.filepath, funDef.range) - if (funcText.split("\n").length > 15) { - let truncated = false - const funRootAst = await getAst(funDef.filepath, funcText) - if (funRootAst) { - const [funNode] = findChildren( - funRootAst?.rootNode, - (node) => FUNCTION_DECLARATION_NODE_TYPEs.includes(node.type), - 1, - ) - if (funNode) { - const [statementBlockNode] = findChildren( - funNode, - (node) => FUNCTION_BLOCK_NODE_TYPES.includes(node.type), - 1, - ) - if (statementBlockNode) { - funcText = funRootAst.rootNode.text.slice(0, statementBlockNode.startIndex).trim() - truncated = true - } - } - } - if (!truncated) { - funcText = funcText.split("\n")[0] - } - } - - ranges.push(funDef) - - const typeDefs = await crawlTypes( - { - ...funDef, - contents: funcText, - }, - ide, - ) - ranges.push(...typeDefs) - break - } - case "variable_declarator": - // variable assignment -> variable definition/type - // usages of the var that appear after the declaration - break - case "impl_item": - // impl of trait -> trait definition - break - case "new_expression": { - // In 'new MyClass(...)', "MyClass" is the classNameNode - const classNameNode = node.children.find((child) => child && child.type === "identifier") - const [classDef] = await executeGotoProvider({ - uri, - line: (classNameNode ?? node).endPosition.row, - character: (classNameNode ?? node).endPosition.column, - name: "vscode.executeDefinitionProvider", - }) - if (!classDef) { - break - } - const contents = await ide.readRangeInFile(classDef.filepath, classDef.range) - - ranges.push({ - ...classDef, - contents: `${ - classNameNode?.text ? `${lang.singleLineComment} ${classNameNode.text}:\n` : "" - }${contents.trim()}`, - }) - - const definitions = await crawlTypes({ ...classDef, contents }, ide) - ranges.push(...definitions.filter(Boolean)) - - break - } - case "": - // function definition -> implementations? - break - } - return await Promise.all( - ranges.map(async (rif) => { - // Convert the VS Code Range type to ours - const range: Range = { - start: { - line: rif.range.start.line, - character: rif.range.start.character, - }, - end: { - line: rif.range.end.line, - character: rif.range.end.character, - }, - } - rif.range = range - - if (!isRifWithContents(rif)) { - return { - ...rif, - contents: await ide.readRangeInFile(rif.filepath, rif.range), - } - } - return rif - }), - ) -} - -/** - * and other stuff not directly on the path: - * - variables defined on line above - * ...etc... - */ - -export const getDefinitionsFromLsp: GetLspDefinitionsFunction = async ( - filepath: string, - contents: string, - cursorIndex: number, - ide: IDE, - lang: AutocompleteLanguageInfo, -): Promise => { - try { - const ast = await getAst(filepath, contents) - if (!ast) { - return [] - } - - const treePath = await getTreePathAtCursor(ast, cursorIndex) - if (!treePath) { - return [] - } - - const results: RangeInFileWithContents[] = [] - for (const node of treePath.reverse()) { - const definitions = await getDefinitionsForNode(vscode.Uri.parse(filepath), node, ide, lang) - results.push(...definitions) - } - - return results.map((result) => ({ - filepath: result.filepath, - content: result.contents, - type: AutocompleteSnippetType.Code, - })) - } catch (e) { - console.warn("Error getting definitions from LSP: ", e) - return [] - } -} - type SymbolProviderName = "vscode.executeDocumentSymbolProvider" interface SymbolInput { diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts index a8ba61f16b8..904b9e20dda 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts @@ -122,10 +122,6 @@ export class NextEditSuggestionManager implements vscode.Disposable { return this.pending !== null } - public getPending(): PendingNextEdit | null { - return this.pending - } - public setPending(p: PendingNextEdit): void { this.clearDecorations() this.pending = p diff --git a/packages/kilo-vscode/src/services/autocomplete/shims/FileIgnoreController.ts b/packages/kilo-vscode/src/services/autocomplete/shims/FileIgnoreController.ts index 9b0e3d9957c..27ec377a334 100644 --- a/packages/kilo-vscode/src/services/autocomplete/shims/FileIgnoreController.ts +++ b/packages/kilo-vscode/src/services/autocomplete/shims/FileIgnoreController.ts @@ -23,7 +23,6 @@ function toPosix(filePath: string): string { export class FileIgnoreController { private workspacePath: string private ignoreInstance: Ignore = ignore() - private loadedContents: Array<{ file: string; content: string }> = [] private readonly realpathCache = new Map() constructor(workspacePath?: string) { @@ -32,7 +31,6 @@ export class FileIgnoreController { async initialize(): Promise { this.ignoreInstance = ignore() - this.loadedContents = [] this.realpathCache.clear() if (!this.workspacePath) { @@ -48,7 +46,6 @@ export class FileIgnoreController { if (kilocodeignoreContent.trim()) { this.ignoreInstance.add(kilocodeignoreContent) this.ignoreInstance.add(KILOCODEIGNORE) - this.loadedContents.push({ file: KILOCODEIGNORE, content: kilocodeignoreContent }) return } } @@ -59,7 +56,6 @@ export class FileIgnoreController { const gitignoreContent = fs.readFileSync(gitignorePath, "utf-8") if (gitignoreContent.trim()) { this.ignoreInstance.add(gitignoreContent) - this.loadedContents.push({ file: GITIGNORE, content: gitignoreContent }) } } @@ -127,31 +123,7 @@ export class FileIgnoreController { return !this.ignoreInstance.ignores(relative) } - /** - * Filter a list of candidate paths to those allowed. - * When no workspace path was provided, returns an empty array. - */ - filterPaths(paths: string[]): string[] { - if (!this.workspacePath) { - return [] - } - return paths.filter((candidate) => this.validateAccess(candidate)) - } - - /** - * Returns user-facing instructions explaining why access is restricted. - */ - getInstructions(): string | undefined { - if (this.loadedContents.length === 0) { - return undefined - } - - const sections = this.loadedContents.map(({ file, content }) => `# ${file}\n\n${content.trimEnd()}`) - return sections.join("\n\n") - } - dispose(): void { - this.loadedContents = [] this.realpathCache.clear() this.ignoreInstance = ignore() } diff --git a/packages/kilo-vscode/src/services/browser-automation/browser-automation-service.ts b/packages/kilo-vscode/src/services/browser-automation/browser-automation-service.ts index ed7ca9bf3c1..5afdf98a8dd 100644 --- a/packages/kilo-vscode/src/services/browser-automation/browser-automation-service.ts +++ b/packages/kilo-vscode/src/services/browser-automation/browser-automation-service.ts @@ -1,13 +1,12 @@ import * as vscode from "vscode" -import type { KiloClient, McpStatus } from "@kilocode/sdk/v2/client" +import type { KiloClient } from "@kilocode/sdk/v2/client" import type { KiloConnectionService } from "../cli-backend" -export type BrowserAutomationState = "disabled" | "registering" | "connected" | "failed" | "disconnected" +type BrowserAutomationState = "disabled" | "registering" | "connected" | "failed" | "disconnected" export class BrowserAutomationService implements vscode.Disposable { private state: BrowserAutomationState = "disabled" private disposables: vscode.Disposable[] = [] - private stateListeners: Array<(state: BrowserAutomationState) => void> = [] // MCP server name used when registering with the CLI backend private static readonly MCP_SERVER_NAME = "kilo-playwright" @@ -23,22 +22,6 @@ export class BrowserAutomationService implements vscode.Disposable { ) } - /** Current state */ - getState(): BrowserAutomationState { - return this.state - } - - /** Subscribe to state changes */ - onStateChange(listener: (state: BrowserAutomationState) => void): () => void { - this.stateListeners.push(listener) - return () => { - const idx = this.stateListeners.indexOf(listener) - if (idx >= 0) { - this.stateListeners.splice(idx, 1) - } - } - } - /** * Read settings and enable/disable accordingly. * Called on construction and when settings change. @@ -150,24 +133,6 @@ export class BrowserAutomationService implements vscode.Disposable { this.setState("disabled") } - /** - * Get the current MCP server status from the CLI backend. - */ - async getServerStatus(): Promise { - const client = this.getClient() - if (!client) { - return null - } - - try { - const directory = this.getWorkspaceDirectory() - const { data: allStatus } = await client.mcp.status({ directory }, { throwOnError: true }) - return allStatus[BrowserAutomationService.MCP_SERVER_NAME] ?? null - } catch { - return null - } - } - private getClient(): KiloClient | null { try { return this.connectionService.getClient() @@ -190,9 +155,6 @@ export class BrowserAutomationService implements vscode.Disposable { } console.log(`[Kilo New] BrowserAutomationService: State ${this.state} → ${state}`) this.state = state - for (const listener of this.stateListeners) { - listener(state) - } } dispose(): void { @@ -200,6 +162,5 @@ export class BrowserAutomationService implements vscode.Disposable { d.dispose() } this.disposables = [] - this.stateListeners = [] } } diff --git a/packages/kilo-vscode/src/services/marketplace/api.ts b/packages/kilo-vscode/src/services/marketplace/api.ts index 5c1bd7c3c4b..8dbd3c5a9b8 100644 --- a/packages/kilo-vscode/src/services/marketplace/api.ts +++ b/packages/kilo-vscode/src/services/marketplace/api.ts @@ -136,10 +136,6 @@ export class MarketplaceApiClient { } } - clearCache(): void { - this.cache.clear() - } - dispose(): void { this.cache.clear() } diff --git a/packages/kilo-vscode/src/session-status.ts b/packages/kilo-vscode/src/session-status.ts index e596603e704..67856f81f54 100644 --- a/packages/kilo-vscode/src/session-status.ts +++ b/packages/kilo-vscode/src/session-status.ts @@ -1,17 +1,5 @@ import type { KiloClient, SessionStatus } from "@kilocode/sdk/v2/client" -/** - * Returns the number of sessions currently in "busy" state. - * Used to warn users before operations that will interrupt running sessions. - */ -export function getBusySessionCount(map: Map): number { - let count = 0 - for (const status of map.values()) { - if (status === "busy") count++ - } - return count -} - /** * Fetch all current session statuses and seed the provided map + webview. * Called on connect so the Settings panel knows about already-running sessions diff --git a/packages/kilo-vscode/src/shared/custom-provider.ts b/packages/kilo-vscode/src/shared/custom-provider.ts index b8ed2dfa294..12150b198c0 100644 --- a/packages/kilo-vscode/src/shared/custom-provider.ts +++ b/packages/kilo-vscode/src/shared/custom-provider.ts @@ -149,13 +149,23 @@ export function sanitizeCustomProviderConfig(provider: unknown): { value: Saniti } type AnyRecord = Record +type ProviderPatch = Omit & { + models: Record< + string, + null | { + name: string + reasoning?: true | null + variants?: Record + } + > +} function isRecord(v: unknown): v is AnyRecord { return !!v && typeof v === "object" && !Array.isArray(v) } /** - * Build a provider patch that includes null sentinels for models and variants + * Build a provider patch that includes null sentinels for model properties * that existed in the previous config but are absent from the new one. The CLI * `config.update` endpoint deep-merges the payload with the existing config; * without explicit nulls, removed entries would persist on disk. @@ -163,7 +173,7 @@ function isRecord(v: unknown): v is AnyRecord { export function withCustomProviderDeletions(existing: unknown, next: SanitizedProviderConfig): SanitizedProviderConfig { if (!isRecord(existing)) return next const oldModels = isRecord(existing.models) ? existing.models : {} - const patched: AnyRecord = { ...next.models } + const patched: ProviderPatch["models"] = { ...next.models } for (const id of Object.keys(oldModels)) { if (!(id in patched)) { @@ -171,15 +181,21 @@ export function withCustomProviderDeletions(existing: unknown, next: SanitizedPr continue } const oldModel = oldModels[id] - const oldVariants = isRecord(oldModel) && isRecord(oldModel.variants) ? oldModel.variants : {} const newModel = patched[id] - if (!isRecord(newModel)) continue + if (!isRecord(oldModel) || !isRecord(newModel)) continue + const oldVariants = isRecord(oldModel.variants) ? oldModel.variants : {} const newVariants = isRecord(newModel.variants) ? newModel.variants : {} - const removedVariants = Object.keys(oldVariants).filter((v) => !(v in newVariants)) - if (removedVariants.length === 0) continue - const nulls = Object.fromEntries(removedVariants.map((v) => [v, null])) - patched[id] = { ...newModel, variants: { ...newVariants, ...nulls } } + const removed = Object.keys(oldVariants).filter((variant) => !(variant in newVariants)) + const variants = + removed.length > 0 + ? { ...newVariants, ...Object.fromEntries(removed.map((variant) => [variant, null])) } + : newModel.variants + patched[id] = { + ...newModel, + ...(variants ? { variants } : {}), + ...(oldModel.reasoning !== undefined && newModel.reasoning === undefined ? { reasoning: null } : {}), + } } - return { ...next, models: patched as SanitizedProviderConfig["models"] } + return { ...next, models: patched } as SanitizedProviderConfig } diff --git a/packages/kilo-vscode/tests/package.json b/packages/kilo-vscode/tests/package.json index f2f1b564bd2..3104f63016b 100644 --- a/packages/kilo-vscode/tests/package.json +++ b/packages/kilo-vscode/tests/package.json @@ -1,6 +1,6 @@ { "type": "module", - "version": "7.3.45", + "version": "7.3.46", "dependencies": {}, "devDependencies": {}, "peerDependencies": {} diff --git a/packages/kilo-vscode/tests/unit/attention.test.ts b/packages/kilo-vscode/tests/unit/attention.test.ts new file mode 100644 index 00000000000..e89adbe240d --- /dev/null +++ b/packages/kilo-vscode/tests/unit/attention.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "bun:test" +import type { TuiAttentionSoundName } from "@kilocode/plugin/tui" +import { AttentionService } from "../../src/services/attention/service" +import type { KiloConnectionService } from "../../src/services/cli-backend/connection-service" +import type { SSEPayload } from "../../src/services/cli-backend/sdk-sse-adapter" +import { CustomSoundIDs, resolveSoundID } from "../../src/services/attention/sound" + +function setup() { + const sounds: TuiAttentionSoundName[] = [] + const events: Array<(event: SSEPayload) => void> = [] + const states: Array<(state: "connecting" | "connected" | "disconnected" | "error") => void> = [] + const connection = { + onEvent: (handler: (event: SSEPayload) => void) => { + events.push(handler) + return () => undefined + }, + onStateChange: (handler: (state: "connecting" | "connected" | "disconnected" | "error") => void) => { + states.push(handler) + return () => undefined + }, + } as unknown as KiloConnectionService + const service = new AttentionService(connection) + ;(service as unknown as { notify: (sound: TuiAttentionSoundName) => void }).notify = (sound) => sounds.push(sound) + return { + sounds, + event: (event: SSEPayload) => events[0]?.(event), + state: (state: "connecting" | "connected" | "disconnected" | "error") => states[0]?.(state), + service, + } +} + +function event(value: unknown) { + return value as SSEPayload +} + +describe("AttentionService", () => { + it("plays the upstream completion sound once after active becomes idle", () => { + const test = setup() + test.event(event({ type: "session.status", properties: { sessionID: "s1", status: { type: "busy" } } })) + test.event(event({ type: "session.status", properties: { sessionID: "s1", status: { type: "idle" } } })) + test.event(event({ type: "session.status", properties: { sessionID: "s1", status: { type: "idle" } } })) + + expect(test.sounds).toEqual(["done"]) + test.service.dispose() + }) + + it("uses the upstream subagent completion sound", () => { + const test = setup() + test.event( + event({ + type: "sync", + name: "session.created.1", + data: { sessionID: "child", info: { parentID: "parent" } }, + }), + ) + test.event( + event({ type: "sync", name: "session.updated.1", data: { sessionID: "child", info: { title: "work" } } }), + ) + test.event(event({ type: "session.status", properties: { sessionID: "child", status: { type: "retry" } } })) + test.event(event({ type: "session.status", properties: { sessionID: "child", status: { type: "idle" } } })) + + expect(test.sounds).toEqual(["subagent_done"]) + test.service.dispose() + }) + + it("deduplicates question and permission requests", () => { + const test = setup() + test.event(event({ type: "question.asked", properties: { id: "q1", sessionID: "s1" } })) + test.event(event({ type: "question.asked", properties: { id: "q1", sessionID: "s1" } })) + test.event(event({ type: "question.replied", properties: { requestID: "q1", sessionID: "s1" } })) + test.event(event({ type: "question.asked", properties: { id: "q1", sessionID: "s1" } })) + test.event(event({ type: "permission.asked", properties: { id: "p1", sessionID: "s1" } })) + test.event(event({ type: "permission.asked", properties: { id: "p1", sessionID: "s1" } })) + + expect(test.sounds).toEqual(["question", "question", "permission"]) + test.service.dispose() + }) + + it("plays the error sound and suppresses the following completion", () => { + const test = setup() + test.event(event({ type: "session.status", properties: { sessionID: "s1", status: { type: "busy" } } })) + test.event(event({ type: "session.error", properties: { sessionID: "s1", error: { name: "ApiError" } } })) + test.event(event({ type: "session.status", properties: { sessionID: "s1", status: { type: "idle" } } })) + + expect(test.sounds).toEqual(["error"]) + test.service.dispose() + }) + + it("clears transitions when the backend disconnects", () => { + const test = setup() + test.event(event({ type: "session.status", properties: { sessionID: "s1", status: { type: "busy" } } })) + test.state("disconnected") + test.event(event({ type: "session.status", properties: { sessionID: "s1", status: { type: "idle" } } })) + + expect(test.sounds).toEqual([]) + test.service.dispose() + }) +}) + +describe("attention defaults", () => { + it("keeps attention sounds opt-in", async () => { + const manifest = (await Bun.file(new URL("../../package.json", import.meta.url)).json()) as { + contributes: { configuration: { properties: Record } } + } + const properties = manifest.contributes.configuration.properties + + expect(properties["kilo-code.new.attention.enabled"]?.default).toBe(false) + expect(properties["kilo-code.new.attention.sound"]?.default).toBe("default") + expect(properties["kilo-code.new.attention.sound"]?.enum).toEqual(["default", "system", ...CustomSoundIDs]) + expect(properties["kilo-code.new.sounds.agentEnabled"]).toBeUndefined() + expect(properties["kilo-code.new.sounds.permissionsEnabled"]).toBeUndefined() + expect(properties["kilo-code.new.sounds.errorsEnabled"]).toBeUndefined() + }) + + it("resolves global sound choices safely", () => { + expect(resolveSoundID("default")).toBe("default") + expect(resolveSoundID("system")).toBe("system") + expect(resolveSoundID("alert-04")).toBe("alert-04") + expect(resolveSoundID("unknown")).toBe("default") + }) + + it("packages every selectable bundled sound", async () => { + const exists = await Promise.all( + CustomSoundIDs.map((name) => Bun.file(new URL(`../../audio-wav/${name}.wav`, import.meta.url)).exists()), + ) + expect(exists.every(Boolean)).toBe(true) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/autocomplete-error-backoff.test.ts b/packages/kilo-vscode/tests/unit/autocomplete-error-backoff.test.ts index 46c986b972f..fab5cbbcc67 100644 --- a/packages/kilo-vscode/tests/unit/autocomplete-error-backoff.test.ts +++ b/packages/kilo-vscode/tests/unit/autocomplete-error-backoff.test.ts @@ -81,7 +81,6 @@ describe("ErrorBackoff", () => { }) it("is not fatal initially", () => { - expect(backoff.isFatal()).toBe(false) expect(backoff.getFatalStatus()).toBeNull() }) @@ -89,21 +88,18 @@ describe("ErrorBackoff", () => { it("blocks after a 402 error", () => { backoff.failure(new Error("SSE failed: 402 Payment Required")) expect(backoff.blocked()).toBe(true) - expect(backoff.isFatal()).toBe(true) expect(backoff.getFatalStatus()).toBe(402) }) it("blocks after a 401 error", () => { backoff.failure(new Error("SSE failed: 401 Unauthorized")) expect(backoff.blocked()).toBe(true) - expect(backoff.isFatal()).toBe(true) expect(backoff.getFatalStatus()).toBe(401) }) it("blocks after a 403 error", () => { backoff.failure(new Error("SSE failed: 403 Forbidden")) expect(backoff.blocked()).toBe(true) - expect(backoff.isFatal()).toBe(true) expect(backoff.getFatalStatus()).toBe(403) }) @@ -168,7 +164,6 @@ describe("ErrorBackoff", () => { backoff.reset() expect(backoff.blocked()).toBe(false) - expect(backoff.isFatal()).toBe(false) expect(backoff.getFatalStatus()).toBeNull() }) @@ -178,7 +173,6 @@ describe("ErrorBackoff", () => { backoff.success() expect(backoff.blocked()).toBe(false) - expect(backoff.isFatal()).toBe(false) }) }) @@ -232,7 +226,6 @@ describe("ErrorBackoff", () => { it("fatal error overrides retriable backoff", () => { backoff.failure(new Error("SSE failed: 500 Internal Server Error")) backoff.failure(new Error("SSE failed: 402 Payment Required")) - expect(backoff.isFatal()).toBe(true) expect(backoff.blocked()).toBe(true) }) @@ -244,7 +237,6 @@ describe("ErrorBackoff", () => { backoff.success() expect(backoff.blocked()).toBe(false) - expect(backoff.isFatal()).toBe(false) }) }) }) diff --git a/packages/kilo-vscode/tests/unit/custom-provider.test.ts b/packages/kilo-vscode/tests/unit/custom-provider.test.ts index 1a3ce2c242c..4b7069bc527 100644 --- a/packages/kilo-vscode/tests/unit/custom-provider.test.ts +++ b/packages/kilo-vscode/tests/unit/custom-provider.test.ts @@ -166,11 +166,12 @@ describe("withCustomProviderDeletions", () => { expect(models.gone).toBeNull() }) - it("emits null for variants removed from a surviving model", () => { + it("emits null for reasoning and variants removed from a surviving model", () => { const existing = { models: { keep: { name: "Keep", + reasoning: true, variants: { high: { reasoningEffort: "high" }, low: { reasoningEffort: "low" } }, }, }, @@ -182,9 +183,11 @@ describe("withCustomProviderDeletions", () => { }, } as typeof baseNext const result = withCustomProviderDeletions(existing, next) - const model = (result.models as Record }>).keep - expect(model.variants.high).toEqual({ reasoningEffort: "high" }) - expect(model.variants.low).toBeNull() + const model = (result.models as Record }>) + .keep + expect(model.reasoning).toBeNull() + expect(model.variants?.high).toEqual({ reasoningEffort: "high" }) + expect(model.variants?.low).toBeNull() }) it("does not touch variants on a model that is being deleted", () => { diff --git a/packages/kilo-vscode/tests/unit/errorUtils.test.ts b/packages/kilo-vscode/tests/unit/errorUtils.test.ts index aef7ae2dcb5..6f660bcb72e 100644 --- a/packages/kilo-vscode/tests/unit/errorUtils.test.ts +++ b/packages/kilo-vscode/tests/unit/errorUtils.test.ts @@ -28,6 +28,26 @@ describe("unwrapError", () => { const json = JSON.stringify({ message: "connection refused" }) expect(unwrapError(`Error: ${json}`)).toBe("connection refused") }) + + it("formats empty provider rate-limit errors", () => { + const body = { + type: "error", + sequence_number: 2, + error: { type: "tokens", code: "rate_limit_exceeded", message: "", param: null }, + } + const input = JSON.stringify({ message: JSON.stringify(body) }) + + expect(unwrapError(input)).toBe("Provider rate limit exceeded. Please try again shortly.") + }) + + it("preserves provider details when a rate-limit message is present", () => { + const input = JSON.stringify({ + type: "error", + error: { type: "tokens", code: "rate_limit_exceeded", message: "Try again in 30 seconds." }, + }) + + expect(unwrapError(input)).toBe("tokens: Try again in 30 seconds.") + }) }) describe("parseAssistantError", () => { diff --git a/packages/kilo-vscode/tests/unit/file-ignore-controller.test.ts b/packages/kilo-vscode/tests/unit/file-ignore-controller.test.ts index e590f92b364..ba4fd3c46e9 100644 --- a/packages/kilo-vscode/tests/unit/file-ignore-controller.test.ts +++ b/packages/kilo-vscode/tests/unit/file-ignore-controller.test.ts @@ -40,7 +40,6 @@ describe("FileIgnoreController", () => { expect(controller.validateAccess("secret/keys.txt")).toBe(false) expect(controller.validateAccess(path.join(workspace, "a.snap"))).toBe(false) expect(controller.validateAccess(path.join(workspace, "src", "main.ts"))).toBe(true) - expect(controller.getInstructions()).toContain(".kilocodeignore") }) it("does NOT block .env files unless explicitly listed", async () => { @@ -81,7 +80,6 @@ describe("FileIgnoreController", () => { expect(controller.validateAccess(path.join(workspace, "node_modules", "foo.js"))).toBe(false) expect(controller.validateAccess(path.join(workspace, "build", "output.js"))).toBe(false) expect(controller.validateAccess(path.join(workspace, "src", "main.ts"))).toBe(true) - expect(controller.getInstructions()).toContain(".gitignore") }) it("blocks .env files via hardcoded sensitive patterns", async () => { @@ -167,12 +165,5 @@ describe("FileIgnoreController", () => { expect(controller.validateAccess("/some/file.ts")).toBe(false) expect(controller.validateAccess("relative/file.ts")).toBe(false) }) - - it("filterPaths returns empty array", async () => { - const controller = new FileIgnoreController("") - await controller.initialize() - - expect(controller.filterPaths(["/some/file.ts", "other.ts"])).toEqual([]) - }) }) }) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts index 6b3b63388b6..31ed93fd4f0 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts @@ -109,6 +109,21 @@ describe("KiloProvider indexing refresh", () => { expect(indexing).toBe(0) }) + it("refreshes providers when prompt-training model visibility changes", async () => { + const conn = createConnection() + const provider = new KiloProvider({} as never, conn.service as never) + const internal = provider as unknown as Internals + let calls = 0 + internal.connectionState = "connected" + internal.fetchAndSendProviders = async () => { + calls += 1 + } + + await internal.handleUpdateConfig({ hide_prompt_training_models: true }) + + expect(calls).toBe(1) + }) + it("passes scoped unset paths to the config overlay endpoint", async () => { const conn = createConnection() const provider = new KiloProvider({} as never, conn.service as never) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts index f2ee5c2f7e0..d3b710d7805 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts @@ -158,6 +158,12 @@ describe("sessionToWebview", () => { expect(() => new Date(result.createdAt)).not.toThrow() expect(new Date(result.createdAt).getTime()).toBe(1700000000000) }) + + it("clears optional state omitted from a full session snapshot", () => { + const result = sessionToWebview(makeSession()) + expect(result.revert).toBeNull() + expect(result.summary).toBeNull() + }) }) describe("applySessionPatch", () => { diff --git a/packages/kilo-vscode/tests/unit/permission-description.test.ts b/packages/kilo-vscode/tests/unit/permission-description.test.ts index 34d6742f1fa..d67145ed20c 100644 --- a/packages/kilo-vscode/tests/unit/permission-description.test.ts +++ b/packages/kilo-vscode/tests/unit/permission-description.test.ts @@ -1,9 +1,11 @@ import { describe, test, expect } from "bun:test" import { describePatterns, + describeRule, resolveLabel, TOOL_LABEL_KEYS, } from "../../webview-ui/src/components/chat/permission-dock-utils" +import { resolveTemplate } from "../../webview-ui/src/context/language-utils" // Mock t() that returns the English label for known keys, or the key itself const labels: Record = { @@ -24,8 +26,10 @@ const labels: Record = { "ui.permission.toolLabel.task": "Task", "ui.permission.toolLabel.skill": "Skill", "ui.permission.toolLabel.lsp": "LSP", + "ui.permission.doomLoop.prompt": "Potential loop detected for the {{tool}} tool. Continue running?", + "ui.permission.doomLoop.rule": "Continue {{tool}} calls", } -const t = (key: string) => labels[key] ?? key +const t = (key: string, params?: Record) => resolveTemplate(labels[key] ?? key, params) describe("describePatterns", () => { test("returns null when patterns is empty", () => { @@ -95,6 +99,14 @@ describe("describePatterns", () => { expect(result).toEqual({ kind: "single", text: "Web Search query" }) }) + test("doom loop describes the repeated tool with its human-readable label", () => { + const result = describePatterns("doom_loop", ["read"], t) + expect(result).toEqual({ + kind: "single", + text: "Potential loop detected for the Read tool. Continue running?", + }) + }) + test("TOOL_LABEL_KEYS maps all expected tools", () => { const expected: Record = { read: "ui.permission.toolLabel.read", @@ -162,6 +174,17 @@ describe("describePatterns", () => { }) }) +describe("describeRule", () => { + test("describes doom loop rules as the action being allowed or denied", () => { + expect(describeRule("doom_loop", "read", t)).toBe("Continue Read calls") + }) + + test("preserves existing permission rule labels", () => { + expect(describeRule("read", "*", t)).toBe("Read") + expect(describeRule("read", "src/app.ts", t)).toBe("Read src/app.ts") + }) +}) + describe("resolveLabel", () => { test("returns translated label for known tool", () => { expect(resolveLabel("read", t)).toBe("Read") diff --git a/packages/kilo-vscode/tests/unit/provider-actions-save.test.ts b/packages/kilo-vscode/tests/unit/provider-actions-save.test.ts index 375a44efdcf..523ba6f94d8 100644 --- a/packages/kilo-vscode/tests/unit/provider-actions-save.test.ts +++ b/packages/kilo-vscode/tests/unit/provider-actions-save.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "bun:test" -import { connectProvider, disconnectProvider, fetchProviderData, saveCustomProvider } from "../../src/provider-actions" +import { + connectProvider, + disconnectProvider, + fetchProviderData, + resolveStoredKey, + saveCustomProvider, +} from "../../src/provider-actions" type ExistingGlobal = { disabled_providers?: string[]; provider?: Record } @@ -237,7 +243,7 @@ describe("saveCustomProvider", () => { expect(payload.myprovider.models["model-gone"]).toBeNull() }) - it("emits null sentinels for variants removed from a model that still exists", async () => { + it("emits null sentinels when reasoning and variants are removed from a model", async () => { const existing = { disabled_providers: [], provider: { @@ -264,11 +270,7 @@ describe("saveCustomProvider", () => { name: "My Provider", options: { baseURL: "https://example.com/v1" }, models: { - "model-1": { - name: "Model One", - reasoning: true, - variants: { high: { reasoningEffort: "high" } }, - }, + "model-1": { name: "Model One" }, }, } await saveCustomProvider(ctx, "req", "myprovider", next, undefined, false, null, setCachedConfig) @@ -277,11 +279,11 @@ describe("saveCustomProvider", () => { const model = ( calls.config[0].config.provider as Record< string, - { models: Record }> } + { models: Record }> } > ).myprovider.models["model-1"] - expect(model.variants).toBeDefined() - expect(model.variants?.high).toBeDefined() + expect(model.reasoning).toBeNull() + expect(model.variants?.high).toBeNull() expect(model.variants?.low).toBeNull() }) @@ -441,4 +443,69 @@ describe("fetchProviderData", () => { expect(result.authStates).toEqual({ "groq-test": "api" }) expect("key" in item).toBe(false) }) + + it("retains stripped keys for providers with a configured baseURL", async () => { + const client = { + provider: { + list: async () => ({ + data: { + all: [ + { + id: "myprovider", + name: "My Provider", + source: "config", + key: "sk-stored", + env: [], + options: { baseURL: "https://example.com/v1" }, + models: {}, + }, + { + id: "no-url", + name: "No URL", + source: "config", + key: "sk-other", + env: [], + models: {}, + }, + ], + connected: [], + default: {}, + }, + }), + auth: async () => ({ data: {} }), + }, + } as unknown as Parameters[0] + + const result = await fetchProviderData(client, "/tmp") + + expect(result.storedKeys).toEqual({ + myprovider: { key: "sk-stored", baseURL: "https://example.com/v1" }, + }) + expect(result.response.all.every((item) => !("key" in (item as Record)))).toBe(true) + }) +}) + +describe("resolveStoredKey", () => { + const storedKeys = { + myprovider: { key: "sk-stored", baseURL: "https://example.com/v1" }, + } + + it("returns the stored key when the fetch URL matches the configured baseURL", () => { + expect(resolveStoredKey(storedKeys, "myprovider", "https://example.com/v1")).toBe("sk-stored") + }) + + it("tolerates trailing-slash differences", () => { + expect(resolveStoredKey(storedKeys, "myprovider", "https://example.com/v1/")).toBe("sk-stored") + }) + + it("refuses to apply the stored key to a different host or path", () => { + expect(resolveStoredKey(storedKeys, "myprovider", "https://evil.example.net/v1")).toBeUndefined() + expect(resolveStoredKey(storedKeys, "myprovider", "https://example.com/v2")).toBeUndefined() + }) + + it("returns undefined for unknown or missing provider ids", () => { + expect(resolveStoredKey(storedKeys, "other", "https://example.com/v1")).toBeUndefined() + expect(resolveStoredKey(storedKeys, undefined, "https://example.com/v1")).toBeUndefined() + expect(resolveStoredKey(storedKeys, "", "https://example.com/v1")).toBeUndefined() + }) }) diff --git a/packages/kilo-vscode/tests/unit/revert-checkpoints.test.ts b/packages/kilo-vscode/tests/unit/revert-checkpoints.test.ts index 83f03b0fa76..5be7489e9c7 100644 --- a/packages/kilo-vscode/tests/unit/revert-checkpoints.test.ts +++ b/packages/kilo-vscode/tests/unit/revert-checkpoints.test.ts @@ -4,8 +4,18 @@ import path from "node:path" const ROOT = path.resolve(import.meta.dir, "../..") const TURN_FILE = path.join(ROOT, "webview-ui/src/components/chat/VscodeSessionTurn.tsx") +const PROVIDER_FILE = path.join(ROOT, "src/KiloProvider.ts") const src = fs.readFileSync(TURN_FILE, "utf-8") +const provider = fs.readFileSync(PROVIDER_FILE, "utf-8") + +function method(name: string, next: string) { + const start = provider.indexOf(` private async ${name}`) + const end = provider.indexOf(` private async ${next}`, start) + expect(start).toBeGreaterThan(-1) + expect(end).toBeGreaterThan(start) + return provider.slice(start, end) +} describe("message revert checkpoints", () => { it("keeps revert actions available after a session is already reverted", () => { @@ -18,3 +28,28 @@ describe("message revert checkpoints", () => { expect(src).not.toMatch(/data-revert-disabled=\{[\s\S]*?!session\.revert\(\)/) }) }) + +describe("revert session synchronization", () => { + it("keeps REST responses as the mutation result", () => { + const revert = method("handleRevertSession", "handleUnrevertSession") + const unrevert = method("handleUnrevertSession", "handleCompact") + + expect(revert).toContain("await this.client.session.revert") + expect(unrevert).toContain("await this.client.session.unrevert") + expect(revert).toContain('type: "sessionUpdated"') + expect(unrevert).toContain('type: "sessionUpdated"') + }) + + it("distinguishes partial sync patches from full bus snapshots", () => { + expect(provider).toMatch(/source: "sync"/) + expect(provider).toMatch( + /if \(event\.type === "session\.updated"\) return "source" in event && event\.source === "sync"/, + ) + expect(provider).toMatch( + /isLegacySyncEvent\(event\)\s*\? applySessionPatch\(this\.currentSession, event\.properties\.info\)\s*:\s*event\.properties\.info/, + ) + expect(provider).toMatch( + /isFullSessionUpdatedEvent\(event\)\s*\? \{ type: "sessionUpdated" as const, session: this\.sessionToWebview\(event\.properties\.info\) \}/, + ) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/session-status.test.ts b/packages/kilo-vscode/tests/unit/session-status.test.ts index 62478587521..f0b3af60baa 100644 --- a/packages/kilo-vscode/tests/unit/session-status.test.ts +++ b/packages/kilo-vscode/tests/unit/session-status.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "bun:test" -import { seedSessionStatuses, getBusySessionCount } from "../../src/session-status" +import { seedSessionStatuses } from "../../src/session-status" import type { SessionStatus } from "@kilocode/sdk/v2/client" /** @@ -196,23 +196,3 @@ describe("seedSessionStatuses", () => { expect(msgs).toEqual([{ type: "sessionStatus", sessionID: "confirmed", status: "busy" }]) }) }) - -// --------------------------------------------------------------------------- -// getBusySessionCount -// --------------------------------------------------------------------------- - -describe("getBusySessionCount", () => { - it("returns 0 for empty map", () => { - expect(getBusySessionCount(new Map())).toBe(0) - }) - - it("counts only busy entries, not idle or retry", () => { - const map = new Map([ - ["a", "busy"], - ["b", "idle"], - ["c", "retry"], - ["d", "busy"], - ]) - expect(getBusySessionCount(map)).toBe(2) - }) -}) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx index 62cbcb56f2e..14534929e10 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx @@ -17,7 +17,7 @@ import { Tooltip } from "@kilocode/kilo-ui/tooltip" import { useSession } from "../../context/session" import { useLanguage } from "../../context/language" import { useConfig } from "../../context/config" -import { describePatterns, resolveLabel, savedRuleStates, type RuleDecision } from "./permission-dock-utils" +import { describePatterns, describeRule, savedRuleStates, type RuleDecision } from "./permission-dock-utils" import { PermissionCommand } from "./PermissionCommand" import { PermissionDiff } from "./PermissionDiff" import { permissionDiffs } from "./permission-diff-utils" @@ -249,11 +249,7 @@ export const PermissionDock: Component<{ - {command() - ? label(rule) - : rule === "*" - ? resolveLabel(props.request.toolName, language.t) - : `${resolveLabel(props.request.toolName, language.t)} ${rule}`} + {command() ? label(rule) : describeRule(props.request.toolName, rule, language.t)} )} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/permission-dock-utils.ts b/packages/kilo-vscode/webview-ui/src/components/chat/permission-dock-utils.ts index 8d726db2901..efa04e468fb 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/permission-dock-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/components/chat/permission-dock-utils.ts @@ -52,6 +52,17 @@ export function resolveLabel(tool: string, t: (key: string) => string): string { return key ? t(key) : tool } +export function describeRule( + tool: string, + rule: string, + t: (key: string, params?: Record) => string, +): string { + if (tool === "doom_loop") { + return t("ui.permission.doomLoop.rule", { tool: resolveLabel(rule, t) }) + } + return rule === "*" ? resolveLabel(tool, t) : `${resolveLabel(tool, t)} ${rule}` +} + /** * Build a human-readable description for a permission request's patterns. * @@ -62,13 +73,20 @@ export function resolveLabel(tool: string, t: (key: string) => string): string { export function describePatterns( tool: string, patterns: string[], - t: (key: string) => string, + t: (key: string, params?: Record) => string, ): PatternDescription | null { const filtered = patterns.filter((p) => p !== "*") if (filtered.length === 0) return null - const key = TOOL_LABEL_KEYS[tool] - const label = key ? t(key) : tool + // doom-loop requests always contain one repeated tool pattern. + if (tool === "doom_loop") { + return { + kind: "single", + text: t("ui.permission.doomLoop.prompt", { tool: resolveLabel(filtered[0], t) }), + } + } + + const label = resolveLabel(tool, t) if (filtered.length === 1) return { kind: "single", text: `${label} ${filtered[0]}` } return { kind: "multi", title: `${label}:`, paths: filtered } } diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx index dd61fa49405..63d45639c48 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/AgentBehaviourTab.tsx @@ -43,6 +43,8 @@ interface SelectOption { import SettingsRow from "./SettingsRow" +const builtin = (skill: SkillInfo) => skill.location === "builtin" || skill.location === "" + // View states for the agents subtab type AgentView = "list" | "create" | "edit" @@ -848,10 +850,10 @@ const AgentBehaviourTab: Component = () => { }} >
{skill.description}
- {skill.location !== "builtin" &&
{skill.location}
} + {!builtin(skill) &&
{skill.location}
} - {skill.location !== "builtin" && ( + {!builtin(skill) && ( confirmRemoveSkill(skill)} /> )} diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderDialog.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderDialog.tsx index 2008b57b652..9b24cacf22e 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderDialog.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderDialog.tsx @@ -195,6 +195,12 @@ const CustomProviderDialog = (props: CustomProviderDialogProps) => { const raw = fetchKey().trim() const env = raw.match(/^\{env:([^}]+)\}$/)?.[1]?.trim() const apiKey = raw && !env ? raw : undefined + // When editing an existing provider with the key field untouched, the + // webview has no key to send — keys are stripped before provider data + // reaches it. Send the providerID so the extension can authenticate the + // fetch with the stored key (#10139). Anything typed into the field + // (a key or {env:VAR} syntax) takes precedence. + const providerID = !raw && props.existing ? props.existing.providerID : undefined const existing = new Set(form.models.map((m) => m.id.trim()).filter(Boolean)) const hdrs = form.headers @@ -253,6 +259,7 @@ const CustomProviderDialog = (props: CustomProviderDialogProps) => { requestId: rid, baseURL: url, apiKey, + providerID, headers, }) } diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/ModelsTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/ModelsTab.tsx index 4960afe249a..bd27ab6cc55 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/ModelsTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/ModelsTab.tsx @@ -1,5 +1,6 @@ import { Component, For, Show, createMemo } from "solid-js" import { Card } from "@kilocode/kilo-ui/card" +import { Switch } from "@kilocode/kilo-ui/switch" import { useConfig } from "../../context/config" import { useLanguage } from "../../context/language" import { useProvider } from "../../context/provider" @@ -154,7 +155,6 @@ const ModelsTab: Component = () => { { description={language.t("settings.autocomplete.model.description")} /> + + updateConfig({ hide_prompt_training_models: checked })} + hideLabel + > + {language.t("settings.models.hidePromptTraining.title")} + +

{language.t("settings.providers.modeModels")}

diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/NotificationsTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/NotificationsTab.tsx index e77f10e42a4..632d3ec84a2 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/NotificationsTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/NotificationsTab.tsx @@ -1,4 +1,5 @@ import { Component, createSignal, onCleanup } from "solid-js" +import { Button } from "@kilocode/kilo-ui/button" import { Switch } from "@kilocode/kilo-ui/switch" import { Select } from "@kilocode/kilo-ui/select" import { Card } from "@kilocode/kilo-ui/card" @@ -12,158 +13,96 @@ interface SoundOption { labelKey: string } +const groups = [ + { value: "alert", key: "alert", count: 10 }, + { value: "bip-bop", key: "bipbop", count: 10 }, + { value: "staplebops", key: "staplebops", count: 7 }, + { value: "nope", key: "nope", count: 12 }, + { value: "yup", key: "yup", count: 6 }, +] + const SOUND_OPTIONS: SoundOption[] = [ { value: "default", labelKey: "settings.notifications.sound.default" }, - { value: "none", labelKey: "settings.notifications.sound.none" }, + { value: "system", labelKey: "settings.notifications.sound.system" }, + ...groups.flatMap((group) => + Array.from({ length: group.count }, (_, index) => { + const suffix = String(index + 1).padStart(2, "0") + return { + value: `${group.value}-${suffix}`, + labelKey: `sound.option.${group.key}${suffix}`, + } + }), + ), ] const NotificationsTab: Component = () => { const vscode = useVSCode() const language = useLanguage() - - const [agentNotify, setAgentNotify] = createSignal(true) - const [permNotify, setPermNotify] = createSignal(true) - const [errorNotify, setErrorNotify] = createSignal(true) - const [agentSound, setAgentSound] = createSignal("default") - const [permSound, setPermSound] = createSignal("default") - const [errorSound, setErrorSound] = createSignal("default") + const [enabled, setEnabled] = createSignal(false) + const [sound, setSound] = createSignal("default") const unsubscribe = vscode.onMessage((message: ExtensionMessage) => { - if (message.type !== "notificationSettingsLoaded") { - return - } - const s = message.settings - setAgentNotify(s.notifyAgent) - setPermNotify(s.notifyPermissions) - setErrorNotify(s.notifyErrors) - setAgentSound(s.soundAgent) - setPermSound(s.soundPermissions) - setErrorSound(s.soundErrors) + if (message.type !== "notificationSettingsLoaded") return + setEnabled(message.settings.attentionEnabled) + setSound( + SOUND_OPTIONS.some((option) => option.value === message.settings.attentionSound) + ? message.settings.attentionSound + : "default", + ) }) onCleanup(unsubscribe) vscode.postMessage({ type: "requestNotificationSettings" }) - const save = (key: string, value: unknown) => { - vscode.postMessage({ type: "updateSetting", key, value }) - } - return ( -
- - - { - setAgentNotify(checked) - save("notifications.agent", checked) - }} - hideLabel - > - {language.t("settings.notifications.agent.title")} - - - - { - setPermNotify(checked) - save("notifications.permissions", checked) - }} - hideLabel - > - {language.t("settings.notifications.permissions.title")} - - - - { - setErrorNotify(checked) - save("notifications.errors", checked) - }} - hideLabel - > - {language.t("settings.notifications.errors.title")} - - - - -

{language.t("settings.notifications.sounds")}

- - + + { + setEnabled(checked) + vscode.postMessage({ type: "updateSetting", key: "attention.enabled", value: checked }) + }} + hideLabel > + {language.t("settings.notifications.enable.title")} + + + +
o.value === permSound())} - value={(o) => o.value} - label={(o) => language.t(o.labelKey)} - onSelect={(o) => { - if (o) { - setPermSound(o.value) - save("sounds.permissions", o.value) - } - }} - variant="secondary" +