Compare commits

..

5 Commits

Author SHA1 Message Date
Dax Raad 7ca8334a8b fix webfetch tool when returning html as text 2025-06-19 10:43:54 -04:00
Dax Raad f1a2b2eba4 support token caching for anthropic via openrouter 2025-06-19 10:32:14 -04:00
adamdottv 4b132656df feat(tui): copy share url to clipboard 2025-06-19 09:06:25 -05:00
Dax Raad 26bab00dab remove opencode_ prefixes from tool names. unfortunately this will break
all old sessions and share links. we'll be more backwards compatible in
the future once we're more stable.
2025-06-19 09:59:44 -04:00
adamdottv 568c04753e feat(tui): expand input to fit message 2025-06-19 08:45:27 -05:00
28 changed files with 2056 additions and 132 deletions
+1 -1
View File
@@ -98,7 +98,7 @@ You can configure custom keybinds, the values listed below are the defaults.
"input_clear": "ctrl+c",
"input_paste": "ctrl+v",
"input_submit": "enter",
"input_newline": "shift+enter",
"input_newline": "shift+enter,ctrl+j",
"history_previous": "up",
"history_next": "down",
"messages_page_up": "pgup",
+9 -9
View File
@@ -11,15 +11,15 @@ import { Flag } from "../../flag/flag"
import { Config } from "../../config/config"
const TOOL: Record<string, [string, string]> = {
opencode_todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
opencode_todoread: ["Todo", UI.Style.TEXT_WARNING_BOLD],
opencode_bash: ["Bash", UI.Style.TEXT_DANGER_BOLD],
opencode_edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD],
opencode_glob: ["Glob", UI.Style.TEXT_INFO_BOLD],
opencode_grep: ["Grep", UI.Style.TEXT_INFO_BOLD],
opencode_list: ["List", UI.Style.TEXT_INFO_BOLD],
opencode_read: ["Read", UI.Style.TEXT_HIGHLIGHT_BOLD],
opencode_write: ["Write", UI.Style.TEXT_SUCCESS_BOLD],
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
todoread: ["Todo", UI.Style.TEXT_WARNING_BOLD],
bash: ["Bash", UI.Style.TEXT_DANGER_BOLD],
edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD],
glob: ["Glob", UI.Style.TEXT_INFO_BOLD],
grep: ["Grep", UI.Style.TEXT_INFO_BOLD],
list: ["List", UI.Style.TEXT_INFO_BOLD],
read: ["Read", UI.Style.TEXT_HIGHLIGHT_BOLD],
write: ["Write", UI.Style.TEXT_SUCCESS_BOLD],
}
export const RunCommand = cmd({
+2 -2
View File
@@ -274,7 +274,7 @@ export namespace Provider {
const cfg = await Config.get()
const provider = await list()
.then((val) => Object.values(val))
.then((x) => x.find((p) => !cfg.provider || cfg.provider === p.info.id))
.then((x) => x.find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.info.id)))
if (!provider) throw new Error("no providers found")
const [model] = sort(Object.values(provider.info.models))
if (!model) throw new Error("no models found")
@@ -304,7 +304,7 @@ export namespace Provider {
]
const TOOL_MAPPING: Record<string, Tool.Info[]> = {
anthropic: TOOLS.filter((t) => t.id !== "opencode.patch"),
anthropic: TOOLS.filter((t) => t.id !== "patch"),
openai: TOOLS.map((t) => ({
...t,
parameters: optionalToNullable(t.parameters),
@@ -0,0 +1,24 @@
import type { CoreMessage } from "ai"
export namespace ProviderTransform {
export function message(
msg: CoreMessage,
index: number,
providerID: string,
modelID: string,
) {
if (
(providerID === "anthropic" || modelID.includes("anthropic")) &&
index < 4
) {
msg.providerOptions = {
...msg.providerOptions,
anthropic: {
cacheControl: { type: "ephemeral" },
},
}
}
return msg
}
}
+8 -21
View File
@@ -32,6 +32,7 @@ import { Flag } from "../flag/flag"
import type { ModelsDev } from "../provider/models"
import { Installation } from "../installation"
import { Config } from "../config/config"
import { ProviderTransform } from "../provider/transform"
export namespace Session {
const log = Log.create({ service: "session" })
@@ -266,15 +267,6 @@ export namespace Session {
(x): CoreMessage => ({
role: "system",
content: x,
providerOptions: {
...(input.providerID === "anthropic"
? {
anthropic: {
cacheControl: { type: "ephemeral" },
},
}
: {}),
},
}),
),
...convertToCoreMessages([
@@ -284,7 +276,9 @@ export namespace Session {
parts: toParts(input.parts),
},
]),
],
].map((msg, i) =>
ProviderTransform.message(msg, i, input.providerID, input.modelID),
),
model: model.language,
})
.then((result) => {
@@ -515,24 +509,17 @@ export namespace Session {
maxSteps: 1000,
messages: [
...system.map(
(x, index): CoreMessage => ({
(x): CoreMessage => ({
role: "system",
content: x,
providerOptions: {
...(input.providerID === "anthropic" && index < 4
? {
anthropic: {
cacheControl: { type: "ephemeral" },
},
}
: {}),
},
}),
),
...convertToCoreMessages(
msgs.map(toUIMessage).filter((x) => x.parts.length > 0),
),
],
].map((msg, i) =>
ProviderTransform.message(msg, i, input.providerID, input.modelID),
),
temperature: model.info.temperature ? 0 : undefined,
tools: {
...tools,
+1 -1
View File
@@ -26,7 +26,7 @@ const DEFAULT_TIMEOUT = 1 * 60 * 1000
const MAX_TIMEOUT = 10 * 60 * 1000
export const BashTool = Tool.define({
id: "opencode.bash",
id: "bash",
description: DESCRIPTION,
parameters: z.object({
command: z.string().describe("The command to execute"),
+2 -2
View File
@@ -9,7 +9,7 @@ import DESCRIPTION from "./edit.txt"
import { App } from "../app/app"
export const EditTool = Tool.define({
id: "opencode.edit",
id: "edit",
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
@@ -35,7 +35,7 @@ export const EditTool = Tool.define({
: path.join(app.path.cwd, params.filePath)
await Permission.ask({
id: "opencode.edit",
id: "edit",
sessionID: ctx.sessionID,
title: "Edit this file: " + filepath,
metadata: {
+1 -1
View File
@@ -5,7 +5,7 @@ import { App } from "../app/app"
import DESCRIPTION from "./glob.txt"
export const GlobTool = Tool.define({
id: "opencode.glob",
id: "glob",
description: DESCRIPTION,
parameters: z.object({
pattern: z.string().describe("The glob pattern to match files against"),
+1 -1
View File
@@ -6,7 +6,7 @@ import { Ripgrep } from "../external/ripgrep"
import DESCRIPTION from "./grep.txt"
export const GrepTool = Tool.define({
id: "opencode.grep",
id: "grep",
description: DESCRIPTION,
parameters: z.object({
pattern: z
+1 -1
View File
@@ -21,7 +21,7 @@ export const IGNORE_PATTERNS = [
const LIMIT = 100
export const ListTool = Tool.define({
id: "opencode.list",
id: "list",
description: DESCRIPTION,
parameters: z.object({
path: z
@@ -6,7 +6,7 @@ import { App } from "../app/app"
import DESCRIPTION from "./lsp-diagnostics.txt"
export const LspDiagnosticTool = Tool.define({
id: "opencode.lsp_diagnostics",
id: "lsp_diagnostics",
description: DESCRIPTION,
parameters: z.object({
path: z.string().describe("The path to the file to get diagnostics."),
+1 -1
View File
@@ -6,7 +6,7 @@ import { App } from "../app/app"
import DESCRIPTION from "./lsp-hover.txt"
export const LspHoverTool = Tool.define({
id: "opencode.lsp_hover",
id: "lsp_hover",
description: DESCRIPTION,
parameters: z.object({
file: z.string().describe("The path to the file to get diagnostics."),
+1 -1
View File
@@ -6,7 +6,7 @@ import path from "path"
import { App } from "../app/app"
export const MultiEditTool = Tool.define({
id: "opencode.multiedit",
id: "multiedit",
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
+1 -1
View File
@@ -232,7 +232,7 @@ async function applyCommit(
}
export const PatchTool = Tool.define({
id: "opencode.patch",
id: "patch",
description: DESCRIPTION,
parameters: PatchParams,
execute: async (params, ctx) => {
+1 -1
View File
@@ -12,7 +12,7 @@ const DEFAULT_READ_LIMIT = 2000
const MAX_LINE_LENGTH = 2000
export const ReadTool = Tool.define({
id: "opencode.read",
id: "read",
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The path to the file to read"),
+1 -1
View File
@@ -6,7 +6,7 @@ import { Bus } from "../bus"
import { Message } from "../session/message"
export const TaskTool = Tool.define({
id: "opencode.task",
id: "task",
description: DESCRIPTION,
parameters: z.object({
description: z
+2 -2
View File
@@ -23,7 +23,7 @@ const state = App.state("todo-tool", () => {
})
export const TodoWriteTool = Tool.define({
id: "opencode.todowrite",
id: "todowrite",
description: DESCRIPTION_WRITE,
parameters: z.object({
todos: z.array(TodoInfo).describe("The updated todo list"),
@@ -42,7 +42,7 @@ export const TodoWriteTool = Tool.define({
})
export const TodoReadTool = Tool.define({
id: "opencode.todoread",
id: "todoread",
description: "Use this tool to read your todo list",
parameters: z.object({}),
async execute(_params, opts) {
+41 -6
View File
@@ -8,7 +8,7 @@ const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
const MAX_TIMEOUT = 120 * 1000 // 2 minutes
export const WebFetchTool = Tool.define({
id: "opencode.webfetch",
id: "webfetch",
description: DESCRIPTION,
parameters: z.object({
url: z.string().describe("The URL to fetch content from"),
@@ -76,7 +76,7 @@ export const WebFetchTool = Tool.define({
switch (params.format) {
case "text":
if (contentType.includes("text/html")) {
const text = extractTextFromHTML(content)
const text = await extractTextFromHTML(content)
return {
output: text,
metadata: {
@@ -127,10 +127,45 @@ export const WebFetchTool = Tool.define({
},
})
function extractTextFromHTML(html: string): string {
const doc = new DOMParser().parseFromString(html, "text/html")
const text = doc.body.textContent || doc.body.innerText || ""
return text.replace(/\s+/g, " ").trim()
async function extractTextFromHTML(html: string) {
let text = ""
let skipContent = false
const rewriter = new HTMLRewriter()
.on("script, style, noscript, iframe, object, embed", {
element() {
skipContent = true
},
text() {
// Skip text content inside these elements
},
})
.on("*", {
element(element) {
// Reset skip flag when entering other elements
if (
![
"script",
"style",
"noscript",
"iframe",
"object",
"embed",
].includes(element.tagName)
) {
skipContent = false
}
},
text(input) {
if (!skipContent) {
text += input.text
}
},
})
.transform(new Response(html))
await rewriter.text()
return text.trim()
}
function convertHTMLToMarkdown(html: string): string {
+2 -2
View File
@@ -8,7 +8,7 @@ import DESCRIPTION from "./write.txt"
import { App } from "../app/app"
export const WriteTool = Tool.define({
id: "opencode.write",
id: "write",
description: DESCRIPTION,
parameters: z.object({
filePath: z
@@ -29,7 +29,7 @@ export const WriteTool = Tool.define({
if (exists) await FileTimes.assert(ctx.sessionID, filepath)
await Permission.ask({
id: "opencode.write",
id: "write",
sessionID: ctx.sessionID,
title: exists
? "Overwrite this file: " + filepath
+11 -11
View File
@@ -208,18 +208,18 @@ func LoadFromConfig(config *client.ConfigInfo) CommandRegistry {
{
Name: InputNewlineCommand,
Description: "insert newline",
Keybindings: parseBindings("shift+enter"),
},
{
Name: HistoryPreviousCommand,
Description: "previous prompt",
Keybindings: parseBindings("up"),
},
{
Name: HistoryNextCommand,
Description: "next prompt",
Keybindings: parseBindings("down"),
Keybindings: parseBindings("shift+enter", "ctrl+j"),
},
// {
// Name: HistoryPreviousCommand,
// Description: "previous prompt",
// Keybindings: parseBindings("up"),
// },
// {
// Name: HistoryNextCommand,
// Description: "next prompt",
// Keybindings: parseBindings("down"),
// },
{
Name: MessagesPageUpCommand,
Description: "page up",
+32 -25
View File
@@ -6,12 +6,12 @@ import (
"strings"
"github.com/charmbracelet/bubbles/v2/spinner"
"github.com/charmbracelet/bubbles/v2/textarea"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/components/textarea"
"github.com/sst/opencode/internal/image"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
@@ -23,6 +23,8 @@ type EditorComponent interface {
tea.Model
tea.ViewModel
layout.Sizeable
Content() string
Lines() int
Value() string
Submit() (tea.Model, tea.Cmd)
Clear() (tea.Model, tea.Cmd)
@@ -50,22 +52,15 @@ func (m *editorComponent) Init() tea.Cmd {
func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyPressMsg:
// Maximize editor responsiveness for printable characters
if msg.Text != "" {
m.textarea, cmd = m.textarea.Update(msg)
return m, cmd
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
// // TODO: ?
// if key.Matches(msg, messageKeys.PageUp) ||
// key.Matches(msg, messageKeys.PageDown) ||
// key.Matches(msg, messageKeys.HalfPageUp) ||
// key.Matches(msg, messageKeys.HalfPageDown) {
// return m, nil
// }
case dialog.ThemeSelectedMsg:
m.textarea = createTextArea(&m.textarea)
m.spinner = createSpinner()
@@ -73,10 +68,11 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case dialog.CompletionSelectedMsg:
if msg.IsCommand {
commandName := strings.TrimPrefix(msg.CompletionValue, "/")
m.textarea.Reset()
return m, util.CmdHandler(
commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)]),
)
updated, cmd := m.Clear()
m = updated.(*editorComponent)
cmds = append(cmds, cmd)
cmds = append(cmds, util.CmdHandler(commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)])))
return m, tea.Batch(cmds...)
} else {
existingValue := m.textarea.Value()
modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
@@ -94,7 +90,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
}
func (m *editorComponent) View() string {
func (m *editorComponent) Content() string {
t := theme.CurrentTheme()
base := styles.BaseStyle().Background(t.Background()).Render
muted := styles.Muted().Background(t.Background()).Render
@@ -139,6 +135,13 @@ func (m *editorComponent) View() string {
return content
}
func (m *editorComponent) View() string {
if m.Lines() > 1 {
return ""
}
return m.Content()
}
func (m *editorComponent) GetSize() (width, height int) {
return m.width, m.height
}
@@ -146,18 +149,21 @@ func (m *editorComponent) GetSize() (width, height int) {
func (m *editorComponent) SetSize(width, height int) tea.Cmd {
m.width = width
m.height = height
m.textarea.SetWidth(width - 5) // account for the prompt and padding right
m.textarea.SetHeight(height - 4) // account for info underneath
m.textarea.SetWidth(width - 5) // account for the prompt and padding right
// m.textarea.SetHeight(height - 4)
return nil
}
func (m *editorComponent) Lines() int {
return m.textarea.LineCount()
}
func (m *editorComponent) Value() string {
return m.textarea.Value()
}
func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
value := strings.TrimSpace(m.Value())
m.textarea.Reset()
if value == "" {
return m, nil
}
@@ -167,6 +173,11 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
return m, nil
}
var cmds []tea.Cmd
updated, cmd := m.Clear()
m = updated.(*editorComponent)
cmds = append(cmds, cmd)
attachments := m.attachments
// Save to history if not empty and not a duplicate of the last entry
@@ -180,12 +191,8 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
m.attachments = nil
return m, tea.Batch(
util.CmdHandler(app.SendMsg{
Text: value,
Attachments: attachments,
}),
)
cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: attachments}))
return m, tea.Batch(cmds...)
}
func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
@@ -253,7 +253,7 @@ func renderToolInvocation(
showDetails bool,
isLast bool,
) string {
ignoredTools := []string{"opencode_todoread"}
ignoredTools := []string{"todoread"}
if slices.Contains(ignoredTools, toolCall.ToolName) {
return ""
}
@@ -350,7 +350,7 @@ func renderToolInvocation(
title := ""
switch toolCall.ToolName {
case "opencode_read":
case "read":
toolArgs = renderArgs(&toolArgsMap, "filePath")
title = fmt.Sprintf("READ %s %s", toolArgs, elapsed)
if preview, ok := metadata.Get("preview"); ok && toolArgsMap["filePath"] != nil {
@@ -358,7 +358,7 @@ func renderToolInvocation(
body = preview.(string)
body = renderFile(filename, body, WithTruncate(6))
}
case "opencode_edit":
case "edit":
if filename, ok := toolArgsMap["filePath"].(string); ok {
title = fmt.Sprintf("EDIT %s %s", relative(filename), elapsed)
if d, ok := metadata.Get("diff"); ok {
@@ -399,14 +399,14 @@ func renderToolInvocation(
)
}
}
case "opencode_write":
case "write":
if filename, ok := toolArgsMap["filePath"].(string); ok {
title = fmt.Sprintf("WRITE %s %s", relative(filename), elapsed)
if content, ok := toolArgsMap["content"].(string); ok {
body = renderFile(filename, content)
}
}
case "opencode_bash":
case "bash":
if description, ok := toolArgsMap["description"].(string); ok {
title = fmt.Sprintf("SHELL %s %s", description, elapsed)
}
@@ -417,7 +417,7 @@ func renderToolInvocation(
body = toMarkdown(body, innerWidth, t.BackgroundSubtle())
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
}
case "opencode_webfetch":
case "webfetch":
toolArgs = renderArgs(&toolArgsMap, "url")
title = fmt.Sprintf("FETCH %s %s", toolArgs, elapsed)
if format, ok := toolArgsMap["format"].(string); ok {
@@ -428,7 +428,7 @@ func renderToolInvocation(
}
body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1))
}
case "opencode_todowrite":
case "todowrite":
title = fmt.Sprintf("PLAN %s", elapsed)
if to, ok := metadata.Get("todos"); ok && finished {
@@ -498,11 +498,11 @@ func renderToolName(name string) string {
switch name {
// case agent.AgentToolName:
// return "Task"
case "opencode_ls":
case "list":
return "LIST"
case "opencode_webfetch":
case "webfetch":
return "FETCH"
case "opencode_todowrite":
case "todowrite":
return "PLAN"
default:
normalizedName := name
@@ -559,27 +559,27 @@ func renderToolAction(name string) string {
switch name {
// case agent.AgentToolName:
// return "Preparing prompt..."
case "opencode_bash":
case "bash":
return "Building command..."
case "opencode_edit":
case "edit":
return "Preparing edit..."
case "opencode_fetch":
case "webfetch":
return "Writing fetch..."
case "opencode_glob":
case "glob":
return "Finding files..."
case "opencode_grep":
case "grep":
return "Searching content..."
case "opencode_ls":
case "list":
return "Listing directory..."
case "opencode_read":
case "read":
return "Reading file..."
case "opencode_write":
case "write":
return "Preparing write..."
case "opencode_todowrite", "opencode_todoread":
case "todowrite", "todoread":
return "Planning..."
case "opencode_patch":
case "patch":
return "Preparing patch..."
case "opencode_batch":
case "batch":
return "Running batch operations..."
}
return "Working..."
@@ -101,10 +101,10 @@ type completionDialogKeyMap struct {
var completionDialogKeys = completionDialogKeyMap{
Complete: key.NewBinding(
key.WithKeys("tab", "enter"),
key.WithKeys("tab", "enter", "right"),
),
Cancel: key.NewBinding(
key.WithKeys(" ", "esc", "backspace"),
key.WithKeys(" ", "esc", "backspace", "ctrl+c"),
),
}
@@ -209,7 +209,7 @@ func (c *completionDialogComponent) View() string {
BorderRight(true).
BorderLeft(true).
BorderBackground(t.Background()).
BorderForeground(t.BackgroundSubtle()).
BorderForeground(t.BackgroundElement()).
Width(c.width).
Render(c.list.View())
}
@@ -0,0 +1,125 @@
// Package memoization implement a simple memoization cache. It's designed to
// improve performance in textarea.
package textarea
import (
"container/list"
"crypto/sha256"
"fmt"
"sync"
)
// Hasher is an interface that requires a Hash method. The Hash method is
// expected to return a string representation of the hash of the object.
type Hasher interface {
Hash() string
}
// entry is a struct that holds a key-value pair. It is used as an element
// in the evictionList of the MemoCache.
type entry[T any] struct {
key string
value T
}
// MemoCache is a struct that represents a cache with a set capacity. It
// uses an LRU (Least Recently Used) eviction policy. It is safe for
// concurrent use.
type MemoCache[H Hasher, T any] struct {
capacity int
mutex sync.Mutex
cache map[string]*list.Element // The cache holding the results
evictionList *list.List // A list to keep track of the order for LRU
hashableItems map[string]T // This map keeps track of the original hashable items (optional)
}
// NewMemoCache is a function that creates a new MemoCache with a given
// capacity. It returns a pointer to the created MemoCache.
func NewMemoCache[H Hasher, T any](capacity int) *MemoCache[H, T] {
return &MemoCache[H, T]{
capacity: capacity,
cache: make(map[string]*list.Element),
evictionList: list.New(),
hashableItems: make(map[string]T),
}
}
// Capacity is a method that returns the capacity of the MemoCache.
func (m *MemoCache[H, T]) Capacity() int {
return m.capacity
}
// Size is a method that returns the current size of the MemoCache. It is
// the number of items currently stored in the cache.
func (m *MemoCache[H, T]) Size() int {
m.mutex.Lock()
defer m.mutex.Unlock()
return m.evictionList.Len()
}
// Get is a method that returns the value associated with the given
// hashable item in the MemoCache. If there is no corresponding value, the
// method returns nil.
func (m *MemoCache[H, T]) Get(h H) (T, bool) {
m.mutex.Lock()
defer m.mutex.Unlock()
hashedKey := h.Hash()
if element, found := m.cache[hashedKey]; found {
m.evictionList.MoveToFront(element)
return element.Value.(*entry[T]).value, true
}
var result T
return result, false
}
// Set is a method that sets the value for the given hashable item in the
// MemoCache. If the cache is at capacity, it evicts the least recently
// used item before adding the new item.
func (m *MemoCache[H, T]) Set(h H, value T) {
m.mutex.Lock()
defer m.mutex.Unlock()
hashedKey := h.Hash()
if element, found := m.cache[hashedKey]; found {
m.evictionList.MoveToFront(element)
element.Value.(*entry[T]).value = value
return
}
// Check if the cache is at capacity
if m.evictionList.Len() >= m.capacity {
// Evict the least recently used item from the cache
toEvict := m.evictionList.Back()
if toEvict != nil {
evictedEntry := m.evictionList.Remove(toEvict).(*entry[T])
delete(m.cache, evictedEntry.key)
delete(m.hashableItems, evictedEntry.key) // if you're keeping track of original items
}
}
// Add the value to the cache and the evictionList
newEntry := &entry[T]{
key: hashedKey,
value: value,
}
element := m.evictionList.PushFront(newEntry)
m.cache[hashedKey] = element
m.hashableItems[hashedKey] = value // if you're keeping track of original items
}
// HString is a type that implements the Hasher interface for strings.
type HString string
// Hash is a method that returns the hash of the string.
func (h HString) Hash() string {
return fmt.Sprintf("%x", sha256.Sum256([]byte(h)))
}
// HInt is a type that implements the Hasher interface for integers.
type HInt int
// Hash is a method that returns the hash of the integer.
func (h HInt) Hash() string {
return fmt.Sprintf("%x", sha256.Sum256([]byte(fmt.Sprintf("%d", h))))
}
@@ -0,0 +1,102 @@
// Package runeutil provides utility functions for tidying up incoming runes
// from Key messages.
package textarea
import (
"unicode"
"unicode/utf8"
)
// Sanitizer is a helper for bubble widgets that want to process
// Runes from input key messages.
type Sanitizer interface {
// Sanitize removes control characters from runes in a KeyRunes
// message, and optionally replaces newline/carriage return/tabs by a
// specified character.
//
// The rune array is modified in-place if possible. In that case, the
// returned slice is the original slice shortened after the control
// characters have been removed/translated.
Sanitize(runes []rune) []rune
}
// NewSanitizer constructs a rune sanitizer.
func NewSanitizer(opts ...Option) Sanitizer {
s := sanitizer{
replaceNewLine: []rune("\n"),
replaceTab: []rune(" "),
}
for _, o := range opts {
s = o(s)
}
return &s
}
// Option is the type of option that can be passed to Sanitize().
type Option func(sanitizer) sanitizer
// ReplaceTabs replaces tabs by the specified string.
func ReplaceTabs(tabRepl string) Option {
return func(s sanitizer) sanitizer {
s.replaceTab = []rune(tabRepl)
return s
}
}
// ReplaceNewlines replaces newline characters by the specified string.
func ReplaceNewlines(nlRepl string) Option {
return func(s sanitizer) sanitizer {
s.replaceNewLine = []rune(nlRepl)
return s
}
}
func (s *sanitizer) Sanitize(runes []rune) []rune {
// dstrunes are where we are storing the result.
dstrunes := runes[:0:len(runes)]
// copied indicates whether dstrunes is an alias of runes
// or a copy. We need a copy when dst moves past src.
// We use this as an optimization to avoid allocating
// a new rune slice in the common case where the output
// is smaller or equal to the input.
copied := false
for src := 0; src < len(runes); src++ {
r := runes[src]
switch {
case r == utf8.RuneError:
// skip
case r == '\r' || r == '\n':
if len(dstrunes)+len(s.replaceNewLine) > src && !copied {
dst := len(dstrunes)
dstrunes = make([]rune, dst, len(runes)+len(s.replaceNewLine))
copy(dstrunes, runes[:dst])
copied = true
}
dstrunes = append(dstrunes, s.replaceNewLine...)
case r == '\t':
if len(dstrunes)+len(s.replaceTab) > src && !copied {
dst := len(dstrunes)
dstrunes = make([]rune, dst, len(runes)+len(s.replaceTab))
copy(dstrunes, runes[:dst])
copied = true
}
dstrunes = append(dstrunes, s.replaceTab...)
case unicode.IsControl(r):
// Other control characters: skip.
default:
// Keep the character.
dstrunes = append(dstrunes, runes[src])
}
}
return dstrunes
}
type sanitizer struct {
replaceNewLine []rune
replaceTab []rune
}
File diff suppressed because it is too large Load Diff
+19 -7
View File
@@ -127,7 +127,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if a.showCompletionDialog {
switch msg.String() {
case "tab", "enter", "esc":
case "tab", "enter", "esc", "ctrl+c":
context, contextCmd := a.completions.Update(msg)
a.completions = context.(dialog.CompletionDialog)
cmds = append(cmds, contextCmd)
@@ -290,14 +290,22 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (a appModel) View() string {
layoutView := a.layout.View()
editorWidth, _ := a.editorContainer.GetSize()
editorX, editorY := a.editorContainer.GetPosition()
if a.editor.Lines() > 1 {
editorY = editorY - a.editor.Lines() + 1
layoutView = layout.PlaceOverlay(
editorX,
editorY,
a.editor.Content(),
layoutView,
)
}
if a.showCompletionDialog {
editorWidth, _ := a.editorContainer.GetSize()
editorX, editorY := a.editorContainer.GetPosition()
a.completions.SetWidth(editorWidth)
overlay := a.completions.View()
layoutView = layout.PlaceOverlay(
editorX,
editorY-lipgloss.Height(overlay)+2,
@@ -390,12 +398,16 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
if a.app.Session.Id == "" {
return a, nil
}
a.app.Client.PostSessionShareWithResponse(
response, _ := a.app.Client.PostSessionShareWithResponse(
context.Background(),
client.PostSessionShareJSONRequestBody{
SessionID: a.app.Session.Id,
},
)
if response.JSON200 != nil && response.JSON200.Share != nil {
shareUrl := response.JSON200.Share.Url
cmds = append(cmds, tea.SetClipboard(shareUrl))
}
case commands.SessionInterruptCommand:
if a.app.Session.Id == "" {
return a, nil
@@ -530,7 +542,7 @@ func NewModel(app *app.App) tea.Model {
layout.WithDirection(layout.FlexDirectionVertical),
layout.WithSizes(
layout.FlexChildSizeGrow,
layout.FlexChildSizeFixed(6),
layout.FlexChildSizeFixed(5),
),
),
}
+10 -10
View File
@@ -859,7 +859,7 @@ export default function Share(props: {
(partIndex() > 0 || !msg.metadata?.assistant)) ||
(msg.role === "assistant" &&
part.type === "tool-invocation" &&
part.toolInvocation.toolName === "opencode_todoread")
part.toolInvocation.toolName === "todoread")
)
return null
@@ -1072,7 +1072,7 @@ export default function Share(props: {
when={
msg.role === "assistant" &&
part.type === "tool-invocation" &&
part.toolInvocation.toolName === "opencode_grep" &&
part.toolInvocation.toolName === "grep" &&
part
}
>
@@ -1175,7 +1175,7 @@ export default function Share(props: {
when={
msg.role === "assistant" &&
part.type === "tool-invocation" &&
part.toolInvocation.toolName === "opencode_glob" &&
part.toolInvocation.toolName === "glob" &&
part
}
>
@@ -1253,7 +1253,7 @@ export default function Share(props: {
when={
msg.role === "assistant" &&
part.type === "tool-invocation" &&
part.toolInvocation.toolName === "opencode_list" &&
part.toolInvocation.toolName === "list" &&
part
}
>
@@ -1322,7 +1322,7 @@ export default function Share(props: {
when={
msg.role === "assistant" &&
part.type === "tool-invocation" &&
part.toolInvocation.toolName === "opencode_read" &&
part.toolInvocation.toolName === "read" &&
part
}
>
@@ -1417,7 +1417,7 @@ export default function Share(props: {
when={
msg.role === "assistant" &&
part.type === "tool-invocation" &&
part.toolInvocation.toolName === "opencode_write" &&
part.toolInvocation.toolName === "write" &&
part
}
>
@@ -1503,7 +1503,7 @@ export default function Share(props: {
when={
msg.role === "assistant" &&
part.type === "tool-invocation" &&
part.toolInvocation.toolName === "opencode_edit" &&
part.toolInvocation.toolName === "edit" &&
part
}
>
@@ -1577,7 +1577,7 @@ export default function Share(props: {
when={
msg.role === "assistant" &&
part.type === "tool-invocation" &&
part.toolInvocation.toolName === "opencode_bash" &&
part.toolInvocation.toolName === "bash" &&
part
}
>
@@ -1620,7 +1620,7 @@ export default function Share(props: {
msg.role === "assistant" &&
part.type === "tool-invocation" &&
part.toolInvocation.toolName ===
"opencode_todowrite" &&
"todowrite" &&
part
}
>
@@ -1686,7 +1686,7 @@ export default function Share(props: {
msg.role === "assistant" &&
part.type === "tool-invocation" &&
part.toolInvocation.toolName ===
"opencode_webfetch" &&
"webfetch" &&
part
}
>