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