Merge pull request #10228 from Kilo-Org/supersede-shell-command-highlighting

feat(ui): add highlighted shell command output
This commit is contained in:
Marius
2026-05-14 11:43:45 +02:00
committed by GitHub
32 changed files with 395 additions and 59 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Add syntax highlighting to shell command output with Shiki, labeled "Command" and "Output" sections, per-section copy buttons, and an "Open in Editor" action that opens the full untruncated output in a VS Code editor tab.
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c3f37193389ad7937c7b2103ad2fe3dd63123a0d4f60a0fbfd65b31d3982c092
size 12105
oid sha256:dbaab3a1ff10eac8f710b0ed8ddc99d96188c06a09c227e634ca712bcee12130
size 11543
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:74a42ec77a8ac8d5a1c555b46e3d550c9acf3198f45cc87da6582c0ff924d5f9
size 17677
oid sha256:7cabfff215cef485ec913b9a082aaac844275e506e88ba0fab112d95ff6de329
size 18200
+4
View File
@@ -91,7 +91,11 @@ export const dict = {
"snapshot.slowRepo.answer.disable.description":
"أوقف لقطات Kilo لهذا المشروع. ستفقد ميزة التراجع/الإعادة لتعديلات Kilo، لكن git سيستمر في تتبع كل شيء.",
// Edit-tool header and shell-tool section labels
"ui.messagePart.openInDiffViewer": "فتح في عارض الفروقات",
"ui.messagePart.shell.command": "الأمر",
"ui.messagePart.shell.output": "المخرجات",
"ui.messagePart.openInEditor": "فتح في المحرر",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "كان هذا مفيدًا",
+4
View File
@@ -91,7 +91,11 @@ export const dict = {
"snapshot.slowRepo.answer.disable.description":
"Desligue os snapshots do Kilo para este projeto. Você perde desfazer/refazer das mudanças feitas pelo Kilo, mas o git continua rastreando tudo.",
// Edit-tool header and shell-tool section labels
"ui.messagePart.openInDiffViewer": "Abrir no Visualizador de Diferenças",
"ui.messagePart.shell.command": "Comando",
"ui.messagePart.shell.output": "Saída",
"ui.messagePart.openInEditor": "Abrir no Editor",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "Isso foi útil",
+4
View File
@@ -96,7 +96,11 @@ export const dict = {
"snapshot.slowRepo.answer.disable.description":
"Isključi Kilo snapshotove za ovaj projekat. Izgubićete poništi/vrati za izmjene koje napravi Kilo, ali git i dalje prati sve.",
// Edit-tool header and shell-tool section labels
"ui.messagePart.openInDiffViewer": "Otvori u pregledniku razlika",
"ui.messagePart.shell.command": "Naredba",
"ui.messagePart.shell.output": "Izlaz",
"ui.messagePart.openInEditor": "Otvori u editoru",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "Ovo je bilo korisno",
+5 -1
View File
@@ -91,7 +91,11 @@ export const dict = {
"snapshot.slowRepo.answer.disable.description":
"Slå Kilos snapshots fra for dette projekt. Du mister fortryd/gentag for Kilo-ændringer, men git sporer stadig alt.",
"ui.messagePart.openInDiffViewer": "Åbn i diff-viser",
// Edit-tool header and shell-tool section labels
"ui.messagePart.openInDiffViewer": "Åbn i Diff-visning",
"ui.messagePart.shell.command": "Kommando",
"ui.messagePart.shell.output": "Output",
"ui.messagePart.openInEditor": "Åbn i editor",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "Dette var nyttigt",
+4
View File
@@ -91,7 +91,11 @@ export const dict = {
"snapshot.slowRepo.answer.disable.description":
"Kilo-Snapshots für dieses Projekt ausschalten. Rückgängig/Wiederherstellen für Kilo-Änderungen ist nicht mehr möglich, aber git verfolgt weiterhin alles.",
// Edit-tool header and shell-tool section labels
"ui.messagePart.openInDiffViewer": "Im Diff-Viewer öffnen",
"ui.messagePart.shell.command": "Befehl",
"ui.messagePart.shell.output": "Ausgabe",
"ui.messagePart.openInEditor": "Im Editor öffnen",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "Das war hilfreich",
+4
View File
@@ -99,6 +99,10 @@ export const dict = {
// Edit-tool header: hover-revealed action opening the diff in a full tab.
"ui.messagePart.openInDiffViewer": "Open in Diff Viewer",
// Shell-tool section labels and actions.
"ui.messagePart.shell.command": "Command",
"ui.messagePart.shell.output": "Output",
"ui.messagePart.openInEditor": "Open in Editor",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "This was helpful",
+4
View File
@@ -91,7 +91,11 @@ export const dict = {
"snapshot.slowRepo.answer.disable.description":
"Apaga las instantáneas de Kilo para este proyecto. Perderás deshacer/rehacer de los cambios de Kilo, pero git seguirá rastreando todo.",
// Edit-tool header and shell-tool section labels
"ui.messagePart.openInDiffViewer": "Abrir en el visor de diferencias",
"ui.messagePart.shell.command": "Comando",
"ui.messagePart.shell.output": "Salida",
"ui.messagePart.openInEditor": "Abrir en el editor",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "Esto fue útil",
+5 -1
View File
@@ -91,7 +91,11 @@ export const dict = {
"snapshot.slowRepo.answer.disable.description":
"Désactivez les instantanés Kilo pour ce projet. Vous perdez l'annulation/restauration des modifications faites par Kilo, mais git continue de tout suivre.",
"ui.messagePart.openInDiffViewer": "Ouvrir dans la visionneuse de différences",
// Edit-tool header and shell-tool section labels
"ui.messagePart.openInDiffViewer": "Ouvrir dans le visualiseur de différences",
"ui.messagePart.shell.command": "Commande",
"ui.messagePart.shell.output": "Sortie",
"ui.messagePart.openInEditor": "Ouvrir dans l'éditeur",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "C'était utile",
+5 -1
View File
@@ -89,7 +89,11 @@ export const dict = {
"snapshot.slowRepo.answer.disable.description":
"このプロジェクトでは Kilo のスナップショットを無効にします。Kilo による変更の取り消し/やり直しはできなくなりますが、git は引き続きすべてを追跡します。",
"ui.messagePart.openInDiffViewer": "差分ビューアで開く",
// Edit-tool header and shell-tool section labels
"ui.messagePart.openInDiffViewer": "差分ビューアーで開く",
"ui.messagePart.shell.command": "コマンド",
"ui.messagePart.shell.output": "出力",
"ui.messagePart.openInEditor": "エディタで開く",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "役に立ちました",
+5 -1
View File
@@ -89,7 +89,11 @@ export const dict = {
"snapshot.slowRepo.answer.disable.description":
"이 프로젝트의 Kilo 스냅샷을 끕니다. Kilo 변경에 대한 실행 취소/다시 실행은 사용할 수 없지만 git은 여전히 모든 것을 추적합니다.",
"ui.messagePart.openInDiffViewer": "Diff 뷰어에서 열기",
// Edit-tool header and shell-tool section labels
"ui.messagePart.openInDiffViewer": "차이점 뷰어에서 열기",
"ui.messagePart.shell.command": "명령어",
"ui.messagePart.shell.output": "출력",
"ui.messagePart.openInEditor": "편집기에서 열기",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "도움이 됐어요",
+5 -1
View File
@@ -93,7 +93,11 @@ export const dict = {
"snapshot.slowRepo.answer.disable.description":
"Zet Kilo-snapshots uit voor dit project. Je verliest ongedaan maken/opnieuw doen van Kilo-wijzigingen, maar git blijft alles volgen.",
"ui.messagePart.openInDiffViewer": "Openen in diff-viewer",
// Edit-tool header and shell-tool section labels
"ui.messagePart.openInDiffViewer": "Openen in Diff-weergave",
"ui.messagePart.shell.command": "Opdracht",
"ui.messagePart.shell.output": "Uitvoer",
"ui.messagePart.openInEditor": "Openen in editor",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "Dit was nuttig",
+5 -1
View File
@@ -91,7 +91,11 @@ export const dict = {
"snapshot.slowRepo.answer.disable.description":
"Slå av Kilos snapshots for dette prosjektet. Du mister angre/gjør om for Kilo-endringer, men git fortsetter å spore alt.",
"ui.messagePart.openInDiffViewer": "Åpne i diff-viser",
// Edit-tool header and shell-tool section labels
"ui.messagePart.openInDiffViewer": "Åpne i diff-visning",
"ui.messagePart.shell.command": "Kommando",
"ui.messagePart.shell.output": "Utdata",
"ui.messagePart.openInEditor": "Åpne i editor",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "Dette var nyttig",
+4
View File
@@ -91,7 +91,11 @@ export const dict = {
"snapshot.slowRepo.answer.disable.description":
"Wyłącz snapshoty Kilo dla tego projektu. Stracisz cofanie/przywracanie zmian Kilo, ale git nadal śledzi wszystko.",
// Edit-tool header and shell-tool section labels
"ui.messagePart.openInDiffViewer": "Otwórz w podglądzie różnic",
"ui.messagePart.shell.command": "Polecenie",
"ui.messagePart.shell.output": "Wyjście",
"ui.messagePart.openInEditor": "Otwórz w edytorze",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "To było pomocne",
+5 -1
View File
@@ -91,7 +91,11 @@ export const dict = {
"snapshot.slowRepo.answer.disable.description":
"Выключите снимки Kilo для этого проекта. Вы потеряете отмену/повтор изменений Kilo, но git по-прежнему отслеживает всё.",
"ui.messagePart.openInDiffViewer": "Открыть в просмотрщике различий",
// Edit-tool header and shell-tool section labels
"ui.messagePart.openInDiffViewer": "Открыть в просмотре различий",
"ui.messagePart.shell.command": "Команда",
"ui.messagePart.shell.output": "Вывод",
"ui.messagePart.openInEditor": "Открыть в редакторе",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "Это было полезно",
+4
View File
@@ -91,7 +91,11 @@ export const dict = {
"snapshot.slowRepo.answer.disable.description":
"ปิดสแน็ปช็อตของ Kilo สำหรับโปรเจกต์นี้ คุณจะสูญเสียการยกเลิก/ทำซ้ำสำหรับการเปลี่ยนแปลงของ Kilo แต่ git ยังคงติดตามทุกอย่าง",
// Edit-tool header and shell-tool section labels
"ui.messagePart.openInDiffViewer": "เปิดในตัวดูความแตกต่าง",
"ui.messagePart.shell.command": "คำสั่ง",
"ui.messagePart.shell.output": "ผลลัพธ์",
"ui.messagePart.openInEditor": "เปิดในตัวแก้ไข",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "สิ่งนี้มีประโยชน์",
+5 -1
View File
@@ -91,7 +91,11 @@ export const dict = {
"snapshot.slowRepo.answer.disable.description":
"Bu proje için Kilo anlık görüntülerini kapat. Kilo değişiklikleri için geri alma/yeniden yapmayı kaybedersiniz, ancak git her şeyi izlemeye devam eder.",
"ui.messagePart.openInDiffViewer": "Fark görüntüleyicide aç",
// Edit-tool header and shell-tool section labels
"ui.messagePart.openInDiffViewer": "Fark Görüntüleyicide Aç",
"ui.messagePart.shell.command": "Komut",
"ui.messagePart.shell.output": "Çıktı",
"ui.messagePart.openInEditor": "Düzenleyicide Aç",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "Bu yardımcı oldu",
+5 -1
View File
@@ -91,7 +91,11 @@ export const dict = {
"snapshot.slowRepo.answer.disable.description":
"Вимкніть знімки Kilo для цього проєкту. Ви втратите скасування/повторення для змін Kilo, але git продовжить відстежувати все.",
"ui.messagePart.openInDiffViewer": "Відкрити у переглядачі відмінностей",
// Edit-tool header and shell-tool section labels
"ui.messagePart.openInDiffViewer": "Відкрити в переглядачі відмінностей",
"ui.messagePart.shell.command": "Команда",
"ui.messagePart.shell.output": "Вивід",
"ui.messagePart.openInEditor": "Відкрити в редакторі",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "Це було корисно",
+4
View File
@@ -86,7 +86,11 @@ export const dict = {
"snapshot.slowRepo.answer.disable.description":
"关闭本项目的 Kilo 快照。你将失去对 Kilo 更改的撤销/重做,但 git 仍会追踪所有内容。",
// Edit-tool header and shell-tool section labels
"ui.messagePart.openInDiffViewer": "在差异查看器中打开",
"ui.messagePart.shell.command": "命令",
"ui.messagePart.shell.output": "输出",
"ui.messagePart.openInEditor": "在编辑器中打开",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "这有帮助",
+4
View File
@@ -86,7 +86,11 @@ export const dict = {
"snapshot.slowRepo.answer.disable.description":
"關閉本專案的 Kilo 快照。你將失去對 Kilo 變更的撤銷/重做,但 git 仍會追蹤所有內容。",
// Edit-tool header and shell-tool section labels
"ui.messagePart.openInDiffViewer": "在差異檢視器中開啟",
"ui.messagePart.shell.command": "命令",
"ui.messagePart.shell.output": "輸出",
"ui.messagePart.openInEditor": "在編輯器中開啟",
// Message feedback (thumbs up/down per assistant response)
"ui.message.feedback.helpful": "這有幫助",
@@ -195,11 +195,69 @@ html[data-theme="kilo-vscode"] [data-component="bash-output"] {
color: var(--vscode-terminal-foreground, var(--vscode-editor-foreground));
}
[data-slot="bash-copy"] [data-component="icon-button"][data-variant="secondary"] {
background: var(--vscode-terminal-background, var(--vscode-panel-background));
[data-slot="bash-divider"] {
background: var(--vscode-panel-border, var(--border-weak-base));
}
}
/* Bash tool: section layout (command + output) */
[data-component="bash-output"] [data-slot="bash-section"] {
position: relative;
}
[data-component="bash-output"] [data-slot="bash-section-code"] {
overflow-y: auto;
overflow-x: hidden;
max-height: 240px;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
[data-component="bash-output"] [data-slot="bash-section-actions"] {
position: absolute;
top: 4px;
right: 4px;
display: flex;
align-items: center;
gap: 2px;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
}
[data-component="bash-output"] [data-slot="bash-section"]:hover [data-slot="bash-section-actions"],
[data-component="bash-output"] [data-slot="bash-section"]:focus-within [data-slot="bash-section-actions"] {
opacity: 1;
pointer-events: auto;
}
/* Edge-to-edge divider (matches MCP tool divider) */
[data-component="bash-output"] [data-slot="bash-divider"] {
height: 1px;
background: var(--border-weak-base);
}
/* Shiki syntax-highlighted shell output */
[data-component="bash-output"] .shiki,
[data-component="shell-expanded-output"] .shiki {
margin: 0;
padding: 12px;
background: transparent !important;
}
[data-component="bash-output"] .shiki code,
[data-component="shell-expanded-output"] .shiki code {
font-family: var(--font-family-mono);
font-feature-settings: var(--font-family-mono--font-feature-settings);
font-size: var(--font-size-small);
line-height: var(--line-height-large);
white-space: pre-wrap;
overflow-wrap: anywhere;
background: transparent;
}
/* Tool error inline card (upstream removed these styles in v1.2.25) */
[data-component="tool-error"] {
display: flex;
+111 -36
View File
@@ -54,6 +54,8 @@ import { ContextToolGroupHeader, ContextToolExpandedList, ContextToolRollingResu
import { ShellRollingResults } from "./shell-rolling-results"
import { extractFilePathFromHref } from "../file-path"
import { normalize } from "./session-diff"
import { deferredHighlight } from "../context/marked"
import { escapeHtml } from "../util/escape-html"
// Windows CLI tools (e.g. winget) use \r to overwrite progress bars in-place.
// Without this, every progress frame renders as a separate visual line.
@@ -2028,6 +2030,112 @@ ToolRegistry.register({
},
})
function BashCopyButton(props: { value: () => string; label: string }) {
const i18n = useI18n()
const [copied, setCopied] = createSignal(false)
const handler = async () => {
const text = props.value()
if (!text) return
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<Tooltip value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} placement="bottom" gutter={4}>
<IconButton
icon={copied() ? "check" : "copy"}
size="small"
variant="ghost"
onClick={handler}
aria-label={props.label}
/>
</Tooltip>
)
}
function BashHighlightedOutput(props: { cmd: string; output: string; outputPath?: string; active?: boolean }) {
const data = useData()
const i18n = useI18n()
const cmdState = { signal: { aborted: false } }
const outState = { signal: { aborted: false } }
let cmdRef: HTMLDivElement | undefined
let outRef: HTMLDivElement | undefined
createEffect(() => {
cmdState.signal.aborted = true
if (!props.active) return
const cmd = props.cmd
if (!cmdRef || !cmd) return
const signal = { aborted: false }
cmdState.signal = signal
cmdRef.innerHTML = `<pre data-slot="bash-pre"><code data-lang="shellscript">${escapeHtml(cmd)}</code></pre>`
void deferredHighlight(cmdRef, undefined, signal)
})
createEffect(() => {
outState.signal.aborted = true
if (!props.active) return
const out = props.output
if (!outRef || !out) return
const signal = { aborted: false }
outState.signal = signal
outRef.innerHTML = `<pre data-slot="bash-pre"><code data-lang="log">${escapeHtml(out)}</code></pre>`
void deferredHighlight(outRef, undefined, signal)
})
onCleanup(() => {
cmdState.signal.aborted = true
outState.signal.aborted = true
})
const openInEditor = () => {
// When output was truncated, open the full output file on disk
if (props.outputPath && data.openFile) {
data.openFile(props.outputPath)
return
}
if (!data.openContent) return
data.openContent(props.output, "log")
}
return (
<div data-component="bash-output">
<Show when={props.cmd}>
<div data-slot="mcp-section-label">{i18n.t("ui.messagePart.shell.command")}</div>
<div data-slot="bash-section">
<div data-slot="bash-section-code" ref={cmdRef} />
<div data-slot="bash-section-actions">
<BashCopyButton value={() => props.cmd} label={i18n.t("ui.message.copy")} />
</div>
</div>
</Show>
<Show when={props.cmd && props.output}>
<div data-slot="bash-divider" />
</Show>
<Show when={props.output}>
<div data-slot="mcp-section-label">{i18n.t("ui.messagePart.shell.output")}</div>
<div data-slot="bash-section">
<div data-slot="bash-section-code" data-scrollable ref={outRef} />
<div data-slot="bash-section-actions">
<Show when={data.openContent || (props.outputPath && data.openFile)}>
<Tooltip value={i18n.t("ui.messagePart.openInEditor")} placement="bottom" gutter={4}>
<IconButton
icon="square-arrow-top-right"
size="small"
variant="ghost"
onClick={openInEditor}
aria-label={i18n.t("ui.messagePart.openInEditor")}
/>
</Tooltip>
</Show>
<BashCopyButton value={() => props.output} label={i18n.t("ui.message.copy")} />
</div>
</div>
</Show>
</div>
)
}
ToolRegistry.register({
name: "bash",
render(props) {
@@ -2035,6 +2143,7 @@ ToolRegistry.register({
const pending = () => busy(props.status)
const reveal = useToolReveal(pending, () => props.reveal !== false)
const subtitle = () => props.input.description ?? props.metadata.description
const [open, setOpen] = createSignal(props.defaultOpen ?? true)
// also apply processCarriageReturns for Windows CLI tools
const cmd = createMemo(() => {
@@ -2048,19 +2157,6 @@ ToolRegistry.register({
return ""
})
const out = createMemo(() => processCarriageReturns(stripAnsi(rawOutput())))
const text = createMemo(() => `$ ${cmd()}${out() ? "\n\n" + out() : ""}`)
const hasOutput = createMemo(() => out().length > 0)
const [copied, setCopied] = createSignal(false)
const handleCopy = async () => {
const command = cmd()
if (!command) return
const content = out() ? `${command}\n\n${out()}` : command
await navigator.clipboard.writeText(content)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<BasicTool
@@ -2068,6 +2164,7 @@ ToolRegistry.register({
icon="console"
animated
defaultOpen={props.defaultOpen ?? true}
onOpenChange={setOpen}
allowPendingToggle
trigger={
<div data-slot="basic-tool-tool-info-structured">
@@ -2080,29 +2177,7 @@ ToolRegistry.register({
</div>
}
>
<div data-component="bash-output">
<div data-slot="bash-copy">
<Tooltip
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
placement="top"
gutter={4}
>
<IconButton
icon={copied() ? "check" : "copy"}
size="small"
variant="secondary"
onMouseDown={(e) => e.preventDefault()}
onClick={handleCopy}
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
/>
</Tooltip>
</div>
<div data-slot="bash-scroll" data-scrollable>
<pre data-slot="bash-pre">
<code>{text()}</code>
</pre>
</div>
</div>
<BashHighlightedOutput cmd={cmd()} output={out()} outputPath={props.metadata.outputPath} active={open()} />
</BasicTool>
)
},
@@ -3,6 +3,8 @@ import stripAnsi from "strip-ansi"
import type { ToolPart } from "@kilocode/sdk/v2"
import { useReducedMotion } from "../hooks/use-reduced-motion"
import { useI18n } from "../context/i18n"
import { deferredHighlight } from "../context/marked"
import { escapeHtml } from "../util/escape-html"
import { RollingResults } from "./rolling-results"
import { Icon } from "./icon"
import { IconButton } from "./icon-button"
@@ -57,6 +59,28 @@ function ShellRollingCommand(props: { text: string; animate?: boolean }) {
)
}
function ShellOutputHighlight(props: { code: string; active?: boolean }) {
const state = { signal: { aborted: false } }
let ref: HTMLDivElement | undefined
createEffect(() => {
state.signal.aborted = true
if (!props.active) return
const code = props.code
if (!ref || !code) return
const signal = { aborted: false }
state.signal = signal
ref.innerHTML = `<pre data-slot="shell-expanded-pre"><code data-lang="log">${escapeHtml(code)}</code></pre>`
void deferredHighlight(ref, undefined, signal)
})
onCleanup(() => {
state.signal.aborted = true
})
return <div ref={ref} />
}
function ShellExpanded(props: { cmd: string; out: string; open: boolean }) {
const i18n = useI18n()
const rows = 10
@@ -156,9 +180,7 @@ function ShellExpanded(props: { cmd: string; out: string; open: boolean }) {
onScroll={updateMask}
style={{ "max-height": `${cap()}px` }}
>
<pre data-slot="shell-expanded-pre">
<code>{props.out}</code>
</pre>
<ShellOutputHighlight code={props.out} active={props.open} />
</div>
</>
</Show>
+3
View File
@@ -0,0 +1,3 @@
export function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
}
+30 -5
View File
@@ -607,6 +607,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
await routeSuggestionWebviewMessage(this.questionCtx, message)
if (await ModelState.handleMessage(message.type, message, this.client, (msg) => this.postMessage(msg))) return
if (await routeAutocompleteMessage(message, (msg) => this.postMessage(msg))) return
if (this.handleEditorOpenMessage(message)) return
if (
await handleSidebarWorktreeMessage(message, {
post: (msg) => this.postMessage(msg),
@@ -771,11 +772,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
break
case "saveImage":
return saveImage(this.getWorkspaceDirectory(this.currentSession?.id), message)
case "openFile":
if (message.filePath) {
this.handleOpenFile(message.filePath, message.line, message.column)
}
break
case "requestProviders":
this.fetchAndSendProviders().catch((e) => console.error("[Kilo New] fetchAndSendProviders failed:", e))
break
@@ -2897,6 +2893,35 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
.then(open, (err) => console.error("[Kilo New] KiloProvider: Failed to preview image:", err))
}
private handleEditorOpenMessage(message: {
type?: string
filePath?: string
line?: number
column?: number
content?: string
language?: string
}): boolean {
if (message.type === "openFile") {
if (message.filePath) this.handleOpenFile(message.filePath, message.line, message.column)
return true
}
if (message.type === "openContent") {
if (message.content) this.handleOpenContent(message.content, message.language)
return true
}
return false
}
/**
* Handle openContent request - open arbitrary text in an untitled VS Code editor tab.
*/
private handleOpenContent(content: string, language?: string): void {
vscode.workspace.openTextDocument({ content, language: language || "log" }).then(
(doc) => vscode.window.showTextDocument(doc, { preview: true }),
(err) => console.error("[Kilo New] KiloProvider: Failed to open content:", err),
)
}
/**
* Handle openFile request from the webview — open a file in the VS Code editor.
* Resolves relative paths against the current session's directory (which may be
@@ -128,6 +128,13 @@ describe("DataProvider contract (runtime)", () => {
expect(src).toContain("OpenDiffFn")
expect(src).toMatch(/openDiff:\s*props\.onOpenDiff/)
})
it("DataProvider accepts onOpenContent prop and exports OpenContentFn (source)", () => {
const src = fs.readFileSync(DATA_CONTEXT_FILE, "utf-8")
expect(src).toContain("onOpenContent")
expect(src).toContain("OpenContentFn")
expect(src).toMatch(/openContent:\s*props\.onOpenContent/)
})
})
describe("Edit tool diff-first click contract (source)", () => {
@@ -164,6 +171,53 @@ describe("Write and apply_patch patch rendering contracts (source)", () => {
})
})
describe("Bash tool syntax highlighting and section labels (source)", () => {
const src = fs.readFileSync(KILO_MESSAGE_PART_FILE, "utf-8")
const block =
src.match(/ToolRegistry\.register\(\{\s*name:\s*"bash"[\s\S]*?(?=ToolRegistry\.register\(|$)/)?.[0] ?? ""
it("bash tool renders BashHighlightedOutput", () => {
expect(block).toContain("BashHighlightedOutput")
})
it("BashHighlightedOutput uses shellscript grammar for commands without $ prefix", () => {
// The command should be highlighted as shellscript, but the $ prompt must
// NOT be inside the highlighted code (it breaks Shiki's parse context)
expect(src).toMatch(/data-lang="shellscript">\$\{escapeHtml\(cmd\)\}/)
expect(src).not.toMatch(/data-lang="shellscript">\$\s/)
})
it("BashHighlightedOutput uses log grammar for output", () => {
expect(src).toMatch(/data-lang="log"/)
})
it("BashHighlightedOutput renders section labels matching MCP tool pattern", () => {
// Must use the same data-slot as MCP tools for consistent styling
expect(src).toMatch(/data-slot="mcp-section-label".*shell\.command/)
expect(src).toMatch(/data-slot="mcp-section-label".*shell\.output/)
})
it("BashHighlightedOutput has edge-to-edge divider between sections", () => {
expect(src).toContain('data-slot="bash-divider"')
})
it("BashHighlightedOutput supports openContent for opening output in editor", () => {
expect(src).toContain("data.openContent")
expect(src).toContain("openInEditor")
})
it("BashHighlightedOutput opens full output file when truncated", () => {
// When the CLI truncates output, metadata.outputPath holds the full file.
// openInEditor should prefer openFile(outputPath) over openContent.
expect(src).toContain("props.outputPath")
expect(src).toMatch(/props\.outputPath.*data\.openFile/)
})
it("bash tool passes outputPath from metadata to BashHighlightedOutput", () => {
expect(block).toContain("props.metadata.outputPath")
})
})
describe("BasicTool export contract (runtime)", () => {
it("BasicTool and GenericTool are exported from basic-tool", () => {
const result = check(`
@@ -144,6 +144,10 @@ export const DataBridge: Component<{ children: any }> = (props) => {
vscode.postMessage({ type: "openExternal", url })
}
const openContent = (content: string, language?: string) => {
vscode.postMessage({ type: "openContent", content, language })
}
const directory = () => {
const dir = server.workspaceDirectory()
if (!dir) return ""
@@ -161,6 +165,7 @@ export const DataBridge: Component<{ children: any }> = (props) => {
onOpenFile={open}
onOpenDiff={openDiff}
onOpenUrl={openUrl}
onOpenContent={openContent}
>
{props.children}
</DataProvider>
@@ -131,6 +131,12 @@ export interface OpenFileRequest {
column?: number
}
export interface OpenContentRequest {
type: "openContent"
content: string
language?: string
}
export interface CancelLoginRequest {
type: "cancelLogin"
}
@@ -1198,6 +1204,7 @@ export type WebviewMessage =
| ToggleSectionCollapsedRequest
| MoveToSectionRequest
| MoveSectionRequest
| OpenContentRequest
| AgentManagerTerminalCreateRequest
| AgentManagerTerminalCloseRequest
| AgentManagerTerminalResizeRequest
@@ -35,6 +35,7 @@ export interface BasicToolProps {
animated?: boolean
allowPendingToggle?: boolean // kilocode_change
onSubtitleClick?: () => void
onOpenChange?: (open: boolean) => void // kilocode_change
onTriggerClick?: JSX.EventHandlerUnion<HTMLElement, MouseEvent>
triggerHref?: string
clickable?: boolean
@@ -124,6 +125,7 @@ export function BasicTool(props: BasicToolProps) {
if (props.hideDetails) return // kilocode_change
if (props.locked && !value) return
setState("open", value)
props.onOpenChange?.(value) // kilocode_change
}
const trigger = () => (
+4
View File
@@ -43,6 +43,8 @@ export type OpenDiffFn = (diff: {
}) => void
export type OpenUrlFn = (url: string) => void
export type OpenContentFn = (content: string, language?: string) => void // kilocode_change
// kilocode_change end
export const { use: useData, provider: DataProvider } = createSimpleContext({
@@ -55,6 +57,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
onOpenFile?: OpenFileFn // kilocode_change
onOpenDiff?: OpenDiffFn // kilocode_change
onOpenUrl?: OpenUrlFn // kilocode_change
onOpenContent?: OpenContentFn // kilocode_change
}) => {
return {
get store() {
@@ -68,6 +71,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
openFile: props.onOpenFile, // kilocode_change
openDiff: props.onOpenDiff, // kilocode_change
openUrl: props.onOpenUrl, // kilocode_change
openContent: props.onOpenContent, // kilocode_change
}
},
})