Merge 4726acf423 into ae614d919f
commit
f9eb86362f
|
|
@ -0,0 +1,200 @@
|
|||
import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import type { Part as SdkPart } from "@opencode-ai/sdk/v2"
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { Keybind } from "@/util/keybind"
|
||||
|
||||
type Value = {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
partID: string
|
||||
kind: string
|
||||
}
|
||||
|
||||
const money = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
})
|
||||
|
||||
function estimate(part: SdkPart): number {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
return Math.ceil((part.text?.length ?? 0) / 4)
|
||||
case "tool": {
|
||||
if (part.state.status !== "completed") return 0
|
||||
const raw = JSON.stringify(part.state.input ?? "").length + (part.state.output ?? "").length
|
||||
return Math.ceil(raw / 4)
|
||||
}
|
||||
case "reasoning":
|
||||
return Math.ceil((part.text?.length ?? 0) / 4)
|
||||
case "subtask":
|
||||
return Math.ceil((part.prompt?.length ?? 0) / 4)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
function label(part: SdkPart): string {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
return (part.text ?? "").slice(0, 60).replace(/\n/g, " ") || "(empty)"
|
||||
case "tool": {
|
||||
const icon =
|
||||
part.state.status === "completed"
|
||||
? "✓"
|
||||
: part.state.status === "error"
|
||||
? "✗"
|
||||
: part.state.status === "running"
|
||||
? "…"
|
||||
: "○"
|
||||
const flag = part.state.status === "completed" && part.state.time?.compacted ? " [compacted]" : ""
|
||||
return `[${part.tool}] ${icon}${flag}`
|
||||
}
|
||||
case "reasoning":
|
||||
return "[reasoning] " + (part.text ?? "").slice(0, 40).replace(/\n/g, " ")
|
||||
case "subtask":
|
||||
return `[subtask] ${part.description ?? part.prompt?.slice(0, 40) ?? ""}`
|
||||
case "file":
|
||||
return `[file] ${part.filename ?? part.url ?? ""}`
|
||||
case "step-start":
|
||||
return "[step-start]"
|
||||
case "step-finish":
|
||||
return `[step-finish] ${part.reason ?? ""}`
|
||||
case "patch":
|
||||
return `[patch] ${part.files?.length ?? 0} files`
|
||||
case "agent":
|
||||
return `[@${part.name}]`
|
||||
case "retry":
|
||||
return `[retry] #${part.attempt}`
|
||||
case "compaction":
|
||||
return "[compaction]"
|
||||
case "snapshot":
|
||||
return "[snapshot]"
|
||||
default:
|
||||
return `[unknown]`
|
||||
}
|
||||
}
|
||||
|
||||
function desc(part: SdkPart): string | undefined {
|
||||
if (part.type === "tool" && part.state.status === "completed") return part.state.title ?? undefined
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function show(api: TuiPluginApi, sessionID: string) {
|
||||
api.ui.dialog.setSize("xlarge")
|
||||
api.ui.dialog.replace(() => <Viewer api={api} sessionID={sessionID} />)
|
||||
}
|
||||
|
||||
function Viewer(props: { api: TuiPluginApi; sessionID: string }) {
|
||||
const dialog = useDialog()
|
||||
const theme = () => props.api.theme.current
|
||||
const [pending, setPending] = createSignal<string>()
|
||||
const msgs = createMemo(() => props.api.state.session.messages(props.sessionID))
|
||||
const cost = createMemo(() => msgs().reduce((sum, m) => sum + (m.role === "assistant" ? m.cost : 0), 0))
|
||||
|
||||
const options = createMemo(() => {
|
||||
const result: DialogSelectOption<Value>[] = []
|
||||
const all = msgs()
|
||||
for (let i = 0; i < all.length; i++) {
|
||||
const msg = all[i]
|
||||
const parts = props.api.state.part(msg.id)
|
||||
const role = msg.role === "user" ? "User" : "Assistant"
|
||||
let info = ""
|
||||
if (msg.role === "assistant") {
|
||||
const t = msg.tokens
|
||||
const total = t.input + t.output + t.reasoning + t.cache.read + t.cache.write
|
||||
info = ` · ${total.toLocaleString()} tok`
|
||||
if (msg.cost > 0) info += ` · ${money.format(msg.cost)}`
|
||||
}
|
||||
const category = `#${i + 1} ${role}${info}`
|
||||
|
||||
for (const part of parts) {
|
||||
const tok = estimate(part)
|
||||
const deleting = pending() === part.id
|
||||
result.push({
|
||||
title: deleting ? "Press again to confirm delete" : label(part),
|
||||
value: {
|
||||
sessionID: props.sessionID,
|
||||
messageID: msg.id,
|
||||
partID: part.id,
|
||||
kind: part.type,
|
||||
},
|
||||
description: deleting ? undefined : desc(part),
|
||||
footer: tok > 0 ? `~${tok.toLocaleString()} tok` : undefined,
|
||||
category,
|
||||
bg: deleting ? theme().error : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
result.push({
|
||||
title: "(no parts)",
|
||||
value: {
|
||||
sessionID: props.sessionID,
|
||||
messageID: msg.id,
|
||||
partID: "",
|
||||
kind: "empty",
|
||||
},
|
||||
category,
|
||||
disabled: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title={`Context · ${money.format(cost())} spent`}
|
||||
options={options()}
|
||||
skipFilter={false}
|
||||
placeholder="Filter parts..."
|
||||
flat={true}
|
||||
onMove={() => setPending(undefined)}
|
||||
keybind={[
|
||||
{
|
||||
keybind: Keybind.parse("alt+c")[0],
|
||||
title: "compact",
|
||||
onTrigger: async (opt: DialogSelectOption<Value>) => {
|
||||
if (opt.value.kind !== "tool") return
|
||||
const parts = props.api.state.part(opt.value.messageID)
|
||||
const part = parts.find((p) => p.id === opt.value.partID)
|
||||
if (!part || part.type !== "tool") return
|
||||
if (part.state.status !== "completed") return
|
||||
if (part.state.time?.compacted) return
|
||||
await props.api.client.part.update({
|
||||
sessionID: opt.value.sessionID,
|
||||
messageID: opt.value.messageID,
|
||||
partID: opt.value.partID,
|
||||
part: {
|
||||
...part,
|
||||
state: {
|
||||
...part.state,
|
||||
time: { ...part.state.time, compacted: Date.now() },
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
keybind: Keybind.parse("alt+d")[0],
|
||||
title: "delete",
|
||||
onTrigger: async (opt: DialogSelectOption<Value>) => {
|
||||
if (!opt.value.partID) return
|
||||
if (pending() !== opt.value.partID) {
|
||||
setPending(opt.value.partID)
|
||||
return
|
||||
}
|
||||
setPending(undefined)
|
||||
await props.api.client.part.delete({
|
||||
sessionID: opt.value.sessionID,
|
||||
messageID: opt.value.messageID,
|
||||
partID: opt.value.partID,
|
||||
})
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
|
||||
import type { AssistantMessage, Part } from "@opencode-ai/sdk/v2"
|
||||
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo } from "solid-js"
|
||||
import { show as showViewer } from "./context-viewer"
|
||||
|
||||
const id = "internal:sidebar-context"
|
||||
|
||||
|
|
@ -9,6 +10,20 @@ const money = new Intl.NumberFormat("en-US", {
|
|||
currency: "USD",
|
||||
})
|
||||
|
||||
function counts(parts: readonly Part[]) {
|
||||
let tools = 0
|
||||
let errors = 0
|
||||
let files = 0
|
||||
for (const p of parts) {
|
||||
if (p.type === "tool") {
|
||||
tools++
|
||||
if (p.state.status === "error") errors++
|
||||
if (p.state.status === "completed" && (p.tool === "read" || p.tool === "glob" || p.tool === "grep")) files++
|
||||
}
|
||||
}
|
||||
return { tools, errors, files }
|
||||
}
|
||||
|
||||
function View(props: { api: TuiPluginApi; session_id: string }) {
|
||||
const theme = () => props.api.theme.current
|
||||
const msg = createMemo(() => props.api.state.session.messages(props.session_id))
|
||||
|
|
@ -32,13 +47,32 @@ function View(props: { api: TuiPluginApi; session_id: string }) {
|
|||
}
|
||||
})
|
||||
|
||||
const stats = createMemo(() => {
|
||||
const all = msg()
|
||||
let tools = 0
|
||||
let errors = 0
|
||||
let files = 0
|
||||
for (const m of all) {
|
||||
const c = counts(props.api.state.part(m.id))
|
||||
tools += c.tools
|
||||
errors += c.errors
|
||||
files += c.files
|
||||
}
|
||||
return { messages: all.length, tools, errors, files }
|
||||
})
|
||||
|
||||
return (
|
||||
<box>
|
||||
<box onMouseDown={() => props.api.command.trigger("session.context_panel.toggle")}>
|
||||
<text fg={theme().text}>
|
||||
<b>Context</b>
|
||||
</text>
|
||||
<text fg={theme().textMuted}>{state().tokens.toLocaleString()} tokens</text>
|
||||
<text fg={theme().textMuted}>{state().percent ?? 0}% used</text>
|
||||
<text fg={theme().textMuted}>
|
||||
{state().tokens.toLocaleString()} tokens · {state().percent ?? 0}% used
|
||||
</text>
|
||||
<text fg={theme().textMuted}>
|
||||
{stats().messages} msgs · {stats().tools} tools{stats().errors > 0 ? ` · ${stats().errors} err` : ""}
|
||||
{stats().files > 0 ? ` · ${stats().files} reads` : ""}
|
||||
</text>
|
||||
<text fg={theme().textMuted}>{money.format(cost())} spent</text>
|
||||
</box>
|
||||
)
|
||||
|
|
@ -53,6 +87,23 @@ const tui: TuiPlugin = async (api) => {
|
|||
},
|
||||
},
|
||||
})
|
||||
|
||||
api.command.register(() => [
|
||||
{
|
||||
title: "View context",
|
||||
value: "context.viewer",
|
||||
category: "Session",
|
||||
description: "Browse session messages and parts with token estimates",
|
||||
slash: { name: "context" },
|
||||
onSelect() {
|
||||
const route = api.route.current
|
||||
if (route.name !== "session") return
|
||||
const sessionID = (route.params as { sessionID?: string }).sessionID
|
||||
if (!sessionID) return
|
||||
showViewer(api, sessionID)
|
||||
},
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
const plugin: TuiPluginModule & { id: string } = {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,484 @@
|
|||
import { useSync } from "@tui/context/sync"
|
||||
import { useTheme, selectedForeground } from "@tui/context/theme"
|
||||
import { useKeybind } from "@tui/context/keybind"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useTuiConfig } from "../../context/tui-config"
|
||||
import { useToast } from "../../ui/toast"
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { Keybind } from "@/util/keybind"
|
||||
import { Locale } from "@/util/locale"
|
||||
import type { Part, AssistantMessage } from "@opencode-ai/sdk/v2"
|
||||
import { batch, createEffect, createMemo, createSignal, For, on, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
|
||||
import * as fuzzysort from "fuzzysort"
|
||||
|
||||
const PANEL_WIDTH = 80
|
||||
|
||||
const money = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
})
|
||||
|
||||
function estimate(part: Part): number {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
return Math.ceil((part.text?.length ?? 0) / 4)
|
||||
case "tool": {
|
||||
if (part.state.status !== "completed") return 0
|
||||
const raw = JSON.stringify(part.state.input ?? "").length + (part.state.output ?? "").length
|
||||
return Math.ceil(raw / 4)
|
||||
}
|
||||
case "reasoning":
|
||||
return Math.ceil((part.text?.length ?? 0) / 4)
|
||||
case "subtask":
|
||||
return Math.ceil((part.prompt?.length ?? 0) / 4)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
type Item = {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
footer?: string
|
||||
category: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
partID: string
|
||||
kind: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
function items(
|
||||
sessionID: string,
|
||||
messages: ReturnType<typeof useSync>["data"]["message"][string],
|
||||
parts: ReturnType<typeof useSync>["data"]["part"],
|
||||
): Item[] {
|
||||
const result: Item[] = []
|
||||
const all = messages ?? []
|
||||
for (let i = 0; i < all.length; i++) {
|
||||
const msg = all[i]
|
||||
const msgParts = parts[msg.id] ?? []
|
||||
const role = msg.role === "user" ? "User" : "Assistant"
|
||||
let info = ""
|
||||
if (msg.role === "assistant") {
|
||||
const t = (msg as AssistantMessage).tokens
|
||||
const total = t.input + t.output + t.reasoning + t.cache.read + t.cache.write
|
||||
info = ` · ${total.toLocaleString()} tok`
|
||||
if ((msg as AssistantMessage).cost > 0) info += ` · ${money.format((msg as AssistantMessage).cost)}`
|
||||
}
|
||||
const category = `#${i + 1} ${role}${info}`
|
||||
|
||||
for (const part of msgParts) {
|
||||
const tok = estimate(part)
|
||||
result.push({
|
||||
id: part.id,
|
||||
title: partLabel(part),
|
||||
description: partDesc(part),
|
||||
footer: tok > 0 ? `~${tok.toLocaleString()} tok` : undefined,
|
||||
category,
|
||||
sessionID,
|
||||
messageID: msg.id,
|
||||
partID: part.id,
|
||||
kind: part.type,
|
||||
})
|
||||
}
|
||||
|
||||
if (msgParts.length === 0) {
|
||||
result.push({
|
||||
id: `empty-${msg.id}`,
|
||||
title: "(no parts)",
|
||||
category,
|
||||
sessionID,
|
||||
messageID: msg.id,
|
||||
partID: "",
|
||||
kind: "empty",
|
||||
disabled: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function partLabel(part: Part): string {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
return (part.text ?? "").slice(0, 60).replace(/\n/g, " ") || "(empty)"
|
||||
case "tool": {
|
||||
const icon =
|
||||
part.state.status === "completed"
|
||||
? "✓"
|
||||
: part.state.status === "error"
|
||||
? "✗"
|
||||
: part.state.status === "running"
|
||||
? "…"
|
||||
: "○"
|
||||
const flag = part.state.status === "completed" && part.state.time?.compacted ? " [compacted]" : ""
|
||||
return `[${part.tool}] ${icon}${flag}`
|
||||
}
|
||||
case "reasoning":
|
||||
return "[reasoning] " + (part.text ?? "").slice(0, 40).replace(/\n/g, " ")
|
||||
case "subtask":
|
||||
return `[subtask] ${part.description ?? part.prompt?.slice(0, 40) ?? ""}`
|
||||
case "file":
|
||||
return `[file] ${part.filename ?? part.url ?? ""}`
|
||||
case "step-start":
|
||||
return "[step-start]"
|
||||
case "step-finish":
|
||||
return `[step-finish] ${part.reason ?? ""}`
|
||||
case "patch":
|
||||
return `[patch] ${part.files?.length ?? 0} files`
|
||||
case "agent":
|
||||
return `[@${part.name}]`
|
||||
case "retry":
|
||||
return `[retry] #${part.attempt}`
|
||||
case "compaction":
|
||||
return "[compaction]"
|
||||
case "snapshot":
|
||||
return "[snapshot]"
|
||||
default:
|
||||
return "[unknown]"
|
||||
}
|
||||
}
|
||||
|
||||
function partDesc(part: Part): string | undefined {
|
||||
if (part.type === "tool" && part.state.status === "completed") return part.state.title ?? undefined
|
||||
return undefined
|
||||
}
|
||||
|
||||
export { PANEL_WIDTH }
|
||||
|
||||
export function ContextPanel(props: { sessionID: string }) {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const { theme } = useTheme()
|
||||
const keybind = useKeybind()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const toast = useToast()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const acceleration = createMemo(() => getScrollAcceleration(tuiConfig))
|
||||
|
||||
const msgs = createMemo(() => sync.data.message[props.sessionID] ?? [])
|
||||
const cost = createMemo(() =>
|
||||
msgs().reduce((sum, m) => sum + (m.role === "assistant" ? (m as AssistantMessage).cost : 0), 0),
|
||||
)
|
||||
|
||||
const all = createMemo(() => items(props.sessionID, msgs(), sync.data.part))
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
selected: 0,
|
||||
filter: "",
|
||||
input: "keyboard" as "keyboard" | "mouse",
|
||||
})
|
||||
|
||||
const [pending, setPending] = createSignal<string>()
|
||||
|
||||
const filtered = createMemo(() => {
|
||||
const opts = all().filter((x) => !x.disabled)
|
||||
const needle = store.filter.toLowerCase()
|
||||
if (!needle) return opts
|
||||
return fuzzysort
|
||||
.go(needle, opts, {
|
||||
keys: ["title", "category"],
|
||||
scoreFn: (r) => r[0].score * 2 + r[1].score,
|
||||
})
|
||||
.map((x) => x.obj)
|
||||
})
|
||||
|
||||
const flatten = createMemo(() => store.filter.length > 0)
|
||||
|
||||
const grouped = createMemo<[string, Item[]][]>(() => {
|
||||
if (flatten()) return [["", filtered()]]
|
||||
const map = new Map<string, Item[]>()
|
||||
for (const item of filtered()) {
|
||||
const key = item.category
|
||||
const arr = map.get(key) ?? []
|
||||
arr.push(item)
|
||||
map.set(key, arr)
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
})
|
||||
|
||||
const flat = createMemo(() => grouped().flatMap(([, opts]) => opts))
|
||||
|
||||
const selected = createMemo(() => flat()[store.selected])
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => store.filter,
|
||||
() => {
|
||||
setTimeout(() => moveTo(0, true), 0)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
filtered()
|
||||
setStore("input", "keyboard")
|
||||
})
|
||||
|
||||
function move(dir: number) {
|
||||
if (flat().length === 0) return
|
||||
let next = store.selected + dir
|
||||
if (next < 0) next = flat().length - 1
|
||||
if (next >= flat().length) next = 0
|
||||
moveTo(next, true)
|
||||
}
|
||||
|
||||
function moveTo(next: number, center = false) {
|
||||
setStore("selected", next)
|
||||
if (!scroll) return
|
||||
const target = scroll.getChildren().find((c) => c.id === selected()?.id)
|
||||
if (!target) return
|
||||
const y = target.y - scroll.y
|
||||
if (center) {
|
||||
scroll.scrollBy(y - Math.floor(scroll.height / 2))
|
||||
} else {
|
||||
if (y >= scroll.height) scroll.scrollBy(y - scroll.height + 1)
|
||||
if (y < 0) scroll.scrollBy(y)
|
||||
}
|
||||
}
|
||||
|
||||
async function compact() {
|
||||
const s = selected()
|
||||
if (!s || s.kind !== "tool") return
|
||||
const parts = sync.data.part[s.messageID] ?? []
|
||||
const part = parts.find((p) => p.id === s.partID)
|
||||
if (!part || part.type !== "tool") return
|
||||
if (part.state.status !== "completed") return
|
||||
if (part.state.time?.compacted) return
|
||||
await sdk.client.part.update({
|
||||
sessionID: s.sessionID,
|
||||
messageID: s.messageID,
|
||||
partID: s.partID,
|
||||
part: {
|
||||
...part,
|
||||
state: {
|
||||
...part.state,
|
||||
time: { ...part.state.time, compacted: Date.now() },
|
||||
},
|
||||
},
|
||||
})
|
||||
toast.show({ message: "Part compacted", variant: "success" })
|
||||
}
|
||||
|
||||
async function del() {
|
||||
const s = selected()
|
||||
if (!s || !s.partID) return
|
||||
if (pending() !== s.partID) {
|
||||
setPending(s.partID)
|
||||
return
|
||||
}
|
||||
setPending(undefined)
|
||||
await sdk.client.part.delete({
|
||||
sessionID: s.sessionID,
|
||||
messageID: s.messageID,
|
||||
partID: s.partID,
|
||||
})
|
||||
}
|
||||
|
||||
async function delSection() {
|
||||
const s = selected()
|
||||
if (!s) return
|
||||
const key = `section:${s.messageID}`
|
||||
if (pending() !== key) {
|
||||
setPending(key)
|
||||
return
|
||||
}
|
||||
setPending(undefined)
|
||||
await sdk.client.session.deleteMessage({
|
||||
sessionID: s.sessionID,
|
||||
messageID: s.messageID,
|
||||
})
|
||||
toast.show({ message: "Message deleted", variant: "success" })
|
||||
}
|
||||
|
||||
const compactKey = Keybind.parse("alt+c")[0]
|
||||
const deleteKey = Keybind.parse("alt+d")[0]
|
||||
const sectionKey = Keybind.parse("alt+s")[0]
|
||||
|
||||
let input: InputRenderable
|
||||
let scroll: ScrollBoxRenderable
|
||||
|
||||
useKeyboard((evt) => {
|
||||
setStore("input", "keyboard")
|
||||
if (evt.name === "up" || (evt.ctrl && evt.name === "p")) move(-1)
|
||||
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1)
|
||||
if (evt.name === "pageup") move(-10)
|
||||
if (evt.name === "pagedown") move(10)
|
||||
if (evt.name === "home") moveTo(0)
|
||||
if (evt.name === "end") moveTo(flat().length - 1)
|
||||
|
||||
if (compactKey && Keybind.match(compactKey, keybind.parse(evt))) {
|
||||
evt.preventDefault()
|
||||
compact()
|
||||
}
|
||||
if (deleteKey && Keybind.match(deleteKey, keybind.parse(evt))) {
|
||||
evt.preventDefault()
|
||||
del()
|
||||
}
|
||||
if (sectionKey && Keybind.match(sectionKey, keybind.parse(evt))) {
|
||||
evt.preventDefault()
|
||||
delSection()
|
||||
}
|
||||
})
|
||||
|
||||
const fg = selectedForeground(theme)
|
||||
const maxTitle = createMemo(() => PANEL_WIDTH - 22)
|
||||
|
||||
return (
|
||||
<box
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
width={PANEL_WIDTH}
|
||||
height="100%"
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
>
|
||||
<box flexDirection="row" justifyContent="space-between" flexShrink={0}>
|
||||
<text fg={theme.text} attributes={TextAttributes.BOLD}>
|
||||
Context · {money.format(cost())}
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{keybind.print("context_panel")} close</text>
|
||||
</box>
|
||||
|
||||
<box paddingTop={1} flexShrink={0}>
|
||||
<input
|
||||
onInput={(e) => {
|
||||
batch(() => {
|
||||
setStore("filter", e)
|
||||
})
|
||||
}}
|
||||
focusedBackgroundColor={theme.backgroundPanel}
|
||||
cursorColor={theme.primary}
|
||||
focusedTextColor={theme.textMuted}
|
||||
ref={(r) => {
|
||||
input = r
|
||||
setTimeout(() => {
|
||||
if (!input || input.isDestroyed) return
|
||||
input.focus()
|
||||
}, 1)
|
||||
}}
|
||||
placeholder="Filter parts..."
|
||||
placeholderColor={theme.textMuted}
|
||||
/>
|
||||
</box>
|
||||
|
||||
<Show
|
||||
when={grouped().length > 0}
|
||||
fallback={
|
||||
<box paddingTop={1}>
|
||||
<text fg={theme.textMuted}>No results found</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<scrollbox
|
||||
ref={(r: ScrollBoxRenderable) => (scroll = r)}
|
||||
flexGrow={1}
|
||||
paddingTop={1}
|
||||
scrollAcceleration={acceleration()}
|
||||
verticalScrollbarOptions={{
|
||||
trackOptions: {
|
||||
backgroundColor: theme.background,
|
||||
foregroundColor: theme.borderActive,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<For each={grouped()}>
|
||||
{([category, opts], index) => (
|
||||
<>
|
||||
<Show when={category}>
|
||||
<box paddingTop={index() > 0 ? 1 : 0}>
|
||||
<text
|
||||
fg={pending() === `section:${opts[0]?.messageID}` ? theme.error : theme.accent}
|
||||
attributes={TextAttributes.BOLD}
|
||||
>
|
||||
{pending() === `section:${opts[0]?.messageID}` ? "Press again to delete section" : category}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
<For each={opts}>
|
||||
{(opt) => {
|
||||
const active = createMemo(() => opt.id === selected()?.id)
|
||||
const deleting = createMemo(() => pending() === opt.partID)
|
||||
return (
|
||||
<box
|
||||
id={opt.id}
|
||||
flexDirection="row"
|
||||
onMouseMove={() => setStore("input", "mouse")}
|
||||
onMouseOver={() => {
|
||||
if (store.input !== "mouse") return
|
||||
const idx = flat().findIndex((x) => x.id === opt.id)
|
||||
if (idx !== -1) moveTo(idx)
|
||||
}}
|
||||
onMouseDown={() => {
|
||||
const idx = flat().findIndex((x) => x.id === opt.id)
|
||||
if (idx !== -1) moveTo(idx)
|
||||
}}
|
||||
backgroundColor={
|
||||
active() ? (deleting() ? theme.error : theme.primary) : RGBA.fromInts(0, 0, 0, 0)
|
||||
}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
<text
|
||||
flexGrow={1}
|
||||
fg={active() ? fg : theme.text}
|
||||
attributes={active() ? TextAttributes.BOLD : undefined}
|
||||
overflow="hidden"
|
||||
wrapMode="none"
|
||||
>
|
||||
{Locale.truncate(deleting() ? "Press again to confirm delete" : opt.title, maxTitle())}
|
||||
<Show when={!deleting() && opt.description}>
|
||||
<span
|
||||
style={{
|
||||
fg: active() ? fg : theme.textMuted,
|
||||
}}
|
||||
>
|
||||
{" "}
|
||||
{opt.description}
|
||||
</span>
|
||||
</Show>
|
||||
</text>
|
||||
<Show when={opt.footer}>
|
||||
<box flexShrink={0}>
|
||||
<text fg={active() ? fg : theme.textMuted}>{opt.footer}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
</Show>
|
||||
|
||||
<box flexShrink={0} flexDirection="row" gap={2} paddingTop={1}>
|
||||
<text>
|
||||
<span style={{ fg: theme.text }}>
|
||||
<b>compact</b>{" "}
|
||||
</span>
|
||||
<span style={{ fg: theme.textMuted }}>alt+c</span>
|
||||
</text>
|
||||
<text>
|
||||
<span style={{ fg: theme.text }}>
|
||||
<b>delete</b>{" "}
|
||||
</span>
|
||||
<span style={{ fg: theme.textMuted }}>alt+d</span>
|
||||
</text>
|
||||
<text>
|
||||
<span style={{ fg: theme.text }}>
|
||||
<b>section</b>{" "}
|
||||
</span>
|
||||
<span style={{ fg: theme.textMuted }}>alt+s</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
|
@ -61,6 +61,7 @@ import { DialogTimeline } from "./dialog-timeline"
|
|||
import { DialogForkFromTimeline } from "./dialog-fork-from-timeline"
|
||||
import { DialogSessionRename } from "../../component/dialog-session-rename"
|
||||
import { Sidebar } from "./sidebar"
|
||||
import { ContextPanel, PANEL_WIDTH as CONTEXT_PANEL_WIDTH } from "./context-panel"
|
||||
import { SubagentFooter } from "./subagent-footer.tsx"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
||||
|
|
@ -144,6 +145,7 @@ export function Session() {
|
|||
const dimensions = useTerminalDimensions()
|
||||
const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "auto")
|
||||
const [sidebarOpen, setSidebarOpen] = createSignal(false)
|
||||
const [contextPanel, setContextPanel] = kv.signal<"hide" | "show">("context_panel", "hide")
|
||||
const [conceal, setConceal] = createSignal(true)
|
||||
const [showThinking, setShowThinking] = kv.signal("thinking_visibility", true)
|
||||
const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide")
|
||||
|
|
@ -155,14 +157,24 @@ export function Session() {
|
|||
const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false)
|
||||
|
||||
const wide = createMemo(() => dimensions().width > 120)
|
||||
const contextPanelVisible = createMemo(() => {
|
||||
if (session()?.parentID) return false
|
||||
return contextPanel() === "show"
|
||||
})
|
||||
const sidebarVisible = createMemo(() => {
|
||||
if (session()?.parentID) return false
|
||||
if (contextPanelVisible()) return false
|
||||
if (sidebarOpen()) return true
|
||||
if (sidebar() === "auto" && wide()) return true
|
||||
return false
|
||||
})
|
||||
const showTimestamps = createMemo(() => timestamps() === "show")
|
||||
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
|
||||
const rightWidth = createMemo(() => {
|
||||
if (contextPanelVisible()) return CONTEXT_PANEL_WIDTH
|
||||
if (sidebarVisible()) return 42
|
||||
return 0
|
||||
})
|
||||
const contentWidth = createMemo(() => dimensions().width - rightWidth() - 4)
|
||||
const providers = createMemo(() => Model.index(sync.data.provider))
|
||||
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
|
||||
|
|
@ -565,6 +577,20 @@ export function Session() {
|
|||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: contextPanelVisible() ? "Hide context panel" : "Show context panel",
|
||||
value: "session.context_panel.toggle",
|
||||
keybind: "context_panel",
|
||||
category: "Session",
|
||||
slash: {
|
||||
name: "context-panel",
|
||||
aliases: ["cp"],
|
||||
},
|
||||
onSelect: (dialog) => {
|
||||
setContextPanel(() => (contextPanelVisible() ? "hide" : "show"))
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: conceal() ? "Disable code concealment" : "Enable code concealment",
|
||||
value: "session.toggle.conceal",
|
||||
|
|
@ -1199,6 +1225,9 @@ export function Session() {
|
|||
</Match>
|
||||
</Switch>
|
||||
</Show>
|
||||
<Show when={contextPanelVisible()}>
|
||||
<ContextPanel sessionID={route.sessionID} />
|
||||
</Show>
|
||||
</box>
|
||||
</context.Provider>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -614,6 +614,7 @@ export namespace Config {
|
|||
editor_open: z.string().optional().default("<leader>e").describe("Open external editor"),
|
||||
theme_list: z.string().optional().default("<leader>t").describe("List available themes"),
|
||||
sidebar_toggle: z.string().optional().default("<leader>b").describe("Toggle sidebar"),
|
||||
context_panel: z.string().optional().default("<leader>i").describe("Toggle context panel"),
|
||||
scrollbar_toggle: z.string().optional().default("none").describe("Toggle session scrollbar"),
|
||||
username_toggle: z.string().optional().default("none").describe("Toggle username visibility"),
|
||||
status_view: z.string().optional().default("<leader>s").describe("View status"),
|
||||
|
|
|
|||
Loading…
Reference in New Issue