refactor(server): canonicalize service API (#31049)
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
||||
|
||||
export function promptOffsetWidth(value: string) {
|
||||
let width = 0
|
||||
for (const part of graphemes.segment(value)) {
|
||||
// Textarea offsets count newlines as one position; Bun.stringWidth counts them as zero.
|
||||
width += part.segment === "\n" ? 1 : Bun.stringWidth(part.segment)
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
function displayOffsetIndex(value: string, offset: number) {
|
||||
if (offset <= 0) return 0
|
||||
|
||||
let width = 0
|
||||
for (const part of graphemes.segment(value)) {
|
||||
const next = width + promptOffsetWidth(part.segment)
|
||||
if (next > offset) return part.index
|
||||
width = next
|
||||
}
|
||||
|
||||
return value.length
|
||||
}
|
||||
|
||||
export function displaySlice(value: string, start = 0, end = promptOffsetWidth(value)) {
|
||||
return value.slice(displayOffsetIndex(value, start), displayOffsetIndex(value, end))
|
||||
}
|
||||
|
||||
export function displayCharAt(value: string, offset: number) {
|
||||
let width = 0
|
||||
for (const part of graphemes.segment(value)) {
|
||||
const next = width + promptOffsetWidth(part.segment)
|
||||
if (offset === width || offset < next) return part.segment
|
||||
width = next
|
||||
}
|
||||
}
|
||||
|
||||
export function mentionTriggerIndex(value: string, offset = promptOffsetWidth(value)) {
|
||||
const text = displaySlice(value, 0, offset)
|
||||
const index = text.lastIndexOf("@")
|
||||
if (index === -1) return
|
||||
|
||||
const before = index === 0 ? undefined : text[index - 1]
|
||||
const query = text.slice(index)
|
||||
if ((before === undefined || /\s/.test(before)) && !/\s/.test(query)) {
|
||||
return promptOffsetWidth(text.slice(0, index))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import path from "path"
|
||||
import { onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "../context/helper"
|
||||
import { useTuiEnvironment } from "../runtime"
|
||||
import { appendText, readText, writeText } from "../util/persistence"
|
||||
|
||||
type FrecencyEntry = { path: string; frequency: number; lastOpen: number }
|
||||
|
||||
export const MAX_FRECENCY_ENTRIES = 1000
|
||||
|
||||
export function parseFrecency(text: string) {
|
||||
const latest = text
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line) as FrecencyEntry
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
.filter((line): line is FrecencyEntry => line !== undefined)
|
||||
.reduce<Record<string, FrecencyEntry>>((result, entry) => {
|
||||
result[entry.path] = entry
|
||||
return result
|
||||
}, {})
|
||||
return Object.values(latest)
|
||||
.sort((a, b) => b.lastOpen - a.lastOpen)
|
||||
.slice(0, MAX_FRECENCY_ENTRIES)
|
||||
}
|
||||
|
||||
function calculateFrecency(entry?: { frequency: number; lastOpen: number }) {
|
||||
if (!entry) return 0
|
||||
return entry.frequency / (1 + (Date.now() - entry.lastOpen) / 86400000)
|
||||
}
|
||||
|
||||
export const { use: useFrecency, provider: FrecencyProvider } = createSimpleContext({
|
||||
name: "Frecency",
|
||||
init: () => {
|
||||
const environment = useTuiEnvironment()
|
||||
const frecencyPath = path.join(environment.paths.state, "frecency.jsonl")
|
||||
onMount(async () => {
|
||||
const lines = parseFrecency(await readText(frecencyPath).catch(() => ""))
|
||||
setStore(
|
||||
"data",
|
||||
Object.fromEntries(
|
||||
lines.map((entry) => [entry.path, { frequency: entry.frequency, lastOpen: entry.lastOpen }]),
|
||||
),
|
||||
)
|
||||
if (lines.length > 0)
|
||||
writeText(frecencyPath, lines.map((entry) => JSON.stringify(entry)).join("\n") + "\n").catch(() => {})
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore({ data: {} as Record<string, { frequency: number; lastOpen: number }> })
|
||||
|
||||
function updateFrecency(filePath: string) {
|
||||
const absolutePath = path.resolve(environment.cwd, filePath)
|
||||
const newEntry = { frequency: (store.data[absolutePath]?.frequency || 0) + 1, lastOpen: Date.now() }
|
||||
setStore("data", absolutePath, newEntry)
|
||||
appendText(frecencyPath, JSON.stringify({ path: absolutePath, ...newEntry }) + "\n").catch(() => {})
|
||||
|
||||
if (Object.keys(store.data).length <= MAX_FRECENCY_ENTRIES) return
|
||||
const sorted = Object.entries(store.data)
|
||||
.sort(([, a], [, b]) => b.lastOpen - a.lastOpen)
|
||||
.slice(0, MAX_FRECENCY_ENTRIES)
|
||||
setStore("data", Object.fromEntries(sorted))
|
||||
writeText(
|
||||
frecencyPath,
|
||||
sorted.map(([entryPath, entry]) => JSON.stringify({ path: entryPath, ...entry })).join("\n") + "\n",
|
||||
).catch(() => {})
|
||||
}
|
||||
|
||||
return {
|
||||
getFrecency: (filePath: string) => calculateFrecency(store.data[path.resolve(environment.cwd, filePath)]),
|
||||
updateFrecency,
|
||||
data: () => store.data,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
import path from "path"
|
||||
import { onMount } from "solid-js"
|
||||
import { createStore, produce, unwrap } from "solid-js/store"
|
||||
import type { AgentPart, FilePart, TextPart } from "@opencode-ai/sdk/v2"
|
||||
import { createSimpleContext } from "../context/helper"
|
||||
import { useTuiEnvironment } from "../runtime"
|
||||
import { appendText, readText, writeText } from "../util/persistence"
|
||||
|
||||
export type PromptInfo = {
|
||||
input: string
|
||||
mode?: "normal" | "shell"
|
||||
parts: (
|
||||
| Omit<FilePart, "id" | "messageID" | "sessionID">
|
||||
| Omit<AgentPart, "id" | "messageID" | "sessionID">
|
||||
| (Omit<TextPart, "id" | "messageID" | "sessionID"> & {
|
||||
source?: {
|
||||
text: {
|
||||
start: number
|
||||
end: number
|
||||
value: string
|
||||
}
|
||||
}
|
||||
})
|
||||
)[]
|
||||
}
|
||||
|
||||
export const MAX_HISTORY_ENTRIES = 50
|
||||
|
||||
export function parsePromptHistory(text: string) {
|
||||
return text
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line) as PromptInfo
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
.filter((line): line is PromptInfo => line !== undefined)
|
||||
.slice(-MAX_HISTORY_ENTRIES)
|
||||
}
|
||||
|
||||
export function isDuplicateEntry(previous: PromptInfo | undefined, next: PromptInfo): boolean {
|
||||
if (!previous) return false
|
||||
return JSON.stringify(previous) === JSON.stringify(next)
|
||||
}
|
||||
|
||||
export const { use: usePromptHistory, provider: PromptHistoryProvider } = createSimpleContext({
|
||||
name: "PromptHistory",
|
||||
init: () => {
|
||||
const environment = useTuiEnvironment()
|
||||
const historyPath = path.join(environment.paths.state, "prompt-history.jsonl")
|
||||
onMount(async () => {
|
||||
const lines = parsePromptHistory(await readText(historyPath).catch(() => ""))
|
||||
setStore("history", lines)
|
||||
|
||||
// Rewrite valid retained entries to self-heal corruption and enforce the limit.
|
||||
if (lines.length > 0)
|
||||
writeText(historyPath, lines.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {})
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
index: 0,
|
||||
history: [] as PromptInfo[],
|
||||
})
|
||||
|
||||
return {
|
||||
move(direction: 1 | -1, input: string) {
|
||||
if (!store.history.length) return undefined
|
||||
const current = store.history.at(store.index)
|
||||
if (!current) return undefined
|
||||
if (current.input !== input && input.length) return
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const next = store.index + direction
|
||||
if (Math.abs(next) > store.history.length) return
|
||||
if (next > 0) return
|
||||
draft.index = next
|
||||
}),
|
||||
)
|
||||
if (store.index === 0) return { input: "", parts: [] }
|
||||
return store.history.at(store.index)
|
||||
},
|
||||
append(item: PromptInfo) {
|
||||
const entry = structuredClone(unwrap(item))
|
||||
if (isDuplicateEntry(store.history.at(-1), entry)) {
|
||||
setStore("index", 0)
|
||||
return
|
||||
}
|
||||
let trimmed = false
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.history.push(entry)
|
||||
if (draft.history.length > MAX_HISTORY_ENTRIES) {
|
||||
draft.history = draft.history.slice(-MAX_HISTORY_ENTRIES)
|
||||
trimmed = true
|
||||
}
|
||||
draft.index = 0
|
||||
}),
|
||||
)
|
||||
|
||||
if (trimmed) {
|
||||
writeText(historyPath, store.history.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {})
|
||||
return
|
||||
}
|
||||
appendText(historyPath, JSON.stringify(entry) + "\n").catch(() => {})
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { displaySlice } from "./display"
|
||||
|
||||
export function stripPromptPartIDs<Part extends { id: string; messageID: string; sessionID: string }>(part: Part) {
|
||||
const { id: _id, messageID: _messageID, sessionID: _sessionID, ...rest } = part
|
||||
return rest
|
||||
}
|
||||
|
||||
export function expandPastedTextPlaceholders(
|
||||
text: string,
|
||||
parts: readonly unknown[],
|
||||
) {
|
||||
return parts.reduce<string>((result, part) => {
|
||||
if (!isPastedTextPart(part)) return result
|
||||
return result.replace(part.source.text.value, part.text)
|
||||
}, text)
|
||||
}
|
||||
|
||||
function isPastedTextPart(part: unknown): part is { type: "text"; text: string; source: { text: { value: string } } } {
|
||||
if (!part || typeof part !== "object" || !("type" in part) || part.type !== "text") return false
|
||||
if (!("text" in part) || typeof part.text !== "string" || !("source" in part)) return false
|
||||
const source = part.source
|
||||
if (!source || typeof source !== "object" || !("text" in source)) return false
|
||||
const text = source.text
|
||||
return Boolean(text && typeof text === "object" && "value" in text && typeof text.value === "string")
|
||||
}
|
||||
|
||||
export function expandTrackedPastedText(text: string, ranges: { start: number; end: number; text: string }[]) {
|
||||
return ranges
|
||||
.slice()
|
||||
.sort((a, b) => b.start - a.start)
|
||||
.reduce((result, part) => displaySlice(result, 0, part.start) + part.text + displaySlice(result, part.end), text)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import path from "path"
|
||||
import { onMount } from "solid-js"
|
||||
import { createStore, produce, unwrap } from "solid-js/store"
|
||||
import { createSimpleContext } from "../context/helper"
|
||||
import { useTuiEnvironment } from "../runtime"
|
||||
import { appendText, readText, writeText } from "../util/persistence"
|
||||
import type { PromptInfo } from "./history"
|
||||
|
||||
export type StashEntry = {
|
||||
input: string
|
||||
parts: PromptInfo["parts"]
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export const MAX_STASH_ENTRIES = 50
|
||||
|
||||
export function parsePromptStash(text: string) {
|
||||
return text
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line) as StashEntry
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
.filter((line): line is StashEntry => line !== undefined)
|
||||
.slice(-MAX_STASH_ENTRIES)
|
||||
}
|
||||
|
||||
export const { use: usePromptStash, provider: PromptStashProvider } = createSimpleContext({
|
||||
name: "PromptStash",
|
||||
init: () => {
|
||||
const environment = useTuiEnvironment()
|
||||
const stashPath = path.join(environment.paths.state, "prompt-stash.jsonl")
|
||||
onMount(async () => {
|
||||
const lines = parsePromptStash(await readText(stashPath).catch(() => ""))
|
||||
setStore("entries", lines)
|
||||
if (lines.length > 0)
|
||||
writeText(stashPath, lines.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {})
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore({ entries: [] as StashEntry[] })
|
||||
|
||||
return {
|
||||
list() {
|
||||
return store.entries
|
||||
},
|
||||
push(entry: Omit<StashEntry, "timestamp">) {
|
||||
const stash = structuredClone(unwrap({ ...entry, timestamp: Date.now() }))
|
||||
let trimmed = false
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.entries.push(stash)
|
||||
if (draft.entries.length > MAX_STASH_ENTRIES) {
|
||||
draft.entries = draft.entries.slice(-MAX_STASH_ENTRIES)
|
||||
trimmed = true
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
if (trimmed) {
|
||||
writeText(stashPath, store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {})
|
||||
return
|
||||
}
|
||||
appendText(stashPath, JSON.stringify(stash) + "\n").catch(() => {})
|
||||
},
|
||||
pop() {
|
||||
if (store.entries.length === 0) return undefined
|
||||
const entry = store.entries[store.entries.length - 1]
|
||||
setStore(produce((draft) => void draft.entries.pop()))
|
||||
writeText(
|
||||
stashPath,
|
||||
store.entries.length > 0 ? store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n" : "",
|
||||
).catch(() => {})
|
||||
return entry
|
||||
},
|
||||
remove(index: number) {
|
||||
if (index < 0 || index >= store.entries.length) return
|
||||
setStore(produce((draft) => void draft.entries.splice(index, 1)))
|
||||
writeText(
|
||||
stashPath,
|
||||
store.entries.length > 0 ? store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n" : "",
|
||||
).catch(() => {})
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { EditorTraits } from "@opentui/core"
|
||||
|
||||
export type PromptMode = "normal" | "shell"
|
||||
|
||||
export interface PromptTraitsInput {
|
||||
mode: PromptMode
|
||||
autocompleteVisible: boolean
|
||||
}
|
||||
|
||||
export type PromptTraits = EditorTraits & {
|
||||
owner: "opencode"
|
||||
role: "prompt"
|
||||
}
|
||||
|
||||
/** The managed textarea keymap owns `suspend`; these traits only describe capture and status. */
|
||||
export function computePromptTraits(input: PromptTraitsInput): PromptTraits {
|
||||
const capture =
|
||||
input.mode === "normal"
|
||||
? input.autocompleteVisible
|
||||
? (["escape", "navigate", "submit", "tab"] as const)
|
||||
: (["tab"] as const)
|
||||
: undefined
|
||||
return {
|
||||
capture,
|
||||
status: input.mode === "shell" ? "SHELL" : undefined,
|
||||
owner: "opencode",
|
||||
role: "prompt",
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user