Improve agent autonomy and diff view readability

Agent behavior improvements:
  - Add project context detection (tsconfig.json, pom.xml, etc.)
  - Enforce validation after changes (tsc --noEmit, mvn compile, etc.)
  - Run tests automatically - never ask "do you want me to run tests"
  - Complete full loop: create → type-check → test → confirm
  - Add command detection for direct execution (run tree, run ls)

  Diff view improvements:
  - Use darker backgrounds for added/removed lines
  - Add diffLineBgAdded, diffLineBgRemoved, diffLineText theme colors
  - Improve text visibility with white text on dark backgrounds
  - Update both React/Ink and SolidJS diff components

  Streaming fixes:
  - Fix tool call argument accumulation using OpenAI index field
  - Fix streaming content display after tool calls
  - Add consecutive error tracking to prevent token waste

  Other changes:
  - ESC to abort operations, Ctrl+C to exit
  - Fix model selection when provider changes in cascade mode
  - Add debug logging for troubleshooting
  - Move tests to root tests/ folder
  - Fix banner test GRADIENT_COLORS reference
This commit is contained in:
2026-01-29 07:33:30 -05:00
parent ad02852489
commit 187cc68304
62 changed files with 2005 additions and 2075 deletions

View File

@@ -9,6 +9,7 @@ import {
} from "solid-js";
import { batch } from "solid-js";
import { getFiles } from "@services/file-picker/files";
import { abortCurrentOperation } from "@services/chat-tui-service";
import versionData from "@/version.json";
import {
ExitProvider,
@@ -90,7 +91,7 @@ function ErrorFallback(props: { error: Error }) {
{props.error.message}
</text>
<text fg={theme.colors.textDim} marginTop={2}>
Press Ctrl+C to exit
Press Ctrl+C twice to exit
</text>
</box>
);
@@ -157,16 +158,29 @@ function AppContent(props: AppProps) {
}
useKeyboard((evt) => {
// ESC aborts current operation
if (evt.name === "escape") {
const aborted = abortCurrentOperation();
if (aborted) {
toast.info("Operation cancelled");
evt.preventDefault();
return;
}
}
// Ctrl+C exits the application
if (evt.ctrl && evt.name === "c") {
if (app.interruptPending()) {
exit.exit(0);
} else {
app.setInterruptPending(true);
toast.warning("Press Ctrl+C again to exit");
setTimeout(() => {
app.setInterruptPending(false);
}, 2000);
evt.preventDefault();
return;
}
app.setInterruptPending(true);
toast.warning("Press Ctrl+C again to exit");
setTimeout(() => {
app.setInterruptPending(false);
}, 2000);
evt.preventDefault();
return;
}

View File

@@ -1,4 +1,4 @@
import { createMemo, For, createSignal, onMount, onCleanup } from "solid-js";
import { For, createSignal, onMount, onCleanup } from "solid-js";
import { useKeyboard } from "@opentui/solid";
import { TextAttributes } from "@opentui/core";
import type { ScrollBoxRenderable } from "@opentui/core";
@@ -10,7 +10,7 @@ const SCROLL_LINES = 2;
interface DebugEntry {
id: string;
timestamp: number;
type: "api" | "stream" | "tool" | "state" | "error" | "info";
type: "api" | "stream" | "tool" | "state" | "error" | "info" | "render";
message: string;
}
@@ -80,6 +80,7 @@ export function DebugLogPanel() {
state: theme.colors.accent,
error: theme.colors.error,
info: theme.colors.textDim,
render: theme.colors.primary,
};
return colorMap[type];
};
@@ -92,6 +93,7 @@ export function DebugLogPanel() {
state: "STA",
error: "ERR",
info: "INF",
render: "RND",
};
return labelMap[type];
};

View File

@@ -56,9 +56,9 @@ function DiffLine(props: DiffLineProps) {
const theme = useTheme();
const lineColor = (): string => {
// Use white text for add/remove lines since they have colored backgrounds
// Use light text for add/remove lines since they have dark colored backgrounds
if (props.line.type === "add" || props.line.type === "remove") {
return theme.colors.text;
return theme.colors.diffLineText;
}
const colorMap: Record<string, string> = {
context: theme.colors.diffContext,
@@ -82,8 +82,8 @@ function DiffLine(props: DiffLineProps) {
};
const bgColor = (): string | undefined => {
if (props.line.type === "add") return theme.colors.bgAdded;
if (props.line.type === "remove") return theme.colors.bgRemoved;
if (props.line.type === "add") return theme.colors.diffLineBgAdded;
if (props.line.type === "remove") return theme.colors.diffLineBgRemoved;
return undefined;
};

View File

@@ -1,8 +1,10 @@
import { Show } from "solid-js";
import { Show, createSignal, createEffect, onMount } from "solid-js";
import { TextAttributes } from "@opentui/core";
import { useTheme } from "@tui-solid/context/theme";
import { useAppStore } from "@tui-solid/context/app";
import type { LogEntry } from "@/types/tui";
import { Spinner } from "@tui-solid/ui/spinner";
import { addDebugLog } from "@tui-solid/components/debug-log-panel";
interface StreamingMessageProps {
entry: LogEntry;
@@ -10,8 +12,50 @@ interface StreamingMessageProps {
export function StreamingMessage(props: StreamingMessageProps) {
const theme = useTheme();
const isStreaming = () => props.entry.metadata?.isStreaming ?? false;
const hasContent = () => Boolean(props.entry.content);
const app = useAppStore();
// Use local signals that are updated via createEffect
// This ensures proper reactivity with the store
const [displayContent, setDisplayContent] = createSignal(props.entry.content);
const [isActiveStreaming, setIsActiveStreaming] = createSignal(
props.entry.metadata?.isStreaming ?? false
);
onMount(() => {
addDebugLog("render", `StreamingMessage mounted for entry: ${props.entry.id}`);
});
// Effect to sync content from store's streamingLog
// Use individual property accessors for fine-grained reactivity
createEffect(() => {
// Use dedicated property accessors that directly access store properties
const logId = app.streamingLogId();
const isActive = app.streamingLogIsActive();
const storeContent = app.streamingLogContent();
// Check if this entry is the currently streaming log
const isCurrentLog = logId === props.entry.id;
addDebugLog("render", `Effect: logId=${logId}, entryId=${props.entry.id}, isActive=${isActive}, contentLen=${storeContent?.length ?? 0}`);
if (isCurrentLog && isActive) {
setDisplayContent(storeContent);
setIsActiveStreaming(true);
} else if (isCurrentLog && !isActive) {
// Streaming just completed for this log
setIsActiveStreaming(false);
// Keep the content we have
} else {
// Not the current streaming log, use entry content
setDisplayContent(props.entry.content);
setIsActiveStreaming(props.entry.metadata?.isStreaming ?? false);
}
});
const hasContent = () => {
const c = displayContent();
return Boolean(c && c.length > 0);
};
return (
<box flexDirection="column" marginBottom={1}>
@@ -19,7 +63,7 @@ export function StreamingMessage(props: StreamingMessageProps) {
<text fg={theme.colors.roleAssistant} attributes={TextAttributes.BOLD}>
CodeTyper
</text>
<Show when={isStreaming()}>
<Show when={isActiveStreaming()}>
<box marginLeft={1}>
<Spinner />
</box>
@@ -27,7 +71,7 @@ export function StreamingMessage(props: StreamingMessageProps) {
</box>
<Show when={hasContent()}>
<box marginLeft={2}>
<text wrapMode="word">{props.entry.content}</text>
<text wrapMode="word">{displayContent()}</text>
</box>
</Show>
</box>

View File

@@ -76,6 +76,9 @@ interface AppContextValue {
exitPending: Accessor<boolean>;
isCompacting: Accessor<boolean>;
streamingLog: Accessor<StreamingLogState>;
streamingLogId: Accessor<string | null>;
streamingLogContent: Accessor<string>;
streamingLogIsActive: Accessor<boolean>;
suggestions: Accessor<SuggestionState>;
cascadeEnabled: Accessor<boolean>;
@@ -263,6 +266,10 @@ export const { provider: AppStoreProvider, use: useAppStore } =
const exitPending = (): boolean => store.exitPending;
const isCompacting = (): boolean => store.isCompacting;
const streamingLog = (): StreamingLogState => store.streamingLog;
// Individual property accessors for fine-grained reactivity
const streamingLogId = (): string | null => store.streamingLog.logId;
const streamingLogContent = (): string => store.streamingLog.content;
const streamingLogIsActive = (): boolean => store.streamingLog.isStreaming;
const suggestions = (): SuggestionState => store.suggestions;
const cascadeEnabled = (): boolean => store.cascadeEnabled;
@@ -532,34 +539,30 @@ export const { provider: AppStoreProvider, use: useAppStore } =
s.logs.push(entry);
}),
);
setStore("streamingLog", {
logId,
content: "",
isStreaming: true,
});
// Use path-based updates to ensure proper proxy reactivity
setStore("streamingLog", "logId", logId);
setStore("streamingLog", "content", "");
setStore("streamingLog", "isStreaming", true);
});
return logId;
};
const appendStreamContent = (content: string): void => {
if (!store.streamingLog.logId || !store.streamingLog.isStreaming) {
const logId = store.streamingLog.logId;
const isCurrentlyStreaming = store.streamingLog.isStreaming;
if (!logId || !isCurrentlyStreaming) {
return;
}
const newContent = store.streamingLog.content + content;
const logIndex = store.logs.findIndex((l) => l.id === logId);
batch(() => {
setStore("streamingLog", {
...store.streamingLog,
content: newContent,
});
setStore(
produce((s) => {
const log = s.logs.find((l) => l.id === store.streamingLog.logId);
if (log) {
log.content = newContent;
}
}),
);
// Use path-based updates for proper reactivity tracking
setStore("streamingLog", "content", newContent);
if (logIndex !== -1) {
setStore("logs", logIndex, "content", newContent);
}
});
};
@@ -569,21 +572,19 @@ export const { provider: AppStoreProvider, use: useAppStore } =
}
const logId = store.streamingLog.logId;
const logIndex = store.logs.findIndex((l) => l.id === logId);
batch(() => {
setStore("streamingLog", createInitialStreamingState());
setStore(
produce((s) => {
const log = s.logs.find((l) => l.id === logId);
if (log) {
log.type = "assistant";
log.metadata = {
...log.metadata,
isStreaming: false,
streamComplete: true,
};
}
}),
);
if (logIndex !== -1) {
const currentMetadata = store.logs[logIndex].metadata ?? {};
setStore("logs", logIndex, "type", "assistant");
setStore("logs", logIndex, "metadata", {
...currentMetadata,
isStreaming: false,
streamComplete: true,
});
}
});
};
@@ -692,6 +693,9 @@ export const { provider: AppStoreProvider, use: useAppStore } =
exitPending,
isCompacting,
streamingLog,
streamingLogId,
streamingLogContent,
streamingLogIsActive,
suggestions,
cascadeEnabled,