feat(coding-agent): introduced LSP writethrough for unified file operations

- Added createLspWritethrough factory for unified LSP-aware file write operations with formatting and diagnostics.
- Added syncContent and notifySaved LSP client methods for in-memory content synchronization.
- Added EditMatchError class and utils module with once and untilAborted helper functions.
- Changed FileDiagnosticsResult interface: renamed diagnostics->messages, hasErrors->errored, serverName->server.
- Changed session title generation to trigger before first message instead of after agent work begins.
- Simplified edit and write tools by delegating to writethrough callback and removing manual abort handling.
This commit is contained in:
can1357
2026-01-04 02:23:00 +01:00
parent f676ef0521
commit 47837a0fa7
12 changed files with 512 additions and 493 deletions
@@ -503,19 +503,19 @@ export class ToolExecutionComponent extends Container {
}
// Show LSP diagnostics if available
if (this.result?.details?.diagnostics?.available) {
if (this.result?.details?.diagnostics) {
const diag = this.result.details.diagnostics;
if (diag.diagnostics.length > 0) {
const icon = diag.hasErrors ? theme.fg("error", "●") : theme.fg("warning", "●");
if (diag.messages.length > 0) {
const icon = diag.errored ? theme.fg("error", "●") : theme.fg("warning", "●");
text += `\n\n${icon} ${theme.fg("toolTitle", "LSP Diagnostics")} ${theme.fg("dim", `(${diag.summary})`)}`;
const maxDiags = this.expanded ? diag.diagnostics.length : 5;
const displayDiags = diag.diagnostics.slice(0, maxDiags);
const maxDiags = this.expanded ? diag.messages.length : 5;
const displayDiags = diag.messages.slice(0, maxDiags);
for (const d of displayDiags) {
const color = d.includes("[error]") ? "error" : d.includes("[warning]") ? "warning" : "dim";
text += `\n ${theme.fg(color, d)}`;
}
if (diag.diagnostics.length > maxDiags) {
text += theme.fg("dim", `\n ... (${diag.diagnostics.length - maxDiags} more)`);
if (diag.messages.length > maxDiags) {
text += theme.fg("dim", `\n ... (${diag.messages.length - maxDiags} more)`);
}
}
}
@@ -552,19 +552,19 @@ export class ToolExecutionComponent extends Container {
}
// Show LSP diagnostics if available
if (this.result?.details?.diagnostics?.available) {
if (this.result?.details?.diagnostics) {
const diag = this.result.details.diagnostics;
if (diag.diagnostics.length > 0) {
const icon = diag.hasErrors ? theme.fg("error", "●") : theme.fg("warning", "●");
if (diag.messages.length > 0) {
const icon = diag.errored ? theme.fg("error", "●") : theme.fg("warning", "●");
text += `\n\n${icon} ${theme.fg("toolTitle", "LSP Diagnostics")} ${theme.fg("dim", `(${diag.summary})`)}`;
const maxDiags = this.expanded ? diag.diagnostics.length : 5;
const displayDiags = diag.diagnostics.slice(0, maxDiags);
const maxDiags = this.expanded ? diag.messages.length : 5;
const displayDiags = diag.messages.slice(0, maxDiags);
for (const d of displayDiags) {
const color = d.includes("[error]") ? "error" : d.includes("[warning]") ? "warning" : "dim";
text += `\n ${theme.fg(color, d)}`;
}
if (diag.diagnostics.length > maxDiags) {
text += theme.fg("dim", `\n ... (${diag.diagnostics.length - maxDiags} more)`);
if (diag.messages.length > maxDiags) {
text += theme.fg("dim", `\n ... (${diag.messages.length - maxDiags} more)`);
}
}
}
@@ -143,9 +143,6 @@ export class InteractiveMode {
// Custom tools for custom rendering
private customTools: Map<string, LoadedCustomTool>;
// Title generation state
private titleGenerationAttempted = false;
// Convenience accessors
private get agent() {
return this.session.agent;
@@ -261,7 +258,6 @@ export class InteractiveMode {
const existingTitle = this.sessionManager.getSessionTitle();
if (existingTitle) {
setTerminalTitle(`pi: ${existingTitle}`);
this.titleGenerationAttempted = true; // Don't try to generate again
}
// Setup UI layout
@@ -401,7 +397,6 @@ export class InteractiveMode {
this.streamingComponent = undefined;
this.streamingMessage = undefined;
this.pendingTools.clear();
this.titleGenerationAttempted = false;
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1));
@@ -903,6 +898,21 @@ export class InteractiveMode {
// First, move any pending bash components to chat
this.flushPendingBashComponents();
// Generate session title on first message
const hasUserMessages = this.agent.state.messages.some((m) => m.role === "user");
if (!hasUserMessages && !this.sessionManager.getSessionTitle()) {
const registry = this.session.modelRegistry;
const smolModel = this.settingsManager.getModelRole("smol");
generateSessionTitle(text, registry, smolModel)
.then((title) => {
if (title) {
this.sessionManager.setSessionTitle(title);
setTerminalTitle(`omp: ${title}`);
}
})
.catch(() => {});
}
if (this.onInputCallback) {
// Include any pending images from clipboard paste
const images = this.pendingImages.length > 0 ? [...this.pendingImages] : undefined;
@@ -940,12 +950,6 @@ export class InteractiveMode {
);
this.statusContainer.addChild(this.loadingAnimation);
this.ui.requestRender();
// Generate session title on first turn (runs in parallel with agent work)
if (!this.titleGenerationAttempted && !this.sessionManager.getSessionTitle()) {
this.titleGenerationAttempted = true;
this.maybeGenerateTitle();
}
break;
case "message_start":
@@ -1635,42 +1639,6 @@ export class InteractiveMode {
this.ui.requestRender();
}
/**
* Generate a title for the session based on the first user message.
* Runs in background, doesn't block UI.
*/
private maybeGenerateTitle(): void {
// Find the first user message
const messages = this.agent.state.messages;
const firstUserMessage = messages.find((m) => m.role === "user");
if (!firstUserMessage) return;
// Extract text content
let messageText = "";
for (const content of firstUserMessage.content) {
if (typeof content === "string") {
messageText += content;
} else if (content.type === "text") {
messageText += content.text;
}
}
if (!messageText.trim()) return;
// Generate title in background
const registry = this.session.modelRegistry;
const smolModel = this.settingsManager.getModelRole("smol");
generateSessionTitle(messageText, registry, smolModel)
.then((title) => {
if (title) {
this.sessionManager.setSessionTitle(title);
setTerminalTitle(`omp: ${title}`);
}
})
.catch(() => {
// Errors logged via logger in title-generator
});
}
private updatePendingMessagesDisplay(): void {
this.pendingMessagesContainer.clear();
const queuedMessages = this.session.getQueuedMessages();