feat: lsp diagnostic severity configuration

This commit is contained in:
rekram1-node 2025-08-19 23:20:36 -05:00
parent 50fb337270
commit 185b4e49da
9 changed files with 167 additions and 44 deletions

View file

@ -14,6 +14,7 @@ import matter from "gray-matter"
import { Flag } from "../flag/flag"
import { Auth } from "../auth"
import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser"
import { LSP } from "../lsp"
export namespace Config {
const log = Log.create({ service: "config" })
@ -382,11 +383,15 @@ export namespace Config {
disabled: z.literal(true),
}),
z.object({
command: z.array(z.string()),
command: z.array(z.string()).optional(),
extensions: z.array(z.string()).optional(),
disabled: z.boolean().optional(),
env: z.record(z.string(), z.string()).optional(),
initialization: z.record(z.string(), z.any()).optional(),
severity: z
.enum(Object.keys(LSP.Diagnostic.Severity) as [string, ...string[]])
.optional()
.describe("Minimum diagnostic severity level to show to agent"),
}),
]),
)

View file

@ -6,6 +6,7 @@ import { LSPServer } from "./server"
import { z } from "zod"
import { Config } from "../config/config"
import { spawn } from "child_process"
import { SeverityMap } from "./severity"
export namespace LSP {
const log = Log.create({ service: "lsp" })
@ -73,18 +74,22 @@ export namespace LSP {
...existing,
root: existing?.root ?? (async (_file, app) => app.path.root),
extensions: item.extensions ?? existing.extensions,
spawn: async (_app, root) => {
return {
process: spawn(item.command[0], item.command.slice(1), {
cwd: root,
env: {
...process.env,
...item.env,
},
}),
initialization: item.initialization,
}
},
severity: item.severity ? SeverityMap[item.severity] : existing.severity,
...(item.command && {
spawn: async (_app, root) => {
const command = item.command! // assertion since we checked above
return {
process: spawn(command[0], command.slice(1), {
cwd: root,
env: {
...process.env,
...item.env,
},
}),
initialization: item.initialization,
}
},
}),
}
}
@ -253,15 +258,11 @@ export namespace LSP {
}
export namespace Diagnostic {
export function pretty(diagnostic: LSPClient.Diagnostic) {
const severityMap = {
1: "ERROR",
2: "WARN",
3: "INFO",
4: "HINT",
}
export const Severity = SeverityMap
export const Severities = Object.keys(Severity).filter((key) => isNaN(Number(key)))
const severity = severityMap[diagnostic.severity || 1]
export function pretty(diagnostic: LSPClient.Diagnostic) {
const severity = Severity[diagnostic.severity || 1]
const line = diagnostic.range.start.line + 1
const col = diagnostic.range.start.character + 1

View file

@ -7,6 +7,7 @@ import { BunProc } from "../bun"
import { $ } from "bun"
import fs from "fs/promises"
import { Filesystem } from "../util/filesystem"
import { Severity } from "./severity"
export namespace LSPServer {
const log = Log.create({ service: "lsp.server" })
@ -34,6 +35,7 @@ export namespace LSPServer {
export interface Info {
id: string
severity?: Severity
extensions: string[]
global?: boolean
root: RootFunction
@ -128,6 +130,8 @@ export namespace LSPServer {
export const ESLint: Info = {
id: "eslint",
// most ESLint feedback has severity HINT
severity: Severity.HINT,
root: NearestRoot([
"eslint.config.js",
"eslint.config.mjs",

View file

@ -0,0 +1,13 @@
export enum Severity {
ERROR = 1,
WARN = 2,
INFO = 3,
HINT = 4,
}
export const SeverityMap: Record<string, Severity> = {
ERROR: Severity.ERROR,
WARN: Severity.WARN,
INFO: Severity.INFO,
HINT: Severity.HINT,
}

View file

@ -16,6 +16,9 @@ import { Bus } from "../bus"
import { FileTime } from "../file/time"
import { Filesystem } from "../util/filesystem"
import { Agent } from "../agent/agent"
import { Config } from "../config/config"
import { Severity, SeverityMap } from "../lsp/severity"
import { Log } from "../util/log"
export const EditTool = Tool.define("edit", {
description: DESCRIPTION,
@ -34,6 +37,7 @@ export const EditTool = Tool.define("edit", {
throw new Error("oldString and newString must be different")
}
const cfg = await Config.get()
const app = App.info()
const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(app.path.cwd, params.filePath)
if (!Filesystem.contains(app.path.cwd, filePath)) {
@ -105,15 +109,28 @@ export const EditTool = Tool.define("edit", {
let output = ""
await LSP.touchFile(filePath, true)
const diagnostics = await LSP.diagnostics()
const lsp = cfg.lsp ?? {}
for (const [file, issues] of Object.entries(diagnostics)) {
Log.Default.info("issue_s", {
issues: issues.map((issue) => JSON.stringify(issue)),
issueLength: issues.length,
})
if (issues.length === 0) continue
if (file === filePath) {
output += `\nThis file has errors, please fix\n<file_diagnostics>\n${issues.map(LSP.Diagnostic.pretty).join("\n")}\n</file_diagnostics>\n`
continue
}
output += `\n<project_diagnostics>\n${file}\n${issues
// TODO: may want to make more leniant for eslint
.filter((item) => item.severity === 1)
.filter((item) => {
if (!item.source) {
return item.severity === Severity.ERROR
}
const settings = lsp[item.source]
if (settings.disabled) return false
const minSeverity = settings.severity ? (SeverityMap[settings.severity] ?? Severity.ERROR) : Severity.ERROR
return item.severity && item.severity >= minSeverity
})
.map(LSP.Diagnostic.pretty)
.join("\n")}\n</project_diagnostics>\n`
}

View file

@ -1,4 +1,4 @@
configured_endpoints: 39
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-e4b6496e5f2c68fa8b3ea1b88e40041eaf5ce2652001344df80bf130675d1766.yml
openapi_spec_hash: df474311dc9e4a89cd483bd8b8d971d8
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-ae6510226d1ce153e1cc099a479b0b1cf0c2f5c905c6330b3fed9efac93f6086.yml
openapi_spec_hash: 88261dd50416e611c62cbaf31b8a430d
config_hash: eab3723c4c2232a6ba1821151259d6da

View file

@ -79,7 +79,8 @@ type Config struct {
SmallModel string `json:"small_model"`
Snapshot bool `json:"snapshot"`
// Theme name to use for the interface
Theme string `json:"theme"`
Theme string `json:"theme"`
Tools map[string]bool `json:"tools"`
// TUI specific settings
Tui ConfigTui `json:"tui"`
// Custom username to display in conversations instead of system username
@ -110,6 +111,7 @@ type configJSON struct {
SmallModel apijson.Field
Snapshot apijson.Field
Theme apijson.Field
Tools apijson.Field
Tui apijson.Field
Username apijson.Field
raw string
@ -803,9 +805,11 @@ type ConfigLsp struct {
// This field can have the runtime type of [[]string].
Extensions interface{} `json:"extensions"`
// This field can have the runtime type of [map[string]interface{}].
Initialization interface{} `json:"initialization"`
JSON configLspJSON `json:"-"`
union ConfigLspUnion
Initialization interface{} `json:"initialization"`
// Minimum diagnostic severity level to show to agent
Severity ConfigLspSeverity `json:"severity"`
JSON configLspJSON `json:"-"`
union ConfigLspUnion
}
// configLspJSON contains the JSON metadata for the struct [ConfigLsp]
@ -815,6 +819,7 @@ type configLspJSON struct {
Env apijson.Field
Extensions apijson.Field
Initialization apijson.Field
Severity apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
@ -898,12 +903,14 @@ func (r ConfigLspDisabledDisabled) IsKnown() bool {
}
type ConfigLspObject struct {
Command []string `json:"command,required"`
Command []string `json:"command"`
Disabled bool `json:"disabled"`
Env map[string]string `json:"env"`
Extensions []string `json:"extensions"`
Initialization map[string]interface{} `json:"initialization"`
JSON configLspObjectJSON `json:"-"`
// Minimum diagnostic severity level to show to agent
Severity ConfigLspObjectSeverity `json:"severity"`
JSON configLspObjectJSON `json:"-"`
}
// configLspObjectJSON contains the JSON metadata for the struct [ConfigLspObject]
@ -913,6 +920,7 @@ type configLspObjectJSON struct {
Env apijson.Field
Extensions apijson.Field
Initialization apijson.Field
Severity apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
@ -927,6 +935,42 @@ func (r configLspObjectJSON) RawJSON() string {
func (r ConfigLspObject) implementsConfigLsp() {}
// Minimum diagnostic severity level to show to agent
type ConfigLspObjectSeverity string
const (
ConfigLspObjectSeverityError ConfigLspObjectSeverity = "ERROR"
ConfigLspObjectSeverityWarn ConfigLspObjectSeverity = "WARN"
ConfigLspObjectSeverityInfo ConfigLspObjectSeverity = "INFO"
ConfigLspObjectSeverityHint ConfigLspObjectSeverity = "HINT"
)
func (r ConfigLspObjectSeverity) IsKnown() bool {
switch r {
case ConfigLspObjectSeverityError, ConfigLspObjectSeverityWarn, ConfigLspObjectSeverityInfo, ConfigLspObjectSeverityHint:
return true
}
return false
}
// Minimum diagnostic severity level to show to agent
type ConfigLspSeverity string
const (
ConfigLspSeverityError ConfigLspSeverity = "ERROR"
ConfigLspSeverityWarn ConfigLspSeverity = "WARN"
ConfigLspSeverityInfo ConfigLspSeverity = "INFO"
ConfigLspSeverityHint ConfigLspSeverity = "HINT"
)
func (r ConfigLspSeverity) IsKnown() bool {
switch r {
case ConfigLspSeverityError, ConfigLspSeverityWarn, ConfigLspSeverityInfo, ConfigLspSeverityHint:
return true
}
return false
}
type ConfigMcp struct {
// Type of MCP server connection
Type ConfigMcpType `json:"type,required"`
@ -1760,6 +1804,8 @@ type KeybindsConfig struct {
SessionNew string `json:"session_new,required"`
// Share current session
SessionShare string `json:"session_share,required"`
// Show session timeline
SessionTimeline string `json:"session_timeline,required"`
// Unshare current session
SessionUnshare string `json:"session_unshare,required"`
// @deprecated use agent_cycle. Next agent
@ -1821,6 +1867,7 @@ type keybindsConfigJSON struct {
SessionList apijson.Field
SessionNew apijson.Field
SessionShare apijson.Field
SessionTimeline apijson.Field
SessionUnshare apijson.Field
SwitchAgent apijson.Field
SwitchAgentReverse apijson.Field

View file

@ -701,7 +701,7 @@ export type Config = {
disabled: true
}
| {
command: Array<string>
command?: Array<string>
extensions?: Array<string>
disabled?: boolean
env?: {
@ -710,6 +710,10 @@ export type Config = {
initialization?: {
[key: string]: unknown
}
/**
* Minimum diagnostic severity level to show to agent
*/
severity?: "ERROR" | "WARN" | "INFO" | "HINT"
}
}
/**

View file

@ -564,7 +564,7 @@ func renderToolDetails(
Padding(1, 2).
Width(width - 4)
if diagnostics := renderDiagnostics(metadata, filename, backgroundColor, width-6); diagnostics != "" {
if diagnostics := renderDiagnostics(app.Config.Lsp, metadata, filename, backgroundColor, width-6); diagnostics != "" {
diagnostics = style.Render(diagnostics)
body += "\n" + diagnostics
}
@ -605,7 +605,7 @@ func renderToolDetails(
if filename, ok := toolInputMap["filePath"].(string); ok {
if content, ok := toolInputMap["content"].(string); ok {
body = util.RenderFile(filename, content, width)
if diagnostics := renderDiagnostics(metadata, filename, backgroundColor, width-4); diagnostics != "" {
if diagnostics := renderDiagnostics(app.Config.Lsp, metadata, filename, backgroundColor, width-4); diagnostics != "" {
body += "\n\n" + diagnostics
}
}
@ -925,12 +925,28 @@ type Diagnostic struct {
Character int `json:"character"`
} `json:"start"`
} `json:"range"`
Severity int `json:"severity"`
Message string `json:"message"`
Severity int `json:"severity"`
Message string `json:"message"`
Source *string `json:"source,omitempty"`
}
var severityToLevel = map[int]string{
1: "ERROR",
2: "WARNING",
3: "INFO",
4: "HINT",
}
var levelToSeverity = map[string]int{
"ERROR": 1,
"WARNING": 2,
"INFO": 3,
"HINT": 4,
}
// renderDiagnostics formats LSP diagnostics for display in the TUI
func renderDiagnostics(
lspCfg map[string]opencode.ConfigLsp,
metadata map[string]any,
filePath string,
backgroundColor compat.AdaptiveColor,
@ -938,7 +954,7 @@ func renderDiagnostics(
) string {
if diagnosticsData, ok := metadata["diagnostics"].(map[string]any); ok {
if fileDiagnostics, ok := diagnosticsData[filePath].([]any); ok {
var errorDiagnostics []string
var diagnostics []string
for _, diagInterface := range fileDiagnostics {
diagMap, ok := diagInterface.(map[string]any)
if !ok {
@ -953,23 +969,39 @@ func renderDiagnostics(
if err := json.Unmarshal(diagBytes, &diag); err != nil {
continue
}
// Only show error diagnostics (severity === 1)
if diag.Severity != 1 {
// if no LSP level configured then default to error level severity
minSev := 1
if diag.Source != nil {
setting := lspCfg[*diag.Source]
if setting.Severity.IsKnown() {
minSev = levelToSeverity[string(setting.Severity)]
}
}
if diag.Severity < minSev {
continue
}
level, ok := severityToLevel[diag.Severity]
if !ok {
// default to error
level = "ERROR"
}
line := diag.Range.Start.Line + 1 // 1-based
column := diag.Range.Start.Character + 1 // 1-based
errorDiagnostics = append(
errorDiagnostics,
fmt.Sprintf("Error [%d:%d] %s", line, column, diag.Message),
diagnostics = append(
diagnostics,
fmt.Sprintf("%s [%d:%d] %s", level, line, column, diag.Message),
)
}
if len(errorDiagnostics) == 0 {
if len(diagnostics) == 0 {
return ""
}
t := theme.CurrentTheme()
var result strings.Builder
for _, diagnostic := range errorDiagnostics {
for _, diagnostic := range diagnostics {
if result.Len() > 0 {
result.WriteString("\n\n")
}